From 6f4c23fe3951511a6f6ae82fe9b157649813ca1c Mon Sep 17 00:00:00 2001 From: Ovilia Date: Fri, 4 Jul 2025 19:29:15 +0800 Subject: [PATCH 1/3] fix: inline block with background color --- src/render/canvas/canvas-renderer.ts | 27 +++++++++++++++++++----- tests/reftests/text/child-textnodes.html | 12 +++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/render/canvas/canvas-renderer.ts b/src/render/canvas/canvas-renderer.ts index 6efb648bf..fdc4e964d 100644 --- a/src/render/canvas/canvas-renderer.ts +++ b/src/render/canvas/canvas-renderer.ts @@ -709,12 +709,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); 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.
From 799a755378ab408f246d28e59beb21a3918da1e1 Mon Sep 17 00:00:00 2001 From: Ovilia Date: Fri, 4 Jul 2025 19:29:49 +0800 Subject: [PATCH 2/3] fix: support CSS filter --- src/css/index.ts | 3 + src/css/property-descriptors/filter.ts | 164 +++++++++++++++++++++++++ src/render/canvas/canvas-renderer.ts | 76 +++++++++++- src/render/effects.ts | 12 +- src/render/stacking-context.ts | 6 +- tests/reftests/filter.html | 129 +++++++++++++++++++ 6 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 src/css/property-descriptors/filter.ts create mode 100644 tests/reftests/filter.html 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 fdc4e964d..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); } @@ -913,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..8534390dc 100644 --- a/src/render/stacking-context.ts +++ b/src/render/stacking-context.ts @@ -1,7 +1,7 @@ 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 +50,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
+
+ + From 207ed9c9fca4bbbf16cb5b2af2a2074e9c3f9adb Mon Sep 17 00:00:00 2001 From: Ovilia Date: Fri, 4 Jul 2025 19:39:57 +0800 Subject: [PATCH 3/3] style: fix lint --- src/render/stacking-context.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/render/stacking-context.ts b/src/render/stacking-context.ts index 8534390dc..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, FilterEffect} 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';