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
5 changes: 5 additions & 0 deletions tests/_support/browser-mocks/typst-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ export const loadFonts = option("loadFonts");
export const preloadFontAssets = option("preloadFontAssets");
export const withAccessModel = option("withAccessModel");
export const withPackageRegistry = option("withPackageRegistry");

// Return no default asset URLs in tests so no fonts are fetched from the CDN.
export function _resolveAssets(): string[] {
return [];
}
20 changes: 20 additions & 0 deletions tests/_support/browser-mocks/typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ export function createTypstCompiler() {
typstMockState.compileCalls.push(options);
return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) });
},
setFonts() {
// no-op in tests
},
};
}

export function createTypstFontBuilder() {
return {
init() {
return Promise.resolve();
},
getFontInfo() {
return Promise.resolve({});
},
addFontData() {
return Promise.resolve();
},
build<T>(cb: (_resolver: unknown) => Promise<T>) {
return cb({});
},
};
}

Expand Down
70 changes: 70 additions & 0 deletions tests/_support/font-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Generates a minimal, tiny sfnt (TrueType) font in memory for tests.
*
* The font is not meant to be rendered (the Typst compiler is mocked in tests);
* it only carries a valid `name` table so that the add-in's family/subfamily
* detection and the Custom fonts UI can be exercised against a real font file
* without committing a large binary fixture.
*/

/** Encodes a string as big-endian UTF-16 (the encoding used by Windows name records). */
function utf16be(value: string): Buffer {
const buffer = Buffer.alloc(value.length * 2);
for (let i = 0; i < value.length; i++) {
buffer.writeUInt16BE(value.charCodeAt(i), i * 2);
}
return buffer;
}

/**
* Builds a minimal font whose `name` table reports the given family and
* subfamily (style) names.
*/
export function makeTestFont(family: string, subfamily: string): Buffer {
const records = [
{ nameId: 1, value: utf16be(family) }, // Font Family
{ nameId: 2, value: utf16be(subfamily) }, // Font Subfamily
];

// --- name table ---
const nameHeaderSize = 6;
const stringStorageOffset = nameHeaderSize + records.length * 12;
const strings = Buffer.concat(records.map(record => record.value));
const nameTable = Buffer.alloc(stringStorageOffset + strings.length);

nameTable.writeUInt16BE(0, 0); // format 0
nameTable.writeUInt16BE(records.length, 2); // count
nameTable.writeUInt16BE(stringStorageOffset, 4); // string storage offset

let recordOffset = nameHeaderSize;
let stringOffset = 0;
for (const record of records) {
nameTable.writeUInt16BE(3, recordOffset); // platformID: Windows
nameTable.writeUInt16BE(1, recordOffset + 2); // encodingID: Unicode BMP
nameTable.writeUInt16BE(0x0409, recordOffset + 4); // languageID: English (US)
nameTable.writeUInt16BE(record.nameId, recordOffset + 6);
nameTable.writeUInt16BE(record.value.length, recordOffset + 8); // length
nameTable.writeUInt16BE(stringOffset, recordOffset + 10); // offset in storage
recordOffset += 12;
stringOffset += record.value.length;
}
strings.copy(nameTable, stringStorageOffset);

// --- sfnt wrapper: offset table + one table record pointing at `name` ---
const offsetTableSize = 12;
const tableRecordSize = 16;
const nameTableFileOffset = offsetTableSize + tableRecordSize;
const font = Buffer.alloc(nameTableFileOffset + nameTable.length);

font.writeUInt32BE(0x00010000, 0); // sfntVersion: TrueType
font.writeUInt16BE(1, 4); // numTables
// searchRange/entrySelector/rangeShift are not read by the parser; leave 0.

font.write("name", offsetTableSize, "ascii"); // tag
font.writeUInt32BE(0, offsetTableSize + 4); // checksum (unused by the parser)
font.writeUInt32BE(nameTableFileOffset, offsetTableSize + 8); // offset
font.writeUInt32BE(nameTable.length, offsetTableSize + 12); // length
nameTable.copy(font, nameTableFileOffset);

return font;
}
59 changes: 59 additions & 0 deletions tests/fonts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect } from "@playwright/test";
import { test } from "./_support/fixtures";
import { makeTestFont } from "./_support/font-fixture";

const FAMILY = "PPTypst Test";

test("adds a custom font from an uploaded file", async ({ powerPointPage }) => {
await powerPointPage.openFontsPanel();
await powerPointPage.addFontFiles([
{ name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") },
]);

await expect(powerPointPage.fontItems()).toHaveCount(1);
await powerPointPage.expectFontFamilyCount(FAMILY, 1);
await powerPointPage.expectFontStyleListed("Regular");
await powerPointPage.expectStatus(`Added: ${FAMILY} Regular`);
});

test("keeps multiple weights of the same family as separate faces", async ({ powerPointPage }) => {
await powerPointPage.openFontsPanel();
await powerPointPage.addFontFiles([
{ name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") },
{ name: "PPTypstTest-Bold.ttf", buffer: makeTestFont(FAMILY, "Bold") },
]);

// Both faces coexist (keyed per family+style) instead of overwriting each other.
await expect(powerPointPage.fontItems()).toHaveCount(2);
await powerPointPage.expectFontFamilyCount(FAMILY, 2);
await powerPointPage.expectFontStyleListed("Regular");
await powerPointPage.expectFontStyleListed("Bold");
});

test("removes a custom font", async ({ powerPointPage }) => {
await powerPointPage.openFontsPanel();
await powerPointPage.addFontFiles([
{ name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") },
]);
await expect(powerPointPage.fontItems()).toHaveCount(1);

await powerPointPage.removeFirstFont();

await expect(powerPointPage.fontItems()).toHaveCount(0);
await powerPointPage.expectNoCustomFonts();
});

test("persists custom fonts across a reload", async ({ powerPointPage }) => {
await powerPointPage.openFontsPanel();
await powerPointPage.addFontFiles([
{ name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") },
]);
await expect(powerPointPage.fontItems()).toHaveCount(1);

await powerPointPage.reload();
await powerPointPage.openFontsPanel();

// The font was restored from IndexedDB, not re-uploaded.
await expect(powerPointPage.fontItems()).toHaveCount(1);
await powerPointPage.expectFontFamilyCount(FAMILY, 1);
});
58 changes: 58 additions & 0 deletions tests/pages/powerpoint-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export const typstShapeMetadata = {
altTextDescription: "Generated by PPTypst. Edit this shape with the PPTypst add-in.",
} as const;

/** Builds a regex matching an element's whole (trimmed) text content exactly. */
function exactText(value: string): RegExp {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^\\s*${escaped}\\s*$`);
}

/** Page object for the PPTypst PowerPoint task pane. */
export class PowerPointPage {
private readonly page: Page;
Expand Down Expand Up @@ -200,4 +206,56 @@ export class PowerPointPage {
async expectPreviewVisible() {
await expect(this.page.locator("#previewContent svg")).toBeVisible();
}

/** Reloads the task pane and waits for the add-in to finish initializing. */
async reload() {
await this.page.reload();
await expect(this.page.locator("#insertBtn")).toContainText(/Insert|Update/);
}

/** Opens the collapsible Custom fonts panel if it is currently closed. */
async openFontsPanel() {
const isOpen = await this.page.locator("#fontsDetails").evaluate(
element => (element as HTMLDetailsElement).open,
);
if (!isOpen) {
await this.page.locator("#fontsDetails summary").click();
}
}

/** Uploads one or more font files into the Custom fonts panel. */
async addFontFiles(files: { name: string; buffer: Buffer }[]) {
await this.page.locator("#fontsInput").setInputFiles(
files.map(file => ({ name: file.name, mimeType: "font/ttf", buffer: file.buffer })),
);
}

/** Removes the first listed custom font via its remove button. */
async removeFirstFont() {
await this.page.locator(".fonts-item-remove").first().click();
}

/** Locator for the listed custom-font rows. */
fontItems() {
return this.page.locator(".fonts-item");
}

/** Asserts how many listed fonts report the given family name. */
async expectFontFamilyCount(family: string, count: number) {
await expect(
this.page.locator(".fonts-item-family", { hasText: exactText(family) }),
).toHaveCount(count);
}

/** Asserts that exactly one listed font shows the given style badge. */
async expectFontStyleListed(style: string) {
await expect(
this.page.locator(".fonts-item-style", { hasText: exactText(style) }),
).toHaveCount(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Asserts that no custom fonts are listed. */
async expectNoCustomFonts() {
await expect(this.page.locator(".fonts-empty")).toBeVisible();
}
}
11 changes: 11 additions & 0 deletions web/powerpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@
></textarea>
</details>

<details id="fontsDetails" class="fonts-panel">
<summary class="fonts-summary" title="Upload font files to use them in your Typst code via #set text(font: ...)">
Custom fonts
</summary>
<label class="fonts-upload">
<span class="fonts-upload-text">+ Add font file</span>
<input id="fontsInput" type="file" class="fonts-input" accept=".ttf,.otf,.ttc" multiple>
</label>
<ul id="fontsList" class="fonts-list"></ul>
</details>

<!-- Status Bar -->
<div id="statusBar" class="status-bar">
<div id="status" class="status-text"></div>
Expand Down
4 changes: 4 additions & 0 deletions web/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export const DOM_IDS = {
ABOUT_LINK: "aboutLink",
ABOUT_MODAL: "aboutModal",
ABOUT_MODAL_CLOSE: "aboutModalClose",
FONTS_DETAILS: "fontsDetails",
FONTS_INPUT: "fontsInput",
FONTS_LIST: "fontsList",
} as const;

/**
Expand All @@ -73,6 +76,7 @@ export const STORAGE_KEYS = {
MATH_MODE: "typstMathMode",
PREAMBLE: "typstPreamble",
PREAMBLE_OPEN: "typstPreambleOpen",
FONTS_OPEN: "typstFontsOpen",
THEME: "typstTheme",
} as const;

Expand Down
Loading