open-board-format 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shay Cojocaru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # open-board-format
2
+
3
+ A type-safe toolkit to parse, validate, and create Open Board Format (OBF/OBZ) files for Augmentative and Alternative Communication (AAC) applications.
4
+
5
+ ## What is Open Board Format?
6
+
7
+ [Open Board Format](https://www.openboardformat.org/) is an open standard for representing AAC communication boards — grids of labeled buttons that a person taps to communicate. It defines two file types:
8
+
9
+ - **OBF** (`.obf`) — A JSON file describing a single communication board: its buttons, images, sounds, grid layout, and metadata.
10
+ - **OBZ** (`.obz`) — A ZIP archive containing one or more `.obf` boards along with their images, sounds, and a `manifest.json` that ties everything together.
11
+
12
+ The format enables users and practitioners to **move boards between AAC apps** without starting from scratch — a common pain point in the AAC community.
13
+
14
+ ## Features
15
+
16
+ - **Parse & validate** OBF boards from JSON strings, objects, or `File` handles
17
+ - **Create & extract** OBZ packages (ZIP archives with boards, images, and sounds)
18
+ - **[Zod](https://zod.dev/) schemas** for every OBF type — use them for runtime validation, form building, or API contracts
19
+ - **Full TypeScript types** inferred from schemas — no separate type maintenance
20
+ - **Spec-compliant coercion** — numeric IDs are coerced to strings, empty strings become `undefined`, and UTF-8 BOM is handled automatically
21
+ - **Tree-shakeable** ESM build with no side effects (`"sideEffects": false` in `package.json`)
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install open-board-format
27
+ ```
28
+
29
+ > Zero peer dependencies. Powered by [zod](https://zod.dev/) for validation and [fflate](https://github.com/101arrowz/fflate) for ZIP — both listed as standard `dependencies` in `package.json`, so your bundler deduplicates them normally.
30
+
31
+ ## Quick start
32
+
33
+ All examples below assume the following imports:
34
+
35
+ ```ts
36
+ import {
37
+ parseOBF,
38
+ validateOBF,
39
+ stringifyOBF,
40
+ loadOBF,
41
+ extractOBZ,
42
+ loadOBZ,
43
+ createOBZ,
44
+ OBFBoardSchema,
45
+ } from "open-board-format";
46
+ import type { OBFBoard, ParsedOBZ } from "open-board-format";
47
+ ```
48
+
49
+ ### Parse a single board (OBF)
50
+
51
+ ```ts
52
+ // Parse from a JSON string
53
+ const board = parseOBF(jsonString);
54
+
55
+ console.log(board.name); // "My Board"
56
+ console.log(board.buttons[0]); // { id: "1", label: "hello", image_id: "img1" }
57
+
58
+ // Validate an unknown object (throws on failure)
59
+ const validated = validateOBF(untrustedData);
60
+
61
+ // Serialize back to a formatted JSON string
62
+ const json = stringifyOBF(board);
63
+
64
+ // Load from a File object (e.g. browser file-input)
65
+ const fromFile = await loadOBF(file);
66
+ ```
67
+
68
+ ### Extract an OBZ package
69
+
70
+ ```ts
71
+ // From a File (e.g. drag-and-drop)
72
+ const { manifest, boards, files }: ParsedOBZ = await loadOBZ(file);
73
+
74
+ // Or from an ArrayBuffer (e.g. fetch response)
75
+ const parsed = await extractOBZ(buffer);
76
+
77
+ // Access boards by ID
78
+ const rootPath = parsed.manifest.root; // "boards/1.obf"
79
+ const homeBoard = parsed.boards.get("1");
80
+
81
+ // Access raw files (images, sounds, etc.)
82
+ const imageBytes = parsed.files.get("images/logo.png");
83
+ ```
84
+
85
+ `ParsedOBZ` has the following shape:
86
+
87
+ ```ts
88
+ interface ParsedOBZ {
89
+ manifest: OBFManifest; // Validated manifest.json
90
+ boards: Map<string, OBFBoard>; // Board ID → parsed board
91
+ files: Map<string, Uint8Array>; // File path → raw bytes
92
+ }
93
+ ```
94
+
95
+ ### Create an OBZ package
96
+
97
+ ```ts
98
+ const boards: OBFBoard[] = [
99
+ {
100
+ format: "open-board-0.1",
101
+ id: "board-1",
102
+ buttons: [{ id: "btn-1", label: "Hello" }],
103
+ grid: { rows: 1, columns: 1, order: [["btn-1"]] },
104
+ },
105
+ ];
106
+
107
+ // Optional: include image/sound resources
108
+ const resources = new Map([["images/logo.png", pngBytes]]);
109
+
110
+ const blob = await createOBZ(boards, "board-1", resources);
111
+ // blob is a Blob you can download, upload, or store
112
+ ```
113
+
114
+ ### Use Zod schemas directly
115
+
116
+ Every OBF type is exported as both a **Zod schema** (for runtime validation) and a **TypeScript type** (for static analysis):
117
+
118
+ ```ts
119
+ // Safe parsing (returns { success, data, error })
120
+ const result = OBFBoardSchema.safeParse(data);
121
+
122
+ if (result.success) {
123
+ console.log(result.data.buttons);
124
+ } else {
125
+ console.error(result.error.issues);
126
+ }
127
+ ```
128
+
129
+ ## API reference
130
+
131
+ ### OBF functions
132
+
133
+ | Signature | Description |
134
+ | ---------------------------------------- | -------------------------------------------------------------- |
135
+ | `parseOBF(json: string): OBFBoard` | Parse a JSON string into a validated board. Handles UTF-8 BOM. |
136
+ | `validateOBF(data: unknown): OBFBoard` | Validate an unknown value against the board schema. |
137
+ | `stringifyOBF(board: OBFBoard): string` | Serialize a board to a formatted JSON string. |
138
+ | `loadOBF(file: File): Promise<OBFBoard>` | Read and parse a board from a `File` object. |
139
+
140
+ ### OBZ functions
141
+
142
+ | Signature | Description |
143
+ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
144
+ | `extractOBZ(buffer: ArrayBuffer): Promise<ParsedOBZ>` | Extract boards and files from an OBZ archive buffer. |
145
+ | `loadOBZ(file: File): Promise<ParsedOBZ>` | Read and extract an OBZ package from a `File` object. |
146
+ | `createOBZ(boards: OBFBoard[], rootBoardId: string, resources?: Map<string, Uint8Array>): Promise<Blob>` | Create an OBZ archive from boards and optional resources. |
147
+ | `parseManifest(json: string): OBFManifest` | Parse and validate a `manifest.json` string. |
148
+
149
+ ### ZIP utilities
150
+
151
+ | Signature | Description |
152
+ | -------------------------------------------------------------- | ------------------------------------------------------- |
153
+ | `zip(files): Promise<Uint8Array>` | Create a ZIP archive from a map of paths to content. |
154
+ | `unzip(buffer: ArrayBuffer): Promise<Map<string, Uint8Array>>` | Extract files from a ZIP archive. |
155
+ | `isZip(buffer: ArrayBuffer): boolean` | Check whether a buffer starts with the ZIP magic bytes. |
156
+
157
+ ### Schemas & types
158
+
159
+ <details>
160
+ <summary><strong>All exported schemas & types</strong></summary>
161
+
162
+ | Schema | Type | Description |
163
+ | --------------------------- | --------------------- | ----------------------------------------------- |
164
+ | `OBFBoardSchema` | `OBFBoard` | Root board object |
165
+ | `OBFButtonSchema` | `OBFButton` | Button with label, action, colors, positioning |
166
+ | `OBFGridSchema` | `OBFGrid` | Grid layout (rows, columns, order) |
167
+ | `OBFImageSchema` | `OBFImage` | Image resource with dimensions and symbol info |
168
+ | `OBFSoundSchema` | `OBFSound` | Sound resource |
169
+ | `OBFMediaSchema` | `OBFMedia` | Common media base (shared by images & sounds) |
170
+ | `OBFLicenseSchema` | `OBFLicense` | Licensing and attribution |
171
+ | `OBFManifestSchema` | `OBFManifest` | OBZ manifest (`manifest.json`) |
172
+ | `OBFLoadBoardSchema` | `OBFLoadBoard` | Reference to another board |
173
+ | `OBFButtonActionSchema` | `OBFButtonAction` | Spelling or specialty action |
174
+ | `OBFSpellingActionSchema` | `OBFSpellingAction` | Spelling action (`+a`, `+oo`, …) |
175
+ | `OBFSpecialtyActionSchema` | `OBFSpecialtyAction` | Specialty action (`:clear`, `:home`, …) |
176
+ | `OBFIDSchema` | `OBFID` | Unique identifier (string, coerced from number) |
177
+ | `OBFFormatVersionSchema` | `OBFFormatVersion` | Format version string |
178
+ | `OBFLocaleCodeSchema` | `OBFLocaleCode` | BCP 47 locale code |
179
+ | `OBFLocalizedStringsSchema` | `OBFLocalizedStrings` | Key-value string translations |
180
+ | `OBFStringsSchema` | `OBFStrings` | Multi-locale string map |
181
+ | `OBFSymbolInfoSchema` | `OBFSymbolInfo` | Proprietary symbol set reference |
182
+
183
+ </details>
184
+
185
+ ## Spec notes
186
+
187
+ This library targets the **`open-board-0.1`** format version. Key behaviors:
188
+
189
+ - **ID coercion** — Numeric IDs (common in real-world files) are automatically coerced to strings, per the spec's parsing guidelines.
190
+ - **Media resolution order** — When an image or sound has multiple references, resolve in order: `data` → `path` → `url` → `symbol`.
191
+ - **Grid validation** — The `order` array must have exactly `rows` sub-arrays, each with exactly `columns` entries.
192
+ - **Extensions** — Properties prefixed with `ext_` are passed through. The schemas rely on Zod's default object parsing behavior, which preserves unrecognized keys — so custom `ext_` fields survive parsing and serialization without throwing validation errors.
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ npm install # Install dependencies
198
+ npm test # Run tests (vitest)
199
+ npm run build # Build for production (tsdown)
200
+ npm run typecheck # Type-check without emitting
201
+ ```
202
+
203
+ ## Related
204
+
205
+ - [Open Board Format specification](https://www.openboardformat.org/docs) — Official standard and format documentation
206
+
207
+ ## License
208
+
209
+ [MIT](LICENSE) © Shay Cojocaru
@@ -0,0 +1,345 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/schema.d.ts
4
+ /**
5
+ * Open Board Format (OBF) Zod Schemas
6
+ *
7
+ * These schemas represent the Open Board Format, designed for sharing communication boards and board sets
8
+ * between Augmentative and Alternative Communication (AAC) applications.
9
+ *
10
+ * Official OBF specification: https://www.openboardformat.org/docs
11
+ *
12
+ * @author Shay Cojocaru
13
+ * @license MIT
14
+ */
15
+ /** Unique identifier as a string. Must be a non-empty string or number (coerced to string). */
16
+ declare const OBFIDSchema: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
17
+ type OBFID = z.infer<typeof OBFIDSchema>;
18
+ /**
19
+ * Format version of the Open Board Format, e.g., 'open-board-0.1'.
20
+ */
21
+ declare const OBFFormatVersionSchema: z.ZodString;
22
+ type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;
23
+ /**
24
+ * Locale code as per BCP 47 language tags, e.g., 'en', 'en-US', 'fr-CA'.
25
+ */
26
+ declare const OBFLocaleCodeSchema: z.ZodString;
27
+ type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;
28
+ /**
29
+ * Mapping of string keys to localized string values.
30
+ */
31
+ declare const OBFLocalizedStringsSchema: z.ZodRecord<z.ZodString, z.ZodString>;
32
+ type OBFLocalizedStrings = z.infer<typeof OBFLocalizedStringsSchema>;
33
+ /**
34
+ * String translations for multiple locales.
35
+ */
36
+ declare const OBFStringsSchema: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>;
37
+ type OBFStrings = z.infer<typeof OBFStringsSchema>;
38
+ /**
39
+ * Represents custom actions for spelling.
40
+ * Prefixed with '+' followed by the text to append.
41
+ */
42
+ declare const OBFSpellingActionSchema: z.ZodString;
43
+ type OBFSpellingAction = z.infer<typeof OBFSpellingActionSchema>;
44
+ /**
45
+ * Represents specialty actions.
46
+ * Standard actions are prefixed with ':'.
47
+ * Custom actions start with ':ext_'.
48
+ */
49
+ declare const OBFSpecialtyActionSchema: z.ZodString;
50
+ type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;
51
+ /**
52
+ * Possible actions associated with a button.
53
+ */
54
+ declare const OBFButtonActionSchema: z.ZodUnion<readonly [z.ZodString, z.ZodString]>;
55
+ type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;
56
+ /**
57
+ * Licensing information for a resource.
58
+ */
59
+ declare const OBFLicenseSchema: z.ZodObject<{
60
+ type: z.ZodString;
61
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
62
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
63
+ author_name: z.ZodOptional<z.ZodString>;
64
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
65
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
66
+ }, z.core.$strip>;
67
+ type OBFLicense = z.infer<typeof OBFLicenseSchema>;
68
+ /**
69
+ * Common properties for media resources (images and sounds).
70
+ *
71
+ * When multiple references are provided, they should be used in the following order:
72
+ * 1. data
73
+ * 2. path
74
+ * 3. url
75
+ */
76
+ declare const OBFMediaSchema: z.ZodObject<{
77
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
78
+ data: z.ZodOptional<z.ZodString>;
79
+ path: z.ZodOptional<z.ZodString>;
80
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
81
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
82
+ content_type: z.ZodOptional<z.ZodString>;
83
+ license: z.ZodOptional<z.ZodObject<{
84
+ type: z.ZodString;
85
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
86
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
87
+ author_name: z.ZodOptional<z.ZodString>;
88
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
89
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
90
+ }, z.core.$strip>>;
91
+ }, z.core.$strip>;
92
+ type OBFMedia = z.infer<typeof OBFMediaSchema>;
93
+ /**
94
+ * Information about a symbol from a proprietary symbol set.
95
+ */
96
+ declare const OBFSymbolInfoSchema: z.ZodObject<{
97
+ set: z.ZodString;
98
+ filename: z.ZodString;
99
+ }, z.core.$strip>;
100
+ type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;
101
+ /**
102
+ * Represents an image resource.
103
+ *
104
+ * When resolving the image, if multiple references are provided, they should be used in the following order:
105
+ * 1. data
106
+ * 2. path
107
+ * 3. url
108
+ * 4. symbol
109
+ */
110
+ declare const OBFImageSchema: z.ZodIntersection<z.ZodObject<{
111
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
112
+ data: z.ZodOptional<z.ZodString>;
113
+ path: z.ZodOptional<z.ZodString>;
114
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
115
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
116
+ content_type: z.ZodOptional<z.ZodString>;
117
+ license: z.ZodOptional<z.ZodObject<{
118
+ type: z.ZodString;
119
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
120
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
121
+ author_name: z.ZodOptional<z.ZodString>;
122
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
123
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
124
+ }, z.core.$strip>>;
125
+ }, z.core.$strip>, z.ZodObject<{
126
+ symbol: z.ZodOptional<z.ZodObject<{
127
+ set: z.ZodString;
128
+ filename: z.ZodString;
129
+ }, z.core.$strip>>;
130
+ width: z.ZodOptional<z.ZodNumber>;
131
+ height: z.ZodOptional<z.ZodNumber>;
132
+ }, z.core.$strip>>;
133
+ type OBFImage = z.infer<typeof OBFImageSchema>;
134
+ /**
135
+ * Represents a sound resource.
136
+ */
137
+ declare const OBFSoundSchema: z.ZodObject<{
138
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
139
+ data: z.ZodOptional<z.ZodString>;
140
+ path: z.ZodOptional<z.ZodString>;
141
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
142
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
143
+ content_type: z.ZodOptional<z.ZodString>;
144
+ license: z.ZodOptional<z.ZodObject<{
145
+ type: z.ZodString;
146
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
147
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
148
+ author_name: z.ZodOptional<z.ZodString>;
149
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
150
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
151
+ }, z.core.$strip>>;
152
+ }, z.core.$strip>;
153
+ type OBFSound = z.infer<typeof OBFSoundSchema>;
154
+ /**
155
+ * Information needed to load another board.
156
+ */
157
+ declare const OBFLoadBoardSchema: z.ZodObject<{
158
+ id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
159
+ name: z.ZodOptional<z.ZodString>;
160
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
161
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
162
+ path: z.ZodOptional<z.ZodString>;
163
+ }, z.core.$strip>;
164
+ type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;
165
+ /**
166
+ * Represents a button on the board.
167
+ */
168
+ declare const OBFButtonSchema: z.ZodObject<{
169
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
170
+ label: z.ZodOptional<z.ZodString>;
171
+ vocalization: z.ZodOptional<z.ZodString>;
172
+ image_id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
173
+ sound_id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
174
+ action: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>;
175
+ actions: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>>;
176
+ load_board: z.ZodOptional<z.ZodObject<{
177
+ id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
178
+ name: z.ZodOptional<z.ZodString>;
179
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
180
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
181
+ path: z.ZodOptional<z.ZodString>;
182
+ }, z.core.$strip>>;
183
+ background_color: z.ZodOptional<z.ZodString>;
184
+ border_color: z.ZodOptional<z.ZodString>;
185
+ top: z.ZodOptional<z.ZodNumber>;
186
+ left: z.ZodOptional<z.ZodNumber>;
187
+ width: z.ZodOptional<z.ZodNumber>;
188
+ height: z.ZodOptional<z.ZodNumber>;
189
+ }, z.core.$strip>;
190
+ type OBFButton = z.infer<typeof OBFButtonSchema>;
191
+ /**
192
+ * Grid layout information for the board.
193
+ */
194
+ declare const OBFGridSchema: z.ZodObject<{
195
+ rows: z.ZodNumber;
196
+ columns: z.ZodNumber;
197
+ order: z.ZodArray<z.ZodArray<z.ZodUnion<readonly [z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>, z.ZodNull]>>>;
198
+ }, z.core.$strip>;
199
+ type OBFGrid = z.infer<typeof OBFGridSchema>;
200
+ /**
201
+ * Represents the root object of an OBF file, defining the structure and layout of a board.
202
+ */
203
+ declare const OBFBoardSchema: z.ZodObject<{
204
+ format: z.ZodString;
205
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
206
+ locale: z.ZodOptional<z.ZodString>;
207
+ buttons: z.ZodArray<z.ZodObject<{
208
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
209
+ label: z.ZodOptional<z.ZodString>;
210
+ vocalization: z.ZodOptional<z.ZodString>;
211
+ image_id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
212
+ sound_id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
213
+ action: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>;
214
+ actions: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>>;
215
+ load_board: z.ZodOptional<z.ZodObject<{
216
+ id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
217
+ name: z.ZodOptional<z.ZodString>;
218
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
219
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
220
+ path: z.ZodOptional<z.ZodString>;
221
+ }, z.core.$strip>>;
222
+ background_color: z.ZodOptional<z.ZodString>;
223
+ border_color: z.ZodOptional<z.ZodString>;
224
+ top: z.ZodOptional<z.ZodNumber>;
225
+ left: z.ZodOptional<z.ZodNumber>;
226
+ width: z.ZodOptional<z.ZodNumber>;
227
+ height: z.ZodOptional<z.ZodNumber>;
228
+ }, z.core.$strip>>;
229
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
230
+ name: z.ZodOptional<z.ZodString>;
231
+ description_html: z.ZodOptional<z.ZodString>;
232
+ grid: z.ZodObject<{
233
+ rows: z.ZodNumber;
234
+ columns: z.ZodNumber;
235
+ order: z.ZodArray<z.ZodArray<z.ZodUnion<readonly [z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>, z.ZodNull]>>>;
236
+ }, z.core.$strip>;
237
+ images: z.ZodOptional<z.ZodArray<z.ZodIntersection<z.ZodObject<{
238
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
239
+ data: z.ZodOptional<z.ZodString>;
240
+ path: z.ZodOptional<z.ZodString>;
241
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
242
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
243
+ content_type: z.ZodOptional<z.ZodString>;
244
+ license: z.ZodOptional<z.ZodObject<{
245
+ type: z.ZodString;
246
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
247
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
248
+ author_name: z.ZodOptional<z.ZodString>;
249
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
250
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
251
+ }, z.core.$strip>>;
252
+ }, z.core.$strip>, z.ZodObject<{
253
+ symbol: z.ZodOptional<z.ZodObject<{
254
+ set: z.ZodString;
255
+ filename: z.ZodString;
256
+ }, z.core.$strip>>;
257
+ width: z.ZodOptional<z.ZodNumber>;
258
+ height: z.ZodOptional<z.ZodNumber>;
259
+ }, z.core.$strip>>>>;
260
+ sounds: z.ZodOptional<z.ZodArray<z.ZodObject<{
261
+ id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
262
+ data: z.ZodOptional<z.ZodString>;
263
+ path: z.ZodOptional<z.ZodString>;
264
+ data_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
265
+ url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
266
+ content_type: z.ZodOptional<z.ZodString>;
267
+ license: z.ZodOptional<z.ZodObject<{
268
+ type: z.ZodString;
269
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
270
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
271
+ author_name: z.ZodOptional<z.ZodString>;
272
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
273
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
274
+ }, z.core.$strip>>;
275
+ }, z.core.$strip>>>;
276
+ license: z.ZodOptional<z.ZodObject<{
277
+ type: z.ZodString;
278
+ copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
279
+ source_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
280
+ author_name: z.ZodOptional<z.ZodString>;
281
+ author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
282
+ author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
283
+ }, z.core.$strip>>;
284
+ strings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>>;
285
+ }, z.core.$strip>;
286
+ type OBFBoard = z.infer<typeof OBFBoardSchema>;
287
+ /**
288
+ * Manifest file in an .obz package.
289
+ */
290
+ declare const OBFManifestSchema: z.ZodObject<{
291
+ format: z.ZodString;
292
+ root: z.ZodString;
293
+ paths: z.ZodObject<{
294
+ boards: z.ZodRecord<z.ZodString, z.ZodString>;
295
+ images: z.ZodRecord<z.ZodString, z.ZodString>;
296
+ sounds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
297
+ }, z.core.$strip>;
298
+ }, z.core.$strip>;
299
+ type OBFManifest = z.infer<typeof OBFManifestSchema>;
300
+ //#endregion
301
+ //#region src/obf.d.ts
302
+ declare function parseOBF(json: string): OBFBoard;
303
+ declare function loadOBF(file: File): Promise<OBFBoard>;
304
+ declare function validateOBF(data: unknown): OBFBoard;
305
+ declare function stringifyOBF(board: OBFBoard): string;
306
+ //#endregion
307
+ //#region src/obz.d.ts
308
+ interface ParsedOBZ {
309
+ manifest: OBFManifest;
310
+ boards: Map<string, OBFBoard>;
311
+ files: Map<string, Uint8Array>;
312
+ }
313
+ /**
314
+ * Load OBZ package from File
315
+ * @param file - File object
316
+ * @returns Parsed OBZ with manifest, boards, and files
317
+ */
318
+ declare function loadOBZ(file: File): Promise<ParsedOBZ>;
319
+ /**
320
+ * Extract OBZ package from ArrayBuffer
321
+ * @param buffer - OBZ file as ArrayBuffer
322
+ * @returns Parsed OBZ with manifest, boards, and files
323
+ */
324
+ declare function extractOBZ(buffer: ArrayBuffer): Promise<ParsedOBZ>;
325
+ /**
326
+ * Parse manifest.json from OBZ package
327
+ * @param json - Manifest JSON string
328
+ * @returns Validated Manifest object
329
+ */
330
+ declare function parseManifest(json: string): OBFManifest;
331
+ /**
332
+ * Create OBZ package from boards and resources
333
+ * @param boards - Array of Board objects
334
+ * @param rootBoardId - ID of the root board
335
+ * @param resources - Optional map of resource paths to buffers
336
+ * @returns OBZ package as Blob
337
+ */
338
+ declare function createOBZ(boards: OBFBoard[], rootBoardId: string, resources?: Map<string, Uint8Array | ArrayBuffer>): Promise<Blob>;
339
+ //#endregion
340
+ //#region src/zip.d.ts
341
+ declare function unzip(buffer: ArrayBuffer): Promise<Map<string, Uint8Array>>;
342
+ declare function zip(files: Map<string, Uint8Array | ArrayBuffer>): Promise<Uint8Array>;
343
+ declare function isZip(buffer: ArrayBuffer): boolean;
344
+ //#endregion
345
+ export { type OBFBoard, OBFBoardSchema, type OBFButton, type OBFButtonAction, OBFButtonActionSchema, OBFButtonSchema, type OBFFormatVersion, OBFFormatVersionSchema, type OBFGrid, OBFGridSchema, type OBFID, OBFIDSchema, type OBFImage, OBFImageSchema, type OBFLicense, OBFLicenseSchema, type OBFLoadBoard, OBFLoadBoardSchema, type OBFLocaleCode, OBFLocaleCodeSchema, type OBFLocalizedStrings, OBFLocalizedStringsSchema, type OBFManifest, OBFManifestSchema, type OBFMedia, OBFMediaSchema, type OBFSound, OBFSoundSchema, type OBFSpecialtyAction, OBFSpecialtyActionSchema, type OBFSpellingAction, OBFSpellingActionSchema, type OBFStrings, OBFStringsSchema, type OBFSymbolInfo, OBFSymbolInfoSchema, type ParsedOBZ, createOBZ, extractOBZ, isZip, loadOBF, loadOBZ, parseManifest, parseOBF, stringifyOBF, unzip, validateOBF, zip };
package/dist/index.mjs ADDED
@@ -0,0 +1,315 @@
1
+ import { z } from "zod";
2
+ import { unzip as unzip$1, zip as zip$1 } from "fflate";
3
+
4
+ //#region src/schema.ts
5
+ /**
6
+ * Open Board Format (OBF) Zod Schemas
7
+ *
8
+ * These schemas represent the Open Board Format, designed for sharing communication boards and board sets
9
+ * between Augmentative and Alternative Communication (AAC) applications.
10
+ *
11
+ * Official OBF specification: https://www.openboardformat.org/docs
12
+ *
13
+ * @author Shay Cojocaru
14
+ * @license MIT
15
+ */
16
+ /** Optional URL that treats empty strings as undefined. */
17
+ const OBFOptionalUrlSchema = z.union([z.url(), z.literal("")]).transform((val) => val === "" ? void 0 : val).optional();
18
+ /** Optional email that treats empty strings as undefined. */
19
+ const OBFOptionalEmailSchema = z.union([z.email(), z.literal("")]).transform((val) => val === "" ? void 0 : val).optional();
20
+ /** Optional ID that treats empty strings as undefined. */
21
+ const OBFOptionalIDSchema = z.union([z.string(), z.number()]).transform((val) => {
22
+ const str = String(val);
23
+ return str === "" ? void 0 : str;
24
+ }).optional();
25
+ /** Unique identifier as a string. Must be a non-empty string or number (coerced to string). */
26
+ const OBFIDSchema = z.union([z.string(), z.number()]).transform((val) => String(val)).pipe(z.string().min(1));
27
+ /**
28
+ * Format version of the Open Board Format, e.g., 'open-board-0.1'.
29
+ */
30
+ const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);
31
+ /**
32
+ * Locale code as per BCP 47 language tags, e.g., 'en', 'en-US', 'fr-CA'.
33
+ */
34
+ const OBFLocaleCodeSchema = z.string();
35
+ /**
36
+ * Mapping of string keys to localized string values.
37
+ */
38
+ const OBFLocalizedStringsSchema = z.record(z.string(), z.string());
39
+ /**
40
+ * String translations for multiple locales.
41
+ */
42
+ const OBFStringsSchema = z.record(z.string(), OBFLocalizedStringsSchema);
43
+ /**
44
+ * Represents custom actions for spelling.
45
+ * Prefixed with '+' followed by the text to append.
46
+ */
47
+ const OBFSpellingActionSchema = z.string().regex(/^\+.+$/);
48
+ /**
49
+ * Represents specialty actions.
50
+ * Standard actions are prefixed with ':'.
51
+ * Custom actions start with ':ext_'.
52
+ */
53
+ const OBFSpecialtyActionSchema = z.string().regex(/^:[a-z][a-z0-9_-]*$/i);
54
+ /**
55
+ * Possible actions associated with a button.
56
+ */
57
+ const OBFButtonActionSchema = z.union([OBFSpellingActionSchema, OBFSpecialtyActionSchema]);
58
+ /**
59
+ * Licensing information for a resource.
60
+ */
61
+ const OBFLicenseSchema = z.object({
62
+ type: z.string(),
63
+ copyright_notice_url: OBFOptionalUrlSchema,
64
+ source_url: OBFOptionalUrlSchema,
65
+ author_name: z.string().optional(),
66
+ author_url: OBFOptionalUrlSchema,
67
+ author_email: OBFOptionalEmailSchema
68
+ });
69
+ /**
70
+ * Common properties for media resources (images and sounds).
71
+ *
72
+ * When multiple references are provided, they should be used in the following order:
73
+ * 1. data
74
+ * 2. path
75
+ * 3. url
76
+ */
77
+ const OBFMediaSchema = z.object({
78
+ id: OBFIDSchema,
79
+ data: z.string().optional(),
80
+ path: z.string().optional(),
81
+ data_url: OBFOptionalUrlSchema,
82
+ url: OBFOptionalUrlSchema,
83
+ content_type: z.string().optional(),
84
+ license: OBFLicenseSchema.optional()
85
+ });
86
+ /**
87
+ * Information about a symbol from a proprietary symbol set.
88
+ */
89
+ const OBFSymbolInfoSchema = z.object({
90
+ set: z.string(),
91
+ filename: z.string()
92
+ });
93
+ /**
94
+ * Represents an image resource.
95
+ *
96
+ * When resolving the image, if multiple references are provided, they should be used in the following order:
97
+ * 1. data
98
+ * 2. path
99
+ * 3. url
100
+ * 4. symbol
101
+ */
102
+ const OBFImageSchema = OBFMediaSchema.and(z.object({
103
+ symbol: OBFSymbolInfoSchema.optional(),
104
+ width: z.number().optional(),
105
+ height: z.number().optional()
106
+ }));
107
+ /**
108
+ * Represents a sound resource.
109
+ */
110
+ const OBFSoundSchema = OBFMediaSchema;
111
+ /**
112
+ * Information needed to load another board.
113
+ */
114
+ const OBFLoadBoardSchema = z.object({
115
+ id: OBFOptionalIDSchema,
116
+ name: z.string().optional(),
117
+ data_url: OBFOptionalUrlSchema,
118
+ url: OBFOptionalUrlSchema,
119
+ path: z.string().optional()
120
+ });
121
+ /**
122
+ * Represents a button on the board.
123
+ */
124
+ const OBFButtonSchema = z.object({
125
+ id: OBFIDSchema,
126
+ label: z.string().optional(),
127
+ vocalization: z.string().optional(),
128
+ image_id: OBFOptionalIDSchema,
129
+ sound_id: OBFOptionalIDSchema,
130
+ action: OBFButtonActionSchema.optional(),
131
+ actions: z.array(OBFButtonActionSchema).optional(),
132
+ load_board: OBFLoadBoardSchema.optional(),
133
+ background_color: z.string().optional(),
134
+ border_color: z.string().optional(),
135
+ top: z.number().min(0).max(1).optional(),
136
+ left: z.number().min(0).max(1).optional(),
137
+ width: z.number().min(0).max(1).optional(),
138
+ height: z.number().min(0).max(1).optional()
139
+ });
140
+ /**
141
+ * Grid layout information for the board.
142
+ */
143
+ const OBFGridSchema = z.object({
144
+ rows: z.number().int().min(1),
145
+ columns: z.number().int().min(1),
146
+ order: z.array(z.array(z.union([OBFIDSchema, z.null()])))
147
+ }).refine((g) => g.order.length === g.rows, { message: "Grid order length must match rows" }).refine((g) => g.order.every((row) => row.length === g.columns), { message: "Each grid row must have length equal to columns" });
148
+ /**
149
+ * Represents the root object of an OBF file, defining the structure and layout of a board.
150
+ */
151
+ const OBFBoardSchema = z.object({
152
+ format: OBFFormatVersionSchema,
153
+ id: OBFIDSchema,
154
+ locale: OBFLocaleCodeSchema.optional(),
155
+ buttons: z.array(OBFButtonSchema),
156
+ url: OBFOptionalUrlSchema,
157
+ name: z.string().optional(),
158
+ description_html: z.string().optional(),
159
+ grid: OBFGridSchema,
160
+ images: z.array(OBFImageSchema).optional(),
161
+ sounds: z.array(OBFSoundSchema).optional(),
162
+ license: OBFLicenseSchema.optional(),
163
+ strings: OBFStringsSchema.optional()
164
+ });
165
+ /**
166
+ * Manifest file in an .obz package.
167
+ */
168
+ const OBFManifestSchema = z.object({
169
+ format: OBFFormatVersionSchema,
170
+ root: z.string(),
171
+ paths: z.object({
172
+ boards: z.record(z.string(), z.string()),
173
+ images: z.record(z.string(), z.string()),
174
+ sounds: z.record(z.string(), z.string()).optional()
175
+ })
176
+ });
177
+
178
+ //#endregion
179
+ //#region src/obf.ts
180
+ const UTF8_BOM = "";
181
+ function parseOBF(json) {
182
+ const trimmed = json.startsWith(UTF8_BOM) ? json.slice(1) : json;
183
+ let data;
184
+ try {
185
+ data = JSON.parse(trimmed);
186
+ } catch (error) {
187
+ throw new Error(`Invalid OBF: JSON parse failed${error?.message ? ` — ${error.message}` : ""}`);
188
+ }
189
+ return validateOBF(data);
190
+ }
191
+ async function loadOBF(file) {
192
+ return parseOBF(await file.text());
193
+ }
194
+ function validateOBF(data) {
195
+ const result = OBFBoardSchema.safeParse(data);
196
+ if (!result.success) throw new Error(`Invalid OBF: ${result.error.message}`);
197
+ return result.data;
198
+ }
199
+ function stringifyOBF(board) {
200
+ return JSON.stringify(board, null, 2);
201
+ }
202
+
203
+ //#endregion
204
+ //#region src/zip.ts
205
+ function unzip(buffer) {
206
+ return new Promise((resolve, reject) => {
207
+ unzip$1(new Uint8Array(buffer), (error, result) => {
208
+ if (error) {
209
+ reject(/* @__PURE__ */ new Error(`Failed to unzip: ${error.message ?? String(error)}`));
210
+ return;
211
+ }
212
+ const files = /* @__PURE__ */ new Map();
213
+ for (const [path, bytes] of Object.entries(result)) files.set(path, bytes);
214
+ resolve(files);
215
+ });
216
+ });
217
+ }
218
+ function zip(files) {
219
+ return new Promise((resolve, reject) => {
220
+ const input = {};
221
+ for (const [path, content] of files) input[path] = content instanceof Uint8Array ? content : new Uint8Array(content);
222
+ zip$1(input, { level: 6 }, (error, result) => {
223
+ if (error) {
224
+ reject(/* @__PURE__ */ new Error(`Failed to zip: ${error.message ?? String(error)}`));
225
+ return;
226
+ }
227
+ resolve(result);
228
+ });
229
+ });
230
+ }
231
+ function isZip(buffer) {
232
+ const view = new Uint8Array(buffer);
233
+ return view.length >= 2 && view[0] === 80 && view[1] === 75;
234
+ }
235
+
236
+ //#endregion
237
+ //#region src/obz.ts
238
+ /**
239
+ * Load OBZ package from File
240
+ * @param file - File object
241
+ * @returns Parsed OBZ with manifest, boards, and files
242
+ */
243
+ async function loadOBZ(file) {
244
+ return extractOBZ(await file.arrayBuffer());
245
+ }
246
+ /**
247
+ * Extract OBZ package from ArrayBuffer
248
+ * @param buffer - OBZ file as ArrayBuffer
249
+ * @returns Parsed OBZ with manifest, boards, and files
250
+ */
251
+ async function extractOBZ(buffer) {
252
+ if (!isZip(buffer)) throw new Error("Invalid OBZ: not a ZIP file");
253
+ const files = await unzip(buffer);
254
+ const manifestBuffer = files.get("manifest.json");
255
+ if (!manifestBuffer) throw new Error("Invalid OBZ: missing manifest.json");
256
+ const manifest = parseManifest(new TextDecoder().decode(manifestBuffer));
257
+ const boards = /* @__PURE__ */ new Map();
258
+ for (const [id, path] of Object.entries(manifest.paths.boards)) {
259
+ const boardBuffer = files.get(path);
260
+ if (!boardBuffer) {
261
+ console.warn(`Board ${id} not found at ${path}`);
262
+ continue;
263
+ }
264
+ const board = parseOBF(new TextDecoder().decode(boardBuffer));
265
+ boards.set(id, board);
266
+ }
267
+ return {
268
+ manifest,
269
+ boards,
270
+ files
271
+ };
272
+ }
273
+ /**
274
+ * Parse manifest.json from OBZ package
275
+ * @param json - Manifest JSON string
276
+ * @returns Validated Manifest object
277
+ */
278
+ function parseManifest(json) {
279
+ const data = JSON.parse(json);
280
+ const result = OBFManifestSchema.safeParse(data);
281
+ if (!result.success) throw new Error(`Invalid manifest: ${result.error.message}`);
282
+ return result.data;
283
+ }
284
+ /**
285
+ * Create OBZ package from boards and resources
286
+ * @param boards - Array of Board objects
287
+ * @param rootBoardId - ID of the root board
288
+ * @param resources - Optional map of resource paths to buffers
289
+ * @returns OBZ package as Blob
290
+ */
291
+ async function createOBZ(boards, rootBoardId, resources) {
292
+ const files = /* @__PURE__ */ new Map();
293
+ const manifest = OBFManifestSchema.parse({
294
+ format: "open-board-0.1",
295
+ root: `boards/${rootBoardId}.obf`,
296
+ paths: {
297
+ boards: Object.fromEntries(boards.map((board) => [board.id, `boards/${board.id}.obf`])),
298
+ images: {},
299
+ sounds: {}
300
+ }
301
+ });
302
+ const manifestJSON = JSON.stringify(manifest, null, 2);
303
+ files.set("manifest.json", new TextEncoder().encode(manifestJSON).buffer);
304
+ for (const board of boards) {
305
+ const boardJSON = JSON.stringify(board, null, 2);
306
+ const path = `boards/${board.id}.obf`;
307
+ files.set(path, new TextEncoder().encode(boardJSON).buffer);
308
+ }
309
+ if (resources) for (const [path, buffer] of resources.entries()) files.set(path, buffer);
310
+ const zipBuffer = await zip(files);
311
+ return new Blob([new Uint8Array(zipBuffer)], { type: "application/zip" });
312
+ }
313
+
314
+ //#endregion
315
+ export { OBFBoardSchema, OBFButtonActionSchema, OBFButtonSchema, OBFFormatVersionSchema, OBFGridSchema, OBFIDSchema, OBFImageSchema, OBFLicenseSchema, OBFLoadBoardSchema, OBFLocaleCodeSchema, OBFLocalizedStringsSchema, OBFManifestSchema, OBFMediaSchema, OBFSoundSchema, OBFSpecialtyActionSchema, OBFSpellingActionSchema, OBFStringsSchema, OBFSymbolInfoSchema, createOBZ, extractOBZ, isZip, loadOBF, loadOBZ, parseManifest, parseOBF, stringifyOBF, unzip, validateOBF, zip };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "open-board-format",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "description": "Parse, validate, and create Open Board Format (OBF/OBZ) files for AAC applications.",
6
+ "author": "Shay Cojocaru <shayc@outlook.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/shayc/open-board-format#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/shayc/open-board-format.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/shayc/open-board-format/issues"
15
+ },
16
+ "keywords": [
17
+ "aac",
18
+ "open-board-format",
19
+ "communication-board"
20
+ ],
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": "./dist/index.mjs",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "types": "./dist/index.d.mts",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsdown",
32
+ "dev": "tsdown --watch",
33
+ "test": "vitest",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^25.3.0",
39
+ "bumpp": "^10.4.1",
40
+ "tsdown": "^0.20.3",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.18"
43
+ },
44
+ "dependencies": {
45
+ "fflate": "^0.8.2",
46
+ "zod": "^4.3.6"
47
+ }
48
+ }