Sitemap

Inside Blink Text Painting (shadow, decoration, emphasis)

canalun
18 min readApr 2, 2024

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.”

There are two images. One is labeled as “expected” and the other is as “actual”. The expected has shadow offset as physically purely mapped. The actual one has shadow offset affected by rotation.
I thought there might be some who believe that “actual” is correct :)

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.

horizontal text with shadow which has correct offset

And here is the state of vertical texts.

There are two images. One is labeled as “expected” and the other is as “actual”. The expected has shadow offset as physically purely mapped. The actual one has shadow offset affected by rotation.

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!?

English alphabets with correct shadow offset and Japanese characters with wrong one. They are both vertical.

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'.

a table showing how scripts with various native orientation rotates or not according to various writing-mode value
UDN: https://udn.realityripple.com/docs/Web/CSS/writing-mode

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 or mixed. Translated with upright.
  • 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.

screenshot of Appendix A: Vertical Scripts in Unicode of CSS Writing Modes Level 4 Editor’s Draft
CSS Writing Modes Level 4 Editor’s Draft, 28 August 2023; Appendix A: Vertical Scripts in Unicode

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

The illustration of Ogham written on the stone
from 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 :)

The text, “Hello.こんにちは” is segmented by `RunSegmenter` into two groups. One is “Hello” with the orientation of sideways and the other is “こんにちは” with the orientation of upright.

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 text, “若youngい”, with the style as text-shadow:10px, 10px, 0, blue, text-emphasis: ‘xyz’, text-decoration: line-through, overline, underline in red.

The conclusion is this :)

Now, let’s look at each process!

  1. how is the text drawn?
  2. 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 LayoutObjects.
  • Each LayoutObject has FragmentData, 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 into PaintLayerPainter::PaintFragmentWithPhase, a FragmentPainter that matches the type of FragmentData will come and convert the fragment into the operation of graphic library (Skia, in this case).
the illustration explaining the listed points

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);
}
blue square and red circle. the latter is over the former.

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.

From WebKit for Developers, a slightly old image

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 by TextDecorationPainter::PaintExceptLineThrough.
  • The text is drawn by TextPainter::Paint.
  • linethrough is drawn by TextDecorationPainter::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:

  1. Setting up text-shadow in UpdateGraphicsContext (details later).
  2. Drawing text using GraphicsContext::DrawText. This internally executes SkCanvas's drawTextBlob.
  3. Drawing emphasis marks using GraphicsContext::DrawEmphasisMarks. This also internally executes SkCanvas's drawTextBlob.

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.

https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/graphics/draw_looper_builder.cc;l=59;drc=0e324b0d1310b4509c227b76e7091e5d9d14bb98

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's DropShadow 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 by text-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 by TextDecorationPainter::PaintExceptLineThrough.
    (The order is underline for spelling and grammar error -> underline -> overline.)
  • Text is drawn by TextPainter::Paint.
  • Linethrough is drawn by TextDecorationPainter::PaintOnlyLineThrough.
  • All of these draw the shadow before drawing the main body.

So, it turns out like this.

The text, “若youngい”, with the style as text-shadow:10px, 10px, 0, blue, text-emphasis: ‘xyz’, text-decoration: line-through, overline, underline in red.

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 the filter 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…!

--

--

No responses yet