rmapi-js 11.1.1 → 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 {
@@ -256,13 +285,10 @@ export interface CommonDocumentContent {
256
285
  /**
257
286
  * the scale for a customFit zoom
258
287
  *
259
- * The linear magnification is proportional to customZoomScale: the visible
260
- * page-height fraction is `sqrt(deviceAspect) / customZoomScale`, where
261
- * `deviceAspect` is `screenWidth / screenHeight` (0.75 for 3:4 models, 0.5625
262
- * for the Paper Pro Move). The rendered page height fills the screen at
263
- * `customZoomScale = sqrt(deviceAspect)` (≈0.87 for 3:4 devices), not at 1.
264
- * reMarkable generally allows 0.5 to 5, but values outside that bound are
265
- * still supported.
288
+ * `customZoomScale = screenHeight / viewHeight` in device pixels, normalized to
289
+ * 1:1 native pixels: at 1 the view is screen-tall, showing `screenHeight /
290
+ * customZoomPageHeight` of the page. reMarkable generally allows 0.5 to 5, but
291
+ * values outside that bound are still supported.
266
292
  */
267
293
  customZoomScale?: number;
268
294
  /** the zoom mode; customFit applies the customZoom* fields, the rest auto-fit */
@@ -382,6 +408,8 @@ export interface Metadata {
382
408
  /** the visible name of the item, what it's called on the reMarkable */
383
409
  visibleName: string;
384
410
  }
411
+ /** parse and validate the json text of a `.metadata` file */
412
+ export declare function parseMetadata(text: string): Metadata;
385
413
  /**
386
414
  * access to the low-level reMarkable api
387
415
  *
@@ -506,6 +534,21 @@ export interface RawRemarkableApi {
506
534
  * @returns the metadata
507
535
  */
508
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>]>;
509
552
  /**
510
553
  * update the current root hash
511
554
  *
@@ -536,17 +579,17 @@ export interface RawRemarkableApi {
536
579
  * NOTE: This won't update the state of the reMarkable until this entry is
537
580
  * incorporated into the root hash.
538
581
  *
539
- * @param id - the id of the file to upload
582
+ * @param fileName - the file name to upload (e.g. `<id>.pdf`)
540
583
  * @param bytes - the bytes to upload
541
584
  * @returns the new entry and a promise to finish the upload
542
585
  */
543
- putFile(id: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
586
+ putFile(fileName: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
544
587
  /** the same as {@link putFile | `putFile`} but with caching for text */
545
- putText(id: string, content: string): Promise<[RawEntry, Promise<void>]>;
588
+ putText(fileName: string, content: string): Promise<[RawEntry, Promise<void>]>;
546
589
  /** the same as {@link putText | `putText`} but with extra validation for Content */
547
- putContent(id: string, content: Content): Promise<[RawEntry, Promise<void>]>;
590
+ putContent(fileName: string, content: Content): Promise<[RawEntry, Promise<void>]>;
548
591
  /** the same as {@link putText | `putText`} but with extra validation for Metadata */
549
- putMetadata(id: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
592
+ putMetadata(fileName: string, metadata: Metadata): Promise<[RawEntry, Promise<void>]>;
550
593
  /**
551
594
  * put a set of entries to make an entry list file
552
595
  *
@@ -562,7 +605,10 @@ export interface RawRemarkableApi {
562
605
  * warning is logged if a schema 3 root index is written.
563
606
  *
564
607
  * @param id - the id of the list to upload - this should be the item id if
565
- * 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.
566
612
  * @param entries - the entries to upload
567
613
  *
568
614
  * @returns the new list entry and a promise to finish the upload
@@ -605,11 +651,13 @@ export declare class RawRemarkable implements RawRemarkableApi {
605
651
  getEntries(fileName: string, hash: string): Promise<Entries>;
606
652
  getContent(fileName: string, hash: string): Promise<Content>;
607
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>]>;
608
656
  putRootHash(hash: string, generation: number, broadcast?: boolean): Promise<[string, number]>;
609
- putFile(id: string, bytes: Uint8Array): Promise<[RawEntry, Promise<void>]>;
610
- putText(id: string, text: string): Promise<[RawEntry, Promise<void>]>;
611
- putContent(id: string, content: Content): Promise<[RawEntry, Promise<void>]>;
612
- 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>]>;
613
661
  putEntries(id: string, entries: readonly RawEntry[], schemaVersion: SchemaVersion): Promise<[RawEntry, Promise<void>]>;
614
662
  uploadFile(visibleName: string, bytes: Uint8Array, mime: UploadMimeType): Promise<SimpleEntry>;
615
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
+ }