Inside Blink Text Painting (shadow, decoration, emphasis)
Through fixing a bug regarding text painting in Blink (Note on 29/5/2025: the CL is landed and finally reverted due to perf issue. Anyway, what is explained on this post is applicable today AFAIK.), I got some knowledge about its text painting process. So I highlight some points about it for me and someone in the future😊
· What the bug looks like
· Ok, fix it!…huh??
· To rotate or Not to rotate
· Then, how to fix it?
· The Flow of Text Painting in Blink
· How is the text drawn?
∘ — What is the Paint Phase?
∘ — Skia and GraphicsContext
∘ — Painting of Text and Text Emphasis Marks by TextFragmentPainter
∘ — Painting of Decoration by DecorationLinePainter
· How is the shadow implemented?
∘ — Shadows for Text and Emphasis Marks
∘ — Shadows for Decorations
· Summary of the painting flow for text related objects
· Rotation
· So…how to fix?
· text-decoration-skip-ink and Clipping
· Wrap up
What the bug looks like
In a nutshell, I’m trying to fix a bug where “when text is set to vertical, if it contains rotating characters, the position of the text-shadow doesn’t align with the spec.”
In other words, the shadow’s offset is not affected by rotation, it’s absolute. This spec is defined in CSS Writing Modes Level 4 as the concept of “Purely Physical Mappings”.
Purely Physical Mappings
The following values are purely physical in their definitions and do not respond to changes in writing mode:
— the rect() notation of the clip property
— the background properties
— the border-image properties
— the offsets of the box-shadow and text-shadow properties
https://drafts.csswg.org/css-writing-modes-4/#physical-only
Here is a link to the bug report. 👶
text-shadow is incorrectly rendered on Latin font texts that are displayed vertically via writing-mode
Ok, fix it!…huh??
Currently, if the writing-mode
is not vertical
, i.e. if the characters are drawn horizontally, they are displayed correctly as shown below.
And here is the state of vertical texts.
From here, I got the following hypotheses:
- It looks like the shadow has been added and then the whole thing is rotated. Internally, it’s probably in the order of shadow ➝ rotation.
- There must be a place where the shadow’s offset is determined from the style information.
- Then, why not look at whether the text is rotating at the place where the shadow’s offset is determined, and convert the offset accordingly!?!?💡
So, I find the place where the text-shadow
offset is determined, and try to incorporate a rotation-considered conversion there.
sk_sp<SkDrawLooper> CreateDrawLooper(
const ShadowList* shadow_list,
+ // `horizontal` is calculated in the upper layer
+ // it's equal to `style.IsHorizontalWritingMode() || style.GetTextOrientation() == ETextOrientation::kUpright`
+ bool horizontal
DrawLooperBuilder::ShadowAlphaMode alpha_mode,
const Color& current_color,
mojom::blink::ColorScheme color_scheme,
TextPainter::ShadowMode shadow_mode) {
DrawLooperBuilder draw_looper_builder;
// ShadowList nullptr means there are no shadows.
if (shadow_mode != TextPainter::kTextProperOnly && shadow_list) {
for (wtf_size_t i = shadow_list->Shadows().size(); i--;) {
const ShadowData& shadow = shadow_list->Shadows()[i];
+ float shadow_x = horizontal ? shadow.X() : shadow.Y();
+ float shadow_y = horizontal ? shadow.Y() : -shadow.X();
draw_looper_builder.AddShadow(
- shadow.Offset(), // it's equal to (shadow.X(), shadow.Y())
+ gfx::Vector2dF(shadow_x, shadow_y),
shadow.Blur(),
shadow.GetColor().Resolve(current_color, color_scheme),
DrawLooperBuilder::kShadowRespectsTransforms, alpha_mode);
}
}
if (shadow_mode != TextPainter::kShadowsOnly) {
draw_looper_builder.AddUnmodifiedContent();
}
return draw_looper_builder.DetachDrawLooper();
}
Yeah, that’s it!……wait!?
Ok, this looks like a big deal.
To rotate or Not to rotate
The reason why the shadow’s offset is wrong when using Hiragana is because this implementation does not consider ‘characters that do not rotate when writing-mode: vertical-XX;
and there is no value for text-orientation
'.
It’s interesting. There seems to be characters whose rotation direction differs between sideways-lr
and sideways-rl
.
Ok, let’s try to understand it more accurately.
Originally in CSS, the process of rotating characters when arranging text vertically is called bi-orientational transform
.
To lay out vertical text, the UA needs to transform the text from its horizontal orientation. This transformation is the bi-orientational transform, and there are two types
rotate Rotate the glyph from horizontal to vertical
translate Translate the glyph from horizontal to vertical
https://drafts.csswg.org/css-writing-modes-4/#intro-text-layout
If translate
is applied as a bi-orientational transform
, it means that rotation
does not occur. How is it determined whether the transform is rotate
or translate
? The spec explains as below.
Scripts with a native vertical orientation have an intrinsic bi-orientational transform, which orients them correctly in vertical text: most CJK (Chinese/Japanese/Korean) characters translate, that is, they are always upright. Characters from other scripts, such as Mongolian, rotate.
Scripts without a native vertical orientation can be either rotated (set sideways) or translated (set upright): […omit]
In other words, it seems as follows:
- Scripts with no native vertical writing 👉 Rotated with
sideways
ormixed
. Translated withupright
. - Scripts with native vertical writing 👉 Do it intrinsically
Now we are interested in the latter. It seems that we can understand what “intrinsic” refers to by Appendix A.
Isn’t it very interesting!?👀 There are variations.
- The direction of the characters remains the same, read from top to bottom
- The direction of the characters rotates 90 degrees clockwise, read from top to bottom
- The direction of the characters rotates 90 degrees counterclockwise, read from top to bottom
- The direction of the characters rotates 90 degrees counterclockwise, read from bottom to top
The Ogham system seems to rotate 90 degrees counterclockwise and read from bottom to top. I’ve not know about it. If you are interested in it, please check out the actual characters on “Omniglot: Writing Systems & Language of the World; Ogham alphabet”
Web Standards often makes me think about social role that browsers and the web should play. For example, in this case, if it’s like “this language system doesn’t work well on the web”, native people of the system would be excluded from the web effectively. Even though there is translation technology, the impact of excluding the language system itself from this major media should be significant.
While a11y has been gradually coming into the spotlight, I realized that characters/scripts/languages are also one of the cornerstone of the web “accessibility”. As Emil Cioran said, “It is no nation that we inhabit, but a language.”
Anyway, whether characters rotate or not with vertical writing mode can be determined by referring to the above table, i.e., based on the script to which the character belongs (though we may have exceptions).
In Blink, this detailed handling is done by RunSegmenter
. This is a module that groups text run
by condition used for shaping. As you can imagine, this module levarages ICU libs :)
However, RunSegmenter
runs much deeper than the part I just modified. It seemed that I should not bring info of RunSegmenter
there.
Then, how to fix it?
So, I discussed with the reviewer and found the below.
- The current change can’t handle scripts like CJK.
- Also, the shadow of
text-decoration
is not fixed. - What’s more, the shadow of
text-emphasis
is newly broken.
I wasn’t able to consider text-decoration
and text-emphasis
.
Then, we decided to go in the direction as follows:
- Shadow of Text: Make the offset not change due to rotation (i.e. follow purely physical mappings)
- Shadow of Decoration: Refactor it and Use the same method as text shadow (Currently, decoration is shadowed by a module different from one of the shadow of text, as described later)
In the following, I will summarize what I learned about how text is painted, including decoration, through this CL😉 (Note on 29/5/2025: Please note that the CL is finally landed but reverted due to perf issue. I mean, the bug is still there.)
The Flow of Text Painting in Blink
The goal of this section is to understand how a set of text related objects like the image are drawn.
The conclusion is this :)
Now, let’s look at each process!
- how is the text drawn?
- how is the shadow implemented?
How is the text drawn?
What is the Paint Phase?
You can overview the rendering process in LocalFrameView::UpdateLifecyclePhasesInternal
, that is, where the LocalFrame
lifecycle is managed.
(This function has many interesting points, such as how it activates ResizeObserver
and IntersectionObserver
after the layout phase.)
At the very end, there is RunPaintLifecyclePhase(PaintBenchmarkMode::kNormal)
.
That's the entrance to the Paint phase! If you follow from there, you will find that it looks like this:
- The Layout phase creates something called “Layout Tree”, which is made up of
LayoutObject
s. - Each
LayoutObject
hasFragmentData
, which mainly contains information about position and size. - For example, in a
TextFragment
, the position and size of the text are included, taking into account things like line breaks and splitting. - If you put each
FragmentData
intoPaintLayerPainter::PaintFragmentWithPhase
, aFragmentPainter
that matches the type ofFragmentData
will come and convert the fragment into the operation of graphic library (Skia, in this case).
Looking at the actual code, you can see that PaintLayerPainter::PaintWithPhase
is looping FragmentData
takenfrom LayoutObject
and passing it to PaintFragmentWithPhase
.
for (const FragmentData& fragment :
FragmentDataIterator(paint_layer_.GetLayoutObject())) {
const PhysicalBoxFragment* physical_fragment = nullptr;
if (layout_box_with_fragments) {
physical_fragment =
layout_box_with_fragments->GetPhysicalFragment(fragment_idx);
DCHECK(physical_fragment);
}
std::optional<ScopedDisplayItemFragment> scoped_display_item_fragment;
if (fragment_idx)
scoped_display_item_fragment.emplace(context, fragment_idx);
PaintFragmentWithPhase(phase, fragment, fragment_idx, physical_fragment,
context, paint_flags);
// [omit by me]
}
Also, as an aside, there is also a place where culling is done👀
Culling is a general term in rendering, which essentially means “let’s draw only the targets/parts that need to be drawn NOW.”
PaintLayerPainter::PaintFragmentWithPhase
CullRect cull_rect = fragment_data.GetCullRect();
if (cull_rect.Rect().IsEmpty())
return;
In short, the Paint phase can be summarized like: a phase that constructs operations of the graphic library (here, Skia) based on the Fragment data (such as position) spit out from the Layout phase.
Skia and GraphicsContext
When I mentioned “operations of the graphics library (Skia in this case),” what exactly does that mean?
Skia is a 2D graphics library (OSS) developed by Google, and below is an example of its code and output.
void draw(SkCanvas* canvas) {
canvas->drawColor(SK_ColorWHITE);
SkPaint paint;
paint.setStyle(SkPaint::kFill_Style);
paint.setStrokeWidth(4);
paint.setColor(0xff4285F4);
SkRect rect = SkRect::MakeXYWH(10, 10, 100, 160);
canvas->drawRect(rect, paint);
SkRRect oval;
oval.setOval(rect);
oval.offset(40, 80);
paint.setColor(0xffDB4437);
canvas->drawRRect(oval, paint);
}
According to the official documentation, you can draw by preparing the basic object (“the primitive being drawn”) and how to draw it (“color/style attributes”: SkPaint
), and putting both into an operation via SkCanvas
. It can draw text as well.
Skia is organized around the SkCanvas object. It is the host for the “draw” calls: drawRect, drawPath, drawText, etc. Each of these has two components: the primitive being drawn (SkRect, SkPath, etc.) and color/style attributes (SkPaint).
https://skia.org/docs/user/api/
canvas->drawRect(rect, paint);
By the way, the style information that can be put into SkPaint
is diverse, including Shade, ColorFilter, BlendMode, etc. These are things that we specify in CSS, aren't they?👀
On the other hand, the matrix for coordinate transformation and the area for clipping are held by SkCanvas
. So, the information for affine transformation like shifting or distorting shapes is stored in SkCanvas
.
Now, back to the internals of Blink, Skia is not exposed as is. In web page rendering, GraphicsContext
seems to play a role of SkCanvas
. We can perform operations like the following through GraphicsContext
. I pick some definitions from graphics_context.h
.
void DrawLine(const gfx::Point&,
const gfx::Point&,
const StyledStrokeData&,
const AutoDarkMode& auto_dark_mode,
bool is_text_line = false,
const cc::PaintFlags* flags = nullptr);
void DrawText(const Font&,
const TextFragmentPaintInfo&,
const gfx::PointF&,
DOMNodeId,
const AutoDarkMode& auto_dark_mode);
void ClipRect(const SkRect&,
AntiAliasingMode = kNotAntiAliased,
SkClipOp = SkClipOp::kIntersect);
void Scale(float x, float y);
void Rotate(float angle_in_radians);
void Translate(float x, float y);
GraphicsContext
itself has a class called PaintCanvas
, which is an abstraction of SkCanvas
. And drawing operations using GraphicsContext
are ultimately converted into SkCanvas
operations and drawn into backing store
. This topic is slightly detailed in "Graphics and Skia" and, although a bit old, WebKit for Developers.
This kind of abstraction seems to have been around since Blink was still WebKit. That’s probably why various browsers using WebKit could use different graphics library like Skia, CoreGraphics and so on.
Painting of Text and Text Emphasis Marks by TextFragmentPainter
Now, let’s see how the drawing operations are created!
The entry point is TextFragmentPainter::Paint
. I omit some codes to focus on the core of the Paint process.
void TextFragmentPainter::Paint(const PaintInfo& paint_info,
const PhysicalOffset& paint_offset) {
// [omit by me]
switch (highlight_case) {
case HighlightPainter::kNoHighlights:
// Fast path: just paint the text, including its decorations.
decoration_painter.Begin(text_item, TextDecorationPainter::kOriginating);
decoration_painter.PaintExceptLineThrough(fragment_paint_info);
text_painter.Paint(fragment_paint_info, text_style, node_id,
auto_dark_mode);
decoration_painter.PaintOnlyLineThrough();
break;
// [omit by me]
}
We can see that the text and its decorations are drawn as follows. Note that the order is actually important (I’ll refer to it later):
- Decorations other than
linethrough
are drawn byTextDecorationPainter::PaintExceptLineThrough
. - The text is drawn by
TextPainter::Paint
. linethrough
is drawn byTextDecorationPainter::PaintOnlyLineThrough
.
Let’s take a closer look at TextPainter::Paint
, too.
void TextPainter::Paint(const TextFragmentPaintInfo& fragment_paint_info,
const TextPaintStyle& text_style,
DOMNodeId node_id,
const AutoDarkMode& auto_dark_mode,
ShadowMode shadow_mode) {
// [omit by me]
UpdateGraphicsContext(graphics_context_, text_style, state_saver,
shadow_mode);
if (svg_text_paint_state_.has_value()) {
// [omit by me]
} else {
graphics_context_.DrawText(font_, fragment_paint_info,
gfx::PointF(text_origin_), node_id,
auto_dark_mode);
}
if (!emphasis_mark_.empty()) {
if (text_style.emphasis_mark_color != text_style.fill_color)
graphics_context_.SetFillColor(text_style.emphasis_mark_color);
graphics_context_.DrawEmphasisMarks(
font_, fragment_paint_info, emphasis_mark_,
gfx::PointF(text_origin_) + gfx::Vector2dF(0, emphasis_mark_offset_),
auto_dark_mode);
}
// [omit by me]
}
Here, three major things are done in the following order:
- Setting up text-shadow in
UpdateGraphicsContext
(details later). - Drawing text using
GraphicsContext::DrawText
. This internally executesSkCanvas
'sdrawTextBlob
. - Drawing emphasis marks using
GraphicsContext::DrawEmphasisMarks
. This also internally executesSkCanvas
'sdrawTextBlob
.
Both the text and its emphasis marks are drawn with SkCanvas
's drawTextBlob
. It’s interesting, isn’t it!? In other words, emphasis marks are treated as the same thing as text within Blink. In fact, internally, the emphasis mark is obtained with ComputedStyle::GetTextEmphasisMark
and turned into GlyphData
with ShapeResultBuffer::EmphasisMarkGlyphData
. This means that emphasis marks are shaped into glyphs by HarfBuzz, just like normal text characters. And notably, dot
and sesame
are always treated as upright
glyphs internally! That is, these symbols behave the same as hiragana against "bi-orientational transform". Their orientation never changes. That's why my changes broke the shadow of the emphasis marks at the same time as the hiragana shadow.
Let’s take a glimpse at the code.
TextEmphasisMark ComputedStyle::GetTextEmphasisMark() const {
TextEmphasisMark mark = TextEmphasisMarkInternal();
if (mark != TextEmphasisMark::kAuto) {
return mark;
}
if (IsHorizontalWritingMode()) {
return TextEmphasisMark::kDot;
}
return TextEmphasisMark::kSesame;
}
//////
GlyphData ShapeResultBuffer::EmphasisMarkGlyphData(
const FontDescription& font_description) const {
for (const auto& result : results_) {
for (const auto& run : result->runs_) {
DCHECK(run->font_data_);
if (run->glyph_data_.IsEmpty())
continue;
return GlyphData(run->glyph_data_[0].glyph,
run->font_data_->EmphasisMarkFontData(font_description),
run->CanvasRotation());
}
}
return GlyphData();
}
You may have noticed that here glyph_data_[0].glyph
is used. This corresponds to the spec that when a string value is entered in text-emphasis
, only the first character is adopted!
Painting of Decoration by DecorationLinePainter
Now that we understand the painting process of text and emphasis marks, let’s move on to decorations.
The main part here is DecorationLinePainter::Paint
.
void DecorationLinePainter::Paint(const Color& color,
const cc::PaintFlags* flags) {
// [omit by me]
switch (decoration_info_.DecorationStyle()) {
case ETextDecorationStyle::kWavy:
PaintWavyTextDecoration(auto_dark_mode);
break;
case ETextDecorationStyle::kDotted:
case ETextDecorationStyle::kDashed:
context_.SetShouldAntialias(decoration_info_.ShouldAntialias());
[[fallthrough]];
default:
DrawLineForText(context_, decoration_info_.StartPoint(),
decoration_info_.Width(), styled_stroke, auto_dark_mode,
flags);
if (decoration_info_.DecorationStyle() == ETextDecorationStyle::kDouble) {
DrawLineForText(context_,
decoration_info_.StartPoint() +
gfx::Vector2dF(0, decoration_info_.DoubleOffset()),
decoration_info_.Width(), styled_stroke, auto_dark_mode,
flags);
}
}
}
The case for Wavy has special handling, because this case involves complex calculations for Bezier curves and converting them into patterns for Skia.
But all other cases seem to be handled by DrawLineForText
. It continues as follows:
- For solid and double: Draws a rect with the specified thickness as the height using
GraphicsContext::DrawRect
. - For wavy, dot, and dash: Draws a line using
GraphicsContext::DrawLine
.
In this way, all roads lead to GraphicsContext
:)
how is the shadow implemented?
Now that we understand how text is drawn, let’s look at how shadows are drawn.
Shadows for Text and Emphasis Marks
The key here is UpdateGraphicsContext
that appeared in TextPainter::Paint
earlier.
This function sets up a mechanism called DrawLooper
provided by Skia to implement shadows. As the name suggests, DrawLooper
loops the drawing process.
Yes, that’s right, shadows are created by looping the drawing of text (and emphasis marks) twice, and the first time it is offset and the color is slightly changed! It’s a bit interesting, isn’t it?
And this is the same for emphasis marks, they are drawn twice in exactly the same way. With that in mind, let’s look at the image from earlier. It’s got a little more interesting than before, isn’t it?
If you’re interested, you can probably understand more by looking at the code for DrawLooperBuilder::AddShadow
from the below.
Shadows for Decorations
Well, how are shadows for decorations implemented?
Since all of underline
, overline
, and line-through
take the same way, let's focus on underline
here.
PaintWithTextShadow(
[&](TextShadowPaintPhase phase) {
for (wtf_size_t i = 0; i < decoration_info.AppliedDecorationCount(); i++) {
decoration_info.SetDecorationIndex(i);
// [omit by me: the same thing as underline is done for `SpellingOrGrammerError`]
if (decoration_info.HasUnderline() && decoration_info.FontData() &&
EnumHasFlags(lines_to_paint, TextDecorationLine::kUnderline)) {
decoration_info.SetUnderlineLineData(decoration_offset);
decoration_info.SetSkipInkIntercepts(text_painter_,
&fragment_paint_info);
text_painter_.PaintDecorationLine(
decoration_info, LineColorForPhase(decoration_info, phase),
text_style);
}
// [omit by me: the same thing as underline is done for `SpellingOrGrammerError`]
}
},
pa
PaintWithTextShadow
? Yes, that is the point. This function does the following:
- Accepts a lambda expression that draws the decoration line.
- If there is no
text-shadow
specified, it executes that lambda expression and that’s it. - If
text-shadow
is specified, it creates a layer with Skia'sDropShadow
filter applied, and draws the decoration line in black on that layer. By doing so, the shadow is created. Once that's done, it creates one more layer and draws the decoration line in the color specified bytext-shadow
on the layer.
As illustrated above, Skia’s DropShadow
should return data that includes the original bitmap with a shadow added. Therefore, I can’t figure out why it's running twice (I guess this CL may be related).
Anyway, the important point here is that while text and emphasis marks implement shadows by drawing twice, decorations use a filter.
Summary of the painting flow for text related objects
Now, summarizing what we’ve discussed so far, we can see that the order is as follows:
- Decorations other than
linethrough
are drawn byTextDecorationPainter::PaintExceptLineThrough
.
(The order is underline for spelling and grammar error -> underline -> overline.) - Text is drawn by
TextPainter::Paint
. Linethrough
is drawn byTextDecorationPainter::PaintOnlyLineThrough
.- All of these draw the shadow before drawing the main body.
So, it turns out like this.
On the other hand, I think it’s difficult to say this implementation is along with the spec.
According to “CSS Text Decoration Module Level 4” “5.1. Painting Order of Text Decorations”, text-shadow
must be drawn at the bottom layer.
Here's a comparison of Chrome and Firefox as of April 2, 2024.
<style>div {
font-size: 6rem;
text-shadow: 10px 10px 0 blue;
text-emphasis: 'x';
text-decoration: line-through overline underline red;
}</style>
<div style="">若youngい</div>
You can see differences in whether the shadow of linethrough is under the text, or whether the shadow of the text is under the underline.
I think Firefox’s behavior is probably correct👀 And I guess that Firefox is drawing objects in an order different from Chromium.
Rotation
Now, let’s talk about rotation. It’s a long journey, haha :)
Rotation is divided into two stages. First, there is a stage where the text, emphasis marks, and decorations are rotated all at once. This is like a rotation for the entire canvas. Then, there’s a stage where each glyph is rotated. This two-stage structure was the reason why my patch didn’t work well.
The former is realized in TextFragmentPainter::Paint
by applying an affine transformation matrix corresponding to the writing-mode
.
void TextFragmentPainter::Paint(const PaintInfo& paint_info,
const PhysicalOffset& paint_offset) {
// [omit by me]
std::optional<AffineTransform> rotation;
const WritingMode writing_mode = style.GetWritingMode();
const bool is_horizontal = IsHorizontalWritingMode(writing_mode);
const LineRelativeRect rotated_box =
LineRelativeRect::CreateFromLineBox(physical_box, is_horizontal);
if (!is_horizontal) {
rotation.emplace(
rotated_box.ComputeRelativeToPhysicalTransform(writing_mode));
}
// [omit by me]
if (rotation) {
state_saver.SaveIfNeeded();
context.ConcatCTM(*rotation);
if (TextPainter::SvgTextPaintState* state = text_painter.GetSvgState()) {
DCHECK(rotation->IsInvertible());
state->EnsureShaderTransform().PostConcat(rotation->Inverse());
}
}
// [omit by me]
}
The latter is handled in DrawBlobs
, which is used for drawing text and its emphasis marks. This is a process within font.cc
.
It loops through the blobs
and processes one glyph at a time. You can see that rotation is also applied here for each glyph based on the calculated rotation
.
for (const auto& blob_info : blobs) {
// [omit by me]
switch (blob_info.rotation) {
case CanvasRotationInVertical::kRegular:
break;
case CanvasRotationInVertical::kRotateCanvasUpright: {
canvas->save();
SkMatrix m;
m.setSinCos(-1, 0, point.x(), point.y());
canvas->concat(SkM44(m));
break;
}
// [omit by me]
}
// [omit by me]
}
So…how to fix?
It has become quite long. Hi, is anyone reading here? :)
The original bug was that “when text is set to vertical, and it contains characters that rotate accordingly, the position of the text-shadow differs from the spec.”
Taking into account what we’ve seen so far, the content of the CL can be summarized as follows:
Current situation
- When a glyph is rotated rather than translated by the bi-orientational transform, its shadow offset does not follow “purely physical mappings”.
- The same thing is happening to text-decoration.
Cause
- The
DrawLooper
that draws the shadow of the text and thefilter
that creates the shadow of the decoration are affected by rotation (as a result, they are not "purely physical mappings").
Solution
- First, make the decoration’s shadow painted by
DrawLooper
. - Then, change the flag passed to
DrawLooper
so that the offset is not affected by rotation (make it "purely physical mappings") for both cases.
The actual CL is here👶 (as of April 2, 2024, it has not yet been merged)
https://chromium-review.googlesource.com/c/chromium/src/+/5380302
Note on 2024/12/25
I aborted the above CL and instead merged a CL replacing DrawLooper
with shadow filter.
This is because it seems that we can’t fix the aforementioned painting order bug as long as using DrawLooper
. It adds shadow atomically, so we can’t control painting order between text, decoration and their shadows with it.
You can see the new CL here: https://chromium-review.googlesource.com/c/chromium/src/+/5484626
Note on 2025/5/29
The new CL is landed but reverted due to perf issue. So now the bug is still there…!
Originally, I was going to end this article here. But I’ll write about skip-ink and clipping, which got me stuck in the mud, at the end.
text-decoration-skip-ink and Clipping
text-decoration-skip-ink
is a spec that allows the line to be interrupted where there are characters, as shown below.
In the process of refactoring the implementation of the decoration’s shadow from a filter to DrawLooper
, the implementation of skip-ink was one of the tricky points.
Currently, when drawing an underline, skip-ink is realized by clipping the canvas.
Specifically, ClipDecorationStripe
performs clipping within TextPainter::PaintDecorationLine
. It receives the areas where lines cannot be drawn due to the appearance of glyphs. This info comes from Font::GetTextIntercepts
as a pair of x-coordinates (i.e., Vector<{float begin_, end_}>
).
void TextPainter::ClipDecorationsStripe( /* [omit by me] */) {
// [omit by me]
Vector<Font::TextIntercept> text_intercepts;
font_.GetTextIntercepts(fragment_paint_info, graphics_context_.FillFlags(),
std::make_tuple(upper, upper + stripe_width),
text_intercepts);
for (auto intercept : text_intercepts) {
gfx::PointF clip_origin(text_origin_);
gfx::RectF clip_rect(
clip_origin + gfx::Vector2dF(intercept.begin_, upper),
gfx::SizeF(intercept.end_ - intercept.begin_, stripe_width));
// [omit by me]
graphics_context_.ClipOut(clip_rect);
}
}
The line drawn on the clipped canvas will be interrupted right where there are characters. Then, the filter is applied to this result, causing the shadow to be interrupted as well!
Here, at first, I adopted DrawLooper
without changing the clipping area. The result is shown below.
The shadow of the underline is not interrupted. This is a natural consequence because the area where DrawLooper
draws the shadow is not clipped.
You might think that we should just double the clipping area for DrawLooper
, but that's not the case. The following is what happened. In short, the blur didn't work properly.
So, in the end, instead of clipping, I decided to divide the line. And it worked :)
It’s interesting, isn’t it?
Wrap up
The CL I created has not been merged as of April 2, 2024.
However, since the basic implementation is complete, I think it will be merged soon. And the content of the article itself will be not affected by the merge, so I published the article :)
I will update it when necessary.
I feel DOM, painting and other rendering/graphic process and modules like magic. The data of 0 and 1 gets displayed in a visible form. It’s amazing.
Anyway, that’s it today! See you again👋
Note on 12/25/2024
Instead of the change mentioned in this post, I’ve finally landed a CL replacing draw looper with shadow filter (i.e., the opposite strategy to the one introduced in this post) on 12/25/2024.
https://chromium-review.googlesource.com/c/chromium/src/+/5484626
Please note that this post has NOT been updated accordingly and the actual change finally merged has replaced draw looper with shadow filter.
More Note on 29/5/2025
The CL replacing draw looper with shadow filter is reverted due to perf issue. So the explained mechanism on this post is still kept and the bug still exists…!