Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions packages/web-renderer/src/drawing/text/draw-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type {DrawFn} from '../drawn-fn';
import {applyTextTransform} from './apply-text-transform';
import {findWords} from './find-line-breaks.text';
import {parseTextShadow} from './parse-text-shadow';

export const drawText = ({
span,
Expand All @@ -25,10 +26,11 @@
letterSpacing,
textTransform,
webkitTextFillColor,
textShadow: textShadowValue,
} = computedStyle;
const isVertical = writingMode !== 'horizontal-tb';
if (isVertical) {
// TODO: Only warn once per render.

Check warning on line 33 in packages/web-renderer/src/drawing/text/draw-text.ts

View workflow job for this annotation

GitHub Actions / Linting + Formatting

Unexpected 'todo' comment: 'TODO: Only warn once per render.'
Internals.Log.warn(
{
logLevel,
Expand Down Expand Up @@ -63,6 +65,8 @@

const tokens = findWords(span);

const textShadows = parseTextShadow(textShadowValue);

for (const token of tokens) {
const measurements = contextToDraw.measureText(originalText);
const {fontBoundingBoxDescent, fontBoundingBoxAscent} = measurements;
Expand All @@ -72,11 +76,27 @@
const leading = token.rect.height - fontHeight;
const halfLeading = leading / 2;

contextToDraw.fillText(
token.text,
(isRTL ? token.rect.right : token.rect.left) - parentRect.x,
token.rect.top + fontBoundingBoxAscent + halfLeading - parentRect.y,
);
const x = (isRTL ? token.rect.right : token.rect.left) - parentRect.x;
const y =
token.rect.top + fontBoundingBoxAscent + halfLeading - parentRect.y;

// Draw text shadows from last to first (so first shadow appears on top)
for (let i = textShadows.length - 1; i >= 0; i--) {
const shadow = textShadows[i];
contextToDraw.shadowColor = shadow.color;
contextToDraw.shadowBlur = shadow.blurRadius;
contextToDraw.shadowOffsetX = shadow.offsetX;
contextToDraw.shadowOffsetY = shadow.offsetY;
contextToDraw.fillText(token.text, x, y);
}
Comment on lines +84 to +91
Copy link
Contributor

Choose a reason for hiding this comment

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

Heads up: each shadow pass calls fillText, which draws both the shadow and the text body. So for N shadows the text body is composited N+1 times. For opaque text this is visually correct, but for semi-transparent webkitTextFillColor the text body would appear more opaque than CSS text-shadow renders it.

The box-shadow implementation works around this with destination-out compositing on an offscreen canvas. A similar technique could be applied here for pixel-perfect fidelity, but it's a reasonable trade-off for v1.


// Reset shadow and draw the actual text on top
contextToDraw.shadowColor = 'transparent';
contextToDraw.shadowBlur = 0;
contextToDraw.shadowOffsetX = 0;
contextToDraw.shadowOffsetY = 0;

contextToDraw.fillText(token.text, x, y);
}

span.textContent = originalText;
Expand Down
59 changes: 59 additions & 0 deletions packages/web-renderer/src/drawing/text/parse-text-shadow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export interface TextShadow {
offsetX: number;
offsetY: number;
blurRadius: number;
color: string;
}

export const parseTextShadow = (textShadowValue: string): TextShadow[] => {
if (!textShadowValue || textShadowValue === 'none') {
return [];
}

const shadows: TextShadow[] = [];

// Split by comma, but respect rgba() colors
const shadowStrings = textShadowValue.split(/,(?![^(]*\))/);

for (const shadowStr of shadowStrings) {
const trimmed = shadowStr.trim();
if (!trimmed || trimmed === 'none') {
continue;
}

const shadow: TextShadow = {
offsetX: 0,
offsetY: 0,
blurRadius: 0,
color: 'rgba(0, 0, 0, 0.5)',
};

let remaining = trimmed;

// Extract color (can be rgb(), rgba(), hsl(), hsla(), hex, or named color)
const colorMatch = remaining.match(
/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i,
);
if (colorMatch) {
shadow.color = colorMatch[0];
remaining = remaining.replace(colorMatch[0], '').trim();
}

// Parse remaining numeric values (offset-x offset-y blur-radius)
const numbers = remaining.match(/[+-]?\d*\.?\d+(?:px|em|rem|%)?/gi) || [];
const values = numbers.map((n) => parseFloat(n) || 0);

if (values.length >= 2) {
shadow.offsetX = values[0];
shadow.offsetY = values[1];

if (values.length >= 3) {
shadow.blurRadius = Math.max(0, values[2]);
}
}

shadows.push(shadow);
}

return shadows;
};
2 changes: 2 additions & 0 deletions packages/web-renderer/src/test/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {backgroundClipText3dTransform} from './fixtures/text/background-clip-tex
import {letterSpacing} from './fixtures/text/letter-spacing';
import {paragraphs} from './fixtures/text/paragraphs';
import {textFixture} from './fixtures/text/text';
import {textShadow} from './fixtures/text/text-shadow';
import {textTransform} from './fixtures/text/text-transform';
import {webkitTextFillColor} from './fixtures/text/webkit-text-fill-color';
import {threeDoverflow} from './fixtures/three-d-overflow';
Expand Down Expand Up @@ -135,6 +136,7 @@ export const Root: React.FC = () => {
<Composition {...webkitTextFillColor} />
<Composition {...backgroundClipText} />
<Composition {...backgroundClipText3dTransform} />
<Composition {...textShadow} />
<Composition {...whiteSpaceCollapsing} />
<Composition {...whiteSpaceCollapsing2} />
</Folder>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 85 additions & 0 deletions packages/web-renderer/src/test/fixtures/text/text-shadow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react';
import {AbsoluteFill} from 'remotion';

const Component: React.FC = () => {
return (
<AbsoluteFill
style={{
backgroundColor: 'white',
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
{/* Simple text shadow */}
<div
style={{
fontSize: 30,
fontWeight: 'bold',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.5)',
}}
>
Shadow
</div>

{/* Colored text shadow */}
<div
style={{
fontSize: 30,
fontWeight: 'bold',
color: 'blue',
textShadow: '3px 3px 0px red',
}}
>
Color
</div>

{/* Multiple text shadows */}
<div
style={{
fontSize: 30,
fontWeight: 'bold',
textShadow:
'1px 1px 2px red, 0 0 10px blue, 0 0 20px rgba(0, 0, 255, 0.3)',
}}
>
Multi
</div>

{/* Text shadow with no blur */}
<div
style={{
fontSize: 30,
fontWeight: 'bold',
textShadow: '2px 2px 0 black',
}}
>
Hard
</div>

{/* Text shadow with glow effect */}
<div
style={{
fontSize: 30,
fontWeight: 'bold',
color: 'white',
backgroundColor: '#333',
padding: 10,
textShadow: '0 0 10px white, 0 0 20px white',
}}
>
Glow
</div>
</AbsoluteFill>
);
};

export const textShadow = {
component: Component,
id: 'text-shadow',
width: 300,
height: 400,
fps: 25,
durationInFrames: 1,
} as const;
17 changes: 17 additions & 0 deletions packages/web-renderer/src/test/text-shadow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {test} from 'vitest';
import {renderStillOnWeb} from '../render-still-on-web';
import '../symbol-dispose';
import {textShadow} from './fixtures/text/text-shadow';
import {testImage} from './utils';

test('should render text-shadow', async () => {
const {blob} = await renderStillOnWeb({
licenseKey: 'free-license',
composition: textShadow,
frame: 0,
inputProps: {},
imageFormat: 'png',
});

await testImage({blob, testId: 'text-shadow', threshold: 0.01});
});
Loading