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 the Canvas text shaping entry points from the updated Enhanced TextMetrics explainer, aligned with the overlapping core of WebKit's Canvas text shaping proposal (WebKit/explainers#137): ShapedText ctx.shapeText(DOMString text); ctx.fillGlyphRun(run, x, y) / ctx.strokeGlyphRun(run, x, y); ctx.fillGlyphRuns(runs, x, y) / ctx.strokeGlyphRuns(runs, x, y); interface ShapedText { readonly attribute double width; sequence<GlyphRun> glyphRuns(); sequence<GlyphRun> glyphClusters(optional start, end); sequence<DOMRectReadOnly> selectionRects(start, end); DOMRectReadOnly actualBoundingBox(start, end); unsigned long advanceToPosition(double advance); double positionToAdvance(unsigned long position); }; interface GlyphRun { readonly attribute unsigned long start, end; readonly attribute double x, y, width; TextMetrics textMetrics(); }; All new APIs are behind the existing ExtendedTextMetrics runtime flag. The legacy TextMetrics methods (getTextClusters(), fillTextCluster(), etc.) remain unchanged for now, and share the ShapedText implementation. Implementation: * ShapedText becomes web-exposed (shaped_text.idl), and its query methods take their new names: selectionRects(), actualBoundingBox(), advanceToPosition(). The TextMetrics getSelectionRects(), getActualBoundingBox(), and getIndexFromOffset() delegate to them. * New positionToAdvance(): caret offset for a code unit position, at the leading edge of the character at the position (downstream affinity). At bidi boundaries, both edges of opposite-direction text can map to the same offset; this limitation is documented. * New glyphRuns(): directional, font-resolved runs in visual order. Runs with fallback fonts are split at font boundaries. * New glyphClusters(): minimal clusters as GlyphRun objects, with their resolved font, built on CollectClustersForRange(). * Cluster fonts are resolved with a per-run font table, built in a single pass over the glyphs of each run with fallback fonts. * New GlyphRun (glyph_run.{idl,h,cc}), and a range TextMetrics constructor backing GlyphRun.textMetrics(). Its horizontal metrics are relative to the run, and its vertical metrics to the primary font's textBaseline anchor, where runs are drawn. * BaseRenderingContext2D: shapeText() becomes web-exposed, and new fill/strokeGlyphRun(s)() draw runs via DrawTextInternal() using the font, textBaseline, and range captured at shapeText() time. Drawing with the captured direction follows in a separate CL. * Calling getTextClusters() with no range on an empty string now returns an empty list instead of hitting a CHECK. * New shaped_text_test.cc unit tests for the ShapedText and GlyphRun APIs, including fallback fonts from test data. The advanceToPosition() bidi tests move there from text_metrics_test.cc, which now checks that getIndexFromOffset() matches advanceToPosition(). WPT coverage for the new APIs follows in a separate CL. * Updated global-interface-listing baselines. Range checks are inherited from the legacy TextMetrics methods and aren't consistent across the range queries yet (crbug.com/580852205). Bug: 578524618 Change-Id: Ia944cda77b252d4a8284cb03bf66ea0a4540abda Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8664078 Reviewed-by: Stephen Chenney <schenney@chromium.org> Reviewed-by: Julia Lavrova <jlavrova@google.com> Commit-Queue: Florin Malita <fmalita@chromium.org> Cr-Commit-Position: refs/heads/main@{#1735070}
* Reconcile with WebKit/explainers#137 * Drop html-in-canvas line
Update the specification and algorithms to reconcile the proposal with WebKit/explainers#137 (ShapedText and GlyphRun). Replace the TextMetrics cluster/selection additions and TextCluster with a dedicated ShapedText shaping entry point and GlyphRun objects: - Add CanvasText.shapeText(text) returning ShapedText (leaving measureText() and TextMetrics unchanged). - Move cluster, selection, bounding-box, and caret queries onto ShapedText: glyphRuns(), glyphClusters(), selectionRects(), actualBoundingBox(), advanceToPosition(), and positionToAdvance(). - Replace TextCluster and TextClusterOptions with GlyphRun (start, end, x, y, width, and textMetrics()) and CanvasText.fillGlyphRun(s) / strokeGlyphRun(s), using the styles captured at shapeText() time. This matches the current explainers [1],[2] and Chromium prototype. [1] https://github.com/Igalia/explainers/blob/main/canvas-formatted-text/text-metrics-additions.md [2] 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