fix(renderer): preserve proportions of FIT image fills

This commit is contained in:
Danila Poyarkov 2026-09-13 13:48:11 +03:00
parent fbacd1a34a
commit de6c0efc00
6 changed files with 100 additions and 13 deletions

View file

@ -53,6 +53,7 @@
### Fixed
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.
- Preserve edited instance text, including cleared labels, when saving and reopening `.fig` files.
- Honor `.pen` frame layout defaults and sizing and padding shorthands so imported auto-layout frames keep their computed dimensions and child positions. (#564)
- Avoid macOS Keychain prompts during credential status checks and pause repeated credential access after failures until explicitly retried from Settings.

View file

@ -405,21 +405,20 @@ export function makeImageFillLocalMatrix(
}
}
let sx: number, sy: number, sw: number, sh: number
if (scaleMode === 'FIT') {
const scale = Math.min(node.width / imgW, node.height / imgH)
sw = imgW
sh = imgH
sx = -(node.width / scale - imgW) / 2
sy = -(node.height / scale - imgH) / 2
} else {
const scale = Math.max(node.width / imgW, node.height / imgH)
sw = node.width / scale
sh = node.height / scale
sx = (imgW - sw) / 2
sy = (imgH - sh) / 2
return r.ck.Matrix.multiply(
r.ck.Matrix.translated((node.width - imgW * scale) / 2, (node.height - imgH * scale) / 2),
r.ck.Matrix.scaled(scale, scale)
)
}
const scale = Math.max(node.width / imgW, node.height / imgH)
const sw = node.width / scale
const sh = node.height / scale
const sx = (imgW - sw) / 2
const sy = (imgH - sh) / 2
return r.ck.Matrix.multiply(
r.ck.Matrix.scaled(node.width / sw, node.height / sh),
r.ck.Matrix.translated(-sx, -sy)
@ -463,9 +462,10 @@ export function applyImageFill(
return true
}
const tileMode = scaleMode === 'FIT' ? r.ck.TileMode.Decal : r.ck.TileMode.Clamp
const shader = img.makeShaderOptions(
r.ck.TileMode.Clamp,
r.ck.TileMode.Clamp,
tileMode,
tileMode,
r.ck.FilterMode.Linear,
r.ck.MipmapMode.Linear,
localMatrix

View file

@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises'
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
@ -64,6 +66,55 @@ test('boolean operations', async () => {
await expectCanvas('boolean-operations')
})
test('FIT avatar preserves the full clipboard fixture image', async () => {
const bytes = await readFile('tests/fixtures/vectorize/pilot_avatar.png')
await editor.page.evaluate(
(data) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const hash = store.storeImage(new Uint8Array(data))
store.graph.createNode('RECTANGLE', store.state.currentPageId, {
name: 'Bitmap background',
x: 80,
y: 80,
width: 320,
height: 222,
cornerRadius: 16,
fills: [
{
type: 'SOLID',
color: { r: 241 / 255, g: 245 / 255, b: 249 / 255, a: 1 },
visible: true,
opacity: 1
}
]
})
store.graph.createNode('RECTANGLE', store.state.currentPageId, {
name: 'Pilot avatar · image fill FIT',
x: 138,
y: 90,
width: 204,
height: 202,
fills: [
{
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FIT',
color: { r: 0, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1
}
]
})
store.clearSelection()
store.requestRender()
},
[...bytes]
)
await editor.canvas.waitForRender()
await expectCanvas('fit-avatar-clipboard-fixture')
})
test('gradients and image fill modes', async () => {
await editor.page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
@ -134,6 +185,24 @@ test('gradients and image fill modes', async () => {
})
}
store.graph.createNode('RECTANGLE', pageId, {
name: 'FIT image with transparent margins',
x: 568,
y: 84,
width: 140,
height: 172,
fills: [
{
type: 'IMAGE',
color: { r: 0, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1,
imageHash,
imageScaleMode: 'FIT'
}
]
})
const scaleModes = ['FILL', 'FIT', 'TILE', 'CROP'] as const
for (const [index, imageScaleMode] of scaleModes.entries()) {
store.graph.createNode('RECTANGLE', pageId, {

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -68,6 +68,23 @@ describe('canvas pattern fills', () => {
})
describe('canvas image fills', () => {
test.each([
{ width: 100, height: 100, scale: 0.8, x: 20, y: 0 },
{ width: 240, height: 80, scale: 0.5, x: 0, y: 20 },
{ width: 60, height: 40, scale: 2, x: 0, y: 0 }
])(
'centers FIT images without stretching ($width × $height)',
({ width, height, scale, x, y }) => {
const renderer = createRenderer()
const fill = { type: 'IMAGE', imageScaleMode: 'FIT' } as Fill
expect(makeImageFillLocalMatrix(renderer, fill, node, width, height)).toEqual([
'multiply',
['translated', x, y],
['scaled', scale, scale]
])
}
)
test('keeps untransformed tile fills in image pixel space', () => {
const renderer = createRenderer()
const fill = { type: 'IMAGE', imageScaleMode: 'TILE' } as Fill