@textmode/document 0.0.0-stage → 1.1.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 +61 -2
- package/dist/constants.d.ts +29 -0
- package/dist/constants.js +29 -0
- package/dist/decode-shapes.d.ts +6 -0
- package/dist/decode-shapes.js +136 -0
- package/dist/decode.d.ts +24 -0
- package/dist/decode.js +92 -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 +16 -0
- package/dist/types/canonical.js +1 -0
- package/dist/types/decode.d.ts +39 -0
- package/dist/types/decode.js +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +1 -0
- package/dist/types/legacy.d.ts +26 -0
- package/dist/types/legacy.js +1 -0
- package/dist/types/v2.d.ts +34 -0
- package/dist/types/v2.js +1 -0
- package/dist/types/wire.d.ts +68 -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 +230 -0
- package/docs/schema-2.0.md +236 -0
- package/docs/schema-3.0.md +117 -0
- package/docs/specification-2.0.md +294 -0
- package/docs/specification-3.0.md +134 -0
- package/package.json +86 -5
- package/schemas/2.0/schema.d.ts +3 -0
- package/schemas/2.0/schema.json +357 -0
- package/schemas/3.0/schema.d.ts +3 -0
- package/schemas/3.0/schema.json +267 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LEGACY_TEXTMODE_ALL_VERSION, LEGACY_TEXTMODE_LAYER_FORMAT, LEGACY_TEXTMODE_SELECTED_VERSION } from '../constants.js';
|
|
2
|
+
import type { TextmodeDocumentMetadata, TextmodeDocumentGrid } from './wire.js';
|
|
3
|
+
import type { TextmodeV2DocumentLayer, TextmodeV2SelectedDocumentLayer } from './v2.js';
|
|
4
|
+
export interface LegacyTextmodeSelectedDocumentJSON {
|
|
5
|
+
$schema?: string;
|
|
6
|
+
format: typeof LEGACY_TEXTMODE_LAYER_FORMAT;
|
|
7
|
+
formatVersion: typeof LEGACY_TEXTMODE_SELECTED_VERSION;
|
|
8
|
+
metadata?: TextmodeDocumentMetadata;
|
|
9
|
+
canvas: {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
grid: TextmodeDocumentGrid;
|
|
14
|
+
layer: TextmodeV2SelectedDocumentLayer;
|
|
15
|
+
}
|
|
16
|
+
export interface LegacyTextmodeAllDocumentJSON {
|
|
17
|
+
$schema?: string;
|
|
18
|
+
format: typeof LEGACY_TEXTMODE_LAYER_FORMAT;
|
|
19
|
+
formatVersion: typeof LEGACY_TEXTMODE_ALL_VERSION;
|
|
20
|
+
metadata?: TextmodeDocumentMetadata;
|
|
21
|
+
canvas: {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
};
|
|
25
|
+
layers: TextmodeV2DocumentLayer[];
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TextmodeDocumentFormat, TextmodeDocumentMetadata, TextmodeDocumentGrid, TextmodeDocumentCellCollection, TextmodeDocumentLayer } from './wire.js';
|
|
2
|
+
export interface TextmodeV2SelectedDocumentLayer {
|
|
3
|
+
id: string;
|
|
4
|
+
cells: TextmodeDocumentCellCollection;
|
|
5
|
+
}
|
|
6
|
+
/** Layer fields required by the v2 wire format and validated during migration. */
|
|
7
|
+
export interface TextmodeV2DocumentLayer extends TextmodeDocumentLayer {
|
|
8
|
+
offsetX: number;
|
|
9
|
+
offsetY: number;
|
|
10
|
+
rotationZ: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TextmodeV2SelectedDocumentJSON {
|
|
13
|
+
format: TextmodeDocumentFormat;
|
|
14
|
+
formatVersion: '2.0.0';
|
|
15
|
+
target: 'selected';
|
|
16
|
+
metadata?: TextmodeDocumentMetadata;
|
|
17
|
+
canvas: {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
grid: TextmodeDocumentGrid;
|
|
22
|
+
layer: TextmodeV2SelectedDocumentLayer;
|
|
23
|
+
}
|
|
24
|
+
export interface TextmodeV2AllDocumentJSON {
|
|
25
|
+
format: TextmodeDocumentFormat;
|
|
26
|
+
formatVersion: '2.0.0';
|
|
27
|
+
target: 'all';
|
|
28
|
+
metadata?: TextmodeDocumentMetadata;
|
|
29
|
+
canvas: {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
layers: TextmodeV2DocumentLayer[];
|
|
34
|
+
}
|
package/dist/types/v2.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { TEXTMODE_DOCUMENT_FORMAT, TEXTMODE_DOCUMENT_FORMAT_VERSION, TEXTMODE_OBJECT_ROWS_ENCODING } from '../constants.js';
|
|
2
|
+
/** Format discriminator encoded in a document. */
|
|
3
|
+
export type TextmodeDocumentFormat = typeof TEXTMODE_DOCUMENT_FORMAT;
|
|
4
|
+
/** Current on-disk format version, independent of the npm package version. */
|
|
5
|
+
export type TextmodeDocumentVersion = typeof TEXTMODE_DOCUMENT_FORMAT_VERSION;
|
|
6
|
+
/** RGBA color channels, each an integer from 0 through 255. */
|
|
7
|
+
export interface TextmodeDocumentRGBAColor {
|
|
8
|
+
r: number;
|
|
9
|
+
g: number;
|
|
10
|
+
b: number;
|
|
11
|
+
a: number;
|
|
12
|
+
}
|
|
13
|
+
/** On-disk color notation; decoded colors use RGBA channels. */
|
|
14
|
+
export type TextmodeDocumentColor = string | TextmodeDocumentRGBAColor;
|
|
15
|
+
/** Per-cell transform in degrees. */
|
|
16
|
+
export interface TextmodeDocumentCellTransform {
|
|
17
|
+
invert: boolean;
|
|
18
|
+
flipX: boolean;
|
|
19
|
+
flipY: boolean;
|
|
20
|
+
rotation: number;
|
|
21
|
+
}
|
|
22
|
+
/** Cell at its redundant row-major x/y coordinates. */
|
|
23
|
+
export interface TextmodeDocumentCell {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
character: string;
|
|
27
|
+
foreground: TextmodeDocumentColor;
|
|
28
|
+
background: TextmodeDocumentColor;
|
|
29
|
+
transform: TextmodeDocumentCellTransform;
|
|
30
|
+
}
|
|
31
|
+
/** Dense row-major cell collection. */
|
|
32
|
+
export interface TextmodeDocumentCellCollection {
|
|
33
|
+
encoding: typeof TEXTMODE_OBJECT_ROWS_ENCODING;
|
|
34
|
+
rows: TextmodeDocumentCell[][];
|
|
35
|
+
}
|
|
36
|
+
/** Logical grid dimensions and pixel extent of each cell. */
|
|
37
|
+
export interface TextmodeDocumentGrid {
|
|
38
|
+
cols: number;
|
|
39
|
+
rows: number;
|
|
40
|
+
cellWidth: number;
|
|
41
|
+
cellHeight: number;
|
|
42
|
+
}
|
|
43
|
+
/** Optional producer information; it does not control decoding behavior. */
|
|
44
|
+
export interface TextmodeDocumentMetadata {
|
|
45
|
+
createdAt: string;
|
|
46
|
+
generator: {
|
|
47
|
+
name: string;
|
|
48
|
+
version: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface TextmodeDocumentLayer {
|
|
52
|
+
id: string;
|
|
53
|
+
visible: boolean;
|
|
54
|
+
opacity: number;
|
|
55
|
+
blendMode: string;
|
|
56
|
+
offsetX: number;
|
|
57
|
+
offsetY: number;
|
|
58
|
+
rotationZ: number;
|
|
59
|
+
grid: TextmodeDocumentGrid;
|
|
60
|
+
cells: TextmodeDocumentCellCollection;
|
|
61
|
+
}
|
|
62
|
+
/** One portable document with one or more ordered layers, bottom first. */
|
|
63
|
+
export interface TextmodeDocumentJSON {
|
|
64
|
+
format: TextmodeDocumentFormat;
|
|
65
|
+
formatVersion: TextmodeDocumentVersion;
|
|
66
|
+
metadata?: TextmodeDocumentMetadata;
|
|
67
|
+
layers: TextmodeDocumentLayer[];
|
|
68
|
+
}
|
|
@@ -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,230 @@
|
|
|
1
|
+
# Consumer guide
|
|
2
|
+
|
|
3
|
+
## Choose a decoder
|
|
4
|
+
|
|
5
|
+
Use `parseTextmodeDocumentJSON(text, options)` when you know the file is a
|
|
6
|
+
textmode document. If your app accepts several JSON formats, use `parseJSON(text)`
|
|
7
|
+
first. Check the parsed object's `format`, then pass textmode documents to
|
|
8
|
+
`decodeTextmodeDocument(value, options)`. A filename or MIME type alone cannot
|
|
9
|
+
identify the format.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { parseJSON, decodeTextmodeDocument } from '@textmode/document';
|
|
13
|
+
|
|
14
|
+
const parsed = parseJSON(jsonText);
|
|
15
|
+
if (!parsed.ok) {
|
|
16
|
+
console.error(parsed.error);
|
|
17
|
+
} else if (
|
|
18
|
+
parsed.value !== null &&
|
|
19
|
+
typeof parsed.value === 'object' &&
|
|
20
|
+
!Array.isArray(parsed.value) &&
|
|
21
|
+
['textmode.document', 'textmode.layer'].includes(parsed.value.format)
|
|
22
|
+
) {
|
|
23
|
+
const result = decodeTextmodeDocument(parsed.value, {
|
|
24
|
+
acceptLegacy: true,
|
|
25
|
+
limits: { maxCells: 16384, maxLayers: 64 },
|
|
26
|
+
});
|
|
27
|
+
console.log(result);
|
|
28
|
+
} else {
|
|
29
|
+
// Route other supported formats to the app's other decoders.
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`parseJSON` uses JavaScript's `JSON.parse`. If an object repeats a key, the last
|
|
34
|
+
value wins. Producers should use unique keys. Valid JSON is not necessarily a
|
|
35
|
+
valid textmode document.
|
|
36
|
+
|
|
37
|
+
## Handle results and budgets
|
|
38
|
+
|
|
39
|
+
Invalid input returns `{ ok: false, error }`. The error includes a `code`,
|
|
40
|
+
`message`, and sometimes a JSON path, such as `$.layer.cells.rows[0][0].foreground`.
|
|
41
|
+
|
|
42
|
+
| Code | Meaning |
|
|
43
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
44
|
+
| `INVALID_JSON` | The input is not valid JSON syntax. |
|
|
45
|
+
| `INVALID_DOCUMENT` | A value or cross-field invariant is invalid. |
|
|
46
|
+
| `UNSUPPORTED_FORMAT` | The discriminator is unsupported, or legacy decoding is disabled. |
|
|
47
|
+
| `UNSUPPORTED_VERSION` | The format version is not explicitly supported. |
|
|
48
|
+
| `UNSUPPORTED_TARGET` | The document shape is unsupported. |
|
|
49
|
+
| `UNSUPPORTED_ENCODING` | The cell encoding is unsupported. |
|
|
50
|
+
| `LIMIT_EXCEEDED` | A resource budget or safe numeric range is exceeded. |
|
|
51
|
+
|
|
52
|
+
Successful results contain a canonical v3 `document` and a `warnings` array.
|
|
53
|
+
Migrated `textmode.layer` v1 files return `LEGACY_DOCUMENT_MIGRATED`. Set
|
|
54
|
+
`acceptLegacy: false` to reject them; the default is `true`. Supported v2 files
|
|
55
|
+
remain readable and return `DOCUMENT_VERSION_MIGRATED`.
|
|
56
|
+
|
|
57
|
+
After decoding, your app must check its own requirements, such as available fonts
|
|
58
|
+
and supported blend modes. The decoder normalizes known values and omits unknown
|
|
59
|
+
properties, so decoding and serializing does not preserve extensions. The format
|
|
60
|
+
does not store font identity or post-processing.
|
|
61
|
+
|
|
62
|
+
Set limits to positive safe integers. Invalid limit configuration throws
|
|
63
|
+
`TypeError`. See the [default limits](specification-3.0.md#validation-budgets-and-compatibility).
|
|
64
|
+
|
|
65
|
+
Check file size before reading: decoder limits apply after JSON parsing and do not
|
|
66
|
+
limit input bytes or parser memory. Choose budgets for your app and check for
|
|
67
|
+
cancellation between reading, decoding, and importing. The library is synchronous;
|
|
68
|
+
you can decode large files in a worker.
|
|
69
|
+
|
|
70
|
+
## Use JSON Schema
|
|
71
|
+
|
|
72
|
+
Node 24 can import the schema with an import attribute:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import schema from '@textmode/document/schema/3.0' with { type: 'json' };
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Bundlers may use different JSON import syntax. Tools that read files can resolve
|
|
79
|
+
the same package subpath. All references within the schema are local.
|
|
80
|
+
|
|
81
|
+
For example, with Ajv installed in the consumer:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
85
|
+
import addFormats from 'ajv-formats';
|
|
86
|
+
import schema from '@textmode/document/schema/3.0' with { type: 'json' };
|
|
87
|
+
|
|
88
|
+
const ajv = new Ajv2020({ validateFormats: true });
|
|
89
|
+
addFormats(ajv, { mode: 'full' });
|
|
90
|
+
const validate = ajv.compile(schema);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Enable date-time format assertions to check timestamps. The schema checks
|
|
94
|
+
structure and local constraints. The decoder also checks relationships such as
|
|
95
|
+
row sizes and coordinates, plus Unicode and resource limits. A schema-valid file
|
|
96
|
+
can still fail decoding. Changing a decoder limit does not change the published
|
|
97
|
+
schema; for example, the schema's glyph `maxLength` remains 64.
|
|
98
|
+
|
|
99
|
+
## Complete single-layer document
|
|
100
|
+
|
|
101
|
+
A single-layer document uses the same structure as a layer stack. The receiving
|
|
102
|
+
application chooses the font.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"format": "textmode.document",
|
|
107
|
+
"formatVersion": "3.0.0",
|
|
108
|
+
"layers": [
|
|
109
|
+
{
|
|
110
|
+
"visible": true,
|
|
111
|
+
"opacity": 1,
|
|
112
|
+
"blendMode": "normal",
|
|
113
|
+
"offsetX": 0,
|
|
114
|
+
"offsetY": 0,
|
|
115
|
+
"rotationZ": 0,
|
|
116
|
+
"grid": {
|
|
117
|
+
"cols": 1,
|
|
118
|
+
"rows": 1,
|
|
119
|
+
"cellWidth": 8,
|
|
120
|
+
"cellHeight": 16
|
|
121
|
+
},
|
|
122
|
+
"id": "base",
|
|
123
|
+
"cells": {
|
|
124
|
+
"encoding": "object-rows-v1",
|
|
125
|
+
"rows": [
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
"x": 0,
|
|
129
|
+
"y": 0,
|
|
130
|
+
"character": "A",
|
|
131
|
+
"foreground": "#ffffffff",
|
|
132
|
+
"background": "#00000000",
|
|
133
|
+
"transform": {
|
|
134
|
+
"invert": false,
|
|
135
|
+
"flipX": false,
|
|
136
|
+
"flipY": false,
|
|
137
|
+
"rotation": 0
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Complete layer stack
|
|
149
|
+
|
|
150
|
+
Layers appear in stack order, with the bottom layer first. Each layer has its own
|
|
151
|
+
grid and presentation values. The document has no root canvas or viewport.
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"format": "textmode.document",
|
|
156
|
+
"formatVersion": "3.0.0",
|
|
157
|
+
"layers": [
|
|
158
|
+
{
|
|
159
|
+
"id": "base",
|
|
160
|
+
"visible": true,
|
|
161
|
+
"opacity": 1,
|
|
162
|
+
"blendMode": "normal",
|
|
163
|
+
"offsetX": 0,
|
|
164
|
+
"offsetY": 0,
|
|
165
|
+
"rotationZ": 0,
|
|
166
|
+
"grid": {
|
|
167
|
+
"cols": 1,
|
|
168
|
+
"rows": 1,
|
|
169
|
+
"cellWidth": 8,
|
|
170
|
+
"cellHeight": 16
|
|
171
|
+
},
|
|
172
|
+
"cells": {
|
|
173
|
+
"encoding": "object-rows-v1",
|
|
174
|
+
"rows": [
|
|
175
|
+
[
|
|
176
|
+
{
|
|
177
|
+
"x": 0,
|
|
178
|
+
"y": 0,
|
|
179
|
+
"character": "A",
|
|
180
|
+
"foreground": "#ffffffff",
|
|
181
|
+
"background": "#000000ff",
|
|
182
|
+
"transform": {
|
|
183
|
+
"invert": false,
|
|
184
|
+
"flipX": false,
|
|
185
|
+
"flipY": false,
|
|
186
|
+
"rotation": 0
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"id": "accent",
|
|
195
|
+
"visible": true,
|
|
196
|
+
"opacity": 0.5,
|
|
197
|
+
"blendMode": "screen",
|
|
198
|
+
"offsetX": 4,
|
|
199
|
+
"offsetY": 0,
|
|
200
|
+
"rotationZ": 15,
|
|
201
|
+
"grid": {
|
|
202
|
+
"cols": 1,
|
|
203
|
+
"rows": 1,
|
|
204
|
+
"cellWidth": 8,
|
|
205
|
+
"cellHeight": 16
|
|
206
|
+
},
|
|
207
|
+
"cells": {
|
|
208
|
+
"encoding": "object-rows-v1",
|
|
209
|
+
"rows": [
|
|
210
|
+
[
|
|
211
|
+
{
|
|
212
|
+
"x": 0,
|
|
213
|
+
"y": 0,
|
|
214
|
+
"character": "*",
|
|
215
|
+
"foreground": "#ff000080",
|
|
216
|
+
"background": "#00000000",
|
|
217
|
+
"transform": {
|
|
218
|
+
"invert": false,
|
|
219
|
+
"flipX": true,
|
|
220
|
+
"flipY": false,
|
|
221
|
+
"rotation": 90
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|