Skip to content

shayc/open-board-format

Repository files navigation

@shayc/open-board-format

npm version CI License: MIT

A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards. OBF (.obf) is a JSON file describing a single communication board: buttons, images, sounds, grid layout, metadata. OBZ (.obz) is a ZIP archive bundling one or more boards with their media and a manifest.json. This package parses, validates, and creates both.

my-board.obz
├── manifest.json        ← table of contents: root board + id-to-path maps
├── boards/
│   └── home.obf         ← one JSON board per file
├── images/
│   └── dog.png
└── sounds/
    └── hello.mp3
  • Typed end to end — every type is inferred from a Zod schema, and every schema is exported for safeParse or composing into your own contracts.
  • Browser and Node.js 22+ — pure ESM, works against File, Blob, ArrayBuffer, or any typed-array view (e.g. Node's Buffer).
  • One entry point for either formatloadBoard sniffs the bytes and tells you whether it found an .obf board or an .obz package.
  • Spec-faithful round trips — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive parseOBFstringifyOBF.
  • Small footprint — one runtime dependency (fflate) plus Zod as a peer, tree-shakeable, no side effects.

Contents: Install · Quick start · Usage · API · Errors · Security · Scope · Versioning · Contributing · Related · License

Install

npm install @shayc/open-board-format zod

zod 4 is a peer dependency — npm 7+ installs it automatically, but pnpm and Yarn users should add it explicitly (as shown above).

ESM only — CommonJS (require) is not supported.

Quick start

import { loadBoard } from "@shayc/open-board-format";

// `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
const loaded = await loadBoard(file);

if (loaded.format === "obf") {
  console.log(loaded.board.buttons.length);
} else {
  console.log(loaded.archive.rootBoard.buttons.length);
}

loadBoard accepts a File, Blob, ArrayBuffer, or any typed-array view (e.g. a Node Buffer), and throws on invalid input (see Errors). Already holding a JSON string? parseOBF(json) returns a validated OBFBoard directly.

In Node.js, read the file first — there's no File API outside the browser, but readFile's Buffer works directly:

import { readFile } from "node:fs/promises";
import { loadBoard } from "@shayc/open-board-format";

const loaded = await loadBoard(await readFile("my-board.obz"));

Usage

Which function do I need?

  • You have a single board (OBF). Use parseOBF for a JSON string, validateOBF for an already-parsed object, loadOBF for a browser File. stringifyOBF serializes back out.
  • You have a package of boards plus media (OBZ). Use extractOBZ for a File, Blob, ArrayBuffer, or typed-array view (loadOBZ is the same thing, File-only); createOBZ to build a new one.
  • You don't know which you have. Use loadBoard — it sniffs the bytes and returns a { format, ... } union so you don't have to inspect the file extension yourself.

Read an OBZ package

import { loadOBZ, extractOBZ } from "@shayc/open-board-format";

// From a File (e.g. drag-and-drop)
const { rootBoard, boards, resources } = await loadOBZ(file);

// Or from anything else — ArrayBuffer, Blob, Node Buffer, etc.
const parsed = await extractOBZ(buffer);

// Untrusted input? Cap declared uncompressed sizes (see Security)
const guarded = await extractOBZ(buffer, {
  limits: { maxTotalOriginalSize: 500e6 },
});

rootBoard is the package's home board — the one manifest.root points at, already resolved. boards is keyed by board ID and resources by archive path; the manifest is also returned if you need the raw table of contents.

Resources are raw bytes. To display an image in the browser:

const bytes = resources.get("images/hello.png")!;
const url = URL.createObjectURL(new Blob([bytes]));

Create an OBZ package

Buttons reference media by ID (image_id, sound_id); the board's images/sounds entries carry the archive path; the resources map supplies the bytes for each path. Every path a board declares must have a matching resource entry, or createOBZ throws.

import { createOBZ } from "@shayc/open-board-format";
import type { OBFBoard } from "@shayc/open-board-format";

const board: OBFBoard = {
  format: "open-board-0.1",
  id: "board-1",
  buttons: [{ id: "btn-1", label: "Hello", image_id: "img-1" }],
  grid: { rows: 1, columns: 1, order: [["btn-1"]] },
  images: [{ id: "img-1", path: "images/hello.png" }],
};

const pngBytes = new Uint8Array(/* ... */);
const resources = new Map([["images/hello.png", pngBytes]]);

const blob = await createOBZ([board], "board-1", resources);

The manifest.json is generated for you — boards are written to boards/<id>.obf (the id is percent-encoded, so it's always a safe filename), and rootBoardId (the second argument) selects the home board.

Validate with Zod directly

import { OBFBoardSchema } from "@shayc/open-board-format";

const result = OBFBoardSchema.safeParse(data);

if (result.success) {
  console.log(result.data.buttons);
} else {
  console.error(result.error.issues);
}

API

One naming convention covers the whole surface: parse* takes a JSON string, validate* takes an already-parsed object, load* takes a browser File, stringify* returns a JSON string — and extractOBZ/loadBoard also accept a Blob, ArrayBuffer, or typed-array view (e.g. a Node Buffer), for use outside the browser.

OBF (single board)

Function Returns Description
parseOBF(json) OBFBoard Parse a JSON string into a validated OBFBoard
validateOBF(data) OBFBoard Validate an unknown object as OBFBoard (throws on failure)
stringifyOBF(board) string Serialize an OBFBoard to a JSON string
loadOBF(file) Promise<OBFBoard> Load an OBFBoard from a browser File

OBZ (board package)

Function Returns Description
loadOBZ(file, options?) Promise<ParsedOBZ> Load an OBZ package from a browser File
extractOBZ(archive, options?) Promise<ParsedOBZ> Extract boards, manifest, root board, and resources from a File, Blob, ArrayBuffer, or typed-array view
createOBZ(boards, rootBoardId, resources?) Promise<Blob> Create an OBZ package as a Blob
parseManifest(json) OBFManifest Parse a manifest.json string into a validated OBFManifest

Format detection

Function Returns Description
loadBoard(input, options?) Promise<LoadedBoard> Detect OBF vs OBZ from a File, Blob, ArrayBuffer, or typed-array view and load it; returns a LoadedBoard union

Utilities

Function Returns Description
isZip(archive) boolean Check if an ArrayBuffer starts with a ZIP magic number
zip(entries) Promise<Uint8Array> Create a ZIP from a map of paths to buffers
unzip(archive, options?) Promise<Map<string, Uint8Array>> Extract a ZIP into a map of paths to Uint8Array

Types

Type Description
OBFBoard A single communication board
OBFGrid Grid layout (rows, columns, order)
OBFButton A button on the board
OBFButtonAction Button action (spelling or specialty)
OBFSpellingAction Spelling action (e.g., +s)
OBFSpecialtyAction Specialty action (e.g., :clear)
OBFLoadBoard Reference to load another board
OBFMedia Common media properties (base for OBFImage and OBFSound)
OBFImage An image resource (extends OBFMedia)
OBFSound A sound resource (alias of OBFMedia)
OBFSymbolInfo Symbol set reference
OBFManifest OBZ package manifest
ParsedOBZ Return type of extractOBZ / loadOBZ{ manifest, boards, rootBoard, resources }
LoadedBoard Return type of loadBoard{ format: "obz", archive } | { format: "obf", board }
BinaryInput Input type of loadBoard / extractOBZFile | Blob | ArrayBuffer | ArrayBufferView
UnzipLimits Optional extraction caps — { maxEntrySize?, maxTotalOriginalSize?, maxEntries? } (see Security)
UnzipOptions Options for unzip / extractOBZ / loadOBZ / loadBoard{ limits?: UnzipLimits }
OBFID Unique identifier (string, coerced from number)
OBFFormatVersion Format version string (e.g., open-board-0.1)
OBFLicense Licensing information
OBFLocaleCode BCP 47 locale code
OBFLocalizedStrings Key-value string translations
OBFStrings Multi-locale string translations

Schemas

Every type above except ParsedOBZ, LoadedBoard, BinaryInput, UnzipLimits, and UnzipOptions is exported alongside a matching Zod schema with a Schema suffix — OBFBoardOBFBoardSchema, OBFManifestOBFManifestSchema, and so on. Import any of them to validate with safeParse/parse or to compose into your own schemas:

import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";

Errors

Every failure throws an OBFError. Branch on error.info.code — a discriminated union where each code carries exactly the fields relevant to that failure. Don't match on error.message; the message is human-readable and may change between releases.

import { loadBoard, OBFError } from "@shayc/open-board-format";

try {
  await loadBoard(file);
} catch (error) {
  if (!(error instanceof OBFError)) throw error;

  switch (error.info.code) {
    case "missing-resource":
      // `kind`, `mediaId`, and `path` are all typed and present here
      console.warn(`Missing ${error.info.kind} at ${error.info.path}`);
      break;
    case "invalid-board":
      // `issues` is the Zod issue list — which field failed and why
      console.error(error.info.issues);
      break;
    default:
      console.error(error.message);
  }
}

The code values, grouped by what they describe:

Group info.code Key fields (on info)
Decoding not-json source
not-zip
unreadable-zip
archive-too-large limit, path, and the tripped cap's fields
Validation invalid-board issues, boardId?
invalid-manifest issues
Read (OBZ) missing-manifest
missing-board boardId, path
board-id-mismatch path, declaredId, actualId
Write (OBZ) unknown-root rootBoardId
duplicate-board boardId
missing-resource kind, mediaId, path
conflicting-paths kind, mediaId, paths
path-collision path
zip-failed
Internal internal detail

OBFErrorInfo and OBFErrorCode are exported for exhaustive handling.

  • The underlying error, when there is one, is always on the standard error.cause — never duplicated on info.
  • Validation failures (invalid-board, invalid-manifest) put the ZodError on error.cause, so z.treeifyError(error.cause) gives nested, UI-friendly output, while info.issues (Zod's issue type, re-exported as OBFIssue) gives you the flat list directly.
  • For not-json / *-zip failures, error.cause is the underlying parser or fflate error.
  • An internal code signals a bug in this library that callers can't recover from — please report it.

Re-zipping an extracted .obz by hand? Zip the folder's contents, not the folder itself. If manifest.json ends up nested inside a top-level folder in the archive, extraction fails with missing-manifest even though the file is right there.

Security

OBZ archives are untrusted input. To guard against zip bombs, pass limits (via UnzipOptions) to loadBoard / loadOBZ / extractOBZ / unzip:

const parsed = await extractOBZ(buffer, {
  limits: {
    maxEntrySize: 100e6, // any single entry, in bytes
    maxTotalOriginalSize: 500e6, // sum of all entries, in bytes
    maxEntries: 10_000, // entry count, including directory entries
  },
});

Size limits are checked per entry against the uncompressed size declared in the archive's ZIP metadata, before that entry is inflated; maxEntries caps how many entries are processed at all. Entries accepted before a later entry trips a limit have already been inflated, but total allocation stays bounded by the caps. Exceeding a limit aborts extraction and rejects with an OBFError whose code is archive-too-large (info carries limit, the entry path that tripped it, and maxBytes/declaredBytes for the size limits or maxEntries/entryCount for the count). No limits are applied by default. A lying header can't force larger allocations — fflate allocates output buffers at exactly the declared size — so the declared-size caps bound memory use.

Entry paths are not sanitized — if you write extracted resources to disk, validate paths yourself first to avoid directory traversal.

Found a security issue? Open a private advisory at github.com/shayc/open-board-format/security/advisories/new.

Scope

What this library deliberately does not do:

  • No network I/O — media referenced by url or data_url is not fetched; resolving external media is up to you.
  • No rendering — it parses and validates data; drawing boards and playing sounds belong to your app.
  • No default extraction limits, no path sanitization — size caps are opt-in via UnzipOptions; see Security before writing archive contents to disk.
  • No referential integrity checks — a grid.order id with no matching button, or an image_id/sound_id with no matching image/sound, is not flagged. Resolving references is up to your rendering layer.

Versioning

Semver. The public API — every exported function, type, and Zod schema — is stable; breaking changes ship as major releases. See CHANGELOG.md.

Contributing

See CONTRIBUTING.md for development setup (Node 22+, Vitest, the changeset workflow).

Related

License

MIT © Shay Cojocaru

About

Parse, validate, and create Open Board Format (OBF/OBZ) files for AAC applications.

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors