Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clarify server API #1863

Merged
merged 14 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
146 changes: 146 additions & 0 deletions src/fontra/client/core/backend-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { getRemoteProxy } from "../core/remote.js";
import { fetchJSON } from "./utils.js";
import { StaticGlyph } from "./var-glyph.js";
import { VarPackedPath } from "./var-path.js";
/** @import { RemoteFont } from "remotefont" */

/**
* @module fontra/client/core/backend-api
* @description
* This module provides a class that can be used to interact with the backend API.
* The default Fontra backend is the Python-based web server. This class provides
* an abstraction over the functionality of the web server, so that alternative
* backends can be used.
*
*/
class AbstractBackend {
/**
* Get a list of projects from the backend.
* @returns {Promise<string[]>} An array of project names.
*/
static async getProjects() {}

/**
* Get a suggested glyph name for a given code point.
* @param {number} codePoint - The code point.
* @returns {Promise<string>} The suggested glyph name.
*/
static async getSuggestedGlyphName(codePoint) {}

/**
* Get the code point for a given glyph name.
* @param {string} glyphName - The glyph name.
* @returns {Promise<number>} The code point.
*/
static async getCodePointFromGlyphName(glyphName) {}

/**
* Parse clipboard data.
*
* Returns a glyph object parsed from either a SVG string or an UFO .glif.
* @param {string} data - The clipboard data.
* @returns {Promise<StaticGlyph>} - The glyph object, if parsable.
*/
static async parseClipboard(data) {}

/**
* Remove overlaps in a path
*
* In this and all following functions, the paths are represented as
* JSON VarPackedPath objects; i.e. they have `coordinates`, `pointTypes`,
* `contourInfo`, and `pointAttrbutes` fields.
*
* @param {VarPackedPath} path - The first path.
* @returns {Promise<VarPackedPath>} The union of the two paths.
*/
static async unionPath(path) {}

/**
* Subtract one path from another.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The difference of the two paths.
*/
static async subtractPath(pathA, pathB) {}

/**
* Intersect two paths.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The intersection of the two paths.
*/
static async intersectPath(pathA, pathB) {}

/**
* Exclude one path from another.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The exclusion of the two paths.
*/
static async excludePath(pathA, pathB) {}
}

class PythonBackend extends AbstractBackend {
static async getProjects() {
return fetchJSON("/projectlist");
}

static async _callServerAPI(functionName, kwargs) {
const response = await fetch(`/api/${functionName}`, {
method: "POST",
body: JSON.stringify(kwargs),
});

const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.returnValue;
}

static async getSuggestedGlyphName(codePoint) {
return await this._callServerAPI("getSuggestedGlyphName", { codePoint });
}

static async getCodePointFromGlyphName(glyphName) {
return await this._callServerAPI("getCodePointFromGlyphName", { glyphName });
}

static async parseClipboard(data) {
let result = await this._callServerAPI("parseClipboard", { data });
return result ? StaticGlyph.fromObject(result) : undefined;
}

static async unionPath(path) {
const newPath = await this._callServerAPI("unionPath", { path });
return VarPackedPath.fromObject(newPath);
}

static async subtractPath(pathA, pathB) {
const newPath = await this._callServerAPI("subtractPath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

static async intersectPath(pathA, pathB) {
const newPath = await this._callServerAPI("intersectPath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

static async excludePath(pathA, pathB) {
const newPath = await this._callServerAPI("excludePath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

/**
*
* @param {string} projectPath
* @returns {Promise<RemoteFont>} Proxy object representing a font on the server.
*/
static async remoteFont(projectPath) {
const protocol = window.location.protocol === "http:" ? "ws" : "wss";
const wsURL = `${protocol}://${window.location.host}/websocket/${projectPath}`;
return getRemoteProxy(wsURL);
}
}

export const Backend = PythonBackend;
18 changes: 14 additions & 4 deletions src/fontra/client/core/font-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ import {
mapBackward,
mapForward,
} from "./var-model.js";
/**
* @import { RemoteFont, FontSource } from 'remotefont';
* */

const GLYPH_CACHE_SIZE = 2000;
const BACKGROUND_IMAGE_CACHE_SIZE = 100;
const NUM_TASKS = 12;

export class FontController {
/**
* @param {RemoteFont} font
*/
constructor(font) {
this.font = font;
this._glyphsPromiseCache = new LRUCache(GLYPH_CACHE_SIZE); // glyph name -> var-glyph promise
Expand Down Expand Up @@ -78,12 +84,12 @@ export class FontController {
this._resolveInitialized();
}

subscribeChanges(change, wantLiveChanges) {
this.font.subscribeChanges(change, wantLiveChanges);
subscribeChanges(pathOrPattern, wantLiveChanges) {
this.font.subscribeChanges(pathOrPattern, wantLiveChanges);
}

unsubscribeChanges(change, wantLiveChanges) {
this.font.unsubscribeChanges(change, wantLiveChanges);
unsubscribeChanges(pathOrPattern, wantLiveChanges) {
this.font.unsubscribeChanges(pathOrPattern, wantLiveChanges);
}

getRootKeys() {
Expand Down Expand Up @@ -1081,6 +1087,10 @@ function ensureDenseAxes(axes) {
return { ...axes, axes: axes.axes || [], mappings: axes.mappings || [] };
}

/**
* @param {Record<string, FontSource>} sources
* @returns {Record<string, FontSource>}
*/
function ensureDenseSources(sources) {
return mapObjectValues(sources, (source) => {
return {
Expand Down
8 changes: 4 additions & 4 deletions src/fontra/client/core/glyph-lines.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCodePointFromGlyphName, getSuggestedGlyphName } from "./server-utils.js";
import { Backend } from "./backend-api.js";
import { splitGlyphNameExtension } from "./utils.js";

export async function glyphLinesFromText(text, characterMap, glyphMap) {
Expand Down Expand Up @@ -56,7 +56,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
// a glyph name associated with that character
let properBaseGlyphName = characterMap[baseCharCode];
if (!properBaseGlyphName) {
properBaseGlyphName = await getSuggestedGlyphName(baseCharCode);
properBaseGlyphName = await Backend.getSuggestedGlyphName(baseCharCode);
}
if (properBaseGlyphName) {
glyphName = properBaseGlyphName + extension;
Expand All @@ -67,7 +67,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
} else {
// This is a regular glyph name, but it doesn't exist in the font.
// Try to see if there's a code point associated with it.
const codePoint = await getCodePointFromGlyphName(glyphName);
const codePoint = await Backend.getCodePointFromGlyphName(glyphName);
if (codePoint) {
char = String.fromCodePoint(codePoint);
}
Expand All @@ -85,7 +85,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
if (glyphName !== "") {
let isUndefined = false;
if (!glyphName && char) {
glyphName = await getSuggestedGlyphName(char.codePointAt(0));
glyphName = await Backend.getSuggestedGlyphName(char.codePointAt(0));
isUndefined = true;
} else if (glyphName) {
isUndefined = !(glyphName in glyphMap);
Expand Down
90 changes: 50 additions & 40 deletions src/fontra/client/core/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { RemoteError } from "./errors.js";

export async function getRemoteProxy(wsURL) {
const remote = new RemoteObject(wsURL);
await remote.connect();
await remote._connect();
const remoteProxy = new Proxy(remote, {
get: (remote, propertyName) => {
if (propertyName === "then" || propertyName === "toJSON") {
Expand All @@ -14,7 +14,7 @@ export async function getRemoteProxy(wsURL) {
return remote[propertyName];
}
return (...args) => {
return remote.doCall(propertyName, args);
return remote._doCall(propertyName, args);
};
},
set: (remote, propertyName, value) => {
Expand All @@ -35,6 +35,13 @@ export class RemoteObject {

this.wsURL = wsURL;
this._callReturnCallbacks = {};
this._handlers = {
close: this._default_onclose,
error: this._default_onerror,
messageFromServer: undefined,
externalChange: undefined,
reloadData: undefined,
};

const g = _genNextClientCallID();
this._getNextClientCallID = () => {
Expand All @@ -46,14 +53,30 @@ export class RemoteObject {
(event) => {
if (document.visibilityState === "visible" && this.websocket.readyState > 1) {
// console.log("wake reconnect");
this.connect();
this._connect();
}
},
false
);
}

connect() {
on(event, callback) {
if (this._handlers.hasOwnProperty(event)) {
this._handlers[event] = callback;
} else {
console.error(`Ignoring attempt to register handler for unknown event: ${event}`);
}
}

async _trigger(event, ...args) {
if (this._handlers.hasOwnProperty(event)) {
return await this._handlers[event](...args);
} else {
throw new Error(`Recieved unknown event from server: ${event}`);
}
}

_connect() {
if (this._connectPromise !== undefined) {
// websocket is still connecting/opening, return the same promise
return this._connectPromise;
Expand All @@ -67,8 +90,8 @@ export class RemoteObject {
this.websocket.onopen = (event) => {
resolve(event);
delete this._connectPromise;
this.websocket.onclose = (event) => this._onclose(event);
this.websocket.onerror = (event) => this._onerror(event);
this.websocket.onclose = (event) => this._trigger("close", event);
this.websocket.onerror = (event) => this._trigger("error", event);
const message = {
"client-uuid": this.clientUUID,
};
Expand All @@ -79,20 +102,12 @@ export class RemoteObject {
return this._connectPromise;
}

_onclose(event) {
if (this.onclose) {
this.onclose(event);
} else {
console.log(`websocket closed`, event);
}
_default_onclose(event) {
console.log(`websocket closed`, event);
}

_onerror(event) {
if (this.onerror) {
this.onerror(event);
} else {
console.log(`websocket error`, event);
}
_default_onerror(event) {
console.log(`websocket error`, event);
}

async _handleIncomingMessage(event) {
Expand All @@ -113,32 +128,27 @@ export class RemoteObject {
delete this._callReturnCallbacks[clientCallID];
} else if (serverCallID !== undefined) {
// this is an incoming server -> client call
if (this.receiver) {
let returnMessage;
try {
let method = this.receiver[message["method-name"]];
if (method === undefined) {
throw new Error(`undefined receiver method: ${message["method-name"]}`);
}
method = method.bind(this.receiver);
const returnValue = await method(...message["arguments"]);
returnMessage = {
"server-call-id": serverCallID,
"return-value": returnValue,
};
} catch (error) {
console.log("exception in receiver call", error.toString());
console.error(error, error.stack);
returnMessage = { "server-call-id": serverCallID, "error": error.toString() };
let returnMessage;
try {
let method = message["method-name"];
if (!this._handlers.hasOwnProperty(method)) {
throw new Error(`undefined method: ${method}`);
}
this.websocket.send(JSON.stringify(returnMessage));
} else {
console.log("no receiver in place to receive server messages", message);
const returnValue = await this._trigger(method, ...message["arguments"]);
returnMessage = {
"server-call-id": serverCallID,
"return-value": returnValue,
};
} catch (error) {
console.log("exception in method call", error.toString());
console.error(error, error.stack);
returnMessage = { "server-call-id": serverCallID, "error": error.toString() };
}
this.websocket.send(JSON.stringify(returnMessage));
}
}

async doCall(methodName, args) {
async _doCall(methodName, args) {
// console.log("--- doCall", methodName);
const clientCallID = this._getNextClientCallID();
const message = {
Expand All @@ -148,7 +158,7 @@ export class RemoteObject {
};
if (this.websocket.readyState !== 1) {
// console.log("waiting for reconnect");
await this.connect();
await this._connect();
}
this.websocket.send(JSON.stringify(message));

Expand Down
Loading
Loading