diff --git a/web/powerpoint.html b/web/powerpoint.html
index a1d06e8..7fd72b3 100644
--- a/web/powerpoint.html
+++ b/web/powerpoint.html
@@ -76,6 +76,17 @@
+
+
+ Global preamble
+
+
+
+
diff --git a/web/src/constants.ts b/web/src/constants.ts
index dc44d01..2f2e237 100644
--- a/web/src/constants.ts
+++ b/web/src/constants.ts
@@ -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;
/**
@@ -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",
@@ -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;
@@ -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;
diff --git a/web/src/insertion.ts b/web/src/insertion.ts
index 1483233..938ce5a 100644
--- a/web/src/insertion.ts
+++ b/web/src/insertion.ts
@@ -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 = {
@@ -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
{
- 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;
@@ -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 };
}
/**
@@ -85,14 +98,28 @@ 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;
@@ -100,12 +127,10 @@ export async function insertOrUpdateFormula() {
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"]);
@@ -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);
@@ -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,
@@ -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 {
- const typstShape = selectedShapes.find(
- shape => isTypstPayload(shape.altTextDescription),
- );
- if (typstShape) return typstShape;
-
if (!lastTypstShapeId) return undefined;
const id = lastTypstShapeId;
@@ -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;
@@ -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);
@@ -263,7 +297,12 @@ 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
@@ -271,7 +310,7 @@ export async function bulkUpdateFontSize() {
: 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;
@@ -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,
diff --git a/web/src/payload.ts b/web/src/payload.ts
index 623b428..c1a5300 100644
--- a/web/src/payload.ts
+++ b/web/src/payload.ts
@@ -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,
+ };
}
diff --git a/web/src/preview.ts b/web/src/preview.ts
index 6f45c90..98ac8a7 100644
--- a/web/src/preview.ts
+++ b/web/src/preview.ts
@@ -5,19 +5,21 @@ import { getAreaElement, getHTMLElement, getInputElement } from "./utils/dom";
import {
getFillColor,
getFontSize,
+ getPreambleCode,
+ getEditorMode,
getMathModeEnabled,
getTypstCode,
setButtonEnabled,
setMathModeEnabled,
} from "./ui";
import { storeValue, getStoredValue } from "./utils/storage.js";
-import { lastTypstShapeId } from "./shape.js";
/**
* Sets up event listeners for preview updates.
*/
export function setupPreviewListeners() {
const typstInput = getAreaElement(DOM_IDS.TYPST_INPUT);
+ const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT);
const fontSizeInput = getInputElement(DOM_IDS.FONT_SIZE);
const fillColorInput = getInputElement(DOM_IDS.FILL_COLOR);
const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED);
@@ -29,6 +31,13 @@ export function setupPreviewListeners() {
void updatePreview();
});
+ preambleInput.addEventListener("input", () => {
+ if (getEditorMode() === "insert") {
+ storeValue(STORAGE_KEYS.PREAMBLE, getPreambleCode());
+ }
+ void updatePreview();
+ });
+
fontSizeInput.addEventListener("input", () => {
const fontSize = getFontSize();
storeValue(STORAGE_KEYS.FONT_SIZE, fontSize);
@@ -56,7 +65,7 @@ export function setupPreviewListeners() {
mathModeEnabled.addEventListener("change", () => {
const mathMode = getMathModeEnabled();
- if (!lastTypstShapeId) {
+ if (getEditorMode() === "insert") {
// Only save to storage when in insert mode (no shape selected)
storeValue(STORAGE_KEYS.MATH_MODE, mathMode.toString());
}
@@ -126,6 +135,7 @@ export function updateMathModeVisuals() {
*/
export async function updatePreview() {
const rawCode = getTypstCode().trim();
+ const preamble = getPreambleCode();
const fontSize = getFontSize();
const mathMode = getMathModeEnabled();
const previewElement = getHTMLElement(DOM_IDS.PREVIEW_CONTENT);
@@ -138,11 +148,11 @@ export async function updatePreview() {
return;
}
- const result = await typst(rawCode, fontSize, mathMode);
+ const result = await typst({ body: rawCode, preamble }, fontSize, mathMode);
if (result.diagnostics && result.diagnostics.length > 0) {
diagnosticsContainer.style.display = "block";
- displayDiagnostics(result.diagnostics, diagnosticsContent, mathMode);
+ displayDiagnostics(result.diagnostics, diagnosticsContent);
} else {
diagnosticsContainer.style.display = "none";
}
@@ -174,7 +184,10 @@ export async function updatePreview() {
/**
* Displays diagnostics in the UI.
*/
-function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content: HTMLElement, mathMode: boolean) {
+function displayDiagnostics(
+ diagnostics: (string | DiagnosticMessage)[],
+ content: HTMLElement,
+) {
content.innerHTML = "";
diagnostics.forEach((diag, index) => {
@@ -202,13 +215,7 @@ function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content
severitySpan.className = "diagnostic-severity";
severitySpan.textContent = diag.severity;
- const rangeSpan = document.createElement("span");
- rangeSpan.className = "diagnostic-range";
- const rangeString = correctDiagnosticRange(diag.range, mathMode);
- rangeSpan.textContent = rangeString;
-
headerDiv.appendChild(severitySpan);
- headerDiv.appendChild(rangeSpan);
const messageSpan = document.createElement("span");
messageSpan.className = "diagnostic-message";
@@ -225,30 +232,11 @@ function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content
* Updates the insert button enabled state based on whether there's input.
*/
export function updateButtonState() {
+ if (getEditorMode() === "multi-select") {
+ setButtonEnabled(false);
+ return;
+ }
+
const rawCode = getTypstCode().trim();
setButtonEnabled(rawCode.length > 0);
}
-
-/**
- * Corrects the diagnostic range to account for added lines in the Typst code.
- *
- * See `buildRawTypstString` for details.
- *
- * @param range The range string from the diagnostic
- * @param mathMode Whether math mode is enabled (adds extra line offset)
- */
-function correctDiagnosticRange(range: string, mathMode: boolean): string {
- const rangeRegex = /(\d+):(\d+)-(\d+):(\d+)/;
- const match = range.match(rangeRegex);
- if (match) {
- // buildRawTypstString adds 2 lines before user code
- // If mathMode is enabled, it adds one more line for the opening $
- const offset = mathMode ? 3 : 2;
- const startLine = parseInt(match[1], 10) - offset;
- const startCol = parseInt(match[2], 10);
- const endLine = parseInt(match[3], 10) - offset;
- const endCol = parseInt(match[4], 10);
- return `${startLine.toString()}:${startCol.toString()}-${endLine.toString()}:${endCol.toString()}`;
- }
- return range;
-}
diff --git a/web/src/selection.ts b/web/src/selection.ts
index 75743c7..a8b3e0c 100644
--- a/web/src/selection.ts
+++ b/web/src/selection.ts
@@ -1,8 +1,19 @@
import { FILL_COLOR_DISABLED, SHAPE_CONFIG, DEFAULTS } from "./constants.js";
-import { extractTypstCode, isTypstPayload } from "./payload.js";
import { updatePreview, updateButtonState, restoreMathModeFromStorage, updateMathModeVisuals, syncPreviewFillToggleFromFillCheckbox } from "./preview.js";
-import { readShapeTag, setLastTypstId } from "./shape.js";
-import { setButtonText, setFillColor, setFontSize, setMathModeEnabled, setStatus, setTypstCode, setBulkUpdateButtonVisible, setFileButtonText } from "./ui.js";
+import { readShapeTag, readTypstSource, setLastTypstId, isLoadedTypstShape } from "./shape.js";
+import {
+ setButtonText,
+ setFillColor,
+ setFontSize,
+ setMathModeEnabled,
+ setStatus,
+ setTypstCode,
+ setPreambleCode,
+ restorePreambleFromStorage,
+ setBulkUpdateButtonVisible,
+ setFileButtonText,
+ setEditorMode,
+} from "./ui.js";
import { debug } from "./utils/logger.js";
/**
@@ -17,15 +28,18 @@ export async function handleSelectionChange() {
await context.sync();
if (shapes.items.length > 0) {
- shapes.items.forEach(shape =>
+ shapes.items.forEach((shape) => {
shape.load(["id", "altTextDescription", "left", "top",
- "width", "height", "rotation", "tags"]),
- );
+ "width", "height", "rotation"]);
+ shape.tags.load("items/key,items/value");
+ });
await context.sync();
}
if (shapes.items.length === 0) {
setLastTypstId(null);
+ setEditorMode("insert");
+ restorePreambleFromStorage();
setButtonText(false);
setBulkUpdateButtonVisible(false);
setFileButtonText(false);
@@ -33,12 +47,12 @@ export async function handleSelectionChange() {
return;
}
- const typstShapes = shapes.items.filter(shape =>
- isTypstPayload(shape.altTextDescription),
- );
+ const typstShapes = shapes.items.filter(isLoadedTypstShape);
if (typstShapes.length > 1) {
// Multiple Typst shapes selected - show bulk update button
+ setEditorMode("multi-select");
+ restorePreambleFromStorage();
setBulkUpdateButtonVisible(true);
setButtonText(true);
setFileButtonText(true);
@@ -55,6 +69,8 @@ export async function handleSelectionChange() {
} else {
// No Typst shapes selected
setLastTypstId(null);
+ setEditorMode("insert");
+ restorePreambleFromStorage();
setButtonText(false);
setFileButtonText(false);
setBulkUpdateButtonVisible(false);
@@ -69,7 +85,12 @@ export async function handleSelectionChange() {
async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | null,
context: PowerPoint.RequestContext) {
try {
- const typstCode = extractTypstCode(typstShape.altTextDescription);
+ const typstSource = await readTypstSource(typstShape, context);
+ if (!typstSource) {
+ setStatus("Failed to read Typst source from selection.", true);
+ return;
+ }
+
const storedFontSize = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.FONT_SIZE, context);
const storedFillColor = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.FILL_COLOR, context);
const storedMathMode = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.MATH_MODE, context);
@@ -87,7 +108,9 @@ async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | nu
setFillColor(fillColorToSet);
syncPreviewFillToggleFromFillCheckbox();
- setTypstCode(typstCode);
+ setTypstCode(typstSource.body);
+ setPreambleCode(typstSource.preamble);
+ setEditorMode("edit");
setMathModeEnabled(storedMathMode === "true");
updateMathModeVisuals();
setLastTypstId({ slideId, shapeId: typstShape.id });
@@ -95,8 +118,8 @@ async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | nu
updateButtonState();
void updatePreview();
} catch (error) {
- console.error("Decode error:", error);
- setStatus("Failed to decode Typst payload from selection.", true);
+ console.error("Selection load error:", error);
+ setStatus("Failed to load Typst data from selection.", true);
}
}
diff --git a/web/src/shape.ts b/web/src/shape.ts
index c4f855e..72b3b7f 100644
--- a/web/src/shape.ts
+++ b/web/src/shape.ts
@@ -1,4 +1,11 @@
import { SHAPE_CONFIG, FILL_COLOR_DISABLED } from "./constants.js";
+import {
+ getLegacyTypstSource,
+ isLegacyTypstPayload,
+ parseTypstSource,
+ serializeTypstSource,
+ TypstSource,
+} from "./payload.js";
import { debug } from "./utils/logger.js";
export type TypstShapeId = {
@@ -16,7 +23,7 @@ export function setLastTypstId(info: TypstShapeId | null) {
}
export type TypstShapeInfo = {
- payload: string;
+ source: TypstSource;
fontSize: string;
fillColor: string | null;
mathMode: boolean;
@@ -28,15 +35,25 @@ export type TypstShapeInfo = {
/**
* Writes shape properties and Typst metadata to a given shape.
*/
-export async function writeShapeProperties(shape: PowerPoint.Shape, info: TypstShapeInfo,
- context: PowerPoint.RequestContext) {
- shape.altTextDescription = info.payload;
+export async function writeShapeProperties(
+ shape: PowerPoint.Shape,
+ info: TypstShapeInfo,
+ context: PowerPoint.RequestContext,
+) {
+ shape.altTextTitle = SHAPE_CONFIG.ALT_TEXT_TITLE;
+ shape.altTextDescription = SHAPE_CONFIG.ALT_TEXT_DESCRIPTION;
shape.name = SHAPE_CONFIG.NAME;
+ shape.tags.add(SHAPE_CONFIG.TAGS.KIND, SHAPE_CONFIG.TAG_VALUES.KIND);
shape.tags.add(SHAPE_CONFIG.TAGS.FONT_SIZE, info.fontSize);
shape.tags.add(SHAPE_CONFIG.TAGS.FILL_COLOR,
info.fillColor === null ? FILL_COLOR_DISABLED : info.fillColor);
shape.tags.add(SHAPE_CONFIG.TAGS.MATH_MODE, info.mathMode.toString());
+ // There can't be leftover XML parts here, as we always create
+ // a new shape when updating
+ const serializedSource = serializeTypstSource(info.source);
+ shape.customXmlParts.add(serializedSource);
+
if (info.size.height > 0 && info.size.width > 0) {
shape.height = info.size.height;
shape.width = info.size.width;
@@ -72,3 +89,44 @@ export async function readShapeTag(
return null;
}
}
+
+/**
+ * Checks whether a loaded shape belongs to PPTypst.
+ */
+export function isLoadedTypstShape(shape: PowerPoint.Shape): boolean {
+ const hasMarkerTag = shape.tags.items.some(tag =>
+ tag.key.toLowerCase() === SHAPE_CONFIG.TAGS.KIND.toLowerCase()
+ && tag.value === SHAPE_CONFIG.TAG_VALUES.KIND,
+ );
+
+ return hasMarkerTag || isLegacyTypstPayload(shape.altTextDescription);
+}
+
+/**
+ * Reads stored Typst source from a shape.
+ */
+export async function readTypstSource(
+ shape: PowerPoint.Shape,
+ context: PowerPoint.RequestContext,
+): Promise {
+ try {
+ const xmlParts = shape.customXmlParts.getByNamespace(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE);
+ xmlParts.load("items/id");
+ await context.sync();
+
+ if (xmlParts.items.length > 0) {
+ const latestPart = xmlParts.items[xmlParts.items.length - 1];
+ const xmlResult = latestPart.getXml();
+ await context.sync();
+ return parseTypstSource(xmlResult.value);
+ }
+ } catch (error) {
+ debug("Error reading Typst custom XML part:", error);
+ }
+
+ if (isLegacyTypstPayload(shape.altTextDescription)) {
+ return getLegacyTypstSource(shape.altTextDescription);
+ }
+
+ return null;
+}
diff --git a/web/src/typst.ts b/web/src/typst.ts
index 039db81..e06ae29 100644
--- a/web/src/typst.ts
+++ b/web/src/typst.ts
@@ -25,6 +25,7 @@ import typstCompilerWasm from "@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts
// @ts-expect-error WASM module import
import typstRendererWasm from "@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url";
import { registryRequest } from "./registry/registry";
+import { TypstSource } from "./payload.js";
let compiler: typstWeb.TypstCompiler;
let renderer: typstWeb.TypstRenderer;
@@ -80,21 +81,21 @@ async function initRenderer() {
/**
* Builds the complete Typst code with page setup and font size.
*
- * Note: If you change the number of lines added here, make sure to update
- * the diagnostic range offset in preview.ts accordingly.
- *
- * @param rawCode The user's Typst code
+ * @param source The user's Typst source
* @param fontSize Font size in points
* @param mathMode Whether to wrap the code in display math delimiters
* @returns Complete Typst code ready for compilation
*/
-function buildRawTypstString(rawCode: string, fontSize: string, mathMode: boolean): string {
- let code = rawCode;
+function buildRawTypstString(source: TypstSource, fontSize: string, mathMode: boolean): string {
+ let body = source.body;
if (mathMode) {
- code = `$\n${rawCode}\n$`;
+ body = `$\n${source.body}\n$`;
}
+
+ const separator = source.preamble && body && !source.preamble.endsWith("\n") ? "\n" : "";
+ const compiledUserSource = `${source.preamble}${separator}${body}`;
return "#set page(margin: 3pt, background: none, width: auto, fill: none, height: auto)"
- + `\n#set text(size: ${fontSize}pt)\n${code}`;
+ + `\n#set text(size: ${fontSize}pt)\n${compiledUserSource}`;
}
export interface CompilationResult {
@@ -122,10 +123,12 @@ export type Diagnostics = (string | DiagnosticMessage)[] | undefined;
/**
* Compiles the given Typst source to SVG.
*/
-export async function typst(source: string, fontSize: string, mathMode: boolean): Promise {
+export async function typst(
+ source: TypstSource, fontSize: string, mathMode: boolean,
+): Promise {
const mainFilePath = "/main.typ";
- const typstCode = buildRawTypstString(source, fontSize, mathMode);
- compiler.addSource(mainFilePath, typstCode);
+ const builtSource = buildRawTypstString(source, fontSize, mathMode);
+ compiler.addSource(mainFilePath, builtSource);
const response = await compiler.compile({ mainFilePath });
const diagnostics: Diagnostics = response.diagnostics;
diff --git a/web/src/ui.ts b/web/src/ui.ts
index 918ac71..46e971a 100644
--- a/web/src/ui.ts
+++ b/web/src/ui.ts
@@ -1,9 +1,25 @@
-import { DOM_IDS, DEFAULTS, BUTTON_TEXT, STORAGE_KEYS, FILL_COLOR_DISABLED } from "./constants.js";
-import { getInputElement, getHTMLElement, getAreaElement, getButtonElement } from "./utils/dom.js";
+import {
+ DOM_IDS,
+ DEFAULTS,
+ BUTTON_TEXT,
+ STORAGE_KEYS,
+ FILL_COLOR_DISABLED,
+ PREAMBLE_UI,
+} from "./constants.js";
+import {
+ getInputElement,
+ getHTMLElement,
+ getAreaElement,
+ getButtonElement,
+ getDetailsElement,
+} from "./utils/dom.js";
import { insertOrUpdateFormula, bulkUpdateFontSize } from "./insertion.js";
-import { getStoredValue } from "./utils/storage.js";
+import { getStoredValue, storeValue } from "./utils/storage.js";
import { handleGenerateFromFile } from "./file/file.js";
+export type EditorMode = "insert" | "edit" | "multi-select";
+let currentEditorMode: EditorMode = "insert";
+
/**
* Initializes the UI state.
*/
@@ -23,10 +39,18 @@ export function initializeUIState() {
setMathModeEnabled(savedMathMode === "true");
}
+ const savedPreamble = getStoredValue(STORAGE_KEYS.PREAMBLE);
+ setPreambleCode(savedPreamble || DEFAULTS.PREAMBLE);
+
+ const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS);
+ preambleDetails.open = getStoredValue(STORAGE_KEYS.PREAMBLE_OPEN) === "true";
+
const savedPreviewFill = getStoredValue(STORAGE_KEYS.PREVIEW_FILL);
if (savedPreviewFill !== null) {
setPreviewFillEnabled(savedPreviewFill === "true");
}
+
+ updatePreamblePresentation();
}
/**
@@ -49,6 +73,23 @@ export function setupEventListeners() {
const typstInput = getAreaElement(DOM_IDS.TYPST_INPUT);
typstInput.addEventListener("keydown", handleCtrlEnter);
+ const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT);
+ preambleInput.addEventListener("keydown", handleCtrlEnter);
+ preambleInput.addEventListener("input", updatePreamblePresentation);
+
+ const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS);
+ preambleDetails.addEventListener("toggle", () => {
+ storeValue(STORAGE_KEYS.PREAMBLE_OPEN, preambleDetails.open.toString());
+
+ if (preambleDetails.open && !preambleInput.disabled) {
+ requestAnimationFrame(() => {
+ preambleInput.focus();
+ const cursor = preambleInput.value.length;
+ preambleInput.setSelectionRange(cursor, cursor);
+ });
+ }
+ });
+
const fontSizeInput = getInputElement(DOM_IDS.FONT_SIZE);
fontSizeInput.addEventListener("keydown", handleCtrlEnter);
@@ -140,6 +181,44 @@ export function setTypstCode(typstCode: string) {
getAreaElement(DOM_IDS.TYPST_INPUT).value = typstCode;
}
+/**
+ * @returns Typst preamble from the UI input
+ */
+export function getPreambleCode(): string {
+ return getAreaElement(DOM_IDS.PREAMBLE_INPUT).value;
+}
+
+/**
+ * Sets the Typst preamble in the UI input.
+ */
+export function setPreambleCode(preamble: string) {
+ getAreaElement(DOM_IDS.PREAMBLE_INPUT).value = preamble;
+ updatePreamblePresentation();
+}
+
+/**
+ * Restores the global preamble from local storage.
+ */
+export function restorePreambleFromStorage() {
+ const savedPreamble = getStoredValue(STORAGE_KEYS.PREAMBLE);
+ setPreambleCode(savedPreamble || DEFAULTS.PREAMBLE);
+}
+
+/**
+ * @returns the current editor mode.
+ */
+export function getEditorMode(): EditorMode {
+ return currentEditorMode;
+}
+
+/**
+ * Tracks whether the task pane is inserting, editing, or locked by multi-select.
+ */
+export function setEditorMode(mode: EditorMode) {
+ currentEditorMode = mode;
+ updatePreamblePresentation();
+}
+
/**
* Updates the button text based on whether a Typst shape is selected.
*/
@@ -195,3 +274,29 @@ export function setFileButtonText(isEditingExistingFormula: boolean) {
const button = getButtonElement(DOM_IDS.GENERATE_FROM_FILE_BTN);
button.textContent = isEditingExistingFormula ? BUTTON_TEXT.UPDATE_FROM_FILE : BUTTON_TEXT.GENERATE_FROM_FILE;
}
+
+/**
+ * Updates the preamble panel label and locked state.
+ */
+function updatePreamblePresentation() {
+ const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT);
+ const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS);
+ const summaryElement = getHTMLElement(DOM_IDS.PREAMBLE_SUMMARY);
+
+ const isLocked = currentEditorMode === "multi-select";
+
+ let labelText: string = PREAMBLE_UI.GLOBAL_LABEL;
+ let summaryTitle: string = PREAMBLE_UI.GLOBAL_TITLE;
+ if (currentEditorMode === "edit") {
+ labelText = PREAMBLE_UI.SHAPE_LABEL;
+ summaryTitle = PREAMBLE_UI.SHAPE_TITLE;
+ } else if (currentEditorMode === "multi-select") {
+ labelText = PREAMBLE_UI.MULTI_LABEL;
+ summaryTitle = PREAMBLE_UI.MULTI_TITLE;
+ }
+
+ preambleInput.disabled = isLocked;
+ preambleDetails.classList.toggle("is-readonly", isLocked);
+ summaryElement.textContent = labelText;
+ summaryElement.title = summaryTitle;
+}
diff --git a/web/src/utils/dom.ts b/web/src/utils/dom.ts
index bafad41..699ec34 100644
--- a/web/src/utils/dom.ts
+++ b/web/src/utils/dom.ts
@@ -43,6 +43,13 @@ export function getButtonElement(id: string): HTMLButtonElement {
return getElement(id, HTMLButtonElement);
}
+/**
+ * Gets an HTMLDetailsElement by ID.
+ */
+export function getDetailsElement(id: string): HTMLDetailsElement {
+ return getElement(id, HTMLDetailsElement);
+}
+
/**
* Gets a generic HTMLElement by ID.
*/
diff --git a/web/styles/main.css b/web/styles/main.css
index 8d68446..8def53c 100644
--- a/web/styles/main.css
+++ b/web/styles/main.css
@@ -27,11 +27,12 @@
body {
margin: 0;
padding: 15px;
- padding-bottom: 60px; /* Space for sticky status bar */
+ padding-bottom: 100px; /* Space for sticky status bar and final controls */
font-family: sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
zoom: 114%;
+ scroll-padding-bottom: 100px;
transition: background 0.2s, color 0.2s;
}
@@ -117,6 +118,51 @@ hr {
user-select: none;
}
+.preamble-panel {
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-secondary);
+ overflow: hidden;
+
+ &.is-readonly {
+ opacity: 0.8;
+ }
+}
+
+.preamble-summary {
+ padding: 8px 10px;
+ cursor: pointer;
+ list-style: none;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 600;
+ user-select: none;
+
+ &::-webkit-details-marker {
+ display: none;
+ }
+}
+
+.preamble-summary:hover {
+ background: color-mix(in srgb, var(--bg-secondary) 88%, var(--text-primary) 12%);
+}
+
+#preambleInput {
+ width: calc(100% - 20px);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ margin: 0 10px 10px;
+ height: 84px;
+ min-height: 64px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+#preambleInput:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
#mathDelimiterTop {
border-bottom: none;
border-radius: 4px 4px 0 0;