-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathinsertion.ts
More file actions
327 lines (280 loc) · 10.4 KB
/
insertion.ts
File metadata and controls
327 lines (280 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
import { debug } from "./utils/logger.js";
import { applyFillColor, normalizeAlphaHexColors, parseAndApplySize } from "./svg.js";
import { typst } from "./typst.js";
import { setStatus, getFontSize, getFillColor, getMathModeEnabled, getTypstCode } from "./ui.js";
import { isTypstPayload, createTypstPayload, extractTypstCode } from "./payload.js";
import { storeValue } from "./utils/storage.js";
import { lastTypstShapeId, TypstShapeInfo, writeShapeProperties, readShapeTag } from "./shape.js";
import { STORAGE_KEYS, SHAPE_CONFIG, FILL_COLOR_DISABLED } from "./constants.js";
type PreparedSvgResult = {
svg: string;
size: { width: number; height: number };
payload: string;
};
/**
* Compiles Typst code to SVG and prepares it for insertion.
*/
async function prepareTypstSvg(
typstCode: string,
fontSize: string,
fillColor: string | null,
mathMode: boolean,
): Promise<PreparedSvgResult | null> {
const result = await typst(typstCode, fontSize, mathMode);
if (!result.svg) {
// diagnostics are only shown for preview, not insertion
return null;
}
const { svgElement, size } = parseAndApplySize(result.svg);
if (fillColor) {
applyFillColor(svgElement, fillColor);
}
normalizeAlphaHexColors(svgElement);
const serializer = new XMLSerializer();
const svg = serializer.serializeToString(svgElement);
const payload = createTypstPayload(typstCode);
return { svg, size, payload };
}
/**
* Inserts SVG into PowerPoint and tags it with Typst metadata.
*
* @returns the newly inserted shape or null if insertion fails.
*/
async function insertSvgAndTag(
svg: string,
info: TypstShapeInfo,
targetSlideId: string,
existingShapeIds: Set<string>,
): Promise<PowerPoint.Shape | null> {
return new Promise<PowerPoint.Shape | null>((resolve) => {
Office.context.document.setSelectedDataAsync(svg, { coercionType: Office.CoercionType.XmlSvg }, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("Insert failed:", result.error);
resolve(null);
return;
}
void PowerPoint.run(async (context) => {
const shapeToTag = await findInsertedShape(targetSlideId, existingShapeIds, context);
if (!shapeToTag) {
console.warn("No shape found after insertion; cannot tag Typst payload.");
resolve(null);
return;
}
await writeShapeProperties(shapeToTag, info, context);
resolve(shapeToTag);
});
});
});
}
/**
* Inserts or updates a Typst formula in PowerPoint.
*/
export async function insertOrUpdateFormula() {
const rawCode = getTypstCode();
const fontSize = getFontSize();
const fillColor = getFillColor();
const mathMode = getMathModeEnabled();
storeValue(STORAGE_KEYS.FONT_SIZE, fontSize);
storeValue(STORAGE_KEYS.FILL_COLOR, fillColor);
const prepared = await prepareTypstSvg(rawCode, fontSize, fillColor, mathMode);
if (!prepared) {
setStatus("Typst compile failed.", true);
return;
}
try {
await PowerPoint.run(async (context) => {
const selection = context.presentation.getSelectedShapes();
const selectedSlides = context.presentation.getSelectedSlides();
const allSlides = context.presentation.slides;
selection.load("items");
selectedSlides.load("items");
allSlides.load("items");
await context.sync();
const targetSlide: PowerPoint.Slide | undefined = selectedSlides.items[0] || allSlides.items[0];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!targetSlide || targetSlide.isNullObject) {
setStatus("No slide available to insert SVG.", true);
return;
}
targetSlide.load(["id", "shapes/items/id"]);
await context.sync();
let position: { left: number; top: number } | undefined;
let rotation: number | undefined;
let isReplacing = false;
const typstShape = await findTypstShape(selection.items, allSlides.items, context);
if (typstShape) {
position = calculateCenteredPosition(typstShape, prepared.size);
rotation = typstShape.rotation;
typstShape.delete();
isReplacing = true;
await context.sync();
} else {
position = await calcShapeTopLeftToBeCentered(prepared.size, context);
}
const existingShapeIds = new Set(targetSlide.shapes.items.map(shape => shape.id));
const insertedShape = await insertSvgAndTag(prepared.svg, {
payload: prepared.payload,
fontSize,
fillColor: fillColor || null,
mathMode,
position,
size: prepared.size,
rotation,
}, targetSlide.id, existingShapeIds);
if (!insertedShape) {
setStatus("Failed to insert SVG into the slide.", true);
return;
}
setStatus(isReplacing ? "Updated Typst SVG." : "Inserted Typst SVG.");
});
} catch (error) {
console.error("PowerPoint context error:", error);
setStatus("PowerPoint API error. See console.", true);
}
}
/**
* Finds a Typst shape in the current selection or uses cached selection.
*/
async function findTypstShape(selectedShapes: PowerPoint.Shape[], allSlides: PowerPoint.Slide[],
context: PowerPoint.RequestContext): Promise<PowerPoint.Shape | undefined> {
const typstShape = selectedShapes.find(
shape => isTypstPayload(shape.altTextDescription),
);
if (typstShape) return typstShape;
if (!lastTypstShapeId) return undefined;
const id = lastTypstShapeId;
try {
const targetSlide = allSlides.find(slide => slide.id === id.slideId) || allSlides[0];
if (targetSlide.isNullObject) return undefined;
targetSlide.shapes.load("items");
await context.sync();
if (targetSlide.shapes.items.length === 0) return undefined;
return targetSlide.shapes.items.find(shape => shape.id === id.shapeId);
} catch (error) {
debug("Fallback to last selection failed:", error);
return undefined;
}
}
/**
* Finds the newly inserted shape on a slide.
*
* @param slideId Target slide ID
* @param existingShapeIds IDs of shapes before insertion
* @param context PowerPoint context
* @returns The new shape or null
*/
async function findInsertedShape(slideId: string, existingShapeIds: Set<string>,
context: PowerPoint.RequestContext): Promise<PowerPoint.Shape | null> {
try {
const slide = context.presentation.slides.getItem(slideId);
slide.shapes.load("items/id");
await context.sync();
const newShapes = slide.shapes.items.filter(shape => !existingShapeIds.has(shape.id));
if (newShapes.length > 0) {
return newShapes[newShapes.length - 1];
}
if (slide.shapes.items.length > 0) {
return slide.shapes.items[slide.shapes.items.length - 1];
}
} catch (error) {
debug("Shape diff fallback failed", error);
}
const postShapes = context.presentation.getSelectedShapes();
postShapes.load("items");
await context.sync();
return postShapes.items.length > 0 ? postShapes.items[postShapes.items.length - 1] : null;
}
/**
* Updates font size for all selected Typst shapes.
*/
export async function bulkUpdateFontSize() {
const newFontSize = getFontSize();
storeValue(STORAGE_KEYS.FONT_SIZE, newFontSize);
try {
await PowerPoint.run(async (context) => {
const selection = context.presentation.getSelectedShapes();
selection.load("items");
await context.sync();
const typstShapes = selection.items.filter(shape =>
isTypstPayload(shape.altTextDescription),
);
if (typstShapes.length === 0) {
setStatus("No Typst shapes selected.", true);
return;
}
let successCount = 0;
for (const shape of typstShapes) {
try {
const typstCode = extractTypstCode(shape.altTextDescription);
const storedFillColor = await readShapeTag(shape, SHAPE_CONFIG.TAGS.FILL_COLOR, context);
const fillColor = !storedFillColor || storedFillColor === FILL_COLOR_DISABLED
? null
: storedFillColor;
const mathMode = getMathModeEnabled();
const prepared = await prepareTypstSvg(typstCode, newFontSize, fillColor, mathMode);
if (!prepared) {
debug(`Typst compile failed for shape ${shape.id}`);
continue;
}
const position = calculateCenteredPosition(shape, prepared.size);
const rotation = shape.rotation;
// Capture slide and existing shapes before deletion
const parentSlide = shape.getParentSlide();
parentSlide.load("id");
parentSlide.shapes.load("items/id");
await context.sync();
const existingShapeIds = new Set(parentSlide.shapes.items.map((s: PowerPoint.Shape) => s.id));
const slideId = parentSlide.id;
shape.delete();
await context.sync();
const insertedShape = await insertSvgAndTag(prepared.svg, {
payload: prepared.payload,
fontSize: newFontSize,
fillColor,
mathMode,
position,
size: prepared.size,
rotation,
}, slideId, existingShapeIds);
if (insertedShape) {
successCount++;
}
} catch (error) {
debug(`Error updating shape ${shape.id}:`, error);
}
}
setStatus(`Updated ${successCount.toString()} of ${typstShapes.length.toString()} Typst shapes with font size ${newFontSize}.`);
});
} catch (error) {
console.error("Bulk update error:", error);
setStatus("Error updating Typst shapes. See console.", true);
}
}
/**
* Calculates the position to center a new shape on an old shape's center point.
*/
function calculateCenteredPosition(
oldShape: { left: number; top: number; width: number; height: number },
newSize: { width: number; height: number },
): { left: number; top: number } {
const centerX = oldShape.left + oldShape.width / 2;
const centerY = oldShape.top + oldShape.height / 2;
return {
left: centerX - newSize.width / 2,
top: centerY - newSize.height / 2,
};
}
/**
* Calculates the top-left position for a shape to be centered on the slide.
*/
async function calcShapeTopLeftToBeCentered(
shapeSize: { width: number; height: number },
context: PowerPoint.RequestContext,
) {
const pageSetup = context.presentation.pageSetup;
pageSetup.load(["slideWidth", "slideHeight"]);
await context.sync();
const centerX = (pageSetup.slideWidth - shapeSize.width) / 2;
const centerY = (pageSetup.slideHeight - shapeSize.height) / 2;
return { left: centerX, top: centerY };
}