fix(generation): contain-fit slide images inside the model's box (#2004)

When an assigned image's real aspect ratio differed from the model's image box by more than 10%, reconciliation kept the width and recomputed the height, so a box flatter than its image grew downward over the elements below it. Fit the image like object-fit: contain instead: the largest box with the image's ratio inside the model's box, centered there. @openmaic/generation 0.3.25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wyucandClaude Opus 5.5 authored and GitHub committed 2026-10-23 18:31:06 +08:00
1 parent ad6c6f72f4
commit ee300e00c7
4 files changed
+85 -9

No files matched your search

+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Bug Fixes
- A slide image whose box has a different shape from the image is now fitted inside the box the model laid out, centered, like `object-fit: contain`. Previously the box kept its width and changed its height, so a box flatter than its image grew downward and covered the text below it. Boxes within 10% of the image's ratio, and images with unknown dimensions, are unchanged. `@openmaic/generation` 0.3.25.
## [1.3.0-rc.1] - 2026-10-22
1.3.0-rc.1 is the first release candidate for 1.3.0. Its main theme is the **knowledge base**: every upload is kept in its owner's knowledge base until it is deleted, organized in folders and parsed once, with the result reused by every conversation. A new library page lists and organizes the materials, the composer offers them through `@` and a picker, and the agent gets tools to read, search and organize them. Interactive scenes also load their libraries from the deployment instead of a public CDN, the editor marks pages that have current problems, and malformed slide shapes no longer hang the renderer. This release contains breaking changes and upgrade conditions: the built-in Veo provider is removed, and the knowledge base adds schema changes that cannot run beside older instances or be rolled back. Read **Breaking Changes** before upgrading from 1.2.x.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openmaic/generation",
"version": "0.3.24",
"version": "0.3.25",
"description": "Pure generation pipeline contracts and packaged prompt assets for MAIC consumers.",
"type": "module",
"main": "./dist/index.js",
@@ -548,13 +548,19 @@ function fixElementDefaults(
const curW = normalized.width || 400;
const curH = normalized.height || 300;
if (Math.abs(curW / curH - knownRatio) / knownRatio > 0.1) {
// Keep width, correct height
const newH = Math.round(curW / knownRatio);
if (newH > 462) {
// canvas 562.5 - margins 50×2
return { ...normalized, width: Math.round(462 * knownRatio), height: 462 };
}
return { ...normalized, height: newH };
// Contain-fit (like `object-fit: contain`): the largest box with the
// real ratio inside the model's box, centered in it. Never grows past
// the model's box, so it cannot cover the elements laid out around it.
const fitByWidth = curW / curH < knownRatio;
const newW = fitByWidth ? curW : Math.round(curH * knownRatio);
const newH = fitByWidth ? Math.round(curW / knownRatio) : curH;
return {
...normalized,
left: Math.round((normalized.left ?? 0) + (curW - newW) / 2),
top: Math.round((normalized.top ?? 0) + (curH - newH) / 2),
width: newW,
height: newH,
};
}
}
}
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from 'vitest';
import { generateSceneContent, resolveImageIds } from '@openmaic/generation';
import type { GeneratedSlideData, PdfImage } from '@openmaic/generation';
import type { GeneratedSlideContent, GeneratedSlideData, PdfImage } from '@openmaic/generation';
import { slideOutline } from './scene-fixtures.js';
@@ -85,3 +85,69 @@ describe('generateSceneContent — non-vision text ordering with a mapping prese
expect(availableMedia).not.toContain('[see attached]');
});
});
describe('generateSceneContent — image aspect-ratio reconciliation (contain-fit)', () => {
// 1189×479 → ratio ≈ 2.48.
const wideImage: PdfImage = { id: 'img_1', src: '', pageNumber: 1, width: 1189, height: 479 };
async function fitImage(
box: { left: number; top: number; width: number; height: number },
assignedImages: PdfImage[] = [wideImage],
) {
const aiCall = vi.fn(async () =>
JSON.stringify({
elements: [{ ...imageElement('img_1'), ...box }],
remark: '',
}),
);
const content = (await generateSceneContent(slideOutline(), aiCall, {
assignedImages,
imageMapping: { img_1: 'ast_1' },
})) as GeneratedSlideContent;
expect(content.elements).toHaveLength(1);
const [image] = content.elements;
if (image.type !== 'image') throw new Error(`expected an image, got ${image.type}`);
const { left, top, width, height } = image;
return { left, top, width, height };
}
test('a box flatter than the image shrinks the width and centers horizontally, never growing down', async () => {
// Keeping the width would have made the box 363 tall and covered the
// elements laid out below it.
expect(await fitImage({ left: 50, top: 160, width: 900, height: 120 })).toEqual({
left: 351,
top: 160,
width: 298,
height: 120,
});
});
test('a box taller than the image keeps the width, shrinks the height and centers vertically', async () => {
// 400 / 2.482 ≈ 161; (400 - 161) / 2 ≈ 119.5 → 120 offset.
expect(await fitImage({ left: 100, top: 50, width: 400, height: 400 })).toEqual({
left: 100,
top: 170,
width: 400,
height: 161,
});
});
test('a box within the 10% ratio tolerance is left unchanged', async () => {
expect(await fitImage({ left: 10, top: 20, width: 500, height: 210 })).toEqual({
left: 10,
top: 20,
width: 500,
height: 210,
});
});
test('an image with unknown dimensions is left unchanged', async () => {
const unknown: PdfImage = { id: 'img_1', src: '', pageNumber: 1 };
expect(await fitImage({ left: 50, top: 160, width: 900, height: 120 }, [unknown])).toEqual({
left: 50,
top: 160,
width: 900,
height: 120,
});
});
});