Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(color-contrast): get text stoke from offset shadows #4079

Merged
merged 6 commits into from
Jul 14, 2023

Conversation

WilcoFiers
Copy link
Contributor

If several text shadows with some offset and with minimal blur are used together, they can combine into a text stroke effect. This PR attempts to identify such cases and combine these shadows into a single color.

Closes: #4064

@WilcoFiers WilcoFiers requested a review from a team as a code owner July 3, 2023 16:07
it('skips shadows offset with 0.5px or less', () => {
fixture.innerHTML = `
<span style="text-shadow:
0.5px 0.5px #000,
Copy link
Contributor

Choose a reason for hiding this comment

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

This test case assumes that 2 shadows on opposite corners would be recognized correctly as a normally-combined case; it'd be good to have a test case verifying that explicitly ahead of this one

return shadowColors;
}

function getStrokeColor(thinShadows) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
function getStrokeColor(thinShadows) {
function getStrokeColors(thinShadows) {

assert.lengthOf(shadowColors, 4);
});

it('skips raised-letter effects (shadows on 1 or 2 sides)', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be good to have a test that verifies that exactly 3 sides is sufficient to be not-skipped

Copy link
Contributor

@dbjorge dbjorge left a comment

Choose a reason for hiding this comment

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

Thanks Wilco! Some suggestions inline

const edges = ['top', 'right', 'bottom', 'left'];

/**
*
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO

Comment on lines 21 to 25
// Remove any shadows that cover 1 or 2 sides only
const strokeShadows = Object.entries(colorMap).filter(([, sides]) => {
const sidesCovered = edges.filter(side => sides[side].length !== 0).length;
return sidesCovered >= 3;
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: figure out calling incomplete when shadows are not on all sides.

@WilcoFiers WilcoFiers requested a review from dbjorge July 13, 2023 12:30
@WilcoFiers WilcoFiers dismissed dbjorge’s stale review July 13, 2023 12:30

This should be good to go now

/**
* Work out which color(s) of an array of text shadows form a stroke around the text.
* @param {Array[]} testShadows Parsed test shadows (see color.parseTestShadow())
* @returns {Array[]} Array of colors
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* @returns {Array[]} Array of colors
* @returns {Array|null} Array of colors or null if text-shadow was too complex to measure

return { colorStr, sides, edgeCount };
});

// Skip shadows that cover too much of the text to be ignored, but not enough to be tested
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Skip shadows that cover too much of the text to be ignored, but not enough to be tested
// Bail immediately if any shadow group covers too much of the text to be ignored, but not enough to be tested

Comment on lines 77 to 82
isSolid &&= sides[edge].every(([x, y]) => {
return (
Math.abs(x) > OPAQUE_STROKE_OFFSET_MIN_PX ||
Math.abs(y) > OPAQUE_STROKE_OFFSET_MIN_PX
);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is a bit off; if one shadow impacts 2 edges, but is only > OPAQUE_STROKE_OFFSET_MIN_PX on one of those edges, this will currently treat both edges as solid instead of just one like it should. Here is a test case that demonstrates this (the same as the current applies an alpha value if not all shadows are offset by more than 1.5px test, except with the 4 shadows collapsed into two corner shadows):

  it('applies an alpha value if not all sides are offset by more than 1.5px', () => {
    const shadows = parseTextShadows(`
      -1.5px -2px #000,
      -2px 1.5px #000
    `);
    const shadowColors = getStrokeColorsFromShadows(shadows);
    assert.lengthOf(shadowColors, 1);
    assert.equal(shadowColors[0].red, 0);
    assert.equal(shadowColors[0].green, 0);
    assert.equal(shadowColors[0].blue, 0);
    assert.closeTo(shadowColors[0].alpha, 0.46, 0.01);
  });

To fix this, I recommend updating getShadowColorsMap to produce a mapping from edge to list-of-thickness, instead of from edge to list-of-pixels-tuples (ie, replace all the borders.EDGE.push(pixels) statements with borders.EDGE.push(/* offsetX or offsetY as appropriate */)), and then update this check to be:

Suggested change
isSolid &&= sides[edge].every(([x, y]) => {
return (
Math.abs(x) > OPAQUE_STROKE_OFFSET_MIN_PX ||
Math.abs(y) > OPAQUE_STROKE_OFFSET_MIN_PX
);
});
isSolid &&= sides[edge].every(
offset => Math.abs(offset) > OPAQUE_STROKE_OFFSET_MIN_PX
);

const strokeColor = new Color();
strokeColor.parseString(colorStr);

// Average the number of shadows around the text
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Average the number of shadows around the text
// Detect whether any sides' shadows are thin enough to be considered
// translucent, and if so, calculate an alpha value to apply on top of
// the parsed color.

* Using colorStr and thickness of sides, create a color object
*/
function shadowGroupToColor({ colorStr, sides, edgeCount }) {
if (edgeCount !== 4) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels off that this check is still here when the caller is now also doing an overlapping edgeCount check. Maybe more clear to move this check out of shadowGroupToColor and update the .filter on L32 to filter out based on edgeCount before mapping this function, instead of mapping this function and then filtering based on its return value.


it('double-counts shadows on corners', () => {
// Corner-shadows overlap on the sides of letters, increasing alpha
const shadows = parseTextShadows(`
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the only test case that's covering shadow-on-corners behavior; it'd be good to have a case that verifies that "2 opposing corner shadows is enough to not return empty/null"

@@ -54,7 +54,8 @@ function _getBackgroundColor(elm, bgElms, shadowOutlineEmMax) {
}

const textRects = getVisibleChildTextRects(elm);
let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax });
let bgColors =
getTextShadowColors(elm, { minRatio: shadowOutlineEmMax }) ?? [];
Copy link
Contributor

Choose a reason for hiding this comment

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

For the ?? [] to come up in color-contrast and not have already resulted in a complexTextShadows exit in color-contrast-evaluate, an element would need to have a "complex" (2-3 edge compound) shadow of blur radius between .001em and 0.2em. I think if that were to actually happen in practice and there was additionally a higher-blur-radius shadow(s), it probably doesn't make sense to silently throw away those higher-blur-radius shadow(s). I can be persuaded that either of "bail as incomplete" or "ignore those cases but still consider the thick shadow behind as a possible background color" would be reasonable.

link-in-text-block also uses getBackgroundColor and I think the ?? [] is even sketchier there, since it doesn't early-exit with incomplete for blur radius < .001 complex compound shadows like color-contrast does. Generally, it seems a bit strange to me that link-in-text-block-evaluate isn't reusing the same support for shadows providing contrast that color contrast is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can't sneak anything past you can I? I realised that too when I wrote this. Wasn't sure whether I should care, but fair enough.

Copy link
Contributor

@dbjorge dbjorge left a comment

Choose a reason for hiding this comment

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

:shipit:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Text stroke (shadow) edge case not getting picked up
2 participants