diff --git a/packages/vrender-core/__tests__/background/background-image-layout.test.ts b/packages/vrender-core/__tests__/background/background-image-layout.test.ts index 21d5cf9ff..5ed584b2f 100644 --- a/packages/vrender-core/__tests__/background/background-image-layout.test.ts +++ b/packages/vrender-core/__tests__/background/background-image-layout.test.ts @@ -1,7 +1,8 @@ import { DefaultAttribute } from '../../src/graphic/config'; import { DefaultBaseBackgroundRenderContribution, - drawBackgroundImage + drawBackgroundImage, + resolveBackgroundDrawMode } 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'; @@ -101,16 +102,96 @@ describe('background image layout', () => { const context = createContext(); drawBackgroundImage(context as any, createImage(200, 100), createBounds(0, 0, 100, 100) as any, { - backgroundMode: 'no-repeat', + backgroundMode: 'no-repeat-contain', backgroundFit: true, backgroundKeepAspectRatio: true, - backgroundSizing: 'contain', backgroundPosition: 'bottom-right' }); expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 50, 100, 50); }); + test('resolves legacy no-repeat config and sizing shorthands correctly', () => { + // Legacy: backgroundFit + backgroundKeepAspectRatio + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: true + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'cover' + }); + + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: false + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'fill' + }); + + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat', + backgroundFit: false, + backgroundKeepAspectRatio: true + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'auto' + }); + + // Shorthands: override backgroundFit/backgroundKeepAspectRatio + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat-contain', + backgroundFit: true, + backgroundKeepAspectRatio: false + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'contain' + }); + + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat-cover', + backgroundFit: false, + backgroundKeepAspectRatio: false + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'cover' + }); + + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat-fill', + backgroundFit: false, + backgroundKeepAspectRatio: true + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'fill' + }); + + expect( + resolveBackgroundDrawMode({ + backgroundMode: 'no-repeat-auto', + backgroundFit: true, + backgroundKeepAspectRatio: true + }) + ).toEqual({ + backgroundRepeatMode: 'no-repeat', + backgroundSizing: 'auto' + }); + }); + test('supports fill layout with scaling and centered alignment', () => { const context = createContext(); @@ -118,7 +199,6 @@ describe('background image layout', () => { backgroundMode: 'no-repeat', backgroundFit: true, backgroundKeepAspectRatio: false, - backgroundSizing: 'fill', backgroundScale: 0.5, backgroundPosition: 'center' }); @@ -133,7 +213,6 @@ describe('background image layout', () => { backgroundMode: 'no-repeat', backgroundFit: false, backgroundKeepAspectRatio: true, - backgroundSizing: 'auto', backgroundPosition: ['50%', '100%'] }); @@ -148,7 +227,7 @@ describe('background image layout', () => { { attribute: { background: { background: 'image-key' }, - backgroundSizing: 'contain', + backgroundMode: 'no-repeat-contain', backgroundPosition: 'center', backgroundClip: true }, @@ -168,7 +247,7 @@ describe('background image layout', () => { {} as any ); - expect(contribution.capturedParams.backgroundSizing).toBe('contain'); + expect(contribution.capturedParams.backgroundMode).toBe('no-repeat-contain'); expect(contribution.capturedParams.backgroundPosition).toBe('center'); expect(context.clip).toHaveBeenCalled(); }); @@ -181,7 +260,7 @@ describe('background image layout', () => { { attribute: { background: 'image-key', - backgroundSizing: 'contain', + backgroundMode: 'no-repeat-contain', backgroundPosition: 'bottom-right', backgroundClip: true }, @@ -202,7 +281,7 @@ describe('background image layout', () => { {} as any ); - expect(contribution.capturedParams.backgroundSizing).toBe('contain'); + expect(contribution.capturedParams.backgroundMode).toBe('no-repeat-contain'); expect(contribution.capturedParams.backgroundPosition).toBe('bottom-right'); expect(context.clip).toHaveBeenCalled(); }); @@ -224,7 +303,8 @@ describe('background image layout', () => { dx: 5, dy: 6 }, - backgroundSizing: 'auto', + backgroundMode: 'no-repeat', + backgroundFit: false, backgroundPosition: 'bottom-right', backgroundClip: true, backgroundCornerRadius: 0 @@ -250,7 +330,8 @@ describe('background image layout', () => { 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.backgroundMode).toBe('no-repeat'); + expect(contribution.capturedParams.backgroundFit).toBe(false); expect(contribution.capturedParams.backgroundPosition).toBe('bottom-right'); expect(context.clip).toHaveBeenCalled(); }); @@ -267,7 +348,6 @@ describe('background image layout', () => { backgroundMode: 'no-repeat', backgroundFit: true, backgroundKeepAspectRatio: true, - backgroundSizing: 'cover', backgroundPosition: 'center', backgroundScale: 1, backgroundOffsetX: 0, diff --git a/packages/vrender-core/__tests__/image/image-layout.test.ts b/packages/vrender-core/__tests__/image/image-layout.test.ts new file mode 100644 index 000000000..261c1094b --- /dev/null +++ b/packages/vrender-core/__tests__/image/image-layout.test.ts @@ -0,0 +1,215 @@ +import { Image } from '../../src/graphic/image'; +import { + DefaultCanvasImageRender, + drawImageWithLayout, + resolveImageMode, + shouldClipImageByLayout +} from '../../src/render/contributions/render/image-render'; +import { drawBackgroundImage } from '../../src/render/contributions/render/contributions/base-contribution-render'; + +function createImageData(width: number, height: number) { + return { width, height }; +} + +function createContext() { + return { + dpr: 1, + globalAlpha: 1, + fillStyle: '', + beginPath: jest.fn(), + rect: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + clip: jest.fn(), + stroke: jest.fn(), + drawImage: jest.fn(), + createPattern: jest.fn(), + translate: jest.fn(), + fillRect: jest.fn(), + setCommonStyle: jest.fn(), + setShadowBlendStyle: jest.fn(), + setStrokeStyle: jest.fn() + }; +} + +function prepareRenderableImage(image: Image) { + image._actualWidth = image.attribute.width as number; + image._actualHeight = image.attribute.height as number; + (image as any).tryUpdateAABBBounds = jest.fn(); + return image; +} + +describe('image layout', () => { + test('supports cover layout with centered crop', () => { + const context = createContext(); + + drawImageWithLayout(context as any, createImageData(200, 100), 0, 0, 100, 100, { + imageMode: 'cover', + imagePosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), -50, 0, 200, 100); + }); + + test('supports contain layout with centered blank space', () => { + const context = createContext(); + + drawImageWithLayout(context as any, createImageData(200, 100), 0, 0, 100, 100, { + imageMode: 'contain', + imagePosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 25, 100, 50); + }); + + test('supports sizing shorthand through imageMode', () => { + const context = createContext(); + + drawImageWithLayout(context as any, createImageData(200, 100), 0, 0, 100, 100, { + imageMode: 'contain', + imagePosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 25, 100, 50); + }); + + test('imageMode is ignored when repeat mode is enabled', () => { + expect( + resolveImageMode({ + repeatX: 'repeat', + repeatY: 'repeat', + imageMode: 'cover' + }) + ).toEqual({ + repeatMode: 'repeat', + sizingMode: 'fill' + }); + }); + + test('defaults to fill when imageMode is undefined', () => { + expect( + resolveImageMode({ + repeatX: 'no-repeat', + repeatY: 'no-repeat' + }) + ).toEqual({ + repeatMode: 'no-repeat', + sizingMode: 'fill' + }); + }); + + test('supports fill layout to match target size', () => { + const context = createContext(); + + drawImageWithLayout(context as any, createImageData(200, 100), 0, 0, 100, 100, { + imageMode: 'fill' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 100, 100); + }); + + test('uses intrinsic size when image element width and height are zero', () => { + const context = createContext(); + + drawBackgroundImage( + context as any, + { + width: 0, + height: 0, + naturalWidth: 200, + naturalHeight: 100 + }, + { + x1: 0, + y1: 0, + x2: 100, + y2: 100, + width: () => 100, + height: () => 100 + } as any, + { + backgroundMode: 'no-repeat', + backgroundFit: true, + backgroundKeepAspectRatio: false + } + ); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 100, 100); + }); + + test('supports auto layout with original size and positioning', () => { + const context = createContext(); + + drawImageWithLayout(context as any, createImageData(20, 10), 0, 0, 100, 100, { + imageMode: 'auto', + imagePosition: 'center' + }); + + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 40, 45, 20, 10); + }); + + test('clips when image content may overflow container', () => { + const render = new DefaultCanvasImageRender({ getContributions: (): any[] => [] } as any); + const context = createContext(); + const image = prepareRenderableImage( + new Image({ + width: 100, + height: 100, + image: createImageData(200, 100) as any, + imageMode: 'cover', + imagePosition: 'center' + }) + ); + + render.drawShape(image, context as any, 0, 0, {} as any); + + expect(context.clip).toHaveBeenCalledTimes(1); + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), -50, 0, 200, 100); + }); + + test('keeps default fill behavior without extra clip', () => { + const render = new DefaultCanvasImageRender({ getContributions: (): any[] => [] } as any); + const context = createContext(); + const image = prepareRenderableImage( + new Image({ + width: 100, + height: 100, + image: createImageData(200, 100) as any + }) + ); + + render.drawShape(image, context as any, 0, 0, {} as any); + + expect(shouldClipImageByLayout(image.attribute)).toBe(false); + expect(context.clip).not.toHaveBeenCalled(); + expect(context.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 100, 100); + }); + + test('does not clip for contain mode at default params', () => { + expect( + shouldClipImageByLayout({ + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imageScale: 1, + imageOffsetX: 0, + imageOffsetY: 0, + imagePosition: 'center' + }) + ).toBe(false); + }); + + test('clips for contain mode when scale or offset overflows', () => { + expect( + shouldClipImageByLayout({ + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imageScale: 2, + imageOffsetX: 0, + imageOffsetY: 0, + imagePosition: 'center' + }) + ).toBe(true); + }); +}); diff --git a/packages/vrender-core/src/graphic/config.ts b/packages/vrender-core/src/graphic/config.ts index a31fd46d3..b7ddc8e8a 100644 --- a/packages/vrender-core/src/graphic/config.ts +++ b/packages/vrender-core/src/graphic/config.ts @@ -142,7 +142,6 @@ export const DefaultStyle: IGraphicStyle = { backgroundMode: 'no-repeat', backgroundFit: true, backgroundKeepAspectRatio: false, - backgroundSizing: undefined, backgroundClip: true, backgroundScale: 1, backgroundOffsetX: 0, @@ -208,7 +207,6 @@ export const DefaultAttribute: Required = { keepStrokeScale: false, clipConfig: null, roughStyle: null, - backgroundSizing: undefined, ...DefaultDebugAttribute, ...DefaultStyle, ...DefaultTransform @@ -386,11 +384,16 @@ export const DefaultRichTextAttribute: Required = { export const DefaultImageAttribute: Required = { repeatX: 'no-repeat', repeatY: 'no-repeat', + imageMode: undefined, image: '', width: 0, height: 0, maxWidth: 500, maxHeight: 500, + imagePosition: 'top-left', + imageScale: 1, + imageOffsetX: 0, + imageOffsetY: 0, ...DefaultAttribute, fill: true, cornerRadius: 0, diff --git a/packages/vrender-core/src/graphic/image.ts b/packages/vrender-core/src/graphic/image.ts index 6fca34ba5..4a9f09157 100644 --- a/packages/vrender-core/src/graphic/image.ts +++ b/packages/vrender-core/src/graphic/image.ts @@ -7,7 +7,17 @@ import { application } from '../application'; import { IMAGE_NUMBER_TYPE } from './constants'; import { updateBoundsOfCommonOuterBorder } from './graphic-service/common-outer-boder-bounds'; -const IMAGE_UPDATE_TAG_KEY = ['width', 'height', 'image', ...GRAPHIC_UPDATE_TAG_KEY]; +const IMAGE_UPDATE_TAG_KEY = [ + 'width', + 'height', + 'image', + 'imageMode', + 'imagePosition', + 'imageScale', + 'imageOffsetX', + 'imageOffsetY', + ...GRAPHIC_UPDATE_TAG_KEY +]; /** * TODO image 需要考虑加载问题 等load模块 @@ -23,6 +33,7 @@ export class Image extends Graphic implements IImage { static NOWORK_ANIMATE_ATTR = { image: 1, + imageMode: 1, repeatX: 1, repeatY: 1, ...NOWORK_ANIMATE_ATTR diff --git a/packages/vrender-core/src/interface/graphic.ts b/packages/vrender-core/src/interface/graphic.ts index cf4448ec0..5e37a314b 100644 --- a/packages/vrender-core/src/interface/graphic.ts +++ b/packages/vrender-core/src/interface/graphic.ts @@ -314,6 +314,9 @@ export type IBackgroundConfig = { }; export type BackgroundSizing = 'cover' | 'contain' | 'fill' | 'auto'; +export type BackgroundRepeatMode = 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; +export type BackgroundSizingShorthand = 'no-repeat-cover' | 'no-repeat-contain' | 'no-repeat-fill' | 'no-repeat-auto'; +export type BackgroundMode = BackgroundRepeatMode | BackgroundSizingShorthand; export type BackgroundPositionHorizontalKeyword = 'left' | 'center' | 'right'; export type BackgroundPositionVerticalKeyword = 'top' | 'center' | 'bottom'; export type BackgroundPositionKeyword = BackgroundPositionHorizontalKeyword | BackgroundPositionVerticalKeyword; @@ -424,9 +427,11 @@ export type IGraphicStyle = ILayout & */ shadowGraphic?: IGraphic | undefined; /** - * 背景填充模式(与具体图元有关) + * 背景图绘制模式。 + * - repeat/repeat-x/repeat-y/no-repeat: 原有平铺语义 + * - no-repeat-cover/no-repeat-contain/no-repeat-fill/no-repeat-auto: no-repeat 下的尺寸简写 */ - backgroundMode: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; + backgroundMode: BackgroundMode; /** * 是否正好填充,只在repeat-x或者repeat-y以及no-repeat的时候生效 */ @@ -435,11 +440,6 @@ export type IGraphicStyle = ILayout & * 是否保持背景图的宽高比 */ backgroundKeepAspectRatio: boolean; - /** - * 背景图布局方式,只在 no-repeat 的图片背景下生效。 - * 设置后优先级高于 backgroundFit/backgroundKeepAspectRatio。 - */ - backgroundSizing?: BackgroundSizing; /** * 背景图缩放,只在no-repeat的时候生效 */ diff --git a/packages/vrender-core/src/interface/graphic/image.ts b/packages/vrender-core/src/interface/graphic/image.ts index 8e5d3021d..33b48a5ff 100644 --- a/packages/vrender-core/src/interface/graphic/image.ts +++ b/packages/vrender-core/src/interface/graphic/image.ts @@ -1,6 +1,7 @@ -import type { IGraphicAttribute, IGraphic } from '../graphic'; +import type { BackgroundPosition, BackgroundSizing, IGraphicAttribute, IGraphic } from '../graphic'; export type IRepeatType = 'no-repeat' | 'repeat'; +export type ImageMode = BackgroundSizing; export type IImageAttribute = { /** @@ -19,6 +20,28 @@ export type IImageAttribute = { * 最大高度 */ maxHeight?: number; + /** + * 图像绘制模式。 + * - cover/contain/fill/auto: no-repeat 下的尺寸语义 + * 仅在 repeatX/repeatY 最终为 no-repeat 时生效。 + */ + imageMode?: ImageMode; + /** + * 图像锚定位置,语义与 backgroundPosition 一致。 + */ + imagePosition: BackgroundPosition; + /** + * 图像额外缩放比例,仅在不重复平铺时生效。 + */ + imageScale: number; + /** + * 图像 x 偏移,仅在不重复平铺时生效。 + */ + imageOffsetX: number; + /** + * 图像 y 偏移,仅在不重复平铺时生效。 + */ + imageOffsetY: number; /** * x方向的重复方式 */ diff --git a/packages/vrender-core/src/render/contributions/render/contributions/base-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/base-contribution-render.ts index 057773cf6..1cfa37cc1 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/base-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/base-contribution-render.ts @@ -1,5 +1,6 @@ import type { IGraphicAttribute, + BackgroundSizing, IContext2d, IGraphic, IThemeAttribute, @@ -44,7 +45,6 @@ export class DefaultBaseBackgroundRenderContribution implements IBaseRenderContr backgroundMode = graphicAttribute.backgroundMode, backgroundFit = graphicAttribute.backgroundFit, backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio, - backgroundSizing = graphicAttribute.backgroundSizing, backgroundScale = graphicAttribute.backgroundScale, backgroundOffsetX = graphicAttribute.backgroundOffsetX, backgroundOffsetY = graphicAttribute.backgroundOffsetY, @@ -77,7 +77,6 @@ export class DefaultBaseBackgroundRenderContribution implements IBaseRenderContr backgroundMode, backgroundFit, backgroundKeepAspectRatio, - backgroundSizing, backgroundScale, backgroundOffsetX, backgroundOffsetY, @@ -105,10 +104,9 @@ export class DefaultBaseBackgroundRenderContribution implements IBaseRenderContr export const defaultBaseBackgroundRenderContribution = new DefaultBaseBackgroundRenderContribution(); export type IBackgroundImageDrawParams = { - backgroundMode: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; + backgroundMode: IGraphicAttribute['backgroundMode']; backgroundFit: boolean; backgroundKeepAspectRatio: boolean; - backgroundSizing?: IGraphicAttribute['backgroundSizing']; backgroundScale?: number; backgroundOffsetX?: number; backgroundOffsetY?: number; @@ -125,20 +123,52 @@ export function getBackgroundImage(background: any) { export function resolveBackgroundSizing({ backgroundFit, - backgroundKeepAspectRatio, - backgroundSizing -}: Pick): NonNullable< - IGraphicAttribute['backgroundSizing'] -> { - if (backgroundSizing) { - return backgroundSizing; - } + backgroundKeepAspectRatio +}: Pick): NonNullable { if (backgroundFit) { return backgroundKeepAspectRatio ? 'cover' : 'fill'; } return 'auto'; } +export function isNoRepeatSizingMode( + mode: IGraphicAttribute['backgroundMode'] +): mode is Extract { + return typeof mode === 'string' && mode.startsWith('no-repeat-'); +} + +const NO_REPEAT_SIZING_MAP: Record = { + 'no-repeat-cover': 'cover', + 'no-repeat-contain': 'contain', + 'no-repeat-fill': 'fill', + 'no-repeat-auto': 'auto' +}; + +export function resolveBackgroundDrawMode({ + backgroundMode, + backgroundFit, + backgroundKeepAspectRatio +}: Pick): { + backgroundRepeatMode: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; + backgroundSizing: BackgroundSizing; +} { + const sizing = NO_REPEAT_SIZING_MAP[backgroundMode]; + if (sizing) { + return { + backgroundRepeatMode: 'no-repeat', + backgroundSizing: sizing + }; + } + + return { + backgroundRepeatMode: backgroundMode as 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat', + backgroundSizing: resolveBackgroundSizing({ + backgroundFit, + backgroundKeepAspectRatio + }) + }; +} + function isPercentageValue(value: string): boolean { return /^-?\d+(\.\d+)?%$/.test(value); } @@ -241,6 +271,32 @@ export function resolveBackgroundPosition( }; } +function pickRenderableDimension(...values: any[]): number | null { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} + +export function resolveRenderableImageSize(data: any): { width: number; height: number } | null { + if (!data) { + return null; + } + + // DOM image-like resources may expose intrinsic size on naturalWidth/videoWidth + // while width/height stays 0, so prefer intrinsic dimensions when available. + const width = pickRenderableDimension(data.naturalWidth, data.videoWidth, data.width); + const height = pickRenderableDimension(data.naturalHeight, data.videoHeight, data.height); + + if (width == null || height == null) { + return null; + } + + return { width, height }; +} + export function drawBackgroundImage( context: IContext2d, data: any, @@ -251,7 +307,6 @@ export function drawBackgroundImage( backgroundMode, backgroundFit, backgroundKeepAspectRatio, - backgroundSizing, backgroundScale = 1, backgroundOffsetX = 0, backgroundOffsetY = 0, @@ -259,30 +314,31 @@ export function drawBackgroundImage( } = params; const targetW = b.width(); const targetH = b.height(); + const sourceSize = resolveRenderableImageSize(data); + const { backgroundRepeatMode, backgroundSizing: resolvedBackgroundSizing } = resolveBackgroundDrawMode({ + backgroundMode, + backgroundFit, + backgroundKeepAspectRatio + }); let w = targetW; let h = targetH; - if (!data?.width || !data?.height || targetW <= 0 || targetH <= 0) { + if (targetW <= 0 || targetH <= 0) { return; } - if (backgroundMode === 'no-repeat') { - const sizing = resolveBackgroundSizing({ - backgroundFit, - backgroundKeepAspectRatio, - backgroundSizing - }); - let drawWidth = data.width; - let drawHeight = data.height; + if (backgroundRepeatMode === 'no-repeat') { + let drawWidth = sourceSize?.width ?? targetW; + let drawHeight = sourceSize?.height ?? targetH; - if (sizing === 'cover' || sizing === 'contain') { + if ((resolvedBackgroundSizing === 'cover' || resolvedBackgroundSizing === 'contain') && sourceSize) { 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') { + resolvedBackgroundSizing === 'cover' + ? Math.max(targetW / sourceSize.width, targetH / sourceSize.height) + : Math.min(targetW / sourceSize.width, targetH / sourceSize.height); + drawWidth = sourceSize.width * scale; + drawHeight = sourceSize.height * scale; + } else if (resolvedBackgroundSizing === 'fill') { drawWidth = targetW; drawHeight = targetH; } @@ -296,16 +352,16 @@ export function drawBackgroundImage( } // TODO 考虑缓存 - if (backgroundFit && backgroundMode !== 'repeat' && (data.width || data.height)) { - const resW = data.width; - const resH = data.height; + if (backgroundFit && backgroundRepeatMode !== 'repeat' && sourceSize) { + const resW = sourceSize.width; + const resH = sourceSize.height; - if (backgroundMode === 'repeat-x') { + if (backgroundRepeatMode === 'repeat-x') { // 高度适应 const ratio = targetH / resH; w = resW * ratio; h = targetH; - } else if (backgroundMode === 'repeat-y') { + } else if (backgroundRepeatMode === 'repeat-y') { // 宽度适应 const ratio = targetW / resW; h = resH * ratio; @@ -326,7 +382,7 @@ export function drawBackgroundImage( canvasAllocate.free(canvas); } const dpr = context.dpr; - const pattern = context.createPattern(data, backgroundMode); + const pattern = context.createPattern(data, backgroundRepeatMode); pattern.setTransform && pattern.setTransform(new DOMMatrix([1 / dpr, 0, 0, 1 / dpr, 0, 0])); context.fillStyle = pattern; context.translate(b.x1, b.y1); 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 dbd1a61a9..42fa6b313 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 @@ -36,7 +36,6 @@ export class DefaultGroupBackgroundRenderContribution backgroundMode = graphicAttribute.backgroundMode, backgroundFit = graphicAttribute.backgroundFit, backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio, - backgroundSizing = graphicAttribute.backgroundSizing, backgroundScale = graphicAttribute.backgroundScale, backgroundOffsetX = graphicAttribute.backgroundOffsetX, backgroundOffsetY = graphicAttribute.backgroundOffsetY, @@ -63,7 +62,6 @@ export class DefaultGroupBackgroundRenderContribution backgroundMode, backgroundFit, backgroundKeepAspectRatio, - backgroundSizing, backgroundScale, backgroundOffsetX, backgroundOffsetY, 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 8db48f8a3..46c05c4db 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 @@ -40,7 +40,6 @@ export class DefaultTextBackgroundRenderContribution backgroundMode = graphicAttribute.backgroundMode, backgroundFit = graphicAttribute.backgroundFit, backgroundKeepAspectRatio = graphicAttribute.backgroundKeepAspectRatio, - backgroundSizing = graphicAttribute.backgroundSizing, backgroundScale = graphicAttribute.backgroundScale, backgroundOffsetX = graphicAttribute.backgroundOffsetX, backgroundOffsetY = graphicAttribute.backgroundOffsetY, @@ -128,7 +127,6 @@ export class DefaultTextBackgroundRenderContribution backgroundMode, backgroundFit, backgroundKeepAspectRatio, - backgroundSizing, backgroundScale, backgroundOffsetX, backgroundOffsetY, 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 bc2fc5611..f55c27167 100644 --- a/packages/vrender-core/src/render/contributions/render/draw-contribution.ts +++ b/packages/vrender-core/src/render/contributions/render/draw-contribution.ts @@ -489,7 +489,6 @@ export class DefaultDrawContribution implements IDrawContribution { 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, diff --git a/packages/vrender-core/src/render/contributions/render/image-render.ts b/packages/vrender-core/src/render/contributions/render/image-render.ts index 83c4cac25..b6329b848 100644 --- a/packages/vrender-core/src/render/contributions/render/image-render.ts +++ b/packages/vrender-core/src/render/contributions/render/image-render.ts @@ -6,7 +6,10 @@ import type { IGraphicAttribute, IContext2d, IMarkAttribute, + BackgroundSizing, + BackgroundMode, IImage, + IImageGraphicAttribute, IThemeAttribute, IGraphicRender, IImageRenderContribution, @@ -16,17 +19,143 @@ import type { IRenderService } from '../../../interface'; import { ImageRenderContribution } from './contributions/constants'; -import { fillVisible, runFill } from './utils'; import { IMAGE_NUMBER_TYPE } from '../../../graphic/constants'; -import { BaseRenderContributionTime } from '../../../common/enums'; import { isArray, isString } from '@visactor/vutils'; import { createRectPath } from '../../../common/shape/rect'; import { BaseRender } from './base-render'; import { defaultImageBackgroundRenderContribution, defaultImageRenderContribution } from './contributions'; import { ResourceLoader } from '../../../resource-loader/loader'; +import { drawBackgroundImage } from './contributions/base-contribution-render'; const repeatStr = ['', 'repeat-x', 'repeat-y', 'repeat']; +export type IImageLayoutDrawParams = Pick< + IImageGraphicAttribute, + 'repeatX' | 'repeatY' | 'imageMode' | 'imageScale' | 'imageOffsetX' | 'imageOffsetY' | 'imagePosition' +>; + +export function resolveImageMode({ + repeatX = 'no-repeat', + repeatY = 'no-repeat', + imageMode +}: Pick): { + repeatMode: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'; + sizingMode: BackgroundSizing; +} { + const repeatMode = resolveImageRepeatMode(repeatX, repeatY); + + return { + repeatMode, + sizingMode: repeatMode === 'no-repeat' ? imageMode ?? 'fill' : 'fill' + }; +} + +const IMAGE_MODE_TO_BACKGROUND_MODE: Record = { + cover: 'no-repeat-cover', + contain: 'no-repeat-contain', + fill: 'no-repeat-fill', + auto: 'no-repeat-auto' +}; + +export function resolveBackgroundParamsByImageSizing(sizingMode: BackgroundSizing): { + backgroundMode: BackgroundMode; + backgroundFit: boolean; + backgroundKeepAspectRatio: boolean; +} { + return { + backgroundMode: IMAGE_MODE_TO_BACKGROUND_MODE[sizingMode], + backgroundFit: false, + backgroundKeepAspectRatio: false + }; +} + +export function resolveImageRepeatMode( + repeatX: IImageGraphicAttribute['repeatX'], + repeatY: IImageGraphicAttribute['repeatY'] +): 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' { + let repeat = 0; + if (repeatX === 'repeat') { + repeat |= 0b0001; + } + if (repeatY === 'repeat') { + repeat |= 0b0010; + } + return repeat ? (repeatStr[repeat] as 'repeat' | 'repeat-x' | 'repeat-y') : 'no-repeat'; +} + +export function shouldClipImageByLayout({ + repeatX = 'no-repeat', + repeatY = 'no-repeat', + imageMode, + imageScale = 1, + imageOffsetX = 0, + imageOffsetY = 0, + imagePosition = 'top-left' +}: IImageLayoutDrawParams): boolean { + const { repeatMode, sizingMode } = resolveImageMode({ + repeatX, + repeatY, + imageMode + }); + return ( + repeatMode === 'no-repeat' && + (sizingMode === 'cover' || sizingMode === 'auto' || imageScale !== 1 || imageOffsetX !== 0 || imageOffsetY !== 0) + ); +} + +export function drawImageWithLayout( + context: IContext2d, + data: any, + x: number, + y: number, + width: number, + height: number, + { + repeatX = 'no-repeat', + repeatY = 'no-repeat', + imageMode, + imageScale = 1, + imageOffsetX = 0, + imageOffsetY = 0, + imagePosition = 'top-left' + }: IImageLayoutDrawParams +) { + const { repeatMode, sizingMode } = resolveImageMode({ + repeatX, + repeatY, + imageMode + }); + const imageBackgroundParams = + repeatMode === 'no-repeat' + ? resolveBackgroundParamsByImageSizing(sizingMode) + : { + backgroundMode: repeatMode, + backgroundFit: false, + backgroundKeepAspectRatio: false + }; + drawBackgroundImage( + context, + data, + { + x1: x, + y1: y, + x2: x + width, + y2: y + height, + width: () => width, + height: () => height + } as any, + { + backgroundMode: imageBackgroundParams.backgroundMode, + backgroundFit: imageBackgroundParams.backgroundFit, + backgroundKeepAspectRatio: imageBackgroundParams.backgroundKeepAspectRatio, + backgroundScale: imageScale, + backgroundOffsetX: imageOffsetX, + backgroundOffsetY: imageOffsetY, + backgroundPosition: imagePosition + } + ); +} + @injectable() export class DefaultCanvasImageRender extends BaseRender implements IGraphicRender { type: 'image'; @@ -70,6 +199,11 @@ export class DefaultCanvasImageRender extends BaseRender implements IGra cornerRadius = imageAttribute.cornerRadius, fillStrokeOrder = imageAttribute.fillStrokeOrder, cornerType = imageAttribute.cornerType, + imageMode = imageAttribute.imageMode, + imageScale = imageAttribute.imageScale, + imageOffsetX = imageAttribute.imageOffsetX, + imageOffsetY = imageAttribute.imageOffsetY, + imagePosition = imageAttribute.imagePosition, image: url } = image.attribute; @@ -92,14 +226,14 @@ export class DefaultCanvasImageRender extends BaseRender implements IGra context.beginPath(); // deal with cornerRadius - let needRestore = false; + let needCornerClip = false; if (cornerRadius === 0 || (isArray(cornerRadius) && (cornerRadius).every(num => num === 0))) { // 不需要处理圆角 context.rect(x, y, width, height); } else { // context.beginPath(); createRectPath(context, x, y, width, height, cornerRadius, cornerType !== 'bevel'); - needRestore = true; + needCornerClip = true; } // shadow @@ -113,22 +247,15 @@ export class DefaultCanvasImageRender extends BaseRender implements IGra fillCb(context, image.attribute, imageAttribute); } else if (fVisible) { context.setCommonStyle(image, image.attribute, x, y, imageAttribute); - let repeat = 0; - if (repeatX === 'repeat') { - repeat |= 0b0001; - } - if (repeatY === 'repeat') { - repeat |= 0b0010; - } - if (repeat) { - const pattern = context.createPattern(res.data, repeatStr[repeat]); - context.fillStyle = pattern; - context.translate(x, y, true); - context.fillRect(0, 0, width, height); - context.translate(-x, -y, true); - } else { - context.drawImage(res.data, x, y, width, height); - } + drawImageWithLayout(context, res.data, x, y, width, height, { + repeatX, + repeatY, + imageMode, + imageScale, + imageOffsetX, + imageOffsetY, + imagePosition + }); } } }; @@ -144,26 +271,37 @@ export class DefaultCanvasImageRender extends BaseRender implements IGra } }; + const needLayoutClip = shouldClipImageByLayout({ + repeatX, + repeatY, + imageMode, + imageScale, + imageOffsetX, + imageOffsetY, + imagePosition + }); + const needClip = needCornerClip || needLayoutClip; + if (!fillStrokeOrder) { - if (needRestore) { + if (needClip) { context.save(); context.clip(); } this.beforeRenderStep(image, context, x, y, doFill, false, fVisible, false, imageAttribute, drawContext, fillCb); _runFill(); - if (needRestore) { + if (needClip) { context.restore(); } _runStroke(); } else { _runStroke(); - if (needRestore) { + if (needClip) { context.save(); context.clip(); } this.beforeRenderStep(image, context, x, y, doFill, false, fVisible, false, imageAttribute, drawContext, fillCb); _runFill(); - if (needRestore) { + if (needClip) { context.restore(); } } diff --git a/packages/vrender/__tests__/browser/src/pages/background.ts b/packages/vrender/__tests__/browser/src/pages/background.ts index 764bb8f9c..11271d8b2 100644 --- a/packages/vrender/__tests__/browser/src/pages/background.ts +++ b/packages/vrender/__tests__/browser/src/pages/background.ts @@ -31,7 +31,7 @@ const layoutSpecs: LayoutSpec[] = [ title: 'cover', description: '等比放大并裁剪', background: { - backgroundSizing: 'cover', + backgroundMode: 'no-repeat-cover', backgroundPosition: 'center' } }, @@ -39,7 +39,7 @@ const layoutSpecs: LayoutSpec[] = [ title: 'contain', description: '等比缩放完整包含', background: { - backgroundSizing: 'contain', + backgroundMode: 'no-repeat-contain', backgroundPosition: 'center' } }, @@ -47,7 +47,7 @@ const layoutSpecs: LayoutSpec[] = [ title: 'fill', description: '拉伸填满图元', background: { - backgroundSizing: 'fill', + backgroundMode: 'no-repeat-fill', backgroundPosition: 'center' } }, @@ -55,7 +55,7 @@ const layoutSpecs: LayoutSpec[] = [ title: 'auto', description: '按原始尺寸显示', background: { - backgroundSizing: 'auto', + backgroundMode: 'no-repeat-auto', backgroundPosition: 'center' } } @@ -160,7 +160,7 @@ export const page = () => { }); stage.setAttributes({ - backgroundSizing: 'cover', + backgroundMode: 'no-repeat-cover', backgroundPosition: 'center', backgroundOpacity: 0.12 }); @@ -198,7 +198,7 @@ export const page = () => { const y = 500; graphics.push( ...createDemoFrame(x, y, 320, 220, spec.title, { - backgroundSizing: 'cover', + backgroundMode: 'no-repeat-cover', backgroundPosition: spec.position }) ); @@ -212,7 +212,7 @@ export const page = () => { cornerRadius: 20, clip: true, background: demoImage, - backgroundSizing: 'contain', + backgroundMode: 'no-repeat-contain', backgroundPosition: 'bottom-right', backgroundClip: true }); @@ -268,7 +268,7 @@ export const page = () => { width: 280, height: 92 }, - backgroundSizing: 'cover', + backgroundMode: 'no-repeat-cover', backgroundPosition: 'center', backgroundClip: true }); diff --git a/packages/vrender/__tests__/browser/src/pages/image.ts b/packages/vrender/__tests__/browser/src/pages/image.ts index df2466a4d..514ab2d0a 100644 --- a/packages/vrender/__tests__/browser/src/pages/image.ts +++ b/packages/vrender/__tests__/browser/src/pages/image.ts @@ -1,72 +1,187 @@ -import { createStage, createImage } from '@visactor/vrender'; -import { addShapesToStage, colorPools } from '../utils'; +import { createStage, createImage, createRect, createText } from '@visactor/vrender'; +import { addShapesToStage } from '../utils'; -const urlPng = 'https://vega.github.io/images/idl-logo.png'; const svg = ''; const svg1 = ''; const base64 = 'data:image/png;base64,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'; -// const urlSvg = 'https://replace-with-svg-link.svg'; - const dogImage = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/vrender/lovely_dog.jpg'; +const layoutImage = ` + + + + + + + + + + + + Image Layout +`; -export const page = () => { - const shapes = []; +const createLabel = (text: string, x: number, y: number, fontSize = 16, fill = '#17324d') => + createText({ + x, + y, + text, + fontSize, + fill, + textBaseline: 'top', + fontFamily: 'Arial' + }); - shapes.push( - createImage({ - x: 10, - y: 10, - image: dogImage, - clipConfig: { - shape: 'rectRound' - } - }) - ); - shapes.push( - createImage({ - x: 10, - y: 300, - width: 100, - stroke: 'green', - cornerRadius: 20, - lineWidth: 10, - fillStrokeOrder: -1, - image: dogImage - }) - ); - shapes.push( +const createHint = (text: string, x: number, y: number) => + createText({ + x, + y, + text, + fontSize: 13, + fill: '#61758a', + textBaseline: 'top', + fontFamily: 'Arial' + }); + +const createLayoutCard = ( + title: string, + description: string, + x: number, + y: number, + imageMode: 'cover' | 'contain' | 'fill' | 'auto' +) => { + const width = 320; + const height = 220; + const frameX = x + 18; + const frameY = y + 42; + const frameWidth = width - 36; + const frameHeight = 140; + + return [ + createRect({ + x, + y, + width, + height, + fill: '#f7f3eb', + stroke: '#d5c7b8', + lineWidth: 1, + cornerRadius: 16 + }), + createLabel(title, x + 18, y + 14), + createHint(description, x + 18, y + 182), + createRect({ + x: frameX, + y: frameY, + width: frameWidth, + height: frameHeight, + fill: '#fff8ef', + stroke: '#102a43', + lineWidth: 1, + cornerRadius: 14 + }), createImage({ - x: 200, - y: 300, - width: 100, - height: 100, - image: dogImage, - stroke: 'pink', - fillStrokeOrder: -1, - lineWidth: 10 + x: frameX, + y: frameY, + width: frameWidth, + height: frameHeight, + image: layoutImage, + imageMode, + imagePosition: 'center', + cornerRadius: 14 }) - ); + ]; +}; + +export const page = () => { + const shapes = []; - shapes.forEach(g => { - g.addEventListener('click', () => { - console.log('click', g._uid); - }); + shapes.push(createLabel('Image Primitive Demo', 48, 28, 28)); + shapes.push(createHint('basic image loading + sizing modes (cover / contain / fill / auto)', 48, 66)); + + const dogRounded = createImage({ + x: 48, + y: 112, + image: dogImage, + clipConfig: { + shape: 'rectRound' + } + }); + const dogStroke = createImage({ + x: 248, + y: 112, + width: 120, + stroke: '#2563eb', + cornerRadius: 24, + lineWidth: 8, + fillStrokeOrder: -1, + image: dogImage + }); + const dogSquare = createImage({ + x: 420, + y: 112, + width: 120, + height: 120, + image: dogImage, + stroke: '#f43f5e', + fillStrokeOrder: -1, + lineWidth: 8 + }); + const tinySvg = createImage({ + x: 592, + y: 112, + width: 120, + height: 120, + image: svg }); + const base64Image = createImage({ + x: 764, + y: 112, + width: 120, + height: 120, + image: base64 + }); + + shapes.push(dogRounded, dogStroke, dogSquare, tinySvg, base64Image); + shapes.push(createHint('clipConfig', 48, 238)); + shapes.push(createHint('stroke + radius', 248, 238)); + shapes.push(createHint('fixed width/height', 420, 238)); + shapes.push(createHint('inline svg', 592, 238)); + shapes.push(createHint('base64 image', 764, 238)); + + const cards = [ + createLayoutCard('cover', '等比缩放裁剪', 48, 300, 'cover'), + createLayoutCard('contain', '等比缩放留白', 404, 300, 'contain'), + createLayoutCard('fill', '拉伸适配设置大小', 760, 300, 'fill'), + createLayoutCard('auto', '保留原始尺寸', 1116, 300, 'auto') + ]; + cards.forEach(card => shapes.push(...card)); + + shapes.push(createLabel('Same API on image primitive', 48, 560, 22)); + shapes.push( + createHint("set `imageMode` to 'cover' | 'contain' | 'fill' | 'auto' after width/height is specified", 48, 592) + ); const stage = createStage({ canvas: 'main', - width: 1200, - height: 600, - viewWidth: 1200, - viewHeight: 600 + width: 1600, + height: 900, + viewWidth: 1600, + viewHeight: 900 }); + addShapesToStage(stage, shapes as any, false); stage.render(); - shapes.forEach(shape => { - stage.defaultLayer.add(shape); - }); + window.updateImage1 = () => { + tinySvg.setAttribute('image', svg1); + stage.render(); + }; + + window.updateImage0 = () => { + tinySvg.setAttribute('image', svg); + stage.render(); + }; };