Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions src/css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {angle} from './types/angle';
import {image} from './types/image';
import {time} from './types/time';
import {opacity} from './property-descriptors/opacity';
import {filter} from './property-descriptors/filter';
import {textDecorationColor} from './property-descriptors/text-decoration-color';
import {textDecorationLine} from './property-descriptors/text-decoration-line';
import {isLengthPercentage, LengthPercentage, ZERO_LENGTH} from './types/length-percentage';
Expand Down Expand Up @@ -127,6 +128,7 @@ export class CSSParsedDeclaration {
marginBottom: CSSValue;
marginLeft: CSSValue;
opacity: ReturnType<typeof opacity.parse>;
filter: ReturnType<typeof filter.parse>;
overflowX: OVERFLOW;
overflowY: OVERFLOW;
overflowWrap: ReturnType<typeof overflowWrap.parse>;
Expand Down Expand Up @@ -195,6 +197,7 @@ export class CSSParsedDeclaration {
this.marginBottom = parse(context, marginBottom, declaration.marginBottom);
this.marginLeft = parse(context, marginLeft, declaration.marginLeft);
this.opacity = parse(context, opacity, declaration.opacity);
this.filter = parse(context, filter, declaration.filter);
const overflowTuple = parse(context, overflow, declaration.overflow);
this.overflowX = overflowTuple[0];
this.overflowY = overflowTuple[overflowTuple.length > 1 ? 1 : 0];
Expand Down
164 changes: 164 additions & 0 deletions src/css/property-descriptors/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {IPropertyListDescriptor, PropertyDescriptorParsingType} from '../IPropertyDescriptor';
import {CSSValue, isIdentToken} from '../syntax/parser';
import {DimensionToken, NumberValueToken, TokenType} from '../syntax/tokenizer';
import {Context} from '../../core/context';

export interface FilterFunction {
name: string;
values: number[];
}

export type Filter = FilterFunction[] | null;

export const filter: IPropertyListDescriptor<Filter> = {
name: 'filter',
initialValue: 'none',
prefix: true,
type: PropertyDescriptorParsingType.LIST,
parse: (_context: Context, tokens: CSSValue[]): Filter => {
if (tokens.length === 1 && isIdentToken(tokens[0]) && tokens[0].value === 'none') {
return null;
}

const filters: FilterFunction[] = [];

for (const token of tokens) {
if (token.type === TokenType.FUNCTION) {
const filterFunction = parseFilterFunction(token.name, token.values);
if (filterFunction) {
filters.push(filterFunction);
}
}
}

return filters.length > 0 ? filters : null;
}
};

const parseFilterFunction = (name: string, values: CSSValue[]): FilterFunction | null => {
const supportedFunctions = [
'blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale',
'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia'
];

if (!supportedFunctions.includes(name)) {
return null;
}

const parsedValues: number[] = [];

switch (name) {
case 'blur':
// blur(5px) - expects length value
if (values.length === 1) {
const value = parseLength(values[0]);
if (value !== null) {
parsedValues.push(value);
}
}
break;

case 'brightness':
case 'contrast':
case 'grayscale':
case 'invert':
case 'saturate':
case 'sepia':
// These functions expect percentage or number
if (values.length === 1) {
const value = parsePercentageOrNumber(values[0]);
if (value !== null) {
parsedValues.push(value);
}
}
break;

case 'hue-rotate':
// hue-rotate(90deg) - expects angle value
if (values.length === 1) {
const value = parseAngle(values[0]);
if (value !== null) {
parsedValues.push(value);
}
}
break;

case 'opacity':
// opacity(50%) - expects percentage or number
if (values.length === 1) {
const value = parsePercentageOrNumber(values[0]);
if (value !== null) {
parsedValues.push(Math.max(0, Math.min(1, value)));
}
}
break;

case 'drop-shadow':
// drop-shadow(2px 2px 4px rgba(0,0,0,0.5)) - expects offset-x, offset-y, blur-radius, color
// For now, we'll parse the first 3 values as lengths (x, y, blur) and ignore color
if (values.length >= 2) {
const x = parseLength(values[0]);
const y = parseLength(values[1]);
const blur = values.length > 2 ? parseLength(values[2]) : 0;

if (x !== null && y !== null && blur !== null) {
parsedValues.push(x, y, blur);
}
}
break;
}

return parsedValues.length > 0 ? { name, values: parsedValues } : null;
};

const parseLength = (value: CSSValue): number | null => {
if (value.type === TokenType.DIMENSION_TOKEN) {
const dimension = value as DimensionToken;
// Convert to pixels (simplified)
switch (dimension.unit) {
case 'px':
return dimension.number;
case 'em':
return dimension.number * 16; // Rough approximation
case 'rem':
return dimension.number * 16; // Rough approximation
default:
return dimension.number;
}
}
if (value.type === TokenType.NUMBER_TOKEN) {
return (value as NumberValueToken).number;
}
return null;
};

const parsePercentageOrNumber = (value: CSSValue): number | null => {
if (value.type === TokenType.PERCENTAGE_TOKEN) {
return (value as any).number / 100;
}
if (value.type === TokenType.NUMBER_TOKEN) {
return (value as NumberValueToken).number;
}
return null;
};

const parseAngle = (value: CSSValue): number | null => {
if (value.type === TokenType.DIMENSION_TOKEN) {
const dimension = value as DimensionToken;
// Convert to degrees
switch (dimension.unit) {
case 'deg':
return dimension.number;
case 'rad':
return dimension.number * 180 / Math.PI;
case 'turn':
return dimension.number * 360;
default:
return dimension.number;
}
}
if (value.type === TokenType.NUMBER_TOKEN) {
return (value as NumberValueToken).number;
}
return null;
};
103 changes: 97 additions & 6 deletions src/render/canvas/canvas-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {contentBox} from '../box-sizing';
import {CanvasElementContainer} from '../../dom/replaced-elements/canvas-element-container';
import {SVGElementContainer} from '../../dom/replaced-elements/svg-element-container';
import {ReplacedElementContainer} from '../../dom/replaced-elements';
import {EffectTarget, IElementEffect, isClipEffect, isOpacityEffect, isTransformEffect} from '../effects';
import {EffectTarget, IElementEffect, isClipEffect, isOpacityEffect, isTransformEffect, isFilterEffect} from '../effects';
import {Filter} from '../../css/property-descriptors/filter';
import {contains} from '../../core/bitwise';
import {calculateGradientDirection, calculateRadius, processColorStops} from '../../css/types/functions/gradient';
import {FIFTY_PERCENT, getAbsoluteValue} from '../../css/types/length-percentage';
Expand Down Expand Up @@ -118,6 +119,10 @@ export class CanvasRenderer extends Renderer {
this.ctx.clip();
}

if (isFilterEffect(effect)) {
this.applyCanvasFilter(effect.filter);
}

this._activeEffects.push(effect);
}

Expand Down Expand Up @@ -709,12 +714,29 @@ export class CanvasRenderer extends Renderer {

if (hasBackground || styles.boxShadow.length) {
this.ctx.save();
this.path(backgroundPaintingArea);
this.ctx.clip();

if (!isTransparent(styles.backgroundColor)) {

// For pure inline elements (not inline-block) with background color, render background for each text line separately
if (styles.display === DISPLAY.INLINE && !isTransparent(styles.backgroundColor) && paint.container.textNodes.length > 0) {
// Use text bounds instead of element bounds for inline elements
this.ctx.fillStyle = asString(styles.backgroundColor);
this.ctx.fill();
for (const textNode of paint.container.textNodes) {
for (const textBounds of textNode.textBounds) {
this.ctx.fillRect(
textBounds.bounds.left,
textBounds.bounds.top,
textBounds.bounds.width,
textBounds.bounds.height
);
}
}
} else {
this.path(backgroundPaintingArea);
this.ctx.clip();

if (!isTransparent(styles.backgroundColor)) {
this.ctx.fillStyle = asString(styles.backgroundColor);
this.ctx.fill();
}
}

await this.renderBackgroundImage(paint.container);
Expand Down Expand Up @@ -896,6 +918,75 @@ export class CanvasRenderer extends Renderer {
this.ctx.restore();
}

applyCanvasFilter(filter: Filter): void {
if (!filter) {
return;
}

// Build the filter string for canvas
const filterParts: string[] = [];

for (const filterFunction of filter) {
switch (filterFunction.name) {
case 'blur':
if (filterFunction.values.length >= 1) {
filterParts.push(`blur(${filterFunction.values[0]}px)`);
}
break;
case 'brightness':
if (filterFunction.values.length >= 1) {
filterParts.push(`brightness(${filterFunction.values[0]})`);
}
break;
case 'contrast':
if (filterFunction.values.length >= 1) {
filterParts.push(`contrast(${filterFunction.values[0]})`);
}
break;
case 'grayscale':
if (filterFunction.values.length >= 1) {
filterParts.push(`grayscale(${filterFunction.values[0]})`);
}
break;
case 'hue-rotate':
if (filterFunction.values.length >= 1) {
filterParts.push(`hue-rotate(${filterFunction.values[0]}deg)`);
}
break;
case 'invert':
if (filterFunction.values.length >= 1) {
filterParts.push(`invert(${filterFunction.values[0]})`);
}
break;
case 'opacity':
if (filterFunction.values.length >= 1) {
filterParts.push(`opacity(${filterFunction.values[0]})`);
}
break;
case 'saturate':
if (filterFunction.values.length >= 1) {
filterParts.push(`saturate(${filterFunction.values[0]})`);
}
break;
case 'sepia':
if (filterFunction.values.length >= 1) {
filterParts.push(`sepia(${filterFunction.values[0]})`);
}
break;
case 'drop-shadow':
if (filterFunction.values.length >= 3) {
const [x, y, blur] = filterFunction.values;
filterParts.push(`drop-shadow(${x}px ${y}px ${blur}px rgba(0,0,0,0.5))`);
}
break;
}
}

if (filterParts.length > 0) {
this.ctx.filter = filterParts.join(' ');
}
}

async render(element: ElementContainer): Promise<HTMLCanvasElement> {
if (this.options.backgroundColor) {
this.ctx.fillStyle = asString(this.options.backgroundColor);
Expand Down
12 changes: 11 additions & 1 deletion src/render/effects.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {Matrix} from '../css/property-descriptors/transform';
import {Filter} from '../css/property-descriptors/filter';
import {Path} from './path';

export const enum EffectType {
TRANSFORM = 0,
CLIP = 1,
OPACITY = 2
OPACITY = 2,
FILTER = 3
}

export const enum EffectTarget {
Expand Down Expand Up @@ -37,7 +39,15 @@ export class OpacityEffect implements IElementEffect {
constructor(readonly opacity: number) {}
}

export class FilterEffect implements IElementEffect {
readonly type: EffectType = EffectType.FILTER;
readonly target: number = EffectTarget.BACKGROUND_BORDERS | EffectTarget.CONTENT;

constructor(readonly filter: Filter) {}
}

export const isTransformEffect = (effect: IElementEffect): effect is TransformEffect =>
effect.type === EffectType.TRANSFORM;
export const isClipEffect = (effect: IElementEffect): effect is ClipEffect => effect.type === EffectType.CLIP;
export const isOpacityEffect = (effect: IElementEffect): effect is OpacityEffect => effect.type === EffectType.OPACITY;
export const isFilterEffect = (effect: IElementEffect): effect is FilterEffect => effect.type === EffectType.FILTER;
14 changes: 13 additions & 1 deletion src/render/stacking-context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import {ElementContainer, FLAGS} from '../dom/element-container';
import {contains} from '../core/bitwise';
import {BoundCurves, calculateBorderBoxPath, calculatePaddingBoxPath} from './bound-curves';
import {ClipEffect, EffectTarget, IElementEffect, isClipEffect, OpacityEffect, TransformEffect} from './effects';
import {
ClipEffect,
EffectTarget,
IElementEffect,
isClipEffect,
OpacityEffect,
TransformEffect,
FilterEffect
} from './effects';
import {OVERFLOW} from '../css/property-descriptors/overflow';
import {equalPath} from './path';
import {DISPLAY} from '../css/property-descriptors/display';
Expand Down Expand Up @@ -50,6 +58,10 @@ export class ElementPaint {
this.effects.push(new TransformEffect(offsetX, offsetY, matrix));
}

if (this.container.styles.filter !== null) {
this.effects.push(new FilterEffect(this.container.styles.filter));
}

if (this.container.styles.overflowX !== OVERFLOW.VISIBLE) {
const borderBox = calculateBorderBoxPath(this.curves);
const paddingBox = calculatePaddingBoxPath(this.curves);
Expand Down
Loading