diff --git a/src/css/index.ts b/src/css/index.ts index b338871ec..f47e2ed04 100644 --- a/src/css/index.ts +++ b/src/css/index.ts @@ -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'; @@ -127,6 +128,7 @@ export class CSSParsedDeclaration { marginBottom: CSSValue; marginLeft: CSSValue; opacity: ReturnType; + filter: ReturnType; overflowX: OVERFLOW; overflowY: OVERFLOW; overflowWrap: ReturnType; @@ -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]; diff --git a/src/css/property-descriptors/filter.ts b/src/css/property-descriptors/filter.ts new file mode 100644 index 000000000..82ba0f7a1 --- /dev/null +++ b/src/css/property-descriptors/filter.ts @@ -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 = { + 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; +}; diff --git a/src/render/canvas/canvas-renderer.ts b/src/render/canvas/canvas-renderer.ts index 6efb648bf..7670d0bfc 100644 --- a/src/render/canvas/canvas-renderer.ts +++ b/src/render/canvas/canvas-renderer.ts @@ -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'; @@ -118,6 +119,10 @@ export class CanvasRenderer extends Renderer { this.ctx.clip(); } + if (isFilterEffect(effect)) { + this.applyCanvasFilter(effect.filter); + } + this._activeEffects.push(effect); } @@ -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); @@ -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 { if (this.options.backgroundColor) { this.ctx.fillStyle = asString(this.options.backgroundColor); diff --git a/src/render/effects.ts b/src/render/effects.ts index d7d1b9504..d255887c6 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -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 { @@ -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; diff --git a/src/render/stacking-context.ts b/src/render/stacking-context.ts index c5ac088b0..31c2225cc 100644 --- a/src/render/stacking-context.ts +++ b/src/render/stacking-context.ts @@ -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'; @@ -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); diff --git a/tests/reftests/filter.html b/tests/reftests/filter.html new file mode 100644 index 000000000..692c4d8d6 --- /dev/null +++ b/tests/reftests/filter.html @@ -0,0 +1,129 @@ + + + + CSS Filter Effects Test + + + + + +

CSS Filter Effects Test

+ +
+
Original
+
+ +
+
Blur 3px
+
+ +
+
Brightness 150%
+
+ +
+
Contrast 200%
+
+ +
+
Grayscale 80%
+
+ +
+
Hue Rotate 90deg
+
+ +
+
Invert 75%
+
+ +
+
Opacity 50%
+
+ +
+
Saturate 200%
+
+ +
+
Sepia 100%
+
+ +
+
Drop Shadow
+
+ +
+
Multiple Filters
+
+ +
+
Complex Filters
+
+ + diff --git a/tests/reftests/text/child-textnodes.html b/tests/reftests/text/child-textnodes.html index c26dcd5c9..ac3a3cb1d 100644 --- a/tests/reftests/text/child-textnodes.html +++ b/tests/reftests/text/child-textnodes.html @@ -7,6 +7,7 @@ @@ -21,5 +31,7 @@ Some inline text followed by text in span followed by more inline text.

Then a block level element.

Then more inline text. + +
When the inline text has some background, it should not be overflowed. This is another test, with inline block with 15px padding crossing the line.