@oim-media/spec 0.0.0-stage → 1.27.9

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.
Files changed (131) hide show
  1. package/BINARY_SPEC.md +190 -0
  2. package/CANONICAL_SPEC.md +409 -0
  3. package/LICENSE +55 -0
  4. package/NOTICE +6 -0
  5. package/README.md +445 -2
  6. package/action-cases.json +13 -0
  7. package/artistic-text-cases.json +690 -0
  8. package/canonical-cases.json +6 -0
  9. package/keyframe-cases.json +32 -0
  10. package/keys.json +22 -0
  11. package/marks-cases.json +34 -0
  12. package/package.json +46 -3
  13. package/pattern-cases.json +552 -0
  14. package/schema/oim.schema.json +2920 -0
  15. package/schema/ovm.schema.json +2701 -0
  16. package/text-effect-cases.json +835 -0
  17. package/tools/parity-core.mjs +122 -0
  18. package/tools/parity.mjs +86 -0
  19. package/vectors/README.md +103 -0
  20. package/vectors/accessibility.expected.json +67 -0
  21. package/vectors/accessibility.oim.png +0 -0
  22. package/vectors/artistic-text.expected.json +84 -0
  23. package/vectors/artistic-text.oim.png +0 -0
  24. package/vectors/asymmetric-borders.expected.json +29 -0
  25. package/vectors/asymmetric-borders.oim.png +0 -0
  26. package/vectors/background-pattern-order.expected.json +95 -0
  27. package/vectors/background-pattern-order.oim.png +0 -0
  28. package/vectors/basic-text.expected.json +29 -0
  29. package/vectors/basic-text.oim.png +0 -0
  30. package/vectors/color-blending-opacity.expected.json +47 -0
  31. package/vectors/color-blending-opacity.oim.png +0 -0
  32. package/vectors/crop.expected.json +32 -0
  33. package/vectors/crop.oim.png +0 -0
  34. package/vectors/embedded-font-woff.expected.json +57 -0
  35. package/vectors/embedded-font-woff.oim.png +0 -0
  36. package/vectors/extreme-aspect-ratios.expected.json +29 -0
  37. package/vectors/extreme-aspect-ratios.oim.png +0 -0
  38. package/vectors/filter-effects.expected.json +33 -0
  39. package/vectors/filter-effects.oim.png +0 -0
  40. package/vectors/flip.expected.json +47 -0
  41. package/vectors/flip.oim.png +0 -0
  42. package/vectors/frame-shapes.expected.json +73 -0
  43. package/vectors/frame-shapes.oim.png +0 -0
  44. package/vectors/full-monty-composite.expected.json +96 -0
  45. package/vectors/full-monty-composite.oim.png +0 -0
  46. package/vectors/golden/accessibility.web.png +0 -0
  47. package/vectors/golden/artistic-text.web.png +0 -0
  48. package/vectors/golden/asymmetric-borders.web.png +0 -0
  49. package/vectors/golden/background-pattern-order.web.png +0 -0
  50. package/vectors/golden/basic-text.web.png +0 -0
  51. package/vectors/golden/color-blending-opacity.web.png +0 -0
  52. package/vectors/golden/crop.web.png +0 -0
  53. package/vectors/golden/embedded-font-woff.web.png +0 -0
  54. package/vectors/golden/exceptions.json +3 -0
  55. package/vectors/golden/extreme-aspect-ratios.web.png +0 -0
  56. package/vectors/golden/filter-effects.web.png +0 -0
  57. package/vectors/golden/flip.web.png +0 -0
  58. package/vectors/golden/frame-shapes.web.png +0 -0
  59. package/vectors/golden/full-monty-composite.web.png +0 -0
  60. package/vectors/golden/groups-and-parallax.web.png +0 -0
  61. package/vectors/golden/image-fill.web.png +0 -0
  62. package/vectors/golden/interactive-chained-actions.web.png +0 -0
  63. package/vectors/golden/interactive-hover.web.png +0 -0
  64. package/vectors/golden/interactive.web.png +0 -0
  65. package/vectors/golden/layer-blur.web.png +0 -0
  66. package/vectors/golden/layer-grain.web.png +0 -0
  67. package/vectors/golden/layer-ordering-zindex.web.png +0 -0
  68. package/vectors/golden/legacy-text-chunk.web.png +0 -0
  69. package/vectors/golden/media-filters.web.png +0 -0
  70. package/vectors/golden/multi-container-layout.web.png +0 -0
  71. package/vectors/golden/multilang-cjk-rtl.web.png +0 -0
  72. package/vectors/golden/nested-groups.web.png +0 -0
  73. package/vectors/golden/palettes.web.png +0 -0
  74. package/vectors/golden/parallax-infinite.web.png +0 -0
  75. package/vectors/golden/pattern-color-roles.web.png +0 -0
  76. package/vectors/golden/reedit-english.web.png +0 -0
  77. package/vectors/golden/reveal-and-play.web.png +0 -0
  78. package/vectors/golden/shadow-effects.web.png +0 -0
  79. package/vectors/golden/shape-shadows.web.png +0 -0
  80. package/vectors/golden/strokes-and-borders.web.png +0 -0
  81. package/vectors/golden/text-runs-precalculated.web.png +0 -0
  82. package/vectors/golden/transparent-background.web.png +0 -0
  83. package/vectors/golden/typography-styling.web.png +0 -0
  84. package/vectors/groups-and-parallax.expected.json +90 -0
  85. package/vectors/groups-and-parallax.oim.png +0 -0
  86. package/vectors/image-fill.expected.json +54 -0
  87. package/vectors/image-fill.oim.png +0 -0
  88. package/vectors/interactive-chained-actions.expected.json +77 -0
  89. package/vectors/interactive-chained-actions.oim.png +0 -0
  90. package/vectors/interactive-hover.expected.json +36 -0
  91. package/vectors/interactive-hover.oim.png +0 -0
  92. package/vectors/interactive.expected.json +121 -0
  93. package/vectors/interactive.oim.png +0 -0
  94. package/vectors/layer-blur.expected.json +48 -0
  95. package/vectors/layer-blur.oim.png +0 -0
  96. package/vectors/layer-grain.expected.json +52 -0
  97. package/vectors/layer-grain.oim.png +0 -0
  98. package/vectors/layer-ordering-zindex.expected.json +62 -0
  99. package/vectors/layer-ordering-zindex.oim.png +0 -0
  100. package/vectors/legacy-text-chunk.expected.json +29 -0
  101. package/vectors/legacy-text-chunk.oim.png +0 -0
  102. package/vectors/media-filters.expected.json +50 -0
  103. package/vectors/media-filters.oim.png +0 -0
  104. package/vectors/multi-container-layout.expected.json +45 -0
  105. package/vectors/multi-container-layout.oim.png +0 -0
  106. package/vectors/multilang-cjk-rtl.expected.json +30 -0
  107. package/vectors/multilang-cjk-rtl.oim.png +0 -0
  108. package/vectors/nested-groups.expected.json +68 -0
  109. package/vectors/nested-groups.oim.png +0 -0
  110. package/vectors/palettes.expected.json +57 -0
  111. package/vectors/palettes.oim.png +0 -0
  112. package/vectors/parallax-infinite.expected.json +39 -0
  113. package/vectors/parallax-infinite.oim.png +0 -0
  114. package/vectors/pattern-color-roles.expected.json +68 -0
  115. package/vectors/pattern-color-roles.oim.png +0 -0
  116. package/vectors/reedit-english.expected.json +45 -0
  117. package/vectors/reedit-english.oim.png +0 -0
  118. package/vectors/reveal-and-play.expected.json +107 -0
  119. package/vectors/reveal-and-play.oim.png +0 -0
  120. package/vectors/shadow-effects.expected.json +34 -0
  121. package/vectors/shadow-effects.oim.png +0 -0
  122. package/vectors/shape-shadows.expected.json +62 -0
  123. package/vectors/shape-shadows.oim.png +0 -0
  124. package/vectors/strokes-and-borders.expected.json +31 -0
  125. package/vectors/strokes-and-borders.oim.png +0 -0
  126. package/vectors/text-runs-precalculated.expected.json +46 -0
  127. package/vectors/text-runs-precalculated.oim.png +0 -0
  128. package/vectors/transparent-background.expected.json +30 -0
  129. package/vectors/transparent-background.oim.png +0 -0
  130. package/vectors/typography-styling.expected.json +34 -0
  131. package/vectors/typography-styling.oim.png +0 -0
package/BINARY_SPEC.md ADDED
@@ -0,0 +1,190 @@
1
+ # OIM (Object Interactive Media) Container Binary Specification
2
+
3
+ **Version:** 1.0
4
+ **Date:** 2026-09-22
5
+ **Status:** Normative / In force
6
+ **Authors:** OIM Architecture Team
7
+
8
+ ---
9
+
10
+ ## 1. Summary and Objectives
11
+
12
+ A **`.oim`** (or **`.oim.png`**) file is an enriched, interactive and verifiable multimedia document carried inside an image file that conforms to the standard **ISO/IEC 15948:2004 (PNG)** specification.
13
+
14
+ The objectives of this binary design are:
15
+ 1. **Universal compatibility (visual fallback):** Any image viewer, browser or client without OIM support can decode and render the file as a standard, high-fidelity static PNG image.
16
+ 2. **Zero network dependency (self-contained):** The document encapsulates all of its vector geometry, multilingual texts, animations, typefaces and interactive metadata within the image itself.
17
+ 3. **Cryptographic integrity and ECDSA signature (zero-trust):** The file embeds an asymmetric digital signature that indelibly binds the interactive metadata to the rendered pixels (`IDAT` chunks), preventing substitution or manipulation of the canvas or its layers.
18
+
19
+ ---
20
+
21
+ ## 2. Anatomy of the PNG Binary Container
22
+
23
+ Every OIM file begins strictly with the canonical 8-byte PNG signature:
24
+
25
+ ```binary
26
+ 89 50 4E 47 0D 0A 1A 0A (\x89PNG\r\n\x1a\n)
27
+ ```
28
+
29
+ ### 2.1. PNG Chunk Structure
30
+
31
+ Each chunk in the PNG file follows the standard 4-field layout:
32
+
33
+ | Field | Length | Description |
34
+ |---|---|---|
35
+ | **Length** | 4 bytes (Big-Endian) | Length of the `Data` field (excludes `Type` and `CRC`). |
36
+ | **Type** | 4 bytes (ASCII) | Four-character identifier (e.g. `IHDR`, `tEXt`, `IDAT`, `IEND`). |
37
+ | **Data** | *Length* bytes | Chunk payload. |
38
+ | **CRC-32** | 4 bytes (Big-Endian) | CRC-32 checksum computed over `Type` + `Data`. |
39
+
40
+ ### 2.2. Placement and Injection of OIM Metadata
41
+
42
+ OIM metadata is preferably stored in an ancillary text chunk:
43
+
44
+ - **Chunk type:** `zTXt` (written since 29 Oct 2026). Readers also accept `tEXt` (earlier files) and `iTXt`.
45
+ - **Chunk keyword:** `OIM_Data` (historically `OIM_JSON_DATA` was also recognised).
46
+ - **Location in the file:**
47
+ - **AFTER** `IHDR` (and any palette/colour header chunks, if present).
48
+ - **BEFORE** or **AFTER** the image `IDAT` chunks.
49
+ - **ALWAYS BEFORE** the closing `IEND` chunk.
50
+
51
+ > **Critical note on injection:** The metadata chunk must be inserted **surgically at byte level** into the PNG binary stream, without re-rendering or re-encoding the `IDAT` blocks, preserving the exact cryptographic hash of the canvas.
52
+
53
+ ---
54
+
55
+ ## 3. Encoding and Compression of the Payload
56
+
57
+ The payload of the `zTXt` chunk has the following structure:
58
+
59
+ ```
60
+ [ Keyword: "OIM_Data" ] + [ Null Separator: 0x00 ] + [ Method: 0x00 ] + [ zlib(JSON), binary ]
61
+ ```
62
+
63
+ No Base64: measured on 29 Oct 2026, the file is 12–23 % smaller than with the
64
+ earlier `tEXt` form (`"OIM_Data\0"` + Base64 of raw Deflate), which readers
65
+ still accept.
66
+
67
+ ### 3.1. Packaging Flow (Serializer)
68
+
69
+ 1. The OIM metadata object (conforming to `OimMetadataSchema`) is serialised to a JSON string encoded in **UTF-8**.
70
+ 2. The UTF-8 string is compressed with **zlib** (RFC 1950: 2-byte header, DEFLATE, Adler-32), level 9.
71
+ 3. The `zTXt` chunk is assembled:
72
+ - `Length`: `8` (keyword) + `1` (null byte) + `1` (method) + `len(zlib)`
73
+ - `Type`: `7A 54 58 74` (`zTXt`)
74
+ - `Data`: `"OIM_Data\0\0"` + `<zlib_bytes>`
75
+ - `CRC-32`: computed over `Type` and `Data`.
76
+
77
+ ### 3.2. Extraction and Decompression Flow (Reader)
78
+
79
+ A `zTXt` is read by skipping the NUL and the method byte and decompressing with
80
+ zlib. For the earlier `tEXt` form, a conforming decoder must process the payload
81
+ with the following fault tolerance:
82
+ 1. Extract the value of the chunk after the `0x00` delimiter.
83
+ 2. Decode the Base64 string into binary bytes.
84
+ 3. If the decoded plain text already starts with `{` (uncompressed JSON from debugging/draft), process it directly with `JSON.parse`.
85
+ 4. If it contains compressed binary data:
86
+ - Try to decompress with **Zlib Deflate** (`new DecompressionStream('deflate')` or `zlib.inflateSync`).
87
+ - If that fails, try to decompress with **Raw Deflate** (`new DecompressionStream('deflate-raw')` or `zlib.inflateRawSync`).
88
+ 5. Decode the resulting bytes with `TextDecoder("utf-8")` and parse the JSON.
89
+
90
+ ---
91
+
92
+ ## 4. The Image Integrity Seal (`image_hash`)
93
+
94
+ To guarantee that a set of valid, signed interactive metadata cannot be cut out and pasted onto an arbitrary image (canvas-transplant attack), the format implements an image seal over the IDAT stream:
95
+
96
+ ### 4.1. Calculation Algorithm
97
+
98
+ ```
99
+ image_hash = SHA256( IDAT_payload_1 || IDAT_payload_2 || ... || IDAT_payload_n )
100
+ ```
101
+
102
+ - All chunks with the exact type `IDAT` are traversed, in the strict order in which they appear in the PNG container.
103
+ - Only the payload of each chunk is extracted (`Data`, discarding the 4 length bytes, the 4 `IDAT` type bytes and the 4 CRC bytes).
104
+ - All the bytes of the compressed raw image data are concatenated.
105
+ - The **SHA-256** digest is computed and expressed in **lowercase hexadecimal** (64 characters).
106
+
107
+ ### 4.2. Metadata Fields
108
+ - `image_hash`: Resulting SHA-256 hexadecimal string.
109
+ - `image_hash_alg`: Literal `"sha256-idat"`.
110
+
111
+ ---
112
+
113
+ ## 5. ECDSA P-256 Cryptographic Signature Specification
114
+
115
+ A signed `.oim` file guarantees the authorship, integrity and non-repudiation of the design.
116
+
117
+ - **Cryptosystem:** ECDSA over the **NIST P-256** elliptic curve (secp256r1 / prime256v1).
118
+ - **Hash function:** SHA-256.
119
+ - **Signature format:** raw IEEE P1363 (`r || s`, 32 bytes for $r$ + 32 bytes for $s$ = exactly **64 binary bytes**).
120
+ - **JSON serialisation:** Base64 of the 64 binary bytes in the `signature` property. *(No ASN.1/DER encoding is used.)*
121
+
122
+ ### 5.1. Canonical Serialisation Rules (OIM profile)
123
+
124
+ So that any implementation in any language (TypeScript, Python, Rust, Go, Swift) verifies the signature identically, without a single byte of discrepancy, the payload to be signed or verified is built by strictly following these **7 rules**.
125
+
126
+ This is OIM's own profile, **not RFC 8785 (JCS)**: rule 3 rounds numbers and rule 4 orders keys by code point, where JCS keeps the number and orders by UTF-16 code unit. A stock JCS library does not produce these bytes. [`canonical-cases.json`](canonical-cases.json) is a known-answer case every implementation in this repository is tested against.
127
+
128
+
129
+ 1. **Base object:** Take the decoded OIM metadata dictionary.
130
+ 2. **Root exclusions:** Exclude from the root only the fields:
131
+ - `signature`
132
+ - `key_version`
133
+ - `timestamp`
134
+ - `creator_user_id` *(excluded for privacy, GDPR / zero-knowledge)*.
135
+ *(Note: if any of these keys exist in nested objects or styles, they are not removed.)*
136
+ 3. **Numeric normalisation:**
137
+ - If a decimal number has an exact integer value (e.g. `2.0` or `-0.0`), it is formatted as an integer (`2`, `0`).
138
+ - Floating-point numbers are formatted to a maximum of **6 decimal places** with standard symmetric rounding.
139
+ - **Range:** every number in the metadata, at any depth, must be finite and no larger in magnitude than **2^53 − 1** (`9007199254740991`). Beyond that, JSON parsers disagree on the value they read, so the number has no canonical form: a signer must refuse the metadata and a verifier must report the signature as invalid.
140
+ 4. **Lexical key ordering:** All object keys (at the root and recursively at every level and inside arrays) are sorted by ascending Unicode code point.
141
+ 5. **Compact serialisation:** No whitespace between keys, colons or commas: `{"a":1,"b":"dos"}`.
142
+ 6. **UTF-8 preservation:** Do not escape non-ASCII Unicode characters with `\uXXXX` sequences. They are emitted directly as native UTF-8 (e.g. `"Zürich"` instead of `"Z\u00fcrich"`).
143
+ 7. **Digest:** The resulting bytes encoded in UTF-8 are passed directly to the SHA-256 + ECDSA P-256 verification/signing function.
144
+
145
+ ---
146
+
147
+ ## 6. Embedded Font Handling (`fonts`)
148
+
149
+ Since version 1.3.0, the `.oim` format is self-contained and makes no calls to third-party CDNs to render typefaces:
150
+
151
+ - **Structure of the `fonts` array at the root:**
152
+ ```json
153
+ [
154
+ {
155
+ "family": "Bricolage Grotesque",
156
+ "name": "oim-a9f81-BricolageGrotesque",
157
+ "weight": 700,
158
+ "style": "normal",
159
+ "format": "woff2",
160
+ "data": "<base64_string_of_the_subsetted_woff2_binary>"
161
+ }
162
+ ]
163
+ ```
164
+ - **Strict subsetting:** The embedded WOFF2 binaries must be subsetted only to the glyphs present in the project's texts, to minimise the total weight of the PNG (typically 4 KB to 18 KB per variant).
165
+ - **Registration in the reader:**
166
+ 1. Decode `data` from Base64.
167
+ 2. Register the font via `FontFace` (in browsers) or register it in the native system's font engine under the unique identifier `name`.
168
+ 3. Bind the slots that refer to `family` to the resolved variant.
169
+
170
+ ---
171
+
172
+ ## 7. Summary Table of Fields and Compatibility
173
+
174
+ | Feature | Chunks / Key | Format | Mandatory |
175
+ |---|---|---|---|
176
+ | **Base image** | `IDAT` | Standard compressed PNG | Yes |
177
+ | **OIM metadata** | `zTXt` (`OIM_Data`) | zlib(JSON), binary | Yes |
178
+ | **IDAT seal** | `image_hash` | SHA-256 hex | Yes (since v1.2) |
179
+ | **Digital signature** | `signature` | Base64(ECDSA P1363 64B) | Optional in drafts, required in published files |
180
+ | **Key version** | `key_version` | String (e.g. `"v1"`) | Required if signed |
181
+ | **Signing date** | `signed_at` | ISO-8601 UTC | Required if signed |
182
+ | **Typefaces** | `fonts` | Array with WOFF2 Base64 | Recommended |
183
+ | **Named palettes** (1.20.0) | `palettes`, `defaultPalette` | Object name → (role → CSS colour); string | Optional |
184
+
185
+ ### Named palettes (1.20.0)
186
+
187
+ `palettes` and `defaultPalette` are optional at the root of the document. The
188
+ rules for names, roles, the limit of six palettes and the resolution order are
189
+ in `README.md` (section "Named palettes"). A reader that does not know them
190
+ ignores both keys and paints each layer's fixed colours.
@@ -0,0 +1,409 @@
1
+ # OIM (Object Interactive Media) Canonical Metadata Specification
2
+
3
+ **Contract version:** 1.27.9
4
+ **Date:** 2026-11-02 (reviewed against `packages/core/src/schema.ts`; the source of truth is the schema, this document summarises it)
5
+ **Status:** Normative / In force
6
+ **License:** Apache-2.0
7
+ **Authority:** Xue Studios LLC & the OIM Architecture Team
8
+
9
+ ---
10
+
11
+ ## 1. Summary and Objectives of the Canonical Specification
12
+
13
+ The **`.oim`** format defines a multi-platform interactive image specification. The metadata layer (historically called `oimdata` or `oimdata.json`, injected into the `OIM_Data` chunk of the PNG container) uses a **strongly typed, serialisable canonical language**.
14
+
15
+ > **Note on OVM (`.ovm`):** The interactive video format (`ovm.schema.json` and `@oim-media/player-ovm`) is currently a **reserved placeholder pending specification**. The normative specification below governs **OIM (`.oim`)**.
16
+
17
+ ### Canonical Design Principles
18
+
19
+ 1. **Total independence from web engines (native platforms):**
20
+ - The format **prohibits free or arbitrary CSS** (`custom_loop_css`, `custom_keyframes`, free style strings).
21
+ - Every visual or dynamic attribute is parameterised in canonical types (numbers, enums, tuples or arrays). This allows native implementations on **iOS (SwiftUI / Core Animation)**, **Android (Jetpack Compose / RenderNode)**, **Desktop (Rust / Tauri / Skia)** or Web Components to paint and interact with 1:1 fidelity without requiring an embedded web browser.
22
+ 2. **One name per concept (zero ambiguity):**
23
+ - The compiler canonicalises shortcuts and duplicate names before signing:
24
+ - `size` ➔ `fontSize`
25
+ - `font` ➔ `fontFamily`
26
+ - `color` ➔ `fillColor`
27
+ - `bold: true` ➔ `fontWeight: 700`
28
+ - `italic: true` ➔ `fontStyle: "italic"`
29
+ - `underline: true` ➔ `textDecoration: "underline"`
30
+ - `fontSizeOverrides` ➔ unified inside the `fontSizes` map.
31
+ - `reedicion` (`"publico" | "solo_autor" | "sellado"`) ➔ `reedit` (`"public" | "author_only" | "sealed"`, 1.23.0; legacy files are normalised on read via `normalizeLegacyKeys`).
32
+ 3. **Determinism and security (zero-trust sandbox):**
33
+ - With no arbitrary CSS injection, attack vectors such as `@import`, covert network requests via `url()`, or breaking out of Shadow DOM isolation are eradicated.
34
+ 4. **Portable or not admitted:**
35
+ - Every visual effect is built from primitives that any graphics engine has cheaply — opacity, translation, scale, rotation and rectangular clipping, per unit (letter, word, line or layer). What exists only in a browser (per-letter blur, animated shadows, free `clip-path`) is not admitted, and what was already there is retired or rewritten into portable primitives (`glitchLayers`, `entranceFrameAt`, `revealFrames`).
36
+
37
+ ---
38
+
39
+ ## 2. Canonical Layer Types (`slot.type`)
40
+
41
+ Every interactive element in the `slots` array declares a canonical type:
42
+
43
+ | Type (`type`) | Purpose | Canonical Characteristics |
44
+ |---|---|---|
45
+ | `text` | Dynamic multilingual text | Supports localised `content`, sizes per language (`fontSizes`), widths per language (`maxWidths`), text marks (`marks`) and precalculated lines (`textRuns`). |
46
+ | `image` | Secondary or embedded bitmap | Embedded `data:image/*` URI in `content`, framing (`objectFit`, `imageCrop`), mirroring (`flipX`, `flipY`), filters and accessible `alt`/`decorative`. |
47
+ | `lottie` | Lottie vector animation | Embedded vector JSON. |
48
+ | `geo` / `shape` | Pure vector geometry or photo frame | Governed by `style.geoShape` (24 catalog shapes including 12 photo-frame silhouettes), custom `svgPath`/`svgViewBox`, or `fillType: "image"` (`fillImage`, `fillImageCrop`, `fillImageFlipX`, `fillImageFlipY`). |
49
+ | `draw` / `svg` | Free vector strokes | Supports standard SVG paths (`svgRawXml`, `svgPath`, `svgViewBox`, `customBlobPath`). |
50
+ | `artistic-text` | 3D text with parametric extrusion | Vector extrusion (`extrusionDepth`, `extrusionAngle`, `curveAmount`, `curveType`). |
51
+ | `pattern` | Repeating procedural pattern | Canonical presets (`clouds`, `bubbles`, `grid`, `waves`, `stars`, `stripes`, `dots`, `zigzags`, `bolt`, `leaf`, `drop`, `moon`, `crown`, `heart`). |
52
+ | `filter` | Colour adjustment / optical filter layer | Named presets (`filterPreset`) and normalised numeric adjustments (`filterBrightness`, `filterContrast`, `filterSaturate`, `filterHueRotate`, `filterBlur`). |
53
+ | `container` | Bounding box or frame | Supports geometry, fills, borders, shadows and clipping. |
54
+ | `group` | Hierarchical container node | Groups child layers via `style.groupId`. Controls clipping (`slotClipping`) and scene loops (`style.sceneLoop`). |
55
+ | `component` | Composite component instance | Encapsulated grouping of modular design. |
56
+ | `mask` | Clipping or die-cut mask | Clipping by a base shape (`maskShape`, `maskMode`) or multiple vector cutouts (`cuts`). |
57
+ | `zone` | Contextual anchor zone | Delimiter for anchoring slots to relative regions of the image. |
58
+
59
+ ---
60
+
61
+ ## 3. Root Structure of the Slot (`slotSchema`)
62
+
63
+ ```typescript
64
+ interface OimSlot {
65
+ id: string; // Unique identifier (maximum 128 characters)
66
+ name?: string; // Name assigned by the author (max 256 chars)
67
+ type?: SlotType; // One of the canonical types in section 2
68
+ languages?: string[]; // Languages in which it is shown (default: ["*"])
69
+ pendingTranslation?: string[]; // Language codes pending translation
70
+ align?: "left" | "center" | "right";
71
+ position?: [number, number]; // Normalised coordinates [x, y] (percentage 0.0 to 100.0%)
72
+ width_px?: number; // Virtual width in reference pixels
73
+ height_px?: number; // Virtual height in reference pixels
74
+ content?: Record<string, string>; // Language -> content dictionary (e.g. { "en": "Text" })
75
+ style?: OimStyle; // Canonical style object (see section 4)
76
+ parallax?: OimParallax; // Parallax configuration (see section 5; may also travel in style.parallax)
77
+ interaction?: OimInteraction; // Declared interactions
78
+ tracking_data?: number[][]; // Tracking keyframes [time, x1..y4, cx, cy]
79
+ zone_id?: string; // ID of the containing zone
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## 4. Canonical Style Contract (`styleSchema`)
86
+
87
+ ### 4.1. Typography and Text
88
+ - `fontFamily`: String with the font name (max 128 chars).
89
+ - `fontSize`: Pure number or container percentage (e.g. `24` or `"4.5cqw"`).
90
+ - `fontWeight`: Numeric weight (`100` to `900`, `400` = normal, `700` = bold) or string keyword.
91
+ - `fontStyle`: `"normal" | "italic" | "oblique"`.
92
+ - `textDecoration`: `"none" | "underline"` (max 64 chars).
93
+ - `textAlign`: `"left" | "center" | "right" | "justify"`.
94
+ - `lineHeight`: Numeric multiplier factor (e.g. `1.2`, `1.5`) or a bounded string.
95
+ - `letterSpacing`: A CSS length with its unit (`"2px"`, `"0.05em"`). A bare number is not a length: readers ignore it, as the browser does (section 4.8).
96
+ - `maxWidth`: Container percentage string (e.g. `"80cqw"`, `"none"`) or numeric value.
97
+ - `fontSizes`: Map of size specific to each language `{ "es": "3.0cqw", "ja": "2.5cqw" }`.
98
+ - `maxWidths`: Map of maximum width specific to each language `{ "en": "60cqw" }`.
99
+ - `textRole`: Semantic hierarchy hint (`"auto" | "title" | "subtitle" | "body"`).
100
+ - **Text marks (1.11.0, `text_marks` capability):** `content` can wrap phrases in `{name}…{/name}`; literal braces are doubled (`{{`, `}}`). `style.marks` defines each mark with a closed set (`color`/`colorRole`, `bold`, `italic`, `underline`, `highlight{style, color, colorRole, paddingX, paddingY, borderRadius}`). Names `^[a-z][a-z0-9-]{0,23}$`, maximum 8 per layer, not nested. A single interpretation: `parseMarks`, `markStyle` (`@oim-media/core`). The backend does not sign malformed tags (`OIM_E_TEXT_MARKS_INVALID`).
101
+ - `dropCap`: `{ enabled, lines: 2|3, fontFamily?, color?, marginRight? }`. Size is set with `dropCapStyle`; the rest of the text keeps its marks (`splitDropCap`).
102
+ - `textHighlight`: **retired** (1.12.0). Kept read-only for published files; new writers use `marks`.
103
+ - `textRuns`: **Precalculated lines**, only for readers **without** a paragraph layout engine. A reader that can lay out text (web, Flutter) ignores them: the exporter's line breaks can disagree with the real layout (section 4.8):
104
+ ```json
105
+ "textRuns": {
106
+ "es": {
107
+ "fontSize": 24,
108
+ "lineHeight": 28,
109
+ "lines": [
110
+ { "text": "First line of text", "width": 280, "yOffset": 0 },
111
+ { "text": "Second line, adjusted", "width": 260, "yOffset": 28 }
112
+ ]
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### 4.2. Colours, Dynamic Roles and Fills (`cssColor`)
118
+ Colours are validated against the strict `cssColor` pattern (hexadecimal `#RGB` / `#RRGGBB` / `#RRGGBBAA`, functions `rgb()`, `hsl()`, `"transparent"`, `"none"`, or dynamic colour tokens `var(--primary)`):
119
+ - **Dynamic colour roles (`DYNAMIC_COLOR_ROLES`):**
120
+ `"primary" | "on-primary" | "secondary" | "on-secondary" | "surface" | "on-surface" | "outline" | "accent" | "text-primary" | "text-secondary" | "text-muted" | "text-on-accent"`.
121
+ - `fillColor`: Main fill colour (or glyph colour in text layers).
122
+ - `fillType`: `"none" | "color" | "gradient" | "pattern" | "generative" | "image"`.
123
+ - `colorRole` / `fillColorRole` / `strokeColorRole` / `textStrokeColorRole` / `shadowColorRole` / `effectColorRole` / `maskBgColorRole` / `maskStrokeColorRole`: Semantic dynamic theme roles.
124
+ - **Pattern colour roles (1.21.0):** `fillPatternColorRole`, `fillPatternBgColorRole`, `backgroundPatternColorRole`, `backgroundPatternBgColorRole`.
125
+ - `fillGradient`: Canonical linear, radial or conic gradient function (`CSS_GRADIENT_PATTERN`).
126
+ - `fillPatternType`: Repeating pattern preset:
127
+ `"clouds" | "bubbles" | "grid" | "waves" | "stars" | "stripes" | "dots" | "zigzags" | "bolt" | "leaf" | "drop" | "moon" | "crown" | "heart"`.
128
+ - **Image fill and photo frames (1.17.0 / 1.19.0, `image_fill` and `frame_shapes` capabilities):**
129
+ - `fillImage`: Embedded `data:image/*` URI (up to 10 MB).
130
+ - `fillImageCrop`: `{ x: 0..100, y: 0..100, zoom: 1..10 }`.
131
+ - `fillImageFlipX` / `fillImageFlipY`: `boolean` (mirrors the photo inside the frame without moving the frame).
132
+ - `fillGenerative`: Algorithmic procedural fill:
133
+ ```typescript
134
+ {
135
+ generatorId: string; // Identifier of the generative algorithm
136
+ seed: number; // Deterministic seed
137
+ params: Record<string, number | string | boolean>;
138
+ colors: {
139
+ mode: "monochrome" | "palette";
140
+ stops: string[]; // Array of cssColor colours (max 16)
141
+ direction: "forward" | "reverse" | "radial";
142
+ background: string;
143
+ backgroundVisible: boolean;
144
+ }
145
+ }
146
+ ```
147
+
148
+ ### 4.3. Shapes, Frames, Strokes and Borders
149
+ - `geoShape`:
150
+ - Base shapes: `"rectangle" | "circle" | "triangle" | "star" | "line" | "arrow" | "diamond" | "hexagon" | "heart" | "blob" | "scribble" | "custom"`.
151
+ - Frame shapes (1.19.0, `frame_shapes` capability): `"pill" | "octagon" | "pentagon" | "cross" | "moon" | "cloud" | "drop" | "bolt" | "bubble" | "flower" | "burst" | "arch"`.
152
+ - `svgPath`: Validated SVG path data (`^[MmLlHhVvCcSsQqTtAaZz0-9eE.,\s+-]*$`, max 20,000 chars) for custom frames (`1.19.0`).
153
+ - `svgViewBox`: Four-number coordinate box (`^-?\d+(\.\d+)?([\s,]+-?\d+(\.\d+)?){3}$`, max 64 chars).
154
+ - `strokeColor`: Colour of the border or stroke.
155
+ - `strokeWidth`: Stroke thickness in canvas units (minimum 0).
156
+ - `strokeStyle`: `"solid" | "dashed" | "dotted"`.
157
+ - `borderRadius`: Uniform numeric radius or four-value spatial form (e.g. `8` or `"1.0cqw 2.0cqw 0.0cqw 0.5cqw"`).
158
+
159
+ ### 4.4. Transform, Framing, Accessibility and Hierarchy
160
+ - `width` / `height`: Layer dimensions in `cqw` or user units.
161
+ - `rotation`: Rotation in degrees.
162
+ - `flipX` / `flipY` (1.14.0): Mirror the layer on its own horizontal/vertical axes (`flipScale`).
163
+ - `opacity`: Layer opacity (`0` to `100`).
164
+ - `isVisible`: Initial visibility (`boolean`).
165
+ - `groupId`: ID of the parent `group` slot (`slotParent`, `childrenByGroup`).
166
+ - `objectFit`: `"contain" | "cover" | "fill" | "none" | "scale-down"`.
167
+ - `imageCrop` (1.15.0): `{ x: 0..100, y: 0..100, zoom: 1..10 }` (`cropPlacement`, `cropCss`).
168
+ - `alt` (1.16.0): `Record<string, string>` (per-language accessible description, max 300 chars per entry).
169
+ - `decorative` (1.16.0): `boolean` (hides decorative imagery from assistive technology).
170
+
171
+ ### 4.5. Unified Effects, Shadows and Media Filters
172
+ - `effects`: Modular array (`max(8)`) of discriminated effect objects (`oimEffectSchema`):
173
+ - `texture`: `{ type: "texture", preset, intensity: 0..100, blend, scale?: 0.2..5, color?, colorRole?, enabled? }`.
174
+ - `drop-shadow`: `{ type: "drop-shadow", x?, y?, blur?: 0..200, spread?, color?, colorRole?, enabled? }`.
175
+ - `blur`: `{ type: "blur", radius: 0..100, enabled? }`.
176
+ - `glow`: `{ type: "glow", color?, colorRole?, radius: 0..100, intensity?: 0..100, enabled? }`.
177
+ - `color-grade`: `{ type: "color-grade", preset: "warm-vintage" | "cool-cinema" | "cyberpunk" | "monochrome-contrast", intensity: 0..100, enabled? }`.
178
+ - `shadowEnabled`, `shadowColor`, `shadowColorRole`, `shadowBlur` (`0..200`), `shadowOffsetX` (`-500..500`), `shadowOffsetY` (`-500..500`), `shadowOpacity` (`0..100`).
179
+ - `layerBlur`: Layer blur (`0..100`).
180
+ - **Media filters (1.18.0, `media_filters` capability):**
181
+ - `filterPreset`: `"none" | "duotone" | "grayscale" | "sepia" | "high-contrast" | "vintage" | "cool" | "warm" | "faded" | "cinema" | "nordic" | "sunset" | "noir" | "dramatic" | "cyberpunk" | "pastel" | "vhs"`.
182
+ - `filterBlur` (`0..100`), `filterBrightness` (`0..500`), `filterContrast` (`0..500`), `filterSaturate` (`0..500`), `filterHueRotate` (`0..360`).
183
+
184
+ ### 4.6. Artistic Text and 3D Relief (`artistic-text`)
185
+ - `artisticPreset`: `"neon-cyberpunk" | "retro-wave-3d" | "comic-pop-extrusion" | "vintage-emboss" | "chrome-metallic" | "arcade-80s" | "custom"`.
186
+ - `extrusionDepth`: Geometric extrusion depth (`0` to `100`).
187
+ - `extrusionAngle`: Projection angle in degrees (`0` to `360`).
188
+ - `extrusionColor`: Colour of the extrusion volume.
189
+ - `extrusionShading`: Volumetric facet shading (`boolean`).
190
+ - `curveAmount`: Text curvature (`-100` to `100`).
191
+ - `curveType`: Curvature geometry (`"arc" | "wave" | "arch"`).
192
+ - `outerStrokeColor`, `outerStrokeWidth`, `innerStrokeColor`, `innerStrokeWidth`, `textGradientType`.
193
+ - **Rendering:** the layer is an SVG box of `width_px x height_px` canvas px (else `style.width`/`height`), fitted into the slot (`meet`) without clipping. `fontSize` is canvas px; a `cqw` value is a percentage of the canvas `natural_width`, not of a fixed width. Each line (`
194
+ `) is drawn on its own, `fontSize * lineHeight` apart around the centre. Back to front: the extrusion layers (fill and a stroke of their colour, `outerStrokeWidth` or 1 px), the outer stroke (`outerStrokeWidth * 2`), the inner stroke and then the face. With `curveAmount` the glyphs sit on the curve path, centred on it. Curve path, line offsets and extrusion layers are in `artistic-text-cases.json`.
195
+
196
+ ### 4.7. Animations, Keyframes and Reveal (`animation`, `reveal`)
197
+ - `animation.loop`: Loop preset (`"none" | "pulse" | "float" | "bounce" | "spin" | "glow" | "custom"`).
198
+ - `animation.hover`: Cursor preset (`"none" | "grow" | "shrink" | "lift" | "glow" | "custom"`).
199
+ - `animation.duration`: Duration in seconds (`0.1` to `20`).
200
+ - `animation.delay`: Initial delay in seconds (`0` to `10`).
201
+ - `animation.trigger` (1.22.0): `"load" | "action"` (`"action"` holds the layer at its first keyframe until triggered by a `"play"` state action).
202
+ - `animation.keyframes`: Structured array of keyframes (`keyframeSchema`, `max(240)`):
203
+ ```typescript
204
+ interface OimKeyframe {
205
+ t: number; // Time in seconds (0 to 60)
206
+ o?: number; // Opacity (0.0 to 2.0, allows elastic overshoot)
207
+ s?: number; // Scale multiplier (0.0 to 10.0)
208
+ r?: number; // Rotation in degrees (-3600 to 3600)
209
+ x?: number; // X displacement in canvas coordinates
210
+ y?: number; // Y displacement in canvas coordinates
211
+ b?: number; // Blur (0 to 50)
212
+ e?: "linear" | "ease-in" | "ease-out" | "ease-in-out" | "steps(1, end)" | [number, number, number, number];
213
+ }
214
+ ```
215
+ - `reveal` (1.22.0): How a layer enters when shown by an action and exits when hidden (`revealSchema`):
216
+ ```typescript
217
+ interface OimReveal {
218
+ effect: "fade" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "scale";
219
+ duration: number; // Duration in ms (0 to 2000)
220
+ easing?: KeyframeEasing;
221
+ }
222
+ ```
223
+ - `textAnimationType`: `"none" | "typewriter" | "wave-chars" | "glitch-cyberpunk" | "glitch-slice" | "fade-chars" | "slide-up" | "bounce-chars" | "reveal-mask" | "scale-in" | "rise-words" | "blur-chars"`, with `textAnimationSpeed` (`0..200`), `textAnimationDelay` (`0..100`), `textAnimationIntensity` (`0..100`) and `typewriterCursor`.
224
+ - `fade-chars`, `slide-up`, `bounce-chars`, `reveal-mask`, `scale-in` and `rise-words` are entrance effects defined as portable data in `@oim-media/core` (`text-effects.ts`).
225
+ - `glitch-cyberpunk` and `glitch-slice` are defined as portable layer primitives in `@oim-media/core` (`glitch.ts`).
226
+ - `blur-chars` is **retired** (not portable): it still validates, and readers draw the text without animation.
227
+
228
+ ### 4.8. Rendering rules for readers
229
+
230
+ The web player (`@oim-media/player-oim`) is the reference renderer (section 9.1). These are the rules it
231
+ follows that the schema alone does not say. Every reader must follow them; each one has cost a visible
232
+ difference between renderers before it was written down.
233
+
234
+ **Order and stacking**
235
+ - Layers paint in `slots` order, except the background pattern layer (`is_background_pattern: true`, or
236
+ id `background-pattern-slot`), which always paints behind every other layer.
237
+
238
+ **Text**
239
+ - The text box is a column centred vertically (`justify-content: center`). Horizontal alignment is
240
+ `style.textAlign`, else the slot's `align`, else `left`.
241
+ - The box has a right padding of `0.25em` and is anchored by its padded width: left-aligned text does not
242
+ move, centred text moves `0.125em` left and right-aligned text `0.25em` left.
243
+ - Text with no colour is black.
244
+ - `letterSpacing` applies only as a length with its unit (`px`, `em`); a bare number is ignored.
245
+ - `textRuns` are ignored by a reader that lays out text itself; it lays out `content` instead.
246
+ - A text layer's shadow follows the glyphs (CSS `text-shadow`).
247
+ - The `lineHeight` leading is split evenly above and below the glyphs, as in CSS.
248
+ - On a text layer, `effects` of type `drop-shadow` add one text-shadow (`x`, `y` default 0 and 4, `blur` default 12) and `glow` adds two, blurred `radius * 0.4` and `radius` (default 16, colour `#f59e0b`).
249
+
250
+ **Shapes, strokes and fills**
251
+ - A plain rectangle's stroke is a border outside its box: the box grows by `strokeWidth` on every side,
252
+ keeps its vertical centre, and is anchored horizontally by `textAlign` (left by default). The fill
253
+ paints under the border. Shapes drawn as SVG (catalogue shape, `svgPath`) stroke their outline instead.
254
+ - Borders are drawn only from `strokeColor`, `strokeWidth` and `strokeStyle`. `borderColor` and
255
+ `borderWidth` are not contract keys and are not drawn.
256
+ - A custom path (`svgPath`) is scaled from its `svgViewBox` into the layer box; a photo frame with its own
257
+ `svgPath` masks its colour layer to that path too, so no box of the fill colour shows around the photo.
258
+ - A `container` layer paints like a `geo` layer.
259
+ - A pattern tile is `160 × fillPatternScale` (or `backgroundPatternScale`) canvas units. A stored tile
260
+ (`svgRawXml`) wins over the one generated from the pattern type.
261
+
262
+ **Effects**
263
+ - The shadow of a non-text layer, and the `drop-shadow` effect, follow the layer's silhouette (its alpha),
264
+ not its bounding box, and paint under the layer.
265
+ - `effects` apply in file order to the whole layer, before its flip and rotation. `blur` blurs with a
266
+ standard deviation of `radius` (default 8). On a non-text layer `drop-shadow` casts the silhouette
267
+ (Gaussian sigma `blur / 2`) and `glow` casts it twice, blurred `radius * 0.4` then `radius`.
268
+ - A non-empty `effects` replaces the legacy `shadow*`, `layerBlur`/`blur` and `grain` fields.
269
+ - `texture` repeats its preset tile (`@oim-media/core` `TEXTURE_CATALOG`) at `tile size * scale` canvas units, blended
270
+ with `blend` (default per preset). Text takes it inside its glyphs; `svg` and `image` layers over their pixels;
271
+ other layers over their box, faded once more by `intensity`. In document `metadata.effects` it covers the canvas.
272
+ - Media filters (`filterPreset`, `filter*`) apply only to `image` layers and to the picture of an image
273
+ fill, never to the frame or to other layer types.
274
+ - `opacity` is read as `0..100`.
275
+ - Retired keys (section 8), such as `blendMode`, are not drawn.
276
+
277
+ ---
278
+
279
+ ## 5. Canonical Parallax (`parallaxSchema`)
280
+
281
+ Configuration for layers with spatial depth and reactive or continuous displacement (carried on `slot.parallax` or `slot.style.parallax`):
282
+
283
+ ```typescript
284
+ interface OimParallax {
285
+ enabled: boolean; // Default: true
286
+ depth: number; // Focal depth (-2.0 to 2.0)
287
+ mode: "mouse" | "scroll" | "infinite_horizontal";
288
+ axis: "both" | "x_only" | "y_only";
289
+ infinite?: {
290
+ enabled?: boolean; // Default: false (true when active)
291
+ speedPxSec?: number; // Continuous advance speed (-500 to 500 px/s; alias: speed_px_sec)
292
+ direction?: "left_to_right" | "right_to_left";
293
+ mirror?: boolean; // Mirror alternate tiles (aliases: seamlessTile, seamless_tile)
294
+ pauseOnHover?: boolean; // Pause when the cursor rests on it (alias: pause_on_hover)
295
+ };
296
+ }
297
+ ```
298
+
299
+ > Note: On group layers that form an editable loop scene, `style.sceneLoop` (`{ speedPxSec, direction, mirror, pauseOnHover }`) and child `style.loopDepth` (`0..1`) preserve the scene parameters for re-editing, while each moving child layer carries its resolved `parallax` (`speedPxSec = scene.speedPxSec * loopDepth`) for the player (`infiniteLayerPlan`).
300
+
301
+ ---
302
+
303
+ ## 6. Interactivity and State Machine (`isInteractive`, `states`)
304
+
305
+ Allows declaring interactivity without writing JavaScript:
306
+
307
+ ```typescript
308
+ interface OimInteractiveStates {
309
+ hover?: OimStateConfig; // State when the cursor rests on it or a touch is held (reverted on leave)
310
+ active?: OimStateConfig; // State when clicked or tapped (persists)
311
+ }
312
+
313
+ interface OimStateConfig {
314
+ scale?: number; // Percentage scale (0 to 1000%, e.g. 105 = 105%)
315
+ rotation?: number; // Rotation (-360 to 360 degrees)
316
+ opacity?: number; // Percentage opacity (0 to 100%)
317
+ transitionDuration?: number; // Transition time in ms (0 to 10000)
318
+ actions?: OimStateAction[]; // Chained actions on other layers (max 64)
319
+ }
320
+
321
+ interface OimStateAction {
322
+ target: string; // ID of the target slot (1 to 128 chars)
323
+ type: "show" | "hide" | "toggle" | "setText" | "play"; // "play" added in 1.22.0
324
+ value?: string; // Value to apply (e.g. new text if type is setText, max 10000 chars)
325
+ }
326
+ ```
327
+
328
+ ---
329
+
330
+ ## 7. Root Document Metadata (`OimMetadataSchema`)
331
+
332
+ Top-level structure carried in the `OIM_Data` payload:
333
+
334
+ | Key | Type | Description |
335
+ |---|---|---|
336
+ | `oim` | `string` | Format version of the document (e.g. `"1.27.9"`), the contract version the backend signs against. |
337
+ | `min_player` | `string` | Minimum player version required to rebuild the document (`minVersionFor`). |
338
+ | `oim_id` | `string` | Unique document identifier for re-editing and registry checks (max 128 chars). |
339
+ | `media_type` | `"image" \| "video"` | Media type of the OIM document. |
340
+ | `name` | `string` | Official title of the work (max 256 chars). |
341
+ | `description` | `Record<string, string>` | Per-language summary of the whole document for screen readers (1.16.0, max 1000 chars per language). |
342
+ | `reedit` | `"public" \| "author_only" \| "sealed"` | Authorised cryptographic re-edit policy (1.23.0; replaces deprecated Spanish `reedicion`). |
343
+ | `immutable_lock` | `boolean` | Cryptographic seal flag (`true` when `reedit === "sealed"`). |
344
+ | `canvas` | `{ natural_width: number, natural_height: number }` | Base reference dimensions of the canvas. |
345
+ | `lang` / `languages` | `string[]` | Catalogue of BCP-47 language codes present in the document (max 10 chars per tag). |
346
+ | `palettes` | `Record<string, Partial<Record<DynamicColorRole, string>>>` | Up to 6 named colour palettes mapping semantic roles to CSS colours (1.20.0). |
347
+ | `defaultPalette` | `string` | Name of the active palette in `palettes` (1.20.0). |
348
+ | `slots` | `OimSlot[]` | Array of interactive layers (maximum `MAX_SLOTS` = 150). |
349
+ | `zones` | `OimZone[]` | Optional catalogue of named bounding box zones. |
350
+ | `capabilities` | `("vector_parallax" \| "infinite_parallax" \| "text_marks" \| "image_fill" \| "media_filters" \| "frame_shapes")[]` | Capabilities used by the document. |
351
+ | `capabilities_required` | same as `capabilities` | Capabilities the reader **must** implement to paint with fidelity (glTF pattern). |
352
+ | `fonts` | `EmbeddedFont[]` | Font variants embedded in base64 for offline rendering (1.3.0): subset, static (no `fvar`, `gvar`, `avar`, `HVAR`, `MVAR`; `STAT` may stay) WOFF, `format: "woff"`, written since 1.27.0; WOFF2 (`format: "woff2"`) is read forever. The content starts with the magic of its format (`wOFF` / `wOF2`). |
353
+ | `image_hash` | `string` | SHA-256 hash of the base image `IDAT` chunk payloads (`imageDataHash`). |
354
+ | `image_hash_alg` | `"sha256-idat"` | Image sealing algorithm identifier. |
355
+ | `signature` | `string` | ECDSA P-256 digital signature (Base64) over RFC 8785 canonical JSON (`serializeCanonical`). |
356
+ | `key_version` | `string` | Public key identifier in `keys.json` (e.g. `"v1"`, `"v2"`). |
357
+ | `signed_at` | `string` | ISO-8601 timestamp with time zone included inside the signed payload to enforce key expiry. |
358
+ | `timestamp` | `string` | ISO-8601 timestamp of package creation. |
359
+
360
+ ---
361
+
362
+ ## 8. Retired Keys (Read-Only for Backward Compatibility)
363
+
364
+ These keys remain declared with `deprecated: true` in `oim.schema.json` so already-published files continue to validate, but **no new writer may emit them**:
365
+ - **In `style`:** `textTransform`, `verticalAlign`, `shader_fx`, `blendMode` (closed to 9 values), `grain`/`grainIntensity`/`grainSize`/`grainBlend` (replaced by `style.effects` texture), `textHighlight` (replaced by `style.marks`), `animation.custom_loop_css`/`custom_keyframes`/`custom_hover_css` (retired in 1.9.0).
366
+ - **At the root:** `reedicion` (renamed to `reedit` in 1.23.0; mapped by `normalizeLegacyKeys`), `grain` and `effects` (background grain is baked into the base image, 1.12.0), `elements` and `layers` (`slots` is the single canonical name, 1.12.0), `allowed_domains` (retired in 1.7.0), `redirect_membership_verified` and `terms_accepted` (retired in 1.1.0).
367
+ - **In the slot:** `p`, `s`, `st`, `c`, `t` (short aliases retired in 1.12.0).
368
+
369
+ ---
370
+
371
+ ## 9. Verification and Conformance
372
+
373
+ Conformance of any generator or reader with this specification is validated automatically by running the canonical vector suite and JSON Schema agreement tests:
374
+
375
+ ```bash
376
+ pnpm conformance
377
+ pnpm --filter @oim-media/core test
378
+ ```
379
+
380
+ ### 9.1. Visual conformance
381
+
382
+ The metadata vectors cannot see pixels: two renderers can read the same JSON and paint it differently.
383
+ Every vector therefore also has a golden: `vectors/golden/<vector>.web.png`, rendered by the web player,
384
+ which is the reference renderer.
385
+
386
+ **Procedure.** 800 px wide; height `round(800 × h / w)` from the vector's PNG header; device pixel ratio 1;
387
+ animations disabled; white background; host font `sans-serif` for text that names no family. Goldens
388
+ are only valid when rendered on the CI job's Linux image (`node tools/render-web-goldens.mjs vectors/golden`),
389
+ because fonts rasterise differently on each system.
390
+
391
+ **Metric** (`tools/parity-core.mjs`). A pixel differs when any channel differs by more than 40. The
392
+ difference mask is eroded with a 3×3 kernel, which removes anti-aliasing edges. The outermost 1 px ring of the image is not measured (the kernel has no neighbours there). A render fails when the
393
+ remaining area exceeds **0.4 %** of the image or any 4-connected blob exceeds **576 px** (a 24 × 24 square).
394
+
395
+ The threshold was calibrated on 2026-11-07 against bugs reproduced on purpose:
396
+
397
+ | Case | Area |
398
+ |---|---|
399
+ | Largest passing vector (text anti-aliasing, `typography-styling`) | 0.265 % |
400
+ | Unitless `letterSpacing` ignored | 0.509 % |
401
+ | Square shadow on a round shape | 6.96 % |
402
+ | Shadow painted over the fill | 6.29 % |
403
+ | Pattern tile at the wrong size | 10.9 % |
404
+ | Pattern background painted over the layers | 24.6 % |
405
+
406
+ **Running it.** A renderer writes `<vector>.png` per vector following the procedure, then
407
+ `node tools/parity.mjs <renders-dir> --renderer <name> --out <diff-dir>` compares them and writes a red
408
+ difference image for each failure. Known differences go in `vectors/golden/exceptions.json`, per renderer,
409
+ with a reason; an excepted vector that passes fails the run, so a fixed bug cannot keep its exception.