diff --git a/common/changes/@visactor/vrender-core/feat-enhance-background_2026-03-31-08-53.json b/common/changes/@visactor/vrender-core/feat-enhance-background_2026-03-31-08-53.json new file mode 100644 index 000000000..e9fd163c9 --- /dev/null +++ b/common/changes/@visactor/vrender-core/feat-enhance-background_2026-03-31-08-53.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: enhance background handling in rendering\n\n", + "type": "none", + "packageName": "@visactor/vrender-core" + } + ], + "packageName": "@visactor/vrender-core", + "email": "dingling112@gmail.com" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender/feat-enhance-background_2026-03-31-08-53.json b/common/changes/@visactor/vrender/feat-enhance-background_2026-03-31-08-53.json new file mode 100644 index 000000000..0e6877c2c --- /dev/null +++ b/common/changes/@visactor/vrender/feat-enhance-background_2026-03-31-08-53.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: enhance background handling in rendering\n\n", + "type": "none", + "packageName": "@visactor/vrender" + } + ], + "packageName": "@visactor/vrender", + "email": "dingling112@gmail.com" +} \ No newline at end of file diff --git a/packages/vrender-animate/src/executor/utils.ts b/packages/vrender-animate/src/executor/utils.ts index 4edf6e3be..f0d4d5c53 100644 --- a/packages/vrender-animate/src/executor/utils.ts +++ b/packages/vrender-animate/src/executor/utils.ts @@ -18,13 +18,19 @@ export function getCustomType(custom: any): number { if (!custom.prototype) { return 2; } - // 检查构造函数是否是它自己(ES5类) + // 检查构造函数是否是它自己 if (custom.prototype.constructor === custom) { - // 检查prototype是否可写,类的prototype是不可写的 + // 检查prototype是否可写,原生class的prototype是不可写的 const descriptor = Object.getOwnPropertyDescriptor(custom, 'prototype'); if (descriptor && !descriptor.writable) { return 1; } + // Babel/TypeScript 转译后的类,prototype 可写但仍有方法在 prototype 上 + // 通过检查 prototype 上是否有除 constructor 之外的自有属性来判断 + const protoKeys = Object.getOwnPropertyNames(custom.prototype); + if (protoKeys.length > 1) { + return 1; + } } return 2; } diff --git a/packages/vrender-core/__tests__/background/background-image-layout.test.ts b/packages/vrender-core/__tests__/background/background-image-layout.test.ts new file mode 100644 index 000000000..21d5cf9ff --- /dev/null +++ b/packages/vrender-core/__tests__/background/background-image-layout.test.ts @@ -0,0 +1,295 @@ +import { DefaultAttribute } from '../../src/graphic/config'; +import { + DefaultBaseBackgroundRenderContribution, + drawBackgroundImage +} from '../../src/render/contributions/render/contributions/base-contribution-render'; +import { DefaultGroupBackgroundRenderContribution } from '../../src/render/contributions/render/contributions/group-contribution-render'; +import { DefaultTextBackgroundRenderContribution } from '../../src/render/contributions/render/contributions/text-contribution-render'; +import { DefaultDrawContribution } from '../../src/render/contributions/render/draw-contribution'; + +function createBounds(x: number, y: number, width: number, height: number) { + return { + x1: x, + y1: y, + x2: x + width, + y2: y + height, + width: () => width, + height: () => height + }; +} + +function createImage(width: number, height: number) { + return { width, height }; +} + +function createContext() { + return { + dpr: 1, + globalAlpha: 1, + drawImage: jest.fn(), + createPattern: jest.fn(), + translate: jest.fn(), + fillRect: jest.fn(), + clearRect: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + clip: jest.fn(), + rect: jest.fn(), + beginPath: jest.fn(), + highPerformanceSave: jest.fn(), + highPerformanceRestore: jest.fn(), + setCommonStyle: jest.fn(), + setTransformFromMatrix: jest.fn(), + setTransformForCurrent: jest.fn(), + currentMatrix: { clone: () => ({}) } + }; +} + +class TestBaseBackgroundRenderContribution extends DefaultBaseBackgroundRenderContribution { + capturedParams: any; + capturedBounds: any; + + protected doDrawImage(context: any, data: any, b: any, params: any): void { + this.capturedBounds = b; + this.capturedParams = params; + } +} + +class TestGroupBackgroundRenderContribution extends DefaultGroupBackgroundRenderContribution { + capturedParams: any; + + protected doDrawImage(context: any, data: any, b: any, params: any): void { + this.capturedParams = params; + } +} + +class TestTextBackgroundRenderContribution extends DefaultTextBackgroundRenderContribution { + capturedParams: any; + capturedBounds: any; + + protected doDrawImage(context: any, data: any, b: any, params: any): void { + this.capturedBounds = b; + this.capturedParams = params; + } +} + +class TestDrawContribution extends DefaultDrawContribution { + constructor() { + super([], { getContributions: (): any[] => [] } as any); + } + + clear(renderService: any, context: any, drawContext: any) { + this.clearScreen(renderService, context, drawContext); + } +} + +describe('background image layout', () => { + test('supports cover layout with centered crop', () => { + const context = createContext(); + + drawBackgroundImage(context as any, createImage(200, 100), createBounds(0, 0, 100, 100) as any, { + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: true, + backgroundPosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), -50, 0, 200, 100); + }); + + test('supports contain layout with anchor positioning', () => { + const context = createContext(); + + drawBackgroundImage(context as any, createImage(200, 100), createBounds(0, 0, 100, 100) as any, { + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: true, + backgroundSizing: 'contain', + backgroundPosition: 'bottom-right' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 50, 100, 50); + }); + + test('supports fill layout with scaling and centered alignment', () => { + const context = createContext(); + + drawBackgroundImage(context as any, createImage(20, 10), createBounds(0, 0, 100, 100) as any, { + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: false, + backgroundSizing: 'fill', + backgroundScale: 0.5, + backgroundPosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 25, 25, 50, 50); + }); + + test('supports auto layout with percentage positioning', () => { + const context = createContext(); + + drawBackgroundImage(context as any, createImage(20, 10), createBounds(0, 0, 100, 100) as any, { + backgroundMode: 'no-repeat', + backgroundFit: false, + backgroundKeepAspectRatio: true, + backgroundSizing: 'auto', + backgroundPosition: ['50%', '100%'] + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 40, 90, 20, 10); + }); + + test('base contribution resolves wrapped background images by inner resource key', () => { + const contribution = new TestBaseBackgroundRenderContribution(); + const context = createContext(); + + contribution.drawShape( + { + attribute: { + background: { background: 'image-key' }, + backgroundSizing: 'contain', + backgroundPosition: 'center', + backgroundClip: true + }, + backgroundImg: true, + resources: new Map([['image-key', { state: 'success', data: createImage(10, 10) }]]), + transMatrix: { onlyTranslate: () => true }, + AABBBounds: createBounds(0, 0, 100, 100) + } as any, + context as any, + 0, + 0, + false, + false, + false, + false, + DefaultAttribute as any, + {} as any + ); + + expect(contribution.capturedParams.backgroundSizing).toBe('contain'); + expect(contribution.capturedParams.backgroundPosition).toBe('center'); + expect(context.clip).toHaveBeenCalled(); + }); + + test('group contribution forwards sizing and position to shared renderer', () => { + const contribution = new TestGroupBackgroundRenderContribution(); + const context = createContext(); + + contribution.drawShape( + { + attribute: { + background: 'image-key', + backgroundSizing: 'contain', + backgroundPosition: 'bottom-right', + backgroundClip: true + }, + backgroundImg: true, + resources: new Map([['image-key', { state: 'success', data: createImage(10, 10) }]]), + parent: { globalTransMatrix: {} }, + transMatrix: { onlyTranslate: () => true }, + AABBBounds: createBounds(0, 0, 100, 100) + } as any, + context as any, + 0, + 0, + false, + false, + false, + false, + DefaultAttribute as any, + {} as any + ); + + expect(contribution.capturedParams.backgroundSizing).toBe('contain'); + expect(contribution.capturedParams.backgroundPosition).toBe('bottom-right'); + expect(context.clip).toHaveBeenCalled(); + }); + + test('text contribution respects wrapped background bounds and passes layout options', () => { + const contribution = new TestTextBackgroundRenderContribution(); + const context = createContext(); + + contribution.drawShape( + { + type: 'text', + attribute: { + background: { + background: 'image-key', + x: 10, + y: 20, + width: 30, + height: 40, + dx: 5, + dy: 6 + }, + backgroundSizing: 'auto', + backgroundPosition: 'bottom-right', + backgroundClip: true, + backgroundCornerRadius: 0 + }, + backgroundImg: true, + resources: new Map([['image-key', { state: 'success', data: createImage(10, 10) }]]), + parent: { globalTransMatrix: {} }, + transMatrix: { onlyTranslate: () => true }, + AABBBounds: createBounds(0, 0, 100, 100) + } as any, + context as any, + 0, + 0, + false, + false, + false, + false, + DefaultAttribute as any, + {} as any + ); + + expect(contribution.capturedBounds.x1).toBe(15); + expect(contribution.capturedBounds.y1).toBe(26); + expect(contribution.capturedBounds.width()).toBe(30); + expect(contribution.capturedBounds.height()).toBe(40); + expect(contribution.capturedParams.backgroundSizing).toBe('auto'); + expect(contribution.capturedParams.backgroundPosition).toBe('bottom-right'); + expect(context.clip).toHaveBeenCalled(); + }); + + test('stage clear screen uses shared background layout options', () => { + const drawContribution = new TestDrawContribution(); + const context = createContext(); + const stage = { + backgroundImg: true, + resources: new Map([['image-key', { state: 'success', data: createImage(200, 100) }]]), + attribute: { + opacity: 0.5, + backgroundOpacity: 0.2, + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: true, + backgroundSizing: 'cover', + backgroundPosition: 'center', + backgroundScale: 1, + backgroundOffsetX: 0, + backgroundOffsetY: 0 + }, + hooks: { + afterClearRect: { + call: jest.fn() + } + } + }; + + drawContribution.clear( + { drawParams: { stage } } as any, + context as any, + { + clear: { background: 'image-key' }, + viewBox: createBounds(0, 0, 100, 100) + } as any + ); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), -50, 0, 200, 100); + expect(context.globalAlpha).toBe(0.1); + }); +}); diff --git a/packages/vrender-core/src/core/layer.ts b/packages/vrender-core/src/core/layer.ts index 39db24ea5..e43dde2e0 100644 --- a/packages/vrender-core/src/core/layer.ts +++ b/packages/vrender-core/src/core/layer.ts @@ -38,7 +38,7 @@ export class Layer extends Group implements ILayer { // 混合模式,用于多图层混合 declare blendMode: BlendMode; - declare background: string; + declare background: ILayer['background']; declare opacity: number; declare layer: this; diff --git a/packages/vrender-core/src/core/stage.ts b/packages/vrender-core/src/core/stage.ts index 396018041..49e76d61d 100644 --- a/packages/vrender-core/src/core/stage.ts +++ b/packages/vrender-core/src/core/stage.ts @@ -1,7 +1,8 @@ import type { IAABBBounds, IBounds, IBoundsLike, IMatrix } from '@visactor/vutils'; -import { Bounds, Point, isString } from '@visactor/vutils'; +import { Bounds, Point, isBase64, isObject, isValidUrl } from '@visactor/vutils'; import type { IGraphic, + IGraphicAttribute, IExportType, IStage, IStageParams, @@ -74,7 +75,7 @@ export class Stage extends Group implements IStage { declare state: IStageState; - private _background: string | IColor; + private _background: IGraphicAttribute['background'] | IColor; protected nextFrameRenderLayerSet: Set; protected willNextFrameRender: boolean; protected _cursor: string; @@ -157,11 +158,12 @@ export class Stage extends Group implements IStage { set dpr(r: number) { this.setDpr(r); } - get background(): string | IColor { + get background(): IGraphicAttribute['background'] | IColor { return this._background ?? DefaultConfig.BACKGROUND; } - set background(b: string | IColor) { + set background(b: IGraphicAttribute['background'] | IColor) { this._background = b; + this.syncBackgroundImage(b); } get defaultLayer(): ILayer { return this.at(0) as unknown as ILayer; @@ -224,6 +226,31 @@ export class Stage extends Group implements IStage { this._ticker.on('tick', this.afterTickCb); } + protected syncBackgroundImage(background: IGraphicAttribute['background'] | IColor) { + const source = (background as any)?.background ?? background; + this.backgroundImg = false; + if (this.isImageBackgroundSource(source)) { + this.loadImage(source, true); + } + } + + protected isImageBackgroundSource(source: any): boolean { + if (!source) { + return false; + } + if (typeof source === 'string') { + return source.startsWith(' = { keepStrokeScale: false, clipConfig: null, roughStyle: null, + backgroundSizing: undefined, ...DefaultDebugAttribute, ...DefaultStyle, ...DefaultTransform diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index bf48f41ea..44eae088d 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -1477,7 +1477,14 @@ export abstract class Graphic = Partial = Partial): NonNullable< + IGraphicAttribute['backgroundSizing'] +> { + if (backgroundSizing) { + return backgroundSizing; + } + if (backgroundFit) { + return backgroundKeepAspectRatio ? 'cover' : 'fill'; + } + return 'auto'; +} + +function isPercentageValue(value: string): boolean { + return /^-?\d+(\.\d+)?%$/.test(value); +} + +function parsePositionToken( + value: BackgroundPositionValue, + remainSpace: number, + startKeyword: 'left' | 'top', + centerKeyword: 'center', + endKeyword: 'right' | 'bottom' +): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + const normalizedValue = `${value ?? ''}`.trim().toLowerCase(); + + if (!normalizedValue || normalizedValue === startKeyword) { + return 0; + } + if (normalizedValue === centerKeyword) { + return remainSpace / 2; + } + if (normalizedValue === endKeyword) { + return remainSpace; + } + if (isPercentageValue(normalizedValue)) { + return (remainSpace * parseFloat(normalizedValue)) / 100; + } + + const parsedValue = Number(normalizedValue); + if (Number.isFinite(parsedValue)) { + return parsedValue; + } + + return 0; +} + +function normalizeBackgroundPosition( + position?: IGraphicAttribute['backgroundPosition'] +): [BackgroundPositionValue, BackgroundPositionValue] { + if (Array.isArray(position)) { + return [position[0] ?? 'left', position[1] ?? 'top']; + } + + const normalizedPosition = `${position ?? 'top-left'}`.trim().toLowerCase().replace(/-/g, ' '); + const tokens = normalizedPosition.split(/\s+/).filter(Boolean); + + if (tokens.length === 0) { + return ['left', 'top']; + } + + if (tokens.length === 1) { + const token = tokens[0]; + if (token === 'center') { + return ['center', 'center']; } - ): void { - const { - backgroundMode, + if (verticalPositionKeywords.has(token)) { + return ['center', token]; + } + return [token, 'center']; + } + + let horizontal: BackgroundPositionValue | undefined; + let vertical: BackgroundPositionValue | undefined; + const genericTokens: BackgroundPositionValue[] = []; + + for (let i = 0; i < 2; i++) { + const token = tokens[i]; + if (token === 'left' || token === 'right') { + horizontal = token; + continue; + } + if (token === 'top' || token === 'bottom') { + vertical = token; + continue; + } + genericTokens.push(token); + } + + if (horizontal == null && genericTokens.length) { + horizontal = genericTokens.shift(); + } + if (vertical == null && genericTokens.length) { + vertical = genericTokens.shift(); + } + + return [horizontal ?? 'left', vertical ?? 'top']; +} + +export function resolveBackgroundPosition( + position: IGraphicAttribute['backgroundPosition'], + remainWidth: number, + remainHeight: number +) { + const [horizontalPosition, verticalPosition] = normalizeBackgroundPosition(position); + return { + x: parsePositionToken(horizontalPosition, remainWidth, 'left', 'center', 'right'), + y: parsePositionToken(verticalPosition, remainHeight, 'top', 'center', 'bottom') + }; +} + +export function drawBackgroundImage( + context: IContext2d, + data: any, + b: IBounds, + params: IBackgroundImageDrawParams +): void { + const { + backgroundMode, + backgroundFit, + backgroundKeepAspectRatio, + backgroundSizing, + backgroundScale = 1, + backgroundOffsetX = 0, + backgroundOffsetY = 0, + backgroundPosition = 'top-left' + } = params; + const targetW = b.width(); + const targetH = b.height(); + let w = targetW; + let h = targetH; + + if (!data?.width || !data?.height || targetW <= 0 || targetH <= 0) { + return; + } + + if (backgroundMode === 'no-repeat') { + const sizing = resolveBackgroundSizing({ backgroundFit, backgroundKeepAspectRatio, - backgroundScale = 1, - backgroundOffsetX = 0, - backgroundOffsetY = 0 - } = params; - const targetW = b.width(); - const targetH = b.height(); - let w = targetW; - let h = targetH; - if (backgroundMode === 'no-repeat') { - if (backgroundFit) { - if (!backgroundKeepAspectRatio) { - context.drawImage(data, b.x1, b.y1, b.width(), b.height()); - } else { - const maxScale = Math.max(targetW / data.width, targetH / data.height); - context.drawImage( - data, - b.x1 + backgroundOffsetX, - b.y1 + backgroundOffsetY, - data.width * maxScale * backgroundScale, - data.height * maxScale * backgroundScale - ); - } - } else { - const resW = data.width * backgroundScale; - const resH = data.height * backgroundScale; - context.drawImage(data, b.x1 + backgroundOffsetX, b.y1 + backgroundOffsetY, resW, resH); - } - } else { - // debugger; - // TODO 考虑缓存 - if (backgroundFit && backgroundMode !== 'repeat' && (data.width || data.height)) { - const resW = data.width; - const resH = data.height; - - if (backgroundMode === 'repeat-x') { - // 高度适应 - const ratio = targetH / resH; - w = resW * ratio; - h = targetH; - } else if (backgroundMode === 'repeat-y') { - // 宽度适应 - const ratio = targetW / resW; - h = resH * ratio; - w = targetW; - } - - const dpr = context.dpr; - const canvas = canvasAllocate.allocate({ width: w, height: h, dpr }); - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.inuse = true; - ctx.clearMatrix(); - ctx.setTransformForCurrent(true); - ctx.clearRect(0, 0, w, h); - ctx.drawImage(data, 0, 0, w, h); - data = canvas.nativeCanvas; - } - canvasAllocate.free(canvas); - } - const dpr = context.dpr; - const pattern = context.createPattern(data, backgroundMode); - pattern.setTransform && pattern.setTransform(new DOMMatrix([1 / dpr, 0, 0, 1 / dpr, 0, 0])); - context.fillStyle = pattern; - context.translate(b.x1, b.y1); - context.fillRect(0, 0, targetW, targetH); - context.translate(-b.x1, -b.y1); + backgroundSizing + }); + let drawWidth = data.width; + let drawHeight = data.height; + + if (sizing === 'cover' || sizing === 'contain') { + const scale = + sizing === 'cover' + ? Math.max(targetW / data.width, targetH / data.height) + : Math.min(targetW / data.width, targetH / data.height); + drawWidth = data.width * scale; + drawHeight = data.height * scale; + } else if (sizing === 'fill') { + drawWidth = targetW; + drawHeight = targetH; } + + drawWidth *= backgroundScale; + drawHeight *= backgroundScale; + + const { x, y } = resolveBackgroundPosition(backgroundPosition, targetW - drawWidth, targetH - drawHeight); + context.drawImage(data, b.x1 + x + backgroundOffsetX, b.y1 + y + backgroundOffsetY, drawWidth, drawHeight); + return; } -} -export const defaultBaseBackgroundRenderContribution = new DefaultBaseBackgroundRenderContribution(); + // TODO 考虑缓存 + if (backgroundFit && backgroundMode !== 'repeat' && (data.width || data.height)) { + const resW = data.width; + const resH = data.height; + + if (backgroundMode === 'repeat-x') { + // 高度适应 + const ratio = targetH / resH; + w = resW * ratio; + h = targetH; + } else if (backgroundMode === 'repeat-y') { + // 宽度适应 + const ratio = targetW / resW; + h = resH * ratio; + w = targetW; + } + + const dpr = context.dpr; + const canvas = canvasAllocate.allocate({ width: w, height: h, dpr }); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.inuse = true; + ctx.clearMatrix(); + ctx.setTransformForCurrent(true); + ctx.clearRect(0, 0, w, h); + ctx.drawImage(data, 0, 0, w, h); + data = canvas.nativeCanvas; + } + canvasAllocate.free(canvas); + } + const dpr = context.dpr; + const pattern = context.createPattern(data, backgroundMode); + pattern.setTransform && pattern.setTransform(new DOMMatrix([1 / dpr, 0, 0, 1 / dpr, 0, 0])); + context.fillStyle = pattern; + context.translate(b.x1, b.y1); + context.fillRect(0, 0, targetW, targetH); + context.translate(-b.x1, -b.y1); +} export interface IInteractiveSubRenderContribution { render: ( diff --git a/packages/vrender-core/src/render/contributions/render/contributions/group-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/group-contribution-render.ts index b9e526570..dbd1a61a9 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/group-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/group-contribution-render.ts @@ -6,7 +6,7 @@ import type { IGroupRenderContribution, IDrawContext } from '../../../../interface'; -import { DefaultBaseBackgroundRenderContribution } from './base-contribution-render'; +import { DefaultBaseBackgroundRenderContribution, getBackgroundImage } from './base-contribution-render'; import { BaseRenderContributionTime } from '../../../../common/enums'; export class DefaultGroupBackgroundRenderContribution @@ -31,20 +31,25 @@ export class DefaultGroupBackgroundRenderContribution ) { const { background, + backgroundOpacity = graphicAttribute.backgroundOpacity, + opacity = graphicAttribute.opacity, backgroundMode = graphicAttribute.backgroundMode, backgroundFit = graphicAttribute.backgroundFit, backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio, + backgroundSizing = graphicAttribute.backgroundSizing, backgroundScale = graphicAttribute.backgroundScale, backgroundOffsetX = graphicAttribute.backgroundOffsetX, - backgroundOffsetY = graphicAttribute.backgroundOffsetY + backgroundOffsetY = graphicAttribute.backgroundOffsetY, + backgroundClip = graphicAttribute.backgroundClip, + backgroundPosition = graphicAttribute.backgroundPosition } = graphic.attribute; if (!background) { return; } if (graphic.backgroundImg && graphic.resources) { - const res = graphic.resources.get(background as any); - if (res.state !== 'success' || !res.data) { + const res = graphic.resources.get(getBackgroundImage(background) as any); + if (!res || res.state !== 'success' || !res.data) { return; } @@ -52,18 +57,23 @@ export class DefaultGroupBackgroundRenderContribution context.setTransformFromMatrix(graphic.parent.globalTransMatrix, true); const b = graphic.AABBBounds; + context.globalAlpha = backgroundOpacity * opacity; + backgroundClip && context.clip(); this.doDrawImage(context, res.data, b, { backgroundMode, backgroundFit, backgroundKeepAspectRatio, + backgroundSizing, backgroundScale, backgroundOffsetX, - backgroundOffsetY + backgroundOffsetY, + backgroundPosition }); context.highPerformanceRestore(); context.setTransformForCurrent(); } else { context.highPerformanceSave(); + context.globalAlpha = backgroundOpacity * opacity; context.fillStyle = background as string; context.fill(); context.highPerformanceRestore(); diff --git a/packages/vrender-core/src/render/contributions/render/contributions/text-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/text-contribution-render.ts index 677c42866..8db48f8a3 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/text-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/text-contribution-render.ts @@ -35,9 +35,18 @@ export class DefaultTextBackgroundRenderContribution strokeCb?: (ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute) => boolean ) { const { + backgroundOpacity = graphicAttribute.backgroundOpacity, + opacity = graphicAttribute.opacity, backgroundMode = graphicAttribute.backgroundMode, backgroundFit = graphicAttribute.backgroundFit, - backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio + backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio, + backgroundSizing = graphicAttribute.backgroundSizing, + backgroundScale = graphicAttribute.backgroundScale, + backgroundOffsetX = graphicAttribute.backgroundOffsetX, + backgroundOffsetY = graphicAttribute.backgroundOffsetY, + backgroundPosition = graphicAttribute.backgroundPosition, + backgroundClip = graphicAttribute.backgroundClip, + backgroundCornerRadius = graphicAttribute.backgroundCornerRadius } = graphic.attribute; let { background } = graphic.attribute; if (!background) { @@ -61,22 +70,22 @@ export class DefaultTextBackgroundRenderContribution }; save(); let b: IAABBBounds; - const shouldReCalBounds = isObject(background) && (background as any).background; + const backgroundConfig = isObject(background) && (background as any).background ? (background as any) : null; const onlyTranslate = graphic.transMatrix.onlyTranslate(); - if (shouldReCalBounds) { + if (backgroundConfig) { const _b = graphic.AABBBounds; - const x = ((background as any).x ?? _b.x1) + ((background as any).dx ?? 0); - const y = ((background as any).y ?? _b.y1) + ((background as any).dy ?? 0); - const w = (background as any).width ?? _b.width(); - const h = (background as any).height ?? _b.height(); + const x = (backgroundConfig.x ?? _b.x1) + (backgroundConfig.dx ?? 0); + const y = (backgroundConfig.y ?? _b.y1) + (backgroundConfig.dy ?? 0); + const w = backgroundConfig.width ?? _b.width(); + const h = backgroundConfig.height ?? _b.height(); b = boundsAllocate.allocate(x, y, x + w, y + h); - background = (background as any).background; + background = backgroundConfig.background; if (!onlyTranslate) { const w = b.width(); const h = b.height(); b.set( - ((background as any).x ?? 0) + ((background as any).dx ?? 0), - ((background as any).y ?? 0) + ((background as any).dy ?? 0), + (backgroundConfig.x ?? 0) + (backgroundConfig.dx ?? 0), + (backgroundConfig.y ?? 0) + (backgroundConfig.dy ?? 0), w, h ); @@ -90,7 +99,7 @@ export class DefaultTextBackgroundRenderContribution if (graphic.backgroundImg && graphic.resources) { const res = graphic.resources.get(background as any); - if (res.state !== 'success' || !res.data) { + if (!res || res.state !== 'success' || !res.data) { restore(); return; } @@ -105,15 +114,32 @@ export class DefaultTextBackgroundRenderContribution context.setTransformFromMatrix(graphic.parent.globalTransMatrix, true); } - context.setCommonStyle(graphic, graphic.attribute, x, y, graphicAttribute); - this.doDrawImage(context, res.data, b, { backgroundMode, backgroundFit, backgroundKeepAspectRatio }); + context.globalAlpha = backgroundOpacity * opacity; + if (backgroundClip) { + context.beginPath(); + if (backgroundCornerRadius) { + createRectPath(context, b.x1, b.y1, b.width(), b.height(), backgroundCornerRadius, true); + } else { + context.rect(b.x1, b.y1, b.width(), b.height()); + } + context.clip(); + } + this.doDrawImage(context, res.data, b, { + backgroundMode, + backgroundFit, + backgroundKeepAspectRatio, + backgroundSizing, + backgroundScale, + backgroundOffsetX, + backgroundOffsetY, + backgroundPosition + }); context.highPerformanceRestore(); context.setTransformForCurrent(); } else { - const { backgroundCornerRadius, backgroundOpacity = 1 } = graphic.attribute; context.highPerformanceSave(); context.setCommonStyle(graphic, graphic.attribute, x, y, graphicAttribute); - context.globalAlpha = backgroundOpacity; + context.globalAlpha = backgroundOpacity * opacity; context.fillStyle = background as string; if (backgroundCornerRadius) { // 测试后,cache对于重绘性能提升不大,但是在首屏有一定性能损耗,因此rect不再使用cache @@ -125,7 +151,7 @@ export class DefaultTextBackgroundRenderContribution context.highPerformanceRestore(); } - if (shouldReCalBounds) { + if (backgroundConfig) { boundsAllocate.free(b); } restore(); diff --git a/packages/vrender-core/src/render/contributions/render/draw-contribution.ts b/packages/vrender-core/src/render/contributions/render/draw-contribution.ts index 59b6b1248..bc2fc5611 100644 --- a/packages/vrender-core/src/render/contributions/render/draw-contribution.ts +++ b/packages/vrender-core/src/render/contributions/render/draw-contribution.ts @@ -27,6 +27,7 @@ import type { ILayerService } from '../../../interface/core'; import { boundsAllocate } from '../../../allocator/bounds-allocate'; import { matrixAllocate } from '../../../allocator/matrix-allocate'; import { application } from '../../../application'; +import { drawBackgroundImage, getBackgroundImage } from './contributions/base-contribution-render'; /** * 默认的渲染contribution,基于树状结构针对图元的渲染 @@ -475,16 +476,31 @@ export class DefaultDrawContribution implements IDrawContribution { renderService.drawParams.stage.hooks.afterClearRect.call(renderService.drawParams); } const stage = renderService.drawParams?.stage; - stage && (context.globalAlpha = (stage as any).attribute.opacity ?? 1); + if (stage) { + context.globalAlpha = + ((stage as any).attribute.opacity ?? 1) * ((stage as any).attribute.backgroundOpacity ?? 1); + } if (stage && (stage as any).backgroundImg && (stage as any).resources) { - const res = (stage as any).resources.get(clear); + const res = (stage as any).resources.get(getBackgroundImage(clear)); if (res && res.state === 'success' && res.data) { - context.drawImage(res.data, x, y, width, height); + const backgroundBounds = boundsAllocate.allocate(x, y, x + width, y + height); + drawBackgroundImage(context, res.data, backgroundBounds, { + backgroundMode: (stage as any).attribute.backgroundMode ?? DefaultAttribute.backgroundMode, + backgroundFit: (stage as any).attribute.backgroundFit ?? DefaultAttribute.backgroundFit, + backgroundKeepAspectRatio: + (stage as any).attribute.backgroundKeepAspectRatio ?? DefaultAttribute.backgroundKeepAspectRatio, + backgroundSizing: (stage as any).attribute.backgroundSizing, + backgroundScale: (stage as any).attribute.backgroundScale ?? DefaultAttribute.backgroundScale, + backgroundOffsetX: (stage as any).attribute.backgroundOffsetX ?? DefaultAttribute.backgroundOffsetX, + backgroundOffsetY: (stage as any).attribute.backgroundOffsetY ?? DefaultAttribute.backgroundOffsetY, + backgroundPosition: (stage as any).attribute.backgroundPosition ?? DefaultAttribute.backgroundPosition + }); + boundsAllocate.free(backgroundBounds); } } else { context.fillStyle = createColor( context, - clear, + clear as any, { AABBBounds: { x1: x, y1: y, x2: x + width, y2: y + height } }, diff --git a/packages/vrender/__tests__/browser/src/pages/background.ts b/packages/vrender/__tests__/browser/src/pages/background.ts new file mode 100644 index 000000000..764bb8f9c --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/background.ts @@ -0,0 +1,281 @@ +import { createGroup, createRect, createStage, createText, IGraphic, IGraphicAttribute } from '@visactor/vrender'; + +const demoImage = ` + + + + + + + + + + + + VRender BG +`; + +type LayoutSpec = { + title: string; + description: string; + background: Partial; +}; + +type PositionSpec = { + title: string; + position: IGraphicAttribute['backgroundPosition']; +}; + +const layoutSpecs: LayoutSpec[] = [ + { + title: 'cover', + description: '等比放大并裁剪', + background: { + backgroundSizing: 'cover', + backgroundPosition: 'center' + } + }, + { + title: 'contain', + description: '等比缩放完整包含', + background: { + backgroundSizing: 'contain', + backgroundPosition: 'center' + } + }, + { + title: 'fill', + description: '拉伸填满图元', + background: { + backgroundSizing: 'fill', + backgroundPosition: 'center' + } + }, + { + title: 'auto', + description: '按原始尺寸显示', + background: { + backgroundSizing: 'auto', + backgroundPosition: 'center' + } + } +]; + +const positionSpecs: PositionSpec[] = [ + { + title: 'top-left', + position: 'top-left' + }, + { + title: 'center', + position: 'center' + }, + { + title: 'bottom-right', + position: 'bottom-right' + }, + { + title: '25% 75%', + position: ['25%', '75%'] + } +]; + +function createCaption(text: string, x: number, y: number, fontSize: number = 18) { + return createText({ + x, + y, + text, + fontSize, + fill: '#17324d', + textBaseline: 'top', + fontFamily: 'Arial' + }); +} + +function createHint(text: string, x: number, y: number) { + return createText({ + x, + y, + text, + fontSize: 13, + fill: '#61758a', + textBaseline: 'top', + fontFamily: 'Arial' + }); +} + +function createDemoFrame( + x: number, + y: number, + width: number, + height: number, + title: string, + attrs: Partial +) { + const graphics: IGraphic[] = []; + + graphics.push( + createRect({ + x, + y, + width, + height, + fill: '#f7f3eb', + stroke: '#d5c7b8', + lineWidth: 1, + cornerRadius: 16 + }) + ); + + graphics.push( + createRect({ + x: x + 18, + y: y + 42, + width: width - 36, + height: height - 60, + cornerRadius: 14, + stroke: '#102a43', + lineWidth: 1, + background: demoImage, + backgroundClip: true, + ...attrs + }) + ); + + graphics.push(createCaption(title, x + 18, y + 14, 16)); + return graphics; +} + +export const page = () => { + const stage = createStage({ + canvas: 'main', + width: 1600, + height: 900, + autoRender: true, + background: { + background: demoImage, + width: 1600, + height: 900 + } + }); + + stage.setAttributes({ + backgroundSizing: 'cover', + backgroundPosition: 'center', + backgroundOpacity: 0.12 + }); + + const graphics: IGraphic[] = []; + + graphics.push( + createRect({ + x: 36, + y: 28, + width: 1520, + height: 844, + fill: '#fffdf9', + cornerRadius: 28, + shadowBlur: 24, + shadowColor: 'rgba(19, 50, 75, 0.12)' + }) + ); + + graphics.push(createCaption('Background Image Layout Demo', 72, 58, 28)); + graphics.push(createHint('cover / contain / fill / auto + position + stage/group/text', 72, 96)); + + layoutSpecs.forEach((spec, index) => { + const x = 72 + index * 360; + const y = 150; + graphics.push(...createDemoFrame(x, y, 320, 230, spec.title, spec.background)); + graphics.push(createHint(spec.description, x + 18, y + 188)); + }); + + graphics.push(createCaption('Position Anchors', 72, 430, 22)); + graphics.push(createHint('同一张图片在 cover 模式下使用不同对齐点', 72, 460)); + + positionSpecs.forEach((spec, index) => { + const x = 72 + index * 360; + const y = 500; + graphics.push( + ...createDemoFrame(x, y, 320, 220, spec.title, { + backgroundSizing: 'cover', + backgroundPosition: spec.position + }) + ); + }); + + const group = createGroup({ + x: 1160, + y: 500, + width: 340, + height: 220, + cornerRadius: 20, + clip: true, + background: demoImage, + backgroundSizing: 'contain', + backgroundPosition: 'bottom-right', + backgroundClip: true + }); + group.add( + createText({ + x: 20, + y: 18, + text: 'group background', + fontSize: 18, + fill: '#17324d', + textBaseline: 'top', + fontFamily: 'Arial' + }) + ); + group.add( + createRect({ + x: 20, + y: 56, + width: 120, + height: 120, + fill: 'rgba(255,255,255,0.82)', + cornerRadius: 14, + stroke: '#17324d', + lineWidth: 1 + }) + ); + group.add( + createText({ + x: 166, + y: 74, + text: 'contain\nright bottom', + fontSize: 18, + fill: '#17324d', + textBaseline: 'top', + fontFamily: 'Arial', + lineHeight: 24 + }) + ); + graphics.push(group); + + const textLabel = createText({ + x: 1160, + y: 756, + text: 'text background', + fontSize: 18, + fill: '#17324d', + textBaseline: 'top', + fontFamily: 'Arial', + background: { + background: demoImage, + x: 1146, + y: 742, + width: 280, + height: 92 + }, + backgroundSizing: 'cover', + backgroundPosition: 'center', + backgroundClip: true + }); + graphics.push(textLabel); + graphics.push(createHint('text 节点使用包装 background 配置,验证 x/y/width/height 与 cover + center', 1160, 792)); + + graphics.forEach(graphic => { + stage.defaultLayer.add(graphic); + }); +}; diff --git a/packages/vrender/__tests__/browser/src/pages/index.ts b/packages/vrender/__tests__/browser/src/pages/index.ts index 2a7ed832d..51a110194 100644 --- a/packages/vrender/__tests__/browser/src/pages/index.ts +++ b/packages/vrender/__tests__/browser/src/pages/index.ts @@ -83,6 +83,10 @@ export const pages = [ name: 'rect绘制', path: 'rect' }, + { + name: 'background布局', + path: 'background' + }, { name: 'path绘制', path: 'path'