rmapi-js 11.1.2 → 11.2.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/dist/raw.d.ts CHANGED
@@ -1,3 +1,24 @@
1
+ import { type RmPageV5 } from "./rm5.js";
2
+ import { type RmScene } from "./rm6.js";
3
+ /**
4
+ * a parsed reMarkable `.rm` page
5
+ *
6
+ * A discriminated union on `version`: versions 3/5 parse to the flat, renderable
7
+ * {@link RmPageV5 | `RmPageV5`}; version 6 parses to the richer
8
+ * {@link RmScene | `RmScene`} CRDT scene (`version: 6`).
9
+ */
10
+ export type RmPage = RmPageV5 | RmScene;
11
+ /**
12
+ * parse the bytes of a reMarkable `.rm` page file
13
+ *
14
+ * Dispatches on the header version: versions 3/5 parse to a flat
15
+ * {@link RmPageV5 | `RmPageV5`}; version 6 parses to an
16
+ * {@link RmScene | `RmScene`}. Throws for an unknown version or malformed data.
17
+ *
18
+ * @param data - the raw `.rm` file bytes
19
+ * @returns the parsed page
20
+ */
21
+ export declare function parseRm(data: Uint8Array): RmPage;
1
22
  /** request types */
2
23
  export type RequestMethod = "POST" | "GET" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
3
24
  /** the supported upload mime types */
@@ -120,6 +141,14 @@ export interface CPagePage {
120
141
  verticalScroll?: CPageNumberValue;
121
142
  /** [unknown] */
122
143
  deleted?: CPageNumberValue;
144
+ /**
145
+ * a per-page last-modified epoch-milliseconds timestamp
146
+ *
147
+ * The misspelling is reMarkable's own: the firmware writes this key as
148
+ * `modifed`. Unlike the sibling fields it is a bare string, not a
149
+ * timestamped value.
150
+ */
151
+ modifed?: string;
123
152
  }
124
153
  /** [unknown] */
125
154
  export interface CPageUUID {
@@ -379,6 +408,8 @@ export interface Metadata {
379
408
  /** the visible name of the item, what it's called on the reMarkable */
380
409
  visibleName: string;
381
410
  }
411
+ /** parse and validate the json text of a `.metadata` file */
412
+ export declare function parseMetadata(text: string): Metadata;
382
413
  /**
383
414
  * access to the low-level reMarkable api
384
415
  *
@@ -503,6 +534,21 @@ export interface RawRemarkableApi {
503
534
  * @returns the metadata
504
535
  */
505
536
  getMetadata(fileName: string, hash: string): Promise<Metadata>;
537
+ /**
538
+ * get the parsed reMarkable lines (`.rm`) drawing of a page hash
539
+
540
+ * @param fileName - typically `"<id>/<pageid>.rm"`
541
+ * @param hash - the hash to get the page for
542
+ * @returns the parsed page
543
+ */
544
+ getRm(fileName: string, hash: string): Promise<RmPage>;
545
+ /**
546
+ * the same as {@link putFile | `putFile`} but rendering an `RmPage` to `.rm`
547
+ * bytes
548
+ *
549
+ * Only version 3 and 5 pages can be rendered; version 6 pages are read-only.
550
+ */
551
+ putRm(fileName: string, page: RmPageV5): Promise<[RawEntry, Promise<void>]>;
506
552
  /**
507
553
  * update the current root hash
508
554
  *
@@ -533,17 +579,17 @@ export interface RawRemarkableApi {
533
579
  * NOTE: This won't update the state of the reMarkable until this entry is
534
580
  * incorporated into the root hash.
535
581
  *
536
- * @param id - the id of the file to upload
582
+ * @param fileName - the file name to upload (e.g. `<id>.pdf`)
537
583
  * @param bytes - the bytes to upload
538
584
  * @returns the new entry and a promise to finish the upload
539
585
  */
540
- putFile(id: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
586
+ putFile(fileName: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
541
587
  /** the same as {@link putFile | `putFile`} but with caching for text */
542
- putText(id: string, content: string): Promise<[RawEntry, Promise<void>]>;
588
+ putText(fileName: string, content: string): Promise<[RawEntry, Promise<void>]>;
543
589
  /** the same as {@link putText | `putText`} but with extra validation for Content */
544
- putContent(id: string, content: Content): Promise<[RawEntry, Promise<void>]>;
590
+ putContent(fileName: string, content: Content): Promise<[RawEntry, Promise<void>]>;
545
591
  /** the same as {@link putText | `putText`} but with extra validation for Metadata */
546
- putMetadata(id: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
592
+ putMetadata(fileName: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
547
593
  /**
548
594
  * put a set of entries to make an entry list file
549
595
  *
@@ -559,7 +605,10 @@ export interface RawRemarkableApi {
559
605
  * warning is logged if a schema 3 root index is written.
560
606
  *
561
607
  * @param id - the id of the list to upload - this should be the item id if
562
- * uploading an item list, or "root" if uploading a new root list.
608
+ * uploading an item list, or "root" if uploading a new root list. Note the
609
+ * asymmetry with {@link getEntries | `getEntries`}: `getEntries` takes the
610
+ * full `"<id>.docSchema"` file name, whereas `putEntries` takes the bare id
611
+ * and appends `.docSchema` (and special-cases `"root"`) itself.
563
612
  * @param entries - the entries to upload
564
613
  *
565
614
  * @returns the new list entry and a promise to finish the upload
@@ -602,11 +651,13 @@ export declare class RawRemarkable implements RawRemarkableApi {
602
651
  getEntries(fileName: string, hash: string): Promise<Entries>;
603
652
  getContent(fileName: string, hash: string): Promise<Content>;
604
653
  getMetadata(fileName: string, hash: string): Promise<Metadata>;
654
+ getRm(fileName: string, hash: string): Promise<RmPage>;
655
+ putRm(fileName: string, page: RmPageV5): Promise<[RawEntry, Promise<void>]>;
605
656
  putRootHash(hash: string, generation: number, broadcast?: boolean): Promise<[string, number]>;
606
- putFile(id: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
607
- putText(id: string, text: string): Promise<[RawEntry, Promise<void>]>;
608
- putContent(id: string, content: Content): Promise<[RawEntry, Promise<void>]>;
609
- putMetadata(id: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
657
+ putFile(fileName: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
658
+ putText(fileName: string, text: string): Promise<[RawEntry, Promise<void>]>;
659
+ putContent(fileName: string, content: Content): Promise<[RawEntry, Promise<void>]>;
660
+ putMetadata(fileName: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
610
661
  putEntries(id: string, entries: readonly RawEntry[], schemaVersion: SchemaVersion): Promise<[RawEntry, Promise<void>]>;
611
662
  uploadFile(visibleName: string, bytes: Uint8Array, mime: UploadMimeType): Promise<SimpleEntry>;
612
663
  dumpCache(): string;
package/dist/raw.js CHANGED
@@ -1,8 +1,39 @@
1
1
  import CRC32C from "crc-32/crc32c";
2
2
  import { z } from "zod";
3
3
  import { ValidationError } from "./error.js";
4
+ import { HEADER_LENGTH, parseV5, serializeRm, VERSION_PREFIX, } from "./rm5.js";
5
+ import { parseRmScene } from "./rm6.js";
4
6
  import { concatArrays } from "./utils.js";
5
7
  const hashReg = /^[0-9a-f]{64}$/;
8
+ /**
9
+ * parse the bytes of a reMarkable `.rm` page file
10
+ *
11
+ * Dispatches on the header version: versions 3/5 parse to a flat
12
+ * {@link RmPageV5 | `RmPageV5`}; version 6 parses to an
13
+ * {@link RmScene | `RmScene`}. Throws for an unknown version or malformed data.
14
+ *
15
+ * @param data - the raw `.rm` file bytes
16
+ * @returns the parsed page
17
+ */
18
+ export function parseRm(data) {
19
+ if (data.length < HEADER_LENGTH) {
20
+ throw new Error("data is too short to be a reMarkable .lines file");
21
+ }
22
+ const header = new TextDecoder().decode(data.subarray(0, HEADER_LENGTH));
23
+ if (!header.startsWith(VERSION_PREFIX)) {
24
+ throw new Error(`unrecognized .lines header: ${JSON.stringify(header)}`);
25
+ }
26
+ const versionChar = header.charAt(VERSION_PREFIX.length);
27
+ if (versionChar === "6") {
28
+ return parseRmScene(data);
29
+ }
30
+ else if (versionChar === "3" || versionChar === "5") {
31
+ return parseV5(data, versionChar === "3" ? 3 : 5);
32
+ }
33
+ else {
34
+ throw new Error(`unsupported .lines version '${versionChar}'`);
35
+ }
36
+ }
6
37
  const tag = z
7
38
  .object({
8
39
  name: z.string(),
@@ -51,6 +82,7 @@ const cPagePage = z
51
82
  .object({ timestamp: z.string(), value: z.number().int() })
52
83
  .passthrough()
53
84
  .optional(),
85
+ modifed: z.string().optional(),
54
86
  })
55
87
  .passthrough();
56
88
  const cPages = z
@@ -183,6 +215,11 @@ const metadata = z
183
215
  source: z.string().optional(),
184
216
  })
185
217
  .passthrough();
218
+ /** parse and validate the json text of a `.metadata` file */
219
+ export function parseMetadata(text) {
220
+ const loaded = JSON.parse(text);
221
+ return metadata.parse(loaded);
222
+ }
186
223
  const updatedRootHash = z
187
224
  .object({
188
225
  hash: z.string(),
@@ -344,6 +381,18 @@ export class RawRemarkable {
344
381
  const loaded = JSON.parse(raw);
345
382
  return metadata.parse(loaded);
346
383
  }
384
+ async getRm(fileName, hash) {
385
+ const bytes = await this.getHash(fileName, hash);
386
+ return parseRm(bytes);
387
+ }
388
+ async putRm(fileName, page) {
389
+ if (!fileName.endsWith(".rm")) {
390
+ throw new Error(`fileName ${fileName} did not end with '.rm'`);
391
+ }
392
+ else {
393
+ return await this.putFile(fileName, serializeRm(page));
394
+ }
395
+ }
347
396
  async putRootHash(hash, generation, broadcast = true) {
348
397
  if (!Number.isSafeInteger(generation)) {
349
398
  throw new Error(`generation ${generation} was not a safe integer`);
@@ -388,21 +437,21 @@ export class RawRemarkable {
388
437
  }
389
438
  }
390
439
  }
391
- async putFile(id, bytes) {
440
+ async putFile(fileName, bytes) {
392
441
  const hash = await digest(bytes);
393
442
  const res = {
394
- id,
443
+ id: fileName,
395
444
  hash,
396
445
  type: 0,
397
446
  subfiles: 0,
398
447
  size: bytes.length,
399
448
  };
400
- return [res, this.#putFile(id, hash, bytes)];
449
+ return [res, this.#putFile(fileName, hash, bytes)];
401
450
  }
402
- async putText(id, text) {
451
+ async putText(fileName, text) {
403
452
  const enc = new TextEncoder();
404
453
  const bytes = enc.encode(text);
405
- const [ent, upload] = await this.putFile(id, bytes);
454
+ const [ent, upload] = await this.putFile(fileName, bytes);
406
455
  return [
407
456
  ent,
408
457
  upload.then(() => {
@@ -411,20 +460,20 @@ export class RawRemarkable {
411
460
  }),
412
461
  ];
413
462
  }
414
- async putContent(id, content) {
415
- if (!id.endsWith(".content")) {
416
- throw new Error(`id ${id} did not end with '.content'`);
463
+ async putContent(fileName, content) {
464
+ if (!fileName.endsWith(".content")) {
465
+ throw new Error(`fileName ${fileName} did not end with '.content'`);
417
466
  }
418
467
  else {
419
- return await this.putText(id, JSON.stringify(content));
468
+ return await this.putText(fileName, JSON.stringify(content));
420
469
  }
421
470
  }
422
- async putMetadata(id, metadata) {
423
- if (!id.endsWith(".metadata")) {
424
- throw new Error(`id ${id} did not end with '.metadata'`);
471
+ async putMetadata(fileName, metadata) {
472
+ if (!fileName.endsWith(".metadata")) {
473
+ throw new Error(`fileName ${fileName} did not end with '.metadata'`);
425
474
  }
426
475
  else {
427
- return await this.putText(id, JSON.stringify(metadata));
476
+ return await this.putText(fileName, JSON.stringify(metadata));
428
477
  }
429
478
  }
430
479
  async putEntries(id, entries, schemaVersion) {
package/dist/rm5.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Parse and render the flat (version 3 and 5) reMarkable `.rm` page format.
3
+ *
4
+ * A `.rm` file is the vector drawing for a single notebook page. The version 3
5
+ * and 5 formats are a flat, little-endian struct of layers, each holding strokes
6
+ * ("lines"), each holding sampled points (they differ only by one extra
7
+ * per-stroke field in version 5). {@link parseV5 | `parseV5`} reads them into an
8
+ * {@link RmPageV5 | `RmPageV5`} and {@link serializeRm | `serializeRm`} renders
9
+ * it back to byte-exact bytes.
10
+ *
11
+ * The newer version 6 "scene tree" format lives in `./rm6.js`; the version
12
+ * dispatch that picks between them (`parseRm`) lives in `./raw.js`.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** the flat reMarkable `.lines` file versions read into an {@link RmPageV5} */
17
+ export type RmVersion = 3 | 5;
18
+ /** a single sampled point along a stroke */
19
+ export interface RmPoint {
20
+ /** the horizontal position in device pixels (see the page type for the origin) */
21
+ x: number;
22
+ /** the vertical position in device pixels (see the page type for the origin) */
23
+ y: number;
24
+ /** the pen speed at this point */
25
+ speed: number;
26
+ /** the pen tilt/heading in radians */
27
+ direction: number;
28
+ /** the stroke width at this point */
29
+ width: number;
30
+ /** the pen pressure, from 0 to 1 */
31
+ pressure: number;
32
+ }
33
+ /**
34
+ * a single stroke (called a "line") within a layer
35
+ *
36
+ * `brushType` and `color` are kept as raw integers because both are overloaded
37
+ * across firmware versions (there are two pen-code families, and the color
38
+ * field is reinterpreted as a palette index when colored annotations are
39
+ * enabled), so the right meaning can only be resolved with the source firmware
40
+ * in mind.
41
+ */
42
+ export interface RmLine {
43
+ /** the raw brush/pen type code */
44
+ brushType: number;
45
+ /** the raw color code */
46
+ color: number;
47
+ /** a per-stroke padding field, typically 0 */
48
+ padding?: number;
49
+ /** the base brush size */
50
+ brushBaseSize: number;
51
+ /** [unknown] a per-stroke field only present in version 5 */
52
+ unknown?: number;
53
+ /** the sampled points making up the stroke, in order */
54
+ points: RmPoint[];
55
+ }
56
+ /** a drawing layer, an ordered set of strokes */
57
+ export interface RmLayer {
58
+ /** the strokes on this layer, in order */
59
+ lines: RmLine[];
60
+ }
61
+ /**
62
+ * a parsed version 3 or 5 page
63
+ *
64
+ * Coordinates use a top-left origin: `x` in `[0, width]`, `y` in `[0, height]`,
65
+ * in device pixels. Only these pages can be re-rendered (via `raw.putRm`).
66
+ */
67
+ export interface RmPageV5 {
68
+ /** the file format version */
69
+ version: 3 | 5;
70
+ /** the drawing layers, back to front */
71
+ layers: RmLayer[];
72
+ }
73
+ /** a decoded pen/tool for a stroke */
74
+ export type RmBrush = "brush" | "pencil" | "ballpoint" | "marker" | "fineliner" | "highlighter" | "eraser" | "mechanicalPencil" | "eraseArea" | "calligraphy" | "shader";
75
+ /**
76
+ * decode a raw {@link RmLine.brushType | `brushType`} code to a pen name
77
+ *
78
+ * Best-effort and advisory: the reMarkable pen codes come in two firmware
79
+ * families and new ones appear over time, so an unrecognized code returns
80
+ * `undefined`. The raw `brushType` int stays authoritative.
81
+ */
82
+ export declare function decodeBrush(code: number): RmBrush | undefined;
83
+ /**
84
+ * the default (monochrome) reMarkable palette, by raw {@link RmLine.color | `color`}
85
+ *
86
+ * Advisory only: when "colored annotations" are enabled or on version 6, the
87
+ * `color` field is reinterpreted, so resolve against the source firmware.
88
+ */
89
+ export declare const rmColors: Readonly<Record<number, string>>;
90
+ /** the length of the fixed `.lines` header, in bytes */
91
+ export declare const HEADER_LENGTH = 43;
92
+ /** the ascii prefix of the header, immediately followed by the version digit */
93
+ export declare const VERSION_PREFIX = "reMarkable .lines file, version=";
94
+ /**
95
+ * parse a version 3 or 5 (flat struct) `.rm` page
96
+ *
97
+ * Named for version 5, the common case, but the two formats differ only by the
98
+ * extra {@link RmLine.unknown | `unknown`} field version 5 adds per stroke.
99
+ */
100
+ export declare function parseV5(data: Uint8Array, version: 3 | 5): RmPageV5;
101
+ /**
102
+ * render a page back into reMarkable `.rm` file bytes
103
+ *
104
+ * The inverse of {@link parseV5 | `parseV5`}: `serializeRm(parseV5(bytes, v))`
105
+ * reproduces the original bytes for version 3 and 5 files. A missing `padding`
106
+ * or (version 5) `unknown` field is written as `0`.
107
+ *
108
+ * @param page - the page to render
109
+ * @returns the `.rm` file bytes
110
+ */
111
+ export declare function serializeRm(page: RmPageV5): Uint8Array;
package/dist/rm5.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Parse and render the flat (version 3 and 5) reMarkable `.rm` page format.
3
+ *
4
+ * A `.rm` file is the vector drawing for a single notebook page. The version 3
5
+ * and 5 formats are a flat, little-endian struct of layers, each holding strokes
6
+ * ("lines"), each holding sampled points (they differ only by one extra
7
+ * per-stroke field in version 5). {@link parseV5 | `parseV5`} reads them into an
8
+ * {@link RmPageV5 | `RmPageV5`} and {@link serializeRm | `serializeRm`} renders
9
+ * it back to byte-exact bytes.
10
+ *
11
+ * The newer version 6 "scene tree" format lives in `./rm6.js`; the version
12
+ * dispatch that picks between them (`parseRm`) lives in `./raw.js`.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ const BRUSHES = {
17
+ 0: "brush",
18
+ 12: "brush",
19
+ 1: "pencil",
20
+ 14: "pencil",
21
+ 2: "ballpoint",
22
+ 15: "ballpoint",
23
+ 3: "marker",
24
+ 16: "marker",
25
+ 4: "fineliner",
26
+ 17: "fineliner",
27
+ 5: "highlighter",
28
+ 18: "highlighter",
29
+ 6: "eraser",
30
+ 7: "mechanicalPencil",
31
+ 13: "mechanicalPencil",
32
+ 8: "eraseArea",
33
+ 21: "calligraphy",
34
+ 23: "shader",
35
+ };
36
+ /**
37
+ * decode a raw {@link RmLine.brushType | `brushType`} code to a pen name
38
+ *
39
+ * Best-effort and advisory: the reMarkable pen codes come in two firmware
40
+ * families and new ones appear over time, so an unrecognized code returns
41
+ * `undefined`. The raw `brushType` int stays authoritative.
42
+ */
43
+ export function decodeBrush(code) {
44
+ return BRUSHES[code];
45
+ }
46
+ /**
47
+ * the default (monochrome) reMarkable palette, by raw {@link RmLine.color | `color`}
48
+ *
49
+ * Advisory only: when "colored annotations" are enabled or on version 6, the
50
+ * `color` field is reinterpreted, so resolve against the source firmware.
51
+ */
52
+ export const rmColors = {
53
+ 0: "black",
54
+ 1: "gray",
55
+ 2: "white",
56
+ };
57
+ /** the length of the fixed `.lines` header, in bytes */
58
+ export const HEADER_LENGTH = 43;
59
+ /** the ascii prefix of the header, immediately followed by the version digit */
60
+ export const VERSION_PREFIX = "reMarkable .lines file, version=";
61
+ /**
62
+ * parse a version 3 or 5 (flat struct) `.rm` page
63
+ *
64
+ * Named for version 5, the common case, but the two formats differ only by the
65
+ * extra {@link RmLine.unknown | `unknown`} field version 5 adds per stroke.
66
+ */
67
+ export function parseV5(data, version) {
68
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
69
+ let offset = HEADER_LENGTH;
70
+ const readInt = () => {
71
+ const value = view.getInt32(offset, true);
72
+ offset += 4;
73
+ return value;
74
+ };
75
+ const readFloat = () => {
76
+ const value = view.getFloat32(offset, true);
77
+ offset += 4;
78
+ return value;
79
+ };
80
+ const numLayers = readInt();
81
+ const layers = [];
82
+ for (let layer = 0; layer < numLayers; layer++) {
83
+ const numLines = readInt();
84
+ const lines = [];
85
+ for (let line = 0; line < numLines; line++) {
86
+ const brushType = readInt();
87
+ const color = readInt();
88
+ const padding = readInt();
89
+ const brushBaseSize = readFloat();
90
+ const unknown = version === 5 ? readInt() : undefined;
91
+ const numPoints = readInt();
92
+ const points = new Array(numPoints);
93
+ for (let point = 0; point < numPoints; point++) {
94
+ points[point] = {
95
+ x: readFloat(),
96
+ y: readFloat(),
97
+ speed: readFloat(),
98
+ direction: readFloat(),
99
+ width: readFloat(),
100
+ pressure: readFloat(),
101
+ };
102
+ }
103
+ const parsed = {
104
+ brushType,
105
+ color,
106
+ padding,
107
+ brushBaseSize,
108
+ points,
109
+ };
110
+ if (unknown !== undefined) {
111
+ parsed.unknown = unknown;
112
+ }
113
+ lines.push(parsed);
114
+ }
115
+ layers.push({ lines });
116
+ }
117
+ return { version, layers };
118
+ }
119
+ /**
120
+ * render a page back into reMarkable `.rm` file bytes
121
+ *
122
+ * The inverse of {@link parseV5 | `parseV5`}: `serializeRm(parseV5(bytes, v))`
123
+ * reproduces the original bytes for version 3 and 5 files. A missing `padding`
124
+ * or (version 5) `unknown` field is written as `0`.
125
+ *
126
+ * @param page - the page to render
127
+ * @returns the `.rm` file bytes
128
+ */
129
+ export function serializeRm(page) {
130
+ const { version } = page;
131
+ // runtime backstop for untyped (JS) callers passing a v6 page
132
+ if (version !== 3 && version !== 5) {
133
+ throw new Error(`rendering version ${version} .lines files is not supported (only 3 and 5)`);
134
+ }
135
+ const strokeHeaderSize = version === 5 ? 24 : 20;
136
+ let size = HEADER_LENGTH + 4;
137
+ for (const layer of page.layers) {
138
+ size += 4;
139
+ for (const line of layer.lines) {
140
+ size += strokeHeaderSize + line.points.length * 24;
141
+ }
142
+ }
143
+ const bytes = new Uint8Array(size);
144
+ const view = new DataView(bytes.buffer);
145
+ const header = `${VERSION_PREFIX}${version}`.padEnd(HEADER_LENGTH, " ");
146
+ for (let index = 0; index < HEADER_LENGTH; index++) {
147
+ bytes[index] = header.charCodeAt(index);
148
+ }
149
+ let offset = HEADER_LENGTH;
150
+ const writeInt = (value) => {
151
+ view.setInt32(offset, value, true);
152
+ offset += 4;
153
+ };
154
+ const writeFloat = (value) => {
155
+ view.setFloat32(offset, value, true);
156
+ offset += 4;
157
+ };
158
+ writeInt(page.layers.length);
159
+ for (const layer of page.layers) {
160
+ writeInt(layer.lines.length);
161
+ for (const line of layer.lines) {
162
+ writeInt(line.brushType);
163
+ writeInt(line.color);
164
+ writeInt(line.padding ?? 0);
165
+ writeFloat(line.brushBaseSize);
166
+ if (version === 5) {
167
+ writeInt(line.unknown ?? 0);
168
+ }
169
+ writeInt(line.points.length);
170
+ for (const point of line.points) {
171
+ writeFloat(point.x);
172
+ writeFloat(point.y);
173
+ writeFloat(point.speed);
174
+ writeFloat(point.direction);
175
+ writeFloat(point.width);
176
+ writeFloat(point.pressure);
177
+ }
178
+ }
179
+ }
180
+ return bytes;
181
+ }