Skip to content

Commit 1a190b8

Browse files
authored
Merge pull request #6607 from remotion-dev/web-renderer-text-shadow
`@remotion/web-renderer`: Add support for text shadows
2 parents 9e8e74d + b53f3fd commit 1a190b8

File tree

12 files changed

+212
-59
lines changed

12 files changed

+212
-59
lines changed

.claude/skills/web-renderer-test/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,4 @@ test('should render background-color', async () => {
7676
2. **Important**: Add the fixture to `packages/web-renderer/src/test/Root.tsx` to add a way to preview it.
7777
3. Add a new test in `packages/web-renderer/src/test`.
7878
4. Run `bunx vitest src/test/video.test.tsx` to execute the test.
79+
5. **Important**: Update `packages/docs/docs/client-side-rendering/limitations.mdx` to reflect the newly supported property.

packages/docs/docs/client-side-rendering/limitations.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The `text-transform` property is <a style={{color: 'green'}}>supported</a>.
6464
The `direction` HTML attribute is <a style={{color: 'green'}}>supported</a>.
6565
The `writing-mode` property is <a style={{color: 'red'}}>not supported</a>.
6666
The `text-decoration` property is <a style={{color: 'red'}}>not supported</a>.
67-
The `text-shadow` property is <a style={{color: 'red'}}>not supported</a>.
67+
The `text-shadow` property is <a style={{color: 'green'}}>supported</a>.
6868
The `-webkit-text-stroke` property is <a style={{color: 'red'}}>not supported</a>.
6969

7070
## Shadows

packages/web-renderer/src/drawing/draw-box-shadow.ts

Lines changed: 12 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import type {LogLevel} from 'remotion';
22
import {Internals} from 'remotion';
33
import type {BorderRadiusCorners} from './border-radius';
44
import {drawRoundedRectPath} from './draw-rounded';
5+
import type {ShadowBase} from './parse-shadow';
6+
import {parseShadowValues} from './parse-shadow';
57

6-
interface BoxShadow {
7-
offsetX: number;
8-
offsetY: number;
9-
blurRadius: number;
10-
color: string;
8+
interface BoxShadow extends ShadowBase {
119
inset: boolean;
1210
}
1311

@@ -16,57 +14,18 @@ export const parseBoxShadow = (boxShadowValue: string): BoxShadow[] => {
1614
return [];
1715
}
1816

19-
const shadows: BoxShadow[] = [];
17+
const baseShadows = parseShadowValues(
18+
// Remove 'inset' before parsing shared values
19+
boxShadowValue,
20+
);
2021

21-
// Split by comma, but respect rgba() colors
22+
// Split by comma to check for inset on each shadow
2223
const shadowStrings = boxShadowValue.split(/,(?![^(]*\))/);
2324

24-
for (const shadowStr of shadowStrings) {
25-
const trimmed = shadowStr.trim();
26-
if (!trimmed || trimmed === 'none') {
27-
continue;
28-
}
29-
30-
const shadow: BoxShadow = {
31-
offsetX: 0,
32-
offsetY: 0,
33-
blurRadius: 0,
34-
color: 'rgba(0, 0, 0, 0.5)',
35-
inset: false,
36-
};
37-
38-
// Check for inset
39-
shadow.inset = /\binset\b/i.test(trimmed);
40-
41-
// Remove 'inset' keyword
42-
let remaining = trimmed.replace(/\binset\b/gi, '').trim();
43-
44-
// Extract color (can be rgb(), rgba(), hsl(), hsla(), hex, or named color)
45-
const colorMatch = remaining.match(
46-
/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i,
47-
);
48-
if (colorMatch) {
49-
shadow.color = colorMatch[0];
50-
remaining = remaining.replace(colorMatch[0], '').trim();
51-
}
52-
53-
// Parse remaining numeric values (offset-x offset-y blur spread)
54-
const numbers = remaining.match(/[+-]?\d*\.?\d+(?:px|em|rem|%)?/gi) || [];
55-
const values = numbers.map((n) => parseFloat(n) || 0);
56-
57-
if (values.length >= 2) {
58-
shadow.offsetX = values[0];
59-
shadow.offsetY = values[1];
60-
61-
if (values.length >= 3) {
62-
shadow.blurRadius = Math.max(0, values[2]); // Blur cannot be negative
63-
}
64-
}
65-
66-
shadows.push(shadow);
67-
}
68-
69-
return shadows;
25+
return baseShadows.map((base, i) => ({
26+
...base,
27+
inset: /\binset\b/i.test(shadowStrings[i] || ''),
28+
}));
7029
};
7130

7231
export const drawBorderRadius = ({
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
export interface ShadowBase {
2+
offsetX: number;
3+
offsetY: number;
4+
blurRadius: number;
5+
color: string;
6+
}
7+
8+
export const parseShadowValues = (shadowValue: string): ShadowBase[] => {
9+
if (!shadowValue || shadowValue === 'none') {
10+
return [];
11+
}
12+
13+
const shadows: ShadowBase[] = [];
14+
15+
// Split by comma, but respect rgba() colors
16+
const shadowStrings = shadowValue.split(/,(?![^(]*\))/);
17+
18+
for (const shadowStr of shadowStrings) {
19+
const trimmed = shadowStr.trim();
20+
if (!trimmed || trimmed === 'none') {
21+
continue;
22+
}
23+
24+
const shadow: ShadowBase = {
25+
offsetX: 0,
26+
offsetY: 0,
27+
blurRadius: 0,
28+
color: 'rgba(0, 0, 0, 0.5)',
29+
};
30+
31+
// Remove 'inset' keyword (only relevant for box-shadow, but strip it
32+
// so it doesn't interfere with color matching)
33+
let remaining = trimmed.replace(/\binset\b/gi, '').trim();
34+
35+
// Extract color (can be rgb(), rgba(), hsl(), hsla(), hex, or named color)
36+
const colorMatch = remaining.match(
37+
/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i,
38+
);
39+
if (colorMatch) {
40+
shadow.color = colorMatch[0];
41+
remaining = remaining.replace(colorMatch[0], '').trim();
42+
}
43+
44+
// Parse remaining numeric values (offset-x offset-y blur-radius [spread])
45+
const numbers = remaining.match(/[+-]?\d*\.?\d+(?:px|em|rem|%)?/gi) || [];
46+
const values = numbers.map((n) => parseFloat(n) || 0);
47+
48+
if (values.length >= 2) {
49+
shadow.offsetX = values[0];
50+
shadow.offsetY = values[1];
51+
52+
if (values.length >= 3) {
53+
shadow.blurRadius = Math.max(0, values[2]);
54+
}
55+
}
56+
57+
shadows.push(shadow);
58+
}
59+
60+
return shadows;
61+
};

packages/web-renderer/src/drawing/text/draw-text.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {Internals} from 'remotion';
33
import type {DrawFn} from '../drawn-fn';
44
import {applyTextTransform} from './apply-text-transform';
55
import {findWords} from './find-line-breaks.text';
6+
import {parseTextShadow} from './parse-text-shadow';
67

78
export const drawText = ({
89
span,
@@ -25,6 +26,7 @@ export const drawText = ({
2526
letterSpacing,
2627
textTransform,
2728
webkitTextFillColor,
29+
textShadow: textShadowValue,
2830
} = computedStyle;
2931
const isVertical = writingMode !== 'horizontal-tb';
3032
if (isVertical) {
@@ -63,6 +65,8 @@ export const drawText = ({
6365

6466
const tokens = findWords(span);
6567

68+
const textShadows = parseTextShadow(textShadowValue);
69+
6670
for (const token of tokens) {
6771
const measurements = contextToDraw.measureText(originalText);
6872
const {fontBoundingBoxDescent, fontBoundingBoxAscent} = measurements;
@@ -72,11 +76,27 @@ export const drawText = ({
7276
const leading = token.rect.height - fontHeight;
7377
const halfLeading = leading / 2;
7478

75-
contextToDraw.fillText(
76-
token.text,
77-
(isRTL ? token.rect.right : token.rect.left) - parentRect.x,
78-
token.rect.top + fontBoundingBoxAscent + halfLeading - parentRect.y,
79-
);
79+
const x = (isRTL ? token.rect.right : token.rect.left) - parentRect.x;
80+
const y =
81+
token.rect.top + fontBoundingBoxAscent + halfLeading - parentRect.y;
82+
83+
// Draw text shadows from last to first (so first shadow appears on top)
84+
for (let i = textShadows.length - 1; i >= 0; i--) {
85+
const shadow = textShadows[i];
86+
contextToDraw.shadowColor = shadow.color;
87+
contextToDraw.shadowBlur = shadow.blurRadius;
88+
contextToDraw.shadowOffsetX = shadow.offsetX;
89+
contextToDraw.shadowOffsetY = shadow.offsetY;
90+
contextToDraw.fillText(token.text, x, y);
91+
}
92+
93+
// Reset shadow and draw the actual text on top
94+
contextToDraw.shadowColor = 'transparent';
95+
contextToDraw.shadowBlur = 0;
96+
contextToDraw.shadowOffsetX = 0;
97+
contextToDraw.shadowOffsetY = 0;
98+
99+
contextToDraw.fillText(token.text, x, y);
80100
}
81101

82102
span.textContent = originalText;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type {ShadowBase} from '../parse-shadow';
2+
import {parseShadowValues} from '../parse-shadow';
3+
4+
export type TextShadow = ShadowBase;
5+
6+
export const parseTextShadow = (textShadowValue: string): TextShadow[] => {
7+
return parseShadowValues(textShadowValue);
8+
};

packages/web-renderer/src/test/Root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {backgroundClipText3dTransform} from './fixtures/text/background-clip-tex
5454
import {letterSpacing} from './fixtures/text/letter-spacing';
5555
import {paragraphs} from './fixtures/text/paragraphs';
5656
import {textFixture} from './fixtures/text/text';
57+
import {textShadow} from './fixtures/text/text-shadow';
5758
import {textTransform} from './fixtures/text/text-transform';
5859
import {webkitTextFillColor} from './fixtures/text/webkit-text-fill-color';
5960
import {threeDoverflow} from './fixtures/three-d-overflow';
@@ -135,6 +136,7 @@ export const Root: React.FC = () => {
135136
<Composition {...webkitTextFillColor} />
136137
<Composition {...backgroundClipText} />
137138
<Composition {...backgroundClipText3dTransform} />
139+
<Composition {...textShadow} />
138140
<Composition {...whiteSpaceCollapsing} />
139141
<Composition {...whiteSpaceCollapsing2} />
140142
</Folder>
25.3 KB
Loading
24 KB
Loading
21.8 KB
Loading

0 commit comments

Comments
 (0)