Skip to content

Commit cee4c1f

Browse files
authored
Fit shapes to slide dimensions (#74)
This PR makes sure that inserted shapes are fully visible on a slide, i.e. that they remain within the slide boundaries. To be tested with the "Periodic Table of Elements" example from Cetz [here](https://raw.githubusercontent.com/typst/packages/main/packages/preview/cetz/0.4.2/gallery/periodic-table.typ).
1 parent 3c3b2de commit cee4c1f

1 file changed

Lines changed: 84 additions & 14 deletions

File tree

web/src/insertion.ts

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ type PreparedSvgResult = {
1313
payload: string;
1414
};
1515

16+
type SlideSize = {
17+
width: number;
18+
height: number;
19+
};
20+
1621
/**
1722
* Compiles Typst code to SVG and prepares it for insertion.
1823
*/
@@ -98,12 +103,21 @@ export async function insertOrUpdateFormula() {
98103
const selection = context.presentation.getSelectedShapes();
99104
const selectedSlides = context.presentation.getSelectedSlides();
100105
const allSlides = context.presentation.slides;
106+
const pageSetup = context.presentation.pageSetup;
101107

102108
selection.load("items");
103109
selectedSlides.load("items");
104110
allSlides.load("items");
111+
pageSetup.load(["slideWidth", "slideHeight"]);
105112
await context.sync();
106113

114+
const slideSize: SlideSize = {
115+
width: pageSetup.slideWidth,
116+
height: pageSetup.slideHeight,
117+
};
118+
119+
const fittedSize = fitSizeWithinSlide(prepared.size, slideSize);
120+
107121
const targetSlide: PowerPoint.Slide | undefined = selectedSlides.items[0] || allSlides.items[0];
108122
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
109123
if (!targetSlide || targetSlide.isNullObject) {
@@ -119,13 +133,14 @@ export async function insertOrUpdateFormula() {
119133

120134
const typstShape = await findTypstShape(selection.items, allSlides.items, context);
121135
if (typstShape) {
122-
position = calculateCenteredPosition(typstShape, prepared.size);
136+
position = calculateCenteredPosition(typstShape, fittedSize);
137+
position = clampPositionWithinSlide(position, fittedSize, slideSize);
123138
rotation = typstShape.rotation;
124139
typstShape.delete();
125140
isReplacing = true;
126141
await context.sync();
127142
} else {
128-
position = await calcShapeTopLeftToBeCentered(prepared.size, context);
143+
position = calcShapeTopLeftToBeCentered(fittedSize, slideSize);
129144
}
130145

131146
const existingShapeIds = new Set(targetSlide.shapes.items.map(shape => shape.id));
@@ -135,7 +150,7 @@ export async function insertOrUpdateFormula() {
135150
fillColor: fillColor || null,
136151
mathMode,
137152
position,
138-
size: prepared.size,
153+
size: fittedSize,
139154
rotation,
140155
}, targetSlide.id, existingShapeIds);
141156

@@ -236,6 +251,14 @@ export async function bulkUpdateFontSize() {
236251
return;
237252
}
238253

254+
const pageSetup = context.presentation.pageSetup;
255+
pageSetup.load(["slideWidth", "slideHeight"]);
256+
await context.sync();
257+
const slideSize: SlideSize = {
258+
width: pageSetup.slideWidth,
259+
height: pageSetup.slideHeight,
260+
};
261+
239262
let successCount = 0;
240263

241264
for (const shape of typstShapes) {
@@ -254,7 +277,13 @@ export async function bulkUpdateFontSize() {
254277
continue;
255278
}
256279

257-
const position = calculateCenteredPosition(shape, prepared.size);
280+
const fittedSize = fitSizeWithinSlide(prepared.size, slideSize);
281+
let position = calculateCenteredPosition(shape, fittedSize);
282+
position = clampPositionWithinSlide(
283+
position,
284+
fittedSize,
285+
slideSize,
286+
);
258287
const rotation = shape.rotation;
259288

260289
// Capture slide and existing shapes before deletion
@@ -274,7 +303,7 @@ export async function bulkUpdateFontSize() {
274303
fillColor,
275304
mathMode,
276305
position,
277-
size: prepared.size,
306+
size: fittedSize,
278307
rotation,
279308
}, slideId, existingShapeIds);
280309

@@ -309,19 +338,60 @@ function calculateCenteredPosition(
309338
};
310339
}
311340

341+
/**
342+
* Scales a shape to fit the slide while preserving aspect ratio.
343+
*
344+
* The scale factor is computed as:
345+
* s = min(slideWidth / shapeWidth, slideHeight / shapeHeight, 1)
346+
*/
347+
function fitSizeWithinSlide(
348+
shapeSize: { width: number; height: number },
349+
slideSize: SlideSize,
350+
): { width: number; height: number } {
351+
if (shapeSize.width <= 0 || shapeSize.height <= 0) {
352+
return shapeSize;
353+
}
354+
355+
const widthScale = slideSize.width / shapeSize.width;
356+
const heightScale = slideSize.height / shapeSize.height;
357+
const scale = Math.min(widthScale, heightScale, 1);
358+
359+
return {
360+
width: shapeSize.width * scale,
361+
height: shapeSize.height * scale,
362+
};
363+
}
364+
365+
/**
366+
* Clamps a position so the full shape remains inside the slide.
367+
*
368+
* The placement is clamped to:
369+
* - left: [0, slideWidth - shapeWidth]
370+
* - top: [0, slideHeight - shapeHeight]
371+
*/
372+
function clampPositionWithinSlide(
373+
position: { left: number; top: number },
374+
shapeSize: { width: number; height: number },
375+
slideSize: SlideSize,
376+
): { left: number; top: number } {
377+
const maxLeft = Math.max(0, slideSize.width - shapeSize.width);
378+
const maxTop = Math.max(0, slideSize.height - shapeSize.height);
379+
380+
return {
381+
left: Math.min(Math.max(0, position.left), maxLeft),
382+
top: Math.min(Math.max(0, position.top), maxTop),
383+
};
384+
}
385+
312386
/**
313387
* Calculates the top-left position for a shape to be centered on the slide.
314388
*/
315-
async function calcShapeTopLeftToBeCentered(
389+
function calcShapeTopLeftToBeCentered(
316390
shapeSize: { width: number; height: number },
317-
context: PowerPoint.RequestContext,
391+
slideSize: SlideSize,
318392
) {
319-
const pageSetup = context.presentation.pageSetup;
320-
pageSetup.load(["slideWidth", "slideHeight"]);
321-
await context.sync();
322-
323-
const centerX = (pageSetup.slideWidth - shapeSize.width) / 2;
324-
const centerY = (pageSetup.slideHeight - shapeSize.height) / 2;
393+
const centerX = (slideSize.width - shapeSize.width) / 2;
394+
const centerY = (slideSize.height - shapeSize.height) / 2;
325395

326-
return { left: centerX, top: centerY };
396+
return clampPositionWithinSlide({ left: centerX, top: centerY }, shapeSize, slideSize);
327397
}

0 commit comments

Comments
 (0)