Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions web/powerpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@
</div>
</div>

<details id="preambleDetails" class="preamble-panel">
<summary id="preambleSummary" class="preamble-summary" title="Used for new Typst shapes. Existing shapes keep their own preamble until you update them.">
Global preamble
</summary>
<textarea
id="preambleInput"
placeholder="Put your preamble here, e.g. frequently used packages, macro definitions, etc."
spellcheck="false"
></textarea>
</details>

<!-- Status Bar -->
<div id="statusBar" class="status-bar">
<div id="status" class="status-text"></div>
Expand Down
30 changes: 30 additions & 0 deletions web/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@
*/
export const SHAPE_CONFIG = {
NAME: "Typst Shape",
ALT_TEXT_TITLE: "PPTypst shape",
ALT_TEXT_DESCRIPTION: "Generated by PPTypst. Edit this shape with the PPTypst add-in.",
TAGS: {
KIND: "TypstKind",
FONT_SIZE: "TypstFontSize",
FILL_COLOR: "TypstFillColor",
MATH_MODE: "TypstMathMode",
},
TAG_VALUES: {
KIND: "typst",
},
CUSTOM_XML: {
NAMESPACE: "https://splines.github.io/pptypst/shape/v1",
ROOT: "pptypst:content",
PREAMBLE_NODE: "pptypst:preamble",
BODY_NODE: "pptypst:body",
},
} as const;

/**
Expand All @@ -29,6 +41,9 @@ export const DOM_IDS = {
FILL_COLOR: "fillColor",
PREVIEW_FILL_ENABLED: "previewFillEnabled",
MATH_MODE_ENABLED: "mathModeEnabled",
PREAMBLE_DETAILS: "preambleDetails",
PREAMBLE_SUMMARY: "preambleSummary",
PREAMBLE_INPUT: "preambleInput",
INPUT_WRAPPER: "inputWrapper",
TYPST_INPUT: "typstInput",
INSERT_BTN: "insertBtn",
Expand Down Expand Up @@ -56,6 +71,8 @@ export const STORAGE_KEYS = {
FILL_COLOR: "typstFillColor",
PREVIEW_FILL: "typstPreviewFill",
MATH_MODE: "typstMathMode",
PREAMBLE: "typstPreamble",
PREAMBLE_OPEN: "typstPreambleOpen",
THEME: "typstTheme",
} as const;

Expand Down Expand Up @@ -101,4 +118,17 @@ export const BUTTON_TEXT = {
export const DEFAULTS = {
FONT_SIZE: "28",
FILL_COLOR: "#000000",
PREAMBLE: "",
} as const;

/**
* Preamble editor copy.
*/
export const PREAMBLE_UI = {
GLOBAL_LABEL: "Global preamble",
SHAPE_LABEL: "Shape preamble",
MULTI_LABEL: "Preamble",
GLOBAL_TITLE: "Used for new Typst shapes. Existing shapes keep their own preamble until you update them.",
SHAPE_TITLE: "This preamble belongs only to the selected shape and is saved when you update it.",
MULTI_TITLE: "Select a single Typst shape to edit its preamble, or clear the selection to edit the global default.",
} as const;
93 changes: 66 additions & 27 deletions web/src/insertion.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
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 {
setStatus,
getFontSize,
getFillColor,
getMathModeEnabled,
getTypstCode,
getPreambleCode,
getEditorMode,
} from "./ui.js";
import { TypstSource } from "./payload.js";
import { storeValue } from "./utils/storage.js";
import { lastTypstShapeId, TypstShapeInfo, writeShapeProperties, readShapeTag } from "./shape.js";
import {
lastTypstShapeId,
TypstShapeInfo,
writeShapeProperties,
readShapeTag,
readTypstSource,
isLoadedTypstShape,
} 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;
};

type SlideSize = {
Expand All @@ -22,12 +36,12 @@ type SlideSize = {
* Compiles Typst code to SVG and prepares it for insertion.
*/
async function prepareTypstSvg(
typstCode: string,
source: TypstSource,
fontSize: string,
fillColor: string | null,
mathMode: boolean,
): Promise<PreparedSvgResult | null> {
const result = await typst(typstCode, fontSize, mathMode);
const result = await typst(source, fontSize, mathMode);
if (!result.svg) {
// diagnostics are only shown for preview, not insertion
return null;
Expand All @@ -41,9 +55,8 @@ async function prepareTypstSvg(

const serializer = new XMLSerializer();
const svg = serializer.serializeToString(svgElement);
const payload = createTypstPayload(typstCode);

return { svg, size, payload };
return { svg, size };
}

/**
Expand Down Expand Up @@ -85,27 +98,39 @@ async function insertSvgAndTag(
* Inserts or updates a Typst formula in PowerPoint.
*/
export async function insertOrUpdateFormula() {
const rawCode = getTypstCode();
if (getEditorMode() === "multi-select") {
setStatus(
"Select a single Typst shape to update it, or clear the selection to insert a new one.", true);
return;
}

const source: TypstSource = {
body: getTypstCode(),
preamble: getPreambleCode(),
};
const fontSize = getFontSize();
const fillColor = getFillColor();
const mathMode = getMathModeEnabled();
storeValue(STORAGE_KEYS.FONT_SIZE, fontSize);
storeValue(STORAGE_KEYS.FILL_COLOR, fillColor);
if (getEditorMode() === "insert") {
// overwrite global preamble only when inserting a new shape, not when
// editing an existing shape
storeValue(STORAGE_KEYS.PREAMBLE, source.preamble);
}

const prepared = await prepareTypstSvg(rawCode, fontSize, fillColor, mathMode);
const prepared = await prepareTypstSvg(source, 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;
const pageSetup = context.presentation.pageSetup;

selection.load("items");
selectedSlides.load("items");
allSlides.load("items");
pageSetup.load(["slideWidth", "slideHeight"]);
Expand All @@ -131,7 +156,7 @@ export async function insertOrUpdateFormula() {
let rotation: number | undefined;
let isReplacing = false;

const typstShape = await findTypstShape(selection.items, allSlides.items, context);
const typstShape = await findTypstShape(allSlides.items, context);
if (typstShape) {
position = calculateCenteredPosition(typstShape, fittedSize);
position = clampPositionWithinSlide(position, fittedSize, slideSize);
Expand All @@ -145,7 +170,7 @@ export async function insertOrUpdateFormula() {

const existingShapeIds = new Set(targetSlide.shapes.items.map(shape => shape.id));
const insertedShape = await insertSvgAndTag(prepared.svg, {
payload: prepared.payload,
source,
fontSize,
fillColor: fillColor || null,
mathMode,
Expand All @@ -170,13 +195,8 @@ export async function insertOrUpdateFormula() {
/**
* Finds a Typst shape in the current selection or uses cached selection.
*/
async function findTypstShape(selectedShapes: PowerPoint.Shape[], allSlides: PowerPoint.Slide[],
async function findTypstShape(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;

Expand All @@ -188,7 +208,15 @@ async function findTypstShape(selectedShapes: PowerPoint.Shape[], allSlides: Pow
await context.sync();
if (targetSlide.shapes.items.length === 0) return undefined;

return targetSlide.shapes.items.find(shape => shape.id === id.shapeId);
const cachedShape = targetSlide.shapes.items.find(shape => shape.id === id.shapeId);
if (!cachedShape) {
return undefined;
}

cachedShape.load(["id", "altTextDescription", "left", "top", "width", "height", "rotation"]);
cachedShape.tags.load("items/key,items/value");
await context.sync();
return cachedShape;
} catch (error) {
debug("Fallback to last selection failed:", error);
return undefined;
Expand Down Expand Up @@ -242,9 +270,15 @@ export async function bulkUpdateFontSize() {
selection.load("items");
await context.sync();

const typstShapes = selection.items.filter(shape =>
isTypstPayload(shape.altTextDescription),
);
if (selection.items.length > 0) {
selection.items.forEach((shape) => {
shape.load(["id", "altTextDescription", "left", "top", "width", "height", "rotation"]);
shape.tags.load("items/key,items/value");
});
await context.sync();
}

const typstShapes = selection.items.filter(isLoadedTypstShape);

if (typstShapes.length === 0) {
setStatus("No Typst shapes selected.", true);
Expand All @@ -263,15 +297,20 @@ export async function bulkUpdateFontSize() {

for (const shape of typstShapes) {
try {
const typstCode = extractTypstCode(shape.altTextDescription);
const source = await readTypstSource(shape, context);
if (!source) {
debug(`No Typst source stored for shape ${shape.id}`);
continue;
}

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);
const prepared = await prepareTypstSvg(source, newFontSize, fillColor, mathMode);
if (!prepared) {
debug(`Typst compile failed for shape ${shape.id}`);
continue;
Expand All @@ -298,7 +337,7 @@ export async function bulkUpdateFontSize() {
await context.sync();

const insertedShape = await insertSvgAndTag(prepared.svg, {
payload: prepared.payload,
source,
fontSize: newFontSize,
fillColor,
mathMode,
Expand Down
89 changes: 77 additions & 12 deletions web/src/payload.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,90 @@
import { decodeBase64, encodeBase64 } from "./utils/base64";
import { decodeBase64 } from "./utils/base64";
import { SHAPE_CONFIG } from "./constants.js";

const TYPST_PREFIX = "TYPST:";
const LEGACY_TYPST_PREFIX = "TYPST:";

export type TypstSource = {
preamble: string;
body: string;
};

/**
* Creates a Typst payload from source code.
* Checks if an alt text description contains a legacy Typst payload.
*/
export function createTypstPayload(code: string): string {
return `${TYPST_PREFIX}${encodeBase64(code)}`;
export function isLegacyTypstPayload(altTextDescription: string | undefined): boolean {
return !!(altTextDescription && altTextDescription.startsWith(LEGACY_TYPST_PREFIX));
}

/**
* Checks if an alt text description is a Typst payload.
* Extracts Typst code from a legacy payload.
*/
export function isTypstPayload(altTextDescription: string | undefined): boolean {
return !!(altTextDescription && altTextDescription.startsWith(TYPST_PREFIX));
export function extractLegacyTypstCode(payload: string): string {
const base64Payload = payload.split(LEGACY_TYPST_PREFIX)[1];
return decodeBase64(base64Payload);
}

/**
* Extracts Typst code from a payload.
* Converts legacy payload data to the current Typst source structure.
*/
export function extractTypstCode(payload: string): string {
const base64Payload = payload.split(TYPST_PREFIX)[1];
return decodeBase64(base64Payload);
export function getLegacyTypstSource(payload: string): TypstSource {
return {
preamble: "",
body: extractLegacyTypstCode(payload),
};
}

/**
* Serializes Typst source for storage in a shape-scoped custom XML part.
*/
export function serializeTypstSource(source: TypstSource): string {
const documentNode = document.implementation.createDocument(
SHAPE_CONFIG.CUSTOM_XML.NAMESPACE,
SHAPE_CONFIG.CUSTOM_XML.ROOT,
null,
);
const root = documentNode.documentElement;

const preambleNode = documentNode.createElementNS(
SHAPE_CONFIG.CUSTOM_XML.NAMESPACE,
SHAPE_CONFIG.CUSTOM_XML.PREAMBLE_NODE,
);
preambleNode.textContent = source.preamble;

const bodyNode = documentNode.createElementNS(
SHAPE_CONFIG.CUSTOM_XML.NAMESPACE,
SHAPE_CONFIG.CUSTOM_XML.BODY_NODE,
);
bodyNode.textContent = source.body;

root.appendChild(preambleNode);
root.appendChild(bodyNode);

return new XMLSerializer().serializeToString(documentNode);
}

/**
* Parses Typst source from a shape-scoped custom XML part.
*/
export function parseTypstSource(xml: string): TypstSource | null {
const documentNode = new DOMParser().parseFromString(xml, "application/xml");
if (documentNode.querySelector("parsererror")) {
return null;
}

const preambleNode = documentNode
.getElementsByTagNameNS(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, "preamble")
.item(0);

const bodyNode = documentNode
.getElementsByTagNameNS(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, "body")
.item(0);

if (preambleNode === null || bodyNode === null) {
return null;
}

return {
preamble: preambleNode.textContent,
body: bodyNode.textContent,
Comment thread
Splines marked this conversation as resolved.
};
Comment thread
Splines marked this conversation as resolved.
}
Loading