@textmode/document 0.0.0-stage → 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 +111 -0
- package/README.md +60 -2
- package/dist/constants.d.ts +29 -0
- package/dist/constants.js +29 -0
- package/dist/decode-shapes.d.ts +4 -0
- package/dist/decode-shapes.js +108 -0
- package/dist/decode.d.ts +24 -0
- package/dist/decode.js +88 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/parse.d.ts +12 -0
- package/dist/parse.js +24 -0
- package/dist/types/canonical.d.ts +24 -0
- package/dist/types/canonical.js +1 -0
- package/dist/types/decode.d.ts +38 -0
- package/dist/types/decode.js +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +1 -0
- package/dist/types/legacy.d.ts +25 -0
- package/dist/types/legacy.js +1 -0
- package/dist/types/wire.d.ts +100 -0
- package/dist/types/wire.js +1 -0
- package/dist/validation.d.ts +37 -0
- package/dist/validation.js +205 -0
- package/docs/consumer-guide.md +197 -0
- package/docs/maintenance.md +56 -0
- package/docs/specification-2.0.md +298 -0
- package/package.json +81 -5
- package/schemas/2.0/schema.d.ts +3 -0
- package/schemas/2.0/schema.json +357 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { TEXTMODE_DOCUMENT_FORMAT, TEXTMODE_DOCUMENT_FORMAT_VERSION, TEXTMODE_OBJECT_ROWS_ENCODING } from '../constants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Scope encoded by a textmode document.
|
|
4
|
+
*
|
|
5
|
+
* @category JSON document data
|
|
6
|
+
*/
|
|
7
|
+
export type TextmodeDocumentTarget = 'selected' | 'all';
|
|
8
|
+
/** Format discriminator encoded in a document. */
|
|
9
|
+
export type TextmodeDocumentFormat = typeof TEXTMODE_DOCUMENT_FORMAT;
|
|
10
|
+
/** Current on-disk format version, independent of the npm package version. */
|
|
11
|
+
export type TextmodeDocumentVersion = typeof TEXTMODE_DOCUMENT_FORMAT_VERSION;
|
|
12
|
+
/** RGBA color channels, each an integer from 0 through 255. */
|
|
13
|
+
export interface TextmodeDocumentRGBAColor {
|
|
14
|
+
r: number;
|
|
15
|
+
g: number;
|
|
16
|
+
b: number;
|
|
17
|
+
a: number;
|
|
18
|
+
}
|
|
19
|
+
/** On-disk color notation; decoded colors use RGBA channels. */
|
|
20
|
+
export type TextmodeDocumentColor = string | TextmodeDocumentRGBAColor;
|
|
21
|
+
/** Per-cell transform in degrees. */
|
|
22
|
+
export interface TextmodeDocumentCellTransform {
|
|
23
|
+
invert: boolean;
|
|
24
|
+
flipX: boolean;
|
|
25
|
+
flipY: boolean;
|
|
26
|
+
rotation: number;
|
|
27
|
+
}
|
|
28
|
+
/** Cell at its redundant row-major x/y coordinates. */
|
|
29
|
+
export interface TextmodeDocumentCell {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
character: string;
|
|
33
|
+
foreground: TextmodeDocumentColor;
|
|
34
|
+
background: TextmodeDocumentColor;
|
|
35
|
+
transform: TextmodeDocumentCellTransform;
|
|
36
|
+
}
|
|
37
|
+
/** Dense row-major cell collection. */
|
|
38
|
+
export interface TextmodeDocumentCellCollection {
|
|
39
|
+
encoding: typeof TEXTMODE_OBJECT_ROWS_ENCODING;
|
|
40
|
+
rows: TextmodeDocumentCell[][];
|
|
41
|
+
}
|
|
42
|
+
/** Logical grid dimensions and pixel extent of each cell. */
|
|
43
|
+
export interface TextmodeDocumentGrid {
|
|
44
|
+
cols: number;
|
|
45
|
+
rows: number;
|
|
46
|
+
cellWidth: number;
|
|
47
|
+
cellHeight: number;
|
|
48
|
+
}
|
|
49
|
+
/** Optional producer information; it does not control decoding behavior. */
|
|
50
|
+
export interface TextmodeDocumentMetadata {
|
|
51
|
+
createdAt: string;
|
|
52
|
+
generator: {
|
|
53
|
+
name: string;
|
|
54
|
+
version: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface TextmodeSelectedDocumentLayer {
|
|
58
|
+
id: string;
|
|
59
|
+
cells: TextmodeDocumentCellCollection;
|
|
60
|
+
}
|
|
61
|
+
export interface TextmodeSelectedDocumentJSON {
|
|
62
|
+
format: TextmodeDocumentFormat;
|
|
63
|
+
formatVersion: TextmodeDocumentVersion;
|
|
64
|
+
target: 'selected';
|
|
65
|
+
metadata?: TextmodeDocumentMetadata;
|
|
66
|
+
canvas: {
|
|
67
|
+
width: number;
|
|
68
|
+
height: number;
|
|
69
|
+
};
|
|
70
|
+
grid: TextmodeDocumentGrid;
|
|
71
|
+
layer: TextmodeSelectedDocumentLayer;
|
|
72
|
+
}
|
|
73
|
+
export interface TextmodeDocumentLayer {
|
|
74
|
+
id: string;
|
|
75
|
+
visible: boolean;
|
|
76
|
+
opacity: number;
|
|
77
|
+
blendMode: string;
|
|
78
|
+
offsetX: number;
|
|
79
|
+
offsetY: number;
|
|
80
|
+
rotationZ: number;
|
|
81
|
+
grid: TextmodeDocumentGrid;
|
|
82
|
+
cells: TextmodeDocumentCellCollection;
|
|
83
|
+
}
|
|
84
|
+
export interface TextmodeAllDocumentJSON {
|
|
85
|
+
format: TextmodeDocumentFormat;
|
|
86
|
+
formatVersion: TextmodeDocumentVersion;
|
|
87
|
+
target: 'all';
|
|
88
|
+
metadata?: TextmodeDocumentMetadata;
|
|
89
|
+
canvas: {
|
|
90
|
+
width: number;
|
|
91
|
+
height: number;
|
|
92
|
+
};
|
|
93
|
+
layers: TextmodeDocumentLayer[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* JSON document produced by the selected-layer or layer-stack exporter.
|
|
97
|
+
*
|
|
98
|
+
* @category JSON document data
|
|
99
|
+
*/
|
|
100
|
+
export type TextmodeDocumentJSON = TextmodeSelectedDocumentJSON | TextmodeAllDocumentJSON;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TextmodeDocumentMetadata, TextmodeDocumentGrid, TextmodeDocumentRGBAColor, TextmodeDocumentDecodeResult, TextmodeDocumentError, TextmodeDocumentErrorCode, TextmodeDocumentLimits } from './types/index.js';
|
|
2
|
+
export type UnknownRecord = Record<string, unknown>;
|
|
3
|
+
export type ResolvedLimits = Required<TextmodeDocumentLimits>;
|
|
4
|
+
export type DecodeContext = {
|
|
5
|
+
limits: ResolvedLimits;
|
|
6
|
+
remainingCells: number;
|
|
7
|
+
};
|
|
8
|
+
export declare class DocumentDecodeFailure extends Error {
|
|
9
|
+
readonly detail: TextmodeDocumentError;
|
|
10
|
+
constructor(detail: TextmodeDocumentError);
|
|
11
|
+
}
|
|
12
|
+
export declare function decodeColor(value: unknown, path: string): TextmodeDocumentRGBAColor;
|
|
13
|
+
export declare function decodeCanvas(value: unknown, path: string): {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function decodeGrid(value: unknown, path: string): TextmodeDocumentGrid;
|
|
18
|
+
export declare function decodeMetadata(value: unknown, path: string, limits: ResolvedLimits): TextmodeDocumentMetadata;
|
|
19
|
+
export declare function decodeLayerId(value: unknown, path: string, limits: ResolvedLimits): string;
|
|
20
|
+
export declare function assertCanvasMatchesGrid(canvas: {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}, grid: TextmodeDocumentGrid, path: string): void;
|
|
24
|
+
export declare function resolveLimits(limits: TextmodeDocumentLimits | undefined): ResolvedLimits;
|
|
25
|
+
export declare function checkedProduct(left: number, right: number, path: string): number;
|
|
26
|
+
export declare function normalizeRotation(rotation: number): number;
|
|
27
|
+
export declare function requireRecord(value: unknown, path: string): UnknownRecord;
|
|
28
|
+
export declare function requireArray(value: unknown, path: string): unknown[];
|
|
29
|
+
export declare function requireString(value: unknown, path: string): string;
|
|
30
|
+
export declare function requireBoundedString(value: unknown, path: string, maxLength: number): string;
|
|
31
|
+
export declare function requireCharacter(value: unknown, path: string, limits: ResolvedLimits): string;
|
|
32
|
+
export declare function requireBoolean(value: unknown, path: string): boolean;
|
|
33
|
+
export declare function requireFiniteNumber(value: unknown, path: string): number;
|
|
34
|
+
export declare function requireNumberInRange(value: unknown, path: string, minimum: number, maximum: number): number;
|
|
35
|
+
export declare function requireSafeInteger(value: unknown, path: string): number;
|
|
36
|
+
export declare function failure(code: TextmodeDocumentErrorCode, message: string, path?: string): TextmodeDocumentDecodeResult;
|
|
37
|
+
export declare function fail(code: TextmodeDocumentErrorCode, message: string, path?: string): never;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { DEFAULT_TEXTMODE_DOCUMENT_LIMITS } from './constants.js';
|
|
2
|
+
export class DocumentDecodeFailure extends Error {
|
|
3
|
+
constructor(detail) {
|
|
4
|
+
super(detail.message);
|
|
5
|
+
this.detail = detail;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function decodeColor(value, path) {
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value);
|
|
11
|
+
if (!match) {
|
|
12
|
+
fail('INVALID_DOCUMENT', 'Expected #RRGGBB or #RRGGBBAA color notation.', path);
|
|
13
|
+
}
|
|
14
|
+
const hex = match[1];
|
|
15
|
+
return {
|
|
16
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
17
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
18
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
19
|
+
a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) : 255,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const color = requireRecord(value, path);
|
|
23
|
+
return {
|
|
24
|
+
r: requireByte(color.r, `${path}.r`),
|
|
25
|
+
g: requireByte(color.g, `${path}.g`),
|
|
26
|
+
b: requireByte(color.b, `${path}.b`),
|
|
27
|
+
a: requireByte(color.a, `${path}.a`),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function decodeCanvas(value, path) {
|
|
31
|
+
const canvas = requireRecord(value, path);
|
|
32
|
+
return {
|
|
33
|
+
width: requirePositiveSafeInteger(canvas.width, `${path}.width`),
|
|
34
|
+
height: requirePositiveSafeInteger(canvas.height, `${path}.height`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function decodeGrid(value, path) {
|
|
38
|
+
const grid = requireRecord(value, path);
|
|
39
|
+
return {
|
|
40
|
+
cols: requirePositiveSafeInteger(grid.cols, `${path}.cols`),
|
|
41
|
+
rows: requirePositiveSafeInteger(grid.rows, `${path}.rows`),
|
|
42
|
+
cellWidth: requirePositiveSafeInteger(grid.cellWidth, `${path}.cellWidth`),
|
|
43
|
+
cellHeight: requirePositiveSafeInteger(grid.cellHeight, `${path}.cellHeight`),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function decodeMetadata(value, path, limits) {
|
|
47
|
+
const metadata = requireRecord(value, path);
|
|
48
|
+
const generator = requireRecord(metadata.generator, `${path}.generator`);
|
|
49
|
+
const generatorName = requireBoundedString(generator.name, `${path}.generator.name`, limits.maxMetadataStringLength);
|
|
50
|
+
const createdAt = requireBoundedString(metadata.createdAt, `${path}.createdAt`, limits.maxMetadataStringLength);
|
|
51
|
+
assertTimestamp(createdAt, `${path}.createdAt`);
|
|
52
|
+
return {
|
|
53
|
+
createdAt,
|
|
54
|
+
generator: {
|
|
55
|
+
name: generatorName,
|
|
56
|
+
version: requireBoundedString(generator.version, `${path}.generator.version`, limits.maxMetadataStringLength),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const TIMESTAMP_PATTERN = /^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$/;
|
|
61
|
+
function assertTimestamp(value, path) {
|
|
62
|
+
const match = TIMESTAMP_PATTERN.exec(value);
|
|
63
|
+
if (!match)
|
|
64
|
+
fail('INVALID_DOCUMENT', 'Expected an uppercase RFC 3339 timestamp with seconds and a timezone.', path);
|
|
65
|
+
const year = Number(match[1]);
|
|
66
|
+
const month = Number(match[2]);
|
|
67
|
+
const day = Number(match[3]);
|
|
68
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
69
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
70
|
+
if (day > daysInMonth[month - 1])
|
|
71
|
+
fail('INVALID_DOCUMENT', 'Timestamp contains an invalid calendar date.', path);
|
|
72
|
+
}
|
|
73
|
+
export function decodeLayerId(value, path, limits) {
|
|
74
|
+
return requireBoundedString(value, path, limits.maxLayerIdLength);
|
|
75
|
+
}
|
|
76
|
+
export function assertCanvasMatchesGrid(canvas, grid, path) {
|
|
77
|
+
const expectedWidth = checkedProduct(grid.cols, grid.cellWidth, `${path}.width`);
|
|
78
|
+
const expectedHeight = checkedProduct(grid.rows, grid.cellHeight, `${path}.height`);
|
|
79
|
+
if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
|
|
80
|
+
fail('INVALID_DOCUMENT', `Canvas must equal the grid pixel extent ${expectedWidth}x${expectedHeight}.`, path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function resolveLimits(limits) {
|
|
84
|
+
return {
|
|
85
|
+
maxCells: resolveLimit(limits?.maxCells, DEFAULT_TEXTMODE_DOCUMENT_LIMITS.maxCells, 'maxCells'),
|
|
86
|
+
maxLayers: resolveLimit(limits?.maxLayers, DEFAULT_TEXTMODE_DOCUMENT_LIMITS.maxLayers, 'maxLayers'),
|
|
87
|
+
maxLayerIdLength: resolveLimit(limits?.maxLayerIdLength, DEFAULT_TEXTMODE_DOCUMENT_LIMITS.maxLayerIdLength, 'maxLayerIdLength'),
|
|
88
|
+
maxCharacterLength: resolveLimit(limits?.maxCharacterLength, DEFAULT_TEXTMODE_DOCUMENT_LIMITS.maxCharacterLength, 'maxCharacterLength'),
|
|
89
|
+
maxMetadataStringLength: resolveLimit(limits?.maxMetadataStringLength, DEFAULT_TEXTMODE_DOCUMENT_LIMITS.maxMetadataStringLength, 'maxMetadataStringLength'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function resolveLimit(value, fallback, name) {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return fallback;
|
|
95
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
96
|
+
throw new TypeError(`${name} must be a positive safe integer.`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
export function checkedProduct(left, right, path) {
|
|
101
|
+
const product = left * right;
|
|
102
|
+
if (!Number.isSafeInteger(product)) {
|
|
103
|
+
fail('LIMIT_EXCEEDED', 'Numeric dimensions exceed the safe integer range.', path);
|
|
104
|
+
}
|
|
105
|
+
return product;
|
|
106
|
+
}
|
|
107
|
+
export function normalizeRotation(rotation) {
|
|
108
|
+
const normalized = ((rotation % 360) + 360) % 360;
|
|
109
|
+
return Object.is(normalized, -0) ? 0 : normalized;
|
|
110
|
+
}
|
|
111
|
+
export function requireRecord(value, path) {
|
|
112
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
113
|
+
fail('INVALID_DOCUMENT', 'Expected an object.', path);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
export function requireArray(value, path) {
|
|
118
|
+
if (!Array.isArray(value))
|
|
119
|
+
fail('INVALID_DOCUMENT', 'Expected an array.', path);
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
export function requireString(value, path) {
|
|
123
|
+
if (typeof value !== 'string')
|
|
124
|
+
fail('INVALID_DOCUMENT', 'Expected a string.', path);
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
export function requireBoundedString(value, path, maxLength) {
|
|
128
|
+
const text = requireString(value, path);
|
|
129
|
+
if (text.length === 0)
|
|
130
|
+
fail('INVALID_DOCUMENT', 'Expected a non-empty string.', path);
|
|
131
|
+
if (text.length > maxLength) {
|
|
132
|
+
fail('LIMIT_EXCEEDED', `String length exceeds the configured limit ${maxLength}.`, path);
|
|
133
|
+
}
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
export function requireCharacter(value, path, limits) {
|
|
137
|
+
const character = requireString(value, path);
|
|
138
|
+
if (character.length === 0) {
|
|
139
|
+
fail('INVALID_DOCUMENT', 'Expected a non-empty glyph string.', path);
|
|
140
|
+
}
|
|
141
|
+
assertWellFormedUnicode(character, path);
|
|
142
|
+
if (Array.from(character).length > limits.maxCharacterLength) {
|
|
143
|
+
fail('LIMIT_EXCEEDED', `Glyph string scalar count exceeds the configured limit ${limits.maxCharacterLength}.`, path);
|
|
144
|
+
}
|
|
145
|
+
return character;
|
|
146
|
+
}
|
|
147
|
+
function assertWellFormedUnicode(value, path) {
|
|
148
|
+
for (let index = 0; index < value.length; index++) {
|
|
149
|
+
const codeUnit = value.charCodeAt(index);
|
|
150
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
151
|
+
const next = value.charCodeAt(index + 1);
|
|
152
|
+
if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) {
|
|
153
|
+
fail('INVALID_DOCUMENT', 'Glyph strings must not contain unpaired surrogates.', path);
|
|
154
|
+
}
|
|
155
|
+
index++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
159
|
+
fail('INVALID_DOCUMENT', 'Glyph strings must not contain unpaired surrogates.', path);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function requireBoolean(value, path) {
|
|
164
|
+
if (typeof value !== 'boolean')
|
|
165
|
+
fail('INVALID_DOCUMENT', 'Expected a boolean.', path);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
export function requireFiniteNumber(value, path) {
|
|
169
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
170
|
+
fail('INVALID_DOCUMENT', 'Expected a finite number.', path);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
export function requireNumberInRange(value, path, minimum, maximum) {
|
|
175
|
+
const number = requireFiniteNumber(value, path);
|
|
176
|
+
if (number < minimum || number > maximum) {
|
|
177
|
+
fail('INVALID_DOCUMENT', `Expected a number from ${minimum} through ${maximum}.`, path);
|
|
178
|
+
}
|
|
179
|
+
return number;
|
|
180
|
+
}
|
|
181
|
+
export function requireSafeInteger(value, path) {
|
|
182
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
183
|
+
fail('INVALID_DOCUMENT', 'Expected a safe integer.', path);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
function requirePositiveSafeInteger(value, path) {
|
|
188
|
+
const integer = requireSafeInteger(value, path);
|
|
189
|
+
if (integer <= 0)
|
|
190
|
+
fail('INVALID_DOCUMENT', 'Expected a positive safe integer.', path);
|
|
191
|
+
return integer;
|
|
192
|
+
}
|
|
193
|
+
function requireByte(value, path) {
|
|
194
|
+
const integer = requireSafeInteger(value, path);
|
|
195
|
+
if (integer < 0 || integer > 255) {
|
|
196
|
+
fail('INVALID_DOCUMENT', 'Expected an integer from 0 through 255.', path);
|
|
197
|
+
}
|
|
198
|
+
return integer;
|
|
199
|
+
}
|
|
200
|
+
export function failure(code, message, path) {
|
|
201
|
+
return { ok: false, error: { code, message, ...(path ? { path } : {}) } };
|
|
202
|
+
}
|
|
203
|
+
export function fail(code, message, path) {
|
|
204
|
+
throw new DocumentDecodeFailure({ code, message, ...(path ? { path } : {}) });
|
|
205
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Consumer guide
|
|
2
|
+
|
|
3
|
+
## Choose a decoder
|
|
4
|
+
|
|
5
|
+
Use `parseTextmodeDocumentJSON(text, options)` when you already know the file is a
|
|
6
|
+
textmode document. Use `parseJSON(text)` when several formats share a `.json`
|
|
7
|
+
extension, then route by the parsed object's `format` and pass it to
|
|
8
|
+
`decodeTextmodeDocument(value, options)`. A filename or MIME type is only a hint.
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { parseJSON, decodeTextmodeDocument } from '@textmode/document';
|
|
12
|
+
|
|
13
|
+
const parsed = parseJSON(jsonText);
|
|
14
|
+
if (!parsed.ok) {
|
|
15
|
+
console.error(parsed.error);
|
|
16
|
+
} else if (
|
|
17
|
+
parsed.value !== null &&
|
|
18
|
+
typeof parsed.value === 'object' &&
|
|
19
|
+
!Array.isArray(parsed.value) &&
|
|
20
|
+
['textmode.document', 'textmode.layer'].includes(parsed.value.format)
|
|
21
|
+
) {
|
|
22
|
+
const result = decodeTextmodeDocument(parsed.value, {
|
|
23
|
+
acceptLegacy: true,
|
|
24
|
+
limits: { maxCells: 16384, maxLayers: 64 },
|
|
25
|
+
});
|
|
26
|
+
console.log(result);
|
|
27
|
+
} else {
|
|
28
|
+
// Route other supported formats to the app's other decoders.
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`parseJSON` uses native `JSON.parse`. It does not detect duplicate object keys;
|
|
33
|
+
the last value wins. Avoid duplicate keys when producing files. Syntax success
|
|
34
|
+
alone says nothing about whether a value is a valid textmode document.
|
|
35
|
+
|
|
36
|
+
## Handle results and budgets
|
|
37
|
+
|
|
38
|
+
Malformed input returns `{ ok: false, error }`. Errors contain `code`, `message`,
|
|
39
|
+
and an optional JSON path such as `$.layer.cells.rows[0][0].foreground`.
|
|
40
|
+
|
|
41
|
+
| Code | Meaning |
|
|
42
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
43
|
+
| `INVALID_JSON` | The input is not valid JSON syntax. |
|
|
44
|
+
| `INVALID_DOCUMENT` | A value or cross-field invariant is invalid. |
|
|
45
|
+
| `UNSUPPORTED_FORMAT` | The discriminator is unsupported, or legacy decoding is disabled. |
|
|
46
|
+
| `UNSUPPORTED_VERSION` | The format version is not explicitly supported. |
|
|
47
|
+
| `UNSUPPORTED_TARGET` | The document shape is unsupported. |
|
|
48
|
+
| `UNSUPPORTED_ENCODING` | The cell encoding is unsupported. |
|
|
49
|
+
| `LIMIT_EXCEEDED` | A resource budget or safe numeric range is exceeded. |
|
|
50
|
+
|
|
51
|
+
Successful results contain `document` and `warnings`. A migrated legacy file
|
|
52
|
+
returns `LEGACY_DOCUMENT_MIGRATED`. `acceptLegacy` defaults to `true`; set it to
|
|
53
|
+
`false` to accept only current documents. App-specific failures, such as a missing
|
|
54
|
+
font or unsupported blend mode, belong to the app after protocol decoding.
|
|
55
|
+
|
|
56
|
+
Every configured limit must be a positive safe integer. Invalid configuration
|
|
57
|
+
throws `TypeError`; it is a programming error rather than a document failure.
|
|
58
|
+
Limits and their defaults are listed in the [specification](specification-2.0.md#safety-limits).
|
|
59
|
+
|
|
60
|
+
Check file bytes before reading. Parsing occurs before decoded-cell budgets can
|
|
61
|
+
apply, so `maxCells` does not cap JSON parsing memory or input bytes. Use lower
|
|
62
|
+
app-specific budgets and check cancellation between file read, decode, and import.
|
|
63
|
+
Large files can be decoded in a worker; the library itself is synchronous.
|
|
64
|
+
|
|
65
|
+
Decoding preserves known values subject to normalization. It omits unknown
|
|
66
|
+
properties, so decode-and-serialize is not a lossless round trip for extensions.
|
|
67
|
+
It also cannot add missing font identity or recreate post-processing.
|
|
68
|
+
|
|
69
|
+
## Use JSON Schema
|
|
70
|
+
|
|
71
|
+
Node 24 can import the schema with an import attribute:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
import schema from '@textmode/document/schema/2.0' with { type: 'json' };
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Bundlers may use their own JSON import syntax. A filesystem-based tool can resolve
|
|
78
|
+
the same package subpath and read it locally. All schema references are local.
|
|
79
|
+
|
|
80
|
+
For example, with Ajv installed in the consumer:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
84
|
+
import addFormats from 'ajv-formats';
|
|
85
|
+
import schema from '@textmode/document/schema/2.0' with { type: 'json' };
|
|
86
|
+
|
|
87
|
+
const ajv = new Ajv2020({ validateFormats: true });
|
|
88
|
+
addFormats(ajv, { mode: 'full' });
|
|
89
|
+
const validate = ajv.compile(schema);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Enable date-time format assertions to check timestamps. Schema validation checks
|
|
93
|
+
structure and local constraints; decoding also checks row/grid agreement,
|
|
94
|
+
coordinates, pixel extents, Unicode well-formedness, and resource budgets. A
|
|
95
|
+
schema-valid document can fail decoding. Configurable decoder budgets can also
|
|
96
|
+
differ from schema constraints: for example, a glyph limit above 64 does not
|
|
97
|
+
change the published schema's `maxLength: 64`.
|
|
98
|
+
|
|
99
|
+
## Complete selected document
|
|
100
|
+
|
|
101
|
+
A selected document contains cell data and grid metrics. It carries no layer
|
|
102
|
+
presentation or font identity.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"format": "textmode.document",
|
|
107
|
+
"formatVersion": "2.0.0",
|
|
108
|
+
"target": "selected",
|
|
109
|
+
"canvas": { "width": 8, "height": 16 },
|
|
110
|
+
"grid": { "cols": 1, "rows": 1, "cellWidth": 8, "cellHeight": 16 },
|
|
111
|
+
"layer": {
|
|
112
|
+
"id": "base",
|
|
113
|
+
"cells": {
|
|
114
|
+
"encoding": "object-rows-v1",
|
|
115
|
+
"rows": [
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
"x": 0,
|
|
119
|
+
"y": 0,
|
|
120
|
+
"character": "A",
|
|
121
|
+
"foreground": "#ffffffff",
|
|
122
|
+
"background": "#00000000",
|
|
123
|
+
"transform": { "invert": false, "flipX": false, "flipY": false, "rotation": 0 }
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Complete layer stack
|
|
133
|
+
|
|
134
|
+
An `all` document contains one or more layers in stack order, bottom first.
|
|
135
|
+
Each layer has its own grid and presentation values. The first grid determines
|
|
136
|
+
the legacy root pixel extent; that extent does not specify a composite viewport.
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"format": "textmode.document",
|
|
141
|
+
"formatVersion": "2.0.0",
|
|
142
|
+
"target": "all",
|
|
143
|
+
"canvas": { "width": 8, "height": 16 },
|
|
144
|
+
"layers": [
|
|
145
|
+
{
|
|
146
|
+
"id": "base",
|
|
147
|
+
"visible": true,
|
|
148
|
+
"opacity": 1,
|
|
149
|
+
"blendMode": "normal",
|
|
150
|
+
"offsetX": 0,
|
|
151
|
+
"offsetY": 0,
|
|
152
|
+
"rotationZ": 0,
|
|
153
|
+
"grid": { "cols": 1, "rows": 1, "cellWidth": 8, "cellHeight": 16 },
|
|
154
|
+
"cells": {
|
|
155
|
+
"encoding": "object-rows-v1",
|
|
156
|
+
"rows": [
|
|
157
|
+
[
|
|
158
|
+
{
|
|
159
|
+
"x": 0,
|
|
160
|
+
"y": 0,
|
|
161
|
+
"character": "A",
|
|
162
|
+
"foreground": "#ffffffff",
|
|
163
|
+
"background": "#000000ff",
|
|
164
|
+
"transform": { "invert": false, "flipX": false, "flipY": false, "rotation": 0 }
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "accent",
|
|
172
|
+
"visible": true,
|
|
173
|
+
"opacity": 0.5,
|
|
174
|
+
"blendMode": "screen",
|
|
175
|
+
"offsetX": 4,
|
|
176
|
+
"offsetY": 0,
|
|
177
|
+
"rotationZ": 15,
|
|
178
|
+
"grid": { "cols": 1, "rows": 1, "cellWidth": 8, "cellHeight": 16 },
|
|
179
|
+
"cells": {
|
|
180
|
+
"encoding": "object-rows-v1",
|
|
181
|
+
"rows": [
|
|
182
|
+
[
|
|
183
|
+
{
|
|
184
|
+
"x": 0,
|
|
185
|
+
"y": 0,
|
|
186
|
+
"character": "*",
|
|
187
|
+
"foreground": "#ff000080",
|
|
188
|
+
"background": "#00000000",
|
|
189
|
+
"transform": { "invert": false, "flipX": true, "flipY": false, "rotation": 90 }
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Maintaining the document contract
|
|
2
|
+
|
|
3
|
+
## Ownership
|
|
4
|
+
|
|
5
|
+
This repository owns the format specification, versioned JSON Schema, portable
|
|
6
|
+
types, decoder, and protocol checks. `textmode.export.js` owns renderer extraction,
|
|
7
|
+
selection, generator metadata, serialization options, and downloads. Apps own
|
|
8
|
+
placement, history, font selection, and supported-feature decisions.
|
|
9
|
+
|
|
10
|
+
The schema describes portable structure and local constraints. The specification
|
|
11
|
+
defines meaning and cross-field rules. The decoder implements these rules plus
|
|
12
|
+
normalization and caller budgets. Keep all three consistent through contract
|
|
13
|
+
tests; do not generate competing schemas or copy protocol logic into callers.
|
|
14
|
+
|
|
15
|
+
## Compatibility
|
|
16
|
+
|
|
17
|
+
Package releases and on-disk versions are independent. Preserve the meaning of
|
|
18
|
+
`textmode.document@2.0.0`, including its selected/all distinction and legacy
|
|
19
|
+
`canvas` field. A new document version needs an explicit reader, specification,
|
|
20
|
+
schema, and compatibility tests before it is accepted. Do not infer support for
|
|
21
|
+
future `2.x` files. Namespaced or required extensions need a future design.
|
|
22
|
+
|
|
23
|
+
Keep published versioned schemas stable. Documentation annotations may improve;
|
|
24
|
+
constraint or semantic changes require a compatibility review and explicit
|
|
25
|
+
versioning. Changes to normalized results, error codes, paths, migration warnings,
|
|
26
|
+
or default limits are also public behavior. Deprecate a JavaScript interface before
|
|
27
|
+
removing it from a stable package major.
|
|
28
|
+
|
|
29
|
+
## Tests and documentation
|
|
30
|
+
|
|
31
|
+
Fixtures are fixed test inputs. They protect legacy migration and edge cases and
|
|
32
|
+
are neither demos nor supported package imports. Keep them in `tests/fixtures`,
|
|
33
|
+
out of the package allowlist. Read the fixture README before changing their data.
|
|
34
|
+
|
|
35
|
+
Validate every complete `json` block in the consumer guide against the schema
|
|
36
|
+
and decoder. Keep syntax fragments in the specification clearly labeled. Run
|
|
37
|
+
`npm run check` for formatting, lint, Markdown, types, API docs, tests, build, and
|
|
38
|
+
an isolated packed-package check. The docs build disables browser sketch checks
|
|
39
|
+
because this package has no browser gallery.
|
|
40
|
+
|
|
41
|
+
## npm releases
|
|
42
|
+
|
|
43
|
+
The package is prepared for npm as `@textmode/document`. Its package version is
|
|
44
|
+
independent of the on-disk `textmode.document@2.0.0` format version. A package
|
|
45
|
+
release does not change the document format.
|
|
46
|
+
|
|
47
|
+
Before publishing, run `npm run check` and inspect `npm pack --dry-run`. Confirm
|
|
48
|
+
that the archive contains the runtime, declarations, schemas, and consumer docs,
|
|
49
|
+
and excludes fixtures and tests. This repository has no automated npm publish
|
|
50
|
+
workflow.
|
|
51
|
+
|
|
52
|
+
The exporter currently pins this repository at an exact Git commit. When the
|
|
53
|
+
registry release is ready for consumer adoption, update the exporter to the exact
|
|
54
|
+
published version and regenerate its lockfile with npm. The source repository is
|
|
55
|
+
private, so Git-based installs require authenticated read access until consumers
|
|
56
|
+
move to the public npm package.
|