Files
megabrain-agentandClaude Opus 5.5 0998cb9e88 feat(web): font_register control op for host-supplied font files
The web build has no font folder, so a text style whose TrueType file is
not a bundled Noto subset falls back to it. font_register takes the file
from the host page:

  {"op":"font_register","file_name":"Brand-Regular.ttf","data_base64":"…"}

The faces join the font database, file_name resolves to the family of
the first face (case-insensitive, without directory, like installed
fonts), and open drawings lay their text out again (fallback glyph
cache cleared, text geometry invalidated per tab). The reply names the
family and the number of faces. Bytes without a face fail with
font_invalid. SysFonts::by_file becomes updatable for this.

Proposed in #1771.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 02:26:51 +02:00

5.3 KiB

Driving the web build

The web build exposes the same control core as --serve and --mcp, through two functions on the WebAssembly module. This page covers only what is specific to the browser; the operations, request envelope and replies are the ones in the README.

Reaching the control channel

The Trunk loader publishes the wasm-bindgen module as window.wasmBindings. The control channel is ready once window.wasmBindings?.ocs_control_submit is defined (the save smoke test waits for exactly that).

Function Behaviour
ocs_control_submit(request: string) → string Queues one request (a JSON string) and returns a ticket immediately.
ocs_control_take(ticket: string) → string | undefined Returns the reply (a JSON string) once it is ready, undefined before that. A reply is removed when taken.

The page also loads ocs-control.js, which wraps the two functions in promises:

API Behaviour
await window.ocsControl.ready Resolves once the control channel exists; the ocs-ready window event fires at the same moment.
await window.ocsControl.request(request, {timeoutMs, pollMs}) Submits one request and resolves with the final reply, following accepted/running replies through op: "operation". Rejects only on timeout or a missing channel.

Without the wrapper, poll ocs_control_take until it returns a reply, as the smoke test does:

async function control(request) {
  const api = window.wasmBindings;
  const ticket = api.ocs_control_submit(JSON.stringify(request));
  for (let i = 0; i < 600; i++) {
    const reply = api.ocs_control_take(ticket);
    if (reply) return JSON.parse(reply);
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
  throw new Error('control timed out');
}

Limits: at most 64 requests wait in the queue (a further submit is answered with the busy error), and only the newest 64 replies are kept, so take a reply soon after it is ready. A request that is not valid JSON is answered with op: "invalid" and a parse_error.

Opening a drawing from bytes

The web build has no file system, so open takes the file's bytes instead of a path:

{"op":"open","name":"plan.dwg","data_base64":"…"}

name is reduced to its file name and labels the tab and the recent-files entry; data_base64 must be valid base64 and non-empty (invalid_request otherwise). While an earlier drawing is still opening the request is answered with busy; retry it once state shows the tab. A path is resolved against the browser's "recent files" store rather than a disk, so use data_base64 for a file the page has not seen. data_base64 is rejected on native builds; use path there.

Getting the drawing back

save serializes the active drawing and returns it in the reply, without a download:

{"op":"save","name":"plan.dwg","target_format":"dwg","target_version":"AC1032"}

The reply carries name, target_format, target_version, dropped_on_save, bytes and data_base64, and the tab is marked clean. target_format and target_version default to the drawing's own; a conversion that would drop unsupported records needs allow_lossy: true, as on native.

Supplying fonts

The web build has no font folder. A text style whose TrueType or OpenType file is not one of the bundled Noto subsets falls back to them. The host page can hand the file over:

{"op":"font_register","file_name":"Brand-Regular.ttf","data_base64":"…"}

file_name is the name the drawing's text styles use; it is matched case-insensitively and without a directory, as for installed fonts. The reply carries the family inside the file and the number of faces it added. Open drawings lay their text out again. The font stays registered for the session and is never written into saved files. Bytes without a readable face fail with font_invalid; a missing file_name or empty or invalid base64 with invalid_request.

Receiving saved files in the host page

A page that embeds the app can take every saved or exported file itself instead of a browser download by defining a function on the app's window before saving:

window.openCadStudioSaveFile = (name, bytes) => {
  // name: file name, bytes: Uint8Array
};

While it is defined, Save, Save As, the save-before-close prompt and the exports call it with the file name and the bytes, and no file picker opens. Without it nothing changes.

Startup dialogs

A dialog can be open when the page is ready. The donation prompt appears once per application version in each browser profile, so a fresh or disposable profile always sees it — unless the session is automated: the first control request drops a prompt that is still queued, and a host that sets localStorage["opencadstudio.automation"] to "1" before the page loads never gets one. A prompt already on screen stays. While a modal is open state.modal is set, and {"op":"new"} returns ok without creating a tab until the modal is closed. Close it first:

const state = await control({ op: 'state' });
if (state.modal) await control({ op: 'action', name: 'close_modal' });
await control({ op: 'new' });

Native builds can show a second dialog at launch (the file-association prompt), so check state.modal again after closing one.