Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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);
});
52 changes: 52 additions & 0 deletions tests/pages/powerpoint-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,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: 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: 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