Skip to content
Merged
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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,11 @@ Easily insert Typst equations with live preview, update them, and even generate

<https://github.com/user-attachments/assets/3cb307af-4c02-4665-8f2c-34c23c6f68fc>

<sub>Maybe we can even integrate packages from the [Typst Universe](https://typst.app/universe/) in the future, vote for [this issue](https://github.com/Myriad-Dreamin/typst.ts/issues/825) or provide a PR if you have a solution in mind ;)</sub>

### Installation

See [the Install guide](./INSTALL.md).

## Tutorial
### Tutorial

[YouTube: Animate equations in PowerPoint with PPTypst](https://youtu.be/c6sfzu2--iY)

Expand Down
6 changes: 6 additions & 0 deletions web/powerpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
<!-- eslint-disable-next-line @html-eslint/use-baseline -->
<input id="fillColor" type="color" value="#000000">
</div>
<div class="control-group checkbox-group">
<label title="Use Typst document colors in preview instead of overriding them">
<input id="previewFillEnabled" type="checkbox">
<span>Preview Typst Fill</span>
</label>
</div>
<div class="control-group checkbox-group">
<label title="Automatically wrap your input in display math delimiters ($)">
<input id="mathModeEnabled" type="checkbox" checked>
Expand Down
4 changes: 3 additions & 1 deletion web/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const DOM_IDS = {
FONT_SIZE: "fontSize",
FILL_COLOR_ENABLED: "fillColorEnabled",
FILL_COLOR: "fillColor",
PREVIEW_FILL_ENABLED: "previewFillEnabled",
MATH_MODE_ENABLED: "mathModeEnabled",
INPUT_WRAPPER: "inputWrapper",
TYPST_INPUT: "typstInput",
Expand All @@ -53,6 +54,7 @@ export const DOM_IDS = {
export const STORAGE_KEYS = {
FONT_SIZE: "typstFontSize",
FILL_COLOR: "typstFillColor",
PREVIEW_FILL: "typstPreviewFill",
MATH_MODE: "typstMathMode",
THEME: "typstTheme",
} as const;
Expand All @@ -78,7 +80,7 @@ export const THEMES = {
* Preview configuration.
*/
export const PREVIEW_CONFIG = {
MAX_HEIGHT: "150px",
MAX_HEIGHT: "320px",
DARK_MODE_FILL: "#ffffff",
LIGHT_MODE_FILL: "#000000",
} as const;
Expand Down
3 changes: 2 additions & 1 deletion web/src/insertion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { debug } from "./utils/logger.js";
import { applyFillColor, parseAndApplySize } from "./svg.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";
Expand Down Expand Up @@ -32,6 +32,7 @@ async function prepareTypstSvg(
if (fillColor) {
applyFillColor(svgElement, fillColor);
}
normalizeAlphaHexColors(svgElement);

const serializer = new XMLSerializer();
const svg = serializer.serializeToString(svgElement);
Expand Down
47 changes: 44 additions & 3 deletions web/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { DiagnosticMessage, typst } from "./typst.js";
import { applyFillColor, parseAndApplySize } from "./svg.js";
import { DOM_IDS, PREVIEW_CONFIG, STORAGE_KEYS, FILL_COLOR_DISABLED } from "./constants.js";
import { getAreaElement, getHTMLElement, getInputElement } from "./utils/dom";
import { getFillColor, getFontSize, getMathModeEnabled, getTypstCode, setButtonEnabled, setMathModeEnabled } from "./ui";
import {
getFillColor,
getFontSize,
getMathModeEnabled,
getTypstCode,
setButtonEnabled,
setMathModeEnabled,
} from "./ui";
import { storeValue, getStoredValue } from "./utils/storage.js";
import { lastTypstShapeId } from "./shape.js";

Expand All @@ -14,6 +21,7 @@ export function setupPreviewListeners() {
const fontSizeInput = getInputElement(DOM_IDS.FONT_SIZE);
const fillColorInput = getInputElement(DOM_IDS.FILL_COLOR);
const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED);
const previewFillEnabled = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED);
const mathModeEnabled = getInputElement(DOM_IDS.MATH_MODE_ENABLED);

typstInput.addEventListener("input", () => {
Expand All @@ -30,17 +38,22 @@ export function setupPreviewListeners() {
fillColorInput.addEventListener("input", () => {
const fillColor = getFillColor();
storeValue(STORAGE_KEYS.FILL_COLOR, fillColor);
void updatePreview();
});

fillColorEnabled.addEventListener("change", () => {
const fillColor = getFillColor();
const colorInput = getInputElement(DOM_IDS.FILL_COLOR);
colorInput.disabled = !fillColorEnabled.checked;
syncPreviewFillToggleState(fillColorEnabled.checked);
storeValue(STORAGE_KEYS.FILL_COLOR, fillColor || FILL_COLOR_DISABLED);
void updatePreview();
});

previewFillEnabled.addEventListener("change", () => {
storeValue(STORAGE_KEYS.PREVIEW_FILL, previewFillEnabled.checked.toString());
void updatePreview();
});

mathModeEnabled.addEventListener("change", () => {
const mathMode = getMathModeEnabled();
if (!lastTypstShapeId) {
Expand All @@ -51,9 +64,34 @@ export function setupPreviewListeners() {
void updatePreview();
});

syncPreviewFillToggleState(fillColorEnabled.checked);
updateMathModeVisuals();
}

/**
* Keeps preview fill toggle consistent with Fill checkbox behavior.
*/
function syncPreviewFillToggleState(isFillEnabled: boolean) {
const previewFillEnabled = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED);

if (isFillEnabled) {
previewFillEnabled.checked = false;
previewFillEnabled.disabled = true;
storeValue(STORAGE_KEYS.PREVIEW_FILL, "false");
return;
}

previewFillEnabled.disabled = false;
}

/**
* Syncs preview fill toggle state based on the current fill checkbox value.
*/
export function syncPreviewFillToggleFromFillCheckbox() {
const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED);
syncPreviewFillToggleState(fillColorEnabled.checked);
}

/**
* Restores the math mode setting from localStorage.
*/
Expand Down Expand Up @@ -127,7 +165,10 @@ export async function updatePreview() {

const isDarkMode = document.documentElement.classList.contains("dark-mode");
const previewFill = isDarkMode ? PREVIEW_CONFIG.DARK_MODE_FILL : PREVIEW_CONFIG.LIGHT_MODE_FILL;
applyFillColor(svgElement, previewFill);
const shouldKeepTypstFill = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED).checked;
if (!shouldKeepTypstFill) {
applyFillColor(svgElement, previewFill);
}
}

/**
Expand Down
57 changes: 57 additions & 0 deletions web/src/registry/font-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Module for font downloading and caching in the browser.
*
* Inspired by the cached font middleware in the typst.ts compiler tempalte:
* https://github.com/Myriad-Dreamin/typst.ts/blob/2a8b32d8cca70cc4d105fef074d2f35fc7546450/templates/compiler-wasm-cjs/src/cached-font-middleware.cts#L1-L52
*/

import { preloadFontAssets } from "@myriaddreamin/typst.ts/dist/esm/options.init.mjs";

const FONT_CACHE_NAME = "typst-font-assets-v1";

/**
* A fetch wrapper that caches font assets in the browser's Cache API.
*/
async function cachedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = input instanceof Request ? input : new Request(input, init);

if (!("caches" in globalThis) || request.method.toUpperCase() !== "GET") {
return fetch(request);
}

let cache: Cache | null = null;
try {
cache = await caches.open(FONT_CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
// 🎈 Cached response
return cached;
}
} catch {
// No cache access possible
return fetch(request);
}

// 🎈 No cached response
const response = await fetch(request);
if (response.ok) {
try {
await cache.put(request, response.clone());
} catch {
// Ignore cache write failures and keep network response.
}
}

return response;
}

export function cachedFontInitOptions() {
return {
beforeBuild: [
preloadFontAssets({
assets: ["text", "cjk", "emoji"],
fetcher: cachedFetch,
}),
],
};
}
41 changes: 41 additions & 0 deletions web/src/registry/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
interface RegistryResponse {
statusCode: number;
getBody: (_encoding?: unknown) => Uint8Array;
}

/**
* Performs a synchronous HTTP request to the given URL and returns the response as a Uint8Array.
*
* Note: This function uses XMLHttpRequest in synchronous mode, which is generally
* discouraged in web development due to potential UI blocking. However, it is
* used here to meet the requirements of the Typst package registry interface,
* which expects a synchronous response.
*/
export function registryRequest(method: string, url: string): RegistryResponse {
const request = new XMLHttpRequest();
request.open(method, url, false);
// Sync XHR from a document cannot use non-text responseType
request.overrideMimeType("text/plain; charset=x-user-defined");
try {
request.send();
} catch (error) {
console.error(`Registry request to ${url} failed:`, error);

// If the request fails synchronously (e.g. CORS/network issues),
// return a non-2xx status code with an empty body instead of throwing
const emptyBody = new Uint8Array();
return {
statusCode: 0,
getBody: () => emptyBody,
};
}

const response = request.response as unknown;
const responseText = typeof response === "string" ? response : "";
const body = Uint8Array.from(responseText, char => char.charCodeAt(0) & 0xff);

return {
statusCode: request.status,
getBody: () => body,
};
}
3 changes: 2 additions & 1 deletion web/src/selection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FILL_COLOR_DISABLED, SHAPE_CONFIG, DEFAULTS } from "./constants.js";
import { extractTypstCode, isTypstPayload } from "./payload.js";
import { updatePreview, updateButtonState, restoreMathModeFromStorage, updateMathModeVisuals } from "./preview.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 { debug } from "./utils/logger.js";
Expand Down Expand Up @@ -86,6 +86,7 @@ async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | nu
}

setFillColor(fillColorToSet);
syncPreviewFillToggleFromFillCheckbox();
setTypstCode(typstCode);
setMathModeEnabled(storedMathMode === "true");
updateMathModeVisuals();
Expand Down
74 changes: 74 additions & 0 deletions web/src/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,77 @@ export function applyFillColor(svg: SVGElement, fillColor: string) {
}
});
}

type ParsedHexAlpha = {
rgbHex: string;
alpha: number;
};

/**
* Parses #RGBA or #RRGGBBAA colors into RGB + alpha.
*/
function parseHexWithAlpha(value: string): ParsedHexAlpha | null {
const color = value.trim();
if (!color.startsWith("#")) {
return null;
}

const hex = color.slice(1);
if (hex.length === 8) {
const rgbHex = `#${hex.slice(0, 6)}`;
const alphaByte = parseInt(hex.slice(6, 8), 16);
if (!Number.isFinite(alphaByte)) {
return null;
}
const alpha = alphaByte / 255;
return { rgbHex, alpha };
}

if (hex.length === 4) {
const rgbHex = `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
const alphaByte = parseInt(`${hex[3]}${hex[3]}`, 16);
if (!Number.isFinite(alphaByte)) {
return null;
}
const alpha = alphaByte / 255;
return { rgbHex, alpha };
}

return null;
}

/**
* Converts alpha hex colors to RGB + explicit opacity attributes.
*
* PowerPoint's SVG import can fail on #RRGGBBAA colors, so we normalize
* these to maximize compatibility when inserting shapes.
*/
export function normalizeAlphaHexColors(svg: SVGElement) {
const colorToOpacityAttr: Record<string, string> = {
"fill": "fill-opacity",
"stroke": "stroke-opacity",
"stop-color": "stop-opacity",
};

const elements: Element[] = [svg, ...Array.from(svg.querySelectorAll("*"))];
elements.forEach((el) => {
Object.entries(colorToOpacityAttr).forEach(([colorAttr, opacityAttr]) => {
const value = el.getAttribute(colorAttr);
if (!value) {
return;
}

const parsed = parseHexWithAlpha(value);
if (!parsed) {
return;
}

el.setAttribute(colorAttr, parsed.rgbHex);

const existingOpacity = parseFloat(el.getAttribute(opacityAttr) || "1");
const safeOpacity = Number.isFinite(existingOpacity) ? existingOpacity : 1;
const combinedOpacity = Math.max(0, Math.min(1, safeOpacity * parsed.alpha));
el.setAttribute(opacityAttr, combinedOpacity.toString());
});
});
}
Loading