diff --git a/.gitignore b/.gitignore index 521c941..46a1167 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ scratch/ +.claude/ diff --git a/README.md b/README.md index a98646d..5866d15 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,232 @@ # notable -reMarkable tooling + +Parse and convert reMarkable tablet `.rm` files (version 6, software ≥ 3). + +`notable` ships as two things: + +- **`lib/`** — a Node.js library for reading and writing `.rm` files at the block/tree level +- **`bin/notable`** — a CLI tool for converting `.rm` files to Markdown, SVG, and PDF + +## Installation + +```bash +npm install notable +``` + +Or globally to use the CLI: + +```bash +npm install -g notable +``` + +## CLI Usage + +``` +notable [options] [input...] + +Options: + -f, --from Format to convert from (default: guess from filename) + -t, --to Format to convert to (default: guess from filename) + -o, --output Output filename (default: stdout) + -v, --verbose Increase verbosity + -V, --version Show version + -h, --help Show help +``` + +Available **FORMAT** values: `rm`, `markdown`, `svg`, `pdf`, `blocks`, `blocks-data`. + +### Examples + +Convert a `.rm` file to Markdown (printed to stdout): + +```bash +notable -t markdown note.rm +``` + +Convert a `.rm` file to SVG, writing to a file: + +```bash +notable -t svg -o note.svg note.rm +``` + +Guess the output format from the file extension: + +```bash +notable note.rm -o note.pdf +``` + +Convert a Markdown file to a `.rm` file: + +```bash +notable -t rm note.md -o note.rm +``` + +Dump the internal block structure of a `.rm` file: + +```bash +notable -t blocks note.rm +notable -t blocks-data note.rm # omit point/byte data +``` + +### Format notes + +| Format | Read | Write | Notes | +|--------|------|-------|-------| +| `rm` | ✓ | ✓ | reMarkable binary file | +| `markdown` | ✓ | ✓ | Text + highlighted passages | +| `svg` | — | ✓ | Vector rendering of strokes | +| `pdf` | — | ✓ | Requires [Inkscape](https://inkscape.org/) | +| `blocks` | — | — | Debug dump (JSON) | +| `blocks-data` | — | — | Debug dump without point data | + +## Library API + +```js +import { + readBlocks, writeBlocks, readTree, + simpleTextDocument, +} from 'notable'; +``` + +### Reading blocks + +```js +import { readFileSync } from 'node:fs'; +import { readBlocks } from 'notable'; + +const data = readFileSync('note.rm'); +for (const block of readBlocks(data)) { + console.log(block.constructor.name); +} +``` + +`readBlocks(buffer)` accepts a `Buffer` and returns a generator of `Block` objects. + +### Writing blocks + +```js +import { writeBlocks } from 'notable'; + +const buf = writeBlocks(blocks, { version: '3.2.2' }); +// buf is a Buffer ready to write to a .rm file +``` + +The `options` object supports a `version` string to control which format features are emitted. + +### Reading the scene tree + +```js +import { readTree } from 'notable'; + +const tree = readTree(data); +// tree.root — root Group containing layers +// tree.rootText — Text block (keyboard text), or null +// tree.sceneInfo +``` + +### Walking strokes + +```js +import { Line } from 'notable'; + +for (const item of tree.walk()) { + if (item instanceof Line) { + console.log(`Stroke with ${item.points.length} points`); + } +} +``` + +### Creating a text document + +```js +import { simpleTextDocument, writeBlocks } from 'notable'; + +const buf = writeBlocks(simpleTextDocument('Hello reMarkable!')); +``` + +### Block types + +| Class | Block type | Description | +|-------|-----------|-------------| +| `AuthorIdsBlock` | `0x09` | Maps author IDs to UUIDs | +| `MigrationInfoBlock` | `0x00` | Migration metadata | +| `PageInfoBlock` | `0x0A` | Page statistics | +| `SceneTreeBlock` | `0x01` | Tree structure entry | +| `TreeNodeBlock` | `0x02` | Layer / group metadata | +| `SceneGroupItemBlock` | `0x04` | Group child reference | +| `SceneLineItemBlock` | `0x05` | Stroke (drawn line) | +| `SceneGlyphItemBlock` | `0x03` | Highlighted text range | +| `SceneTextItemBlock` | `0x06` | Text item in layer | +| `SceneTombstoneItemBlock` | `0x08` | Deleted item marker | +| `RootTextBlock` | `0x07` | Keyboard-entered text | +| `SceneInfo` | `0x0D` | Canvas / paper info | +| `UnreadableBlock` | — | Unrecognised or corrupt block | + +### Scene items + +```js +import { PenColor, Pen, ParagraphStyle } from 'notable'; + +// PenColor values: BLACK(0), GRAY(1), WHITE(2), YELLOW(3), GREEN(4), +// PINK(5), BLUE(6), RED(7), GRAY_OVERLAP(8), HIGHLIGHT(9), ... + +// Pen values: BALLPOINT_1(2), BALLPOINT_2(15), FINELINER_1(4), FINELINER_2(17), +// HIGHLIGHTER_1(5), HIGHLIGHTER_2(18), PENCIL_1(1), PENCIL_2(14), ... + +// ParagraphStyle values: PLAIN(1), HEADING(2), BOLD(3), BULLET(4), BULLET2(5), ... +``` + +### Text extraction + +```js +import { readTree, TextDocument } from 'notable'; + +const tree = readTree(data); +if (tree.rootText) { + const doc = TextDocument.fromSceneItem(tree.rootText); + for (const paragraph of doc.contents) { + console.log(paragraph.style.value, paragraph.toString()); + } +} +``` + +### CRDT sequence + +`CrdtSequence` is an ordered sequence backed by a topological sort of CRDT items: + +```js +import { CrdtSequence, CrdtSequenceItem, CrdtId } from 'notable'; + +const seq = new CrdtSequence([ + new CrdtSequenceItem(new CrdtId(1, 1), new CrdtId(0, 0), new CrdtId(0, 0), 0, 'A'), + new CrdtSequenceItem(new CrdtId(1, 2), new CrdtId(1, 1), new CrdtId(0, 0), 0, 'B'), +]); + +seq.values(); // ['A', 'B'] +``` + +## SVG / PDF conversion notes + +SVG conversion renders strokes with pressure, speed, and direction data but text rendering is basic. If text boxes are present: + +- Multi-line text may render on a single line +- Stroke positions relative to text may be approximate + +PDF conversion requires [Inkscape](https://inkscape.org/) to be installed. The CLI will look for `inkscape` on the PATH, and also at `/Applications/Inkscape.app/Contents/MacOS/inkscape` on macOS. + +## Running tests + +```bash +npm test +``` + +Tests cover binary round-trips of all 12 test `.rm` files, block serialisation, CRDT sequence ordering, text extraction, and inline formatting. All 131 tests pass. + +## Acknowledgements + +This is a JavaScript port of: + +- [rmscene](https://github.com/ricklupton/rmscene) by Rick Lupton — the core `.rm` v6 parser +- [rmc](https://github.com/ricklupton/rmc) — the CLI converter + +The binary format was reverse-engineered with help from [ddvk's reader](https://github.com/ddvk/reader). diff --git a/bin/exporters/markdown.js b/bin/exporters/markdown.js new file mode 100644 index 0000000..e8df6ad --- /dev/null +++ b/bin/exporters/markdown.js @@ -0,0 +1,63 @@ +import { readTree } from '../../lib/scene-stream.js'; +import * as si from '../../lib/scene-items.js'; +import { TextDocument } from '../../lib/text.js'; + +export function rmToMarkdown (data) { + const tree = readTree(data); + const parts = []; + + const anchorIds = new Set(collectAnchorIds(tree.root)); + + if (tree.rootText) { + parts.push(rootTextToMarkdown(tree.rootText, anchorIds)); + } + + const highlightLines = ['\n\n# Highlights']; + let lastPos = 0; + const JOIN_TOLERANCE = 2; + for (const item of tree.walk()) { + if (item instanceof si.GlyphRange) { + if (item.start > lastPos + JOIN_TOLERANCE) highlightLines.push(''); + highlightLines.push(`> ${item.text}`); + lastPos = item.start + item.text.length; + } + } + highlightLines.push(''); + parts.push(highlightLines.join('\n')); + + return parts.join(''); +} + +function rootTextToMarkdown (rootText, anchorIds) { + const doc = TextDocument.fromSceneItem(rootText); + const lines = []; + for (const p of doc.contents) { + const charIds = p.contents.flatMap(s => s.i); + const annotated = annotateAnchorIds(anchorIds, p.toString(), charIds); + const style = p.style.value; + if (style === si.ParagraphStyle.BULLET) lines.push(`- ${annotated}`); + else if (style === si.ParagraphStyle.BULLET2) lines.push(` + ${annotated}`); + else if (style === si.ParagraphStyle.BOLD) lines.push(`> ${annotated}`); + else if (style === si.ParagraphStyle.HEADING) lines.push(`# ${annotated}`); + else if (style === si.ParagraphStyle.PLAIN) lines.push(annotated); + else lines.push(`[unknown format ${style}] ${annotated}`); + } + return lines.join('\n'); +} + +function annotateAnchorIds (anchorIds, line, ids) { + let result = ''; + for (let i = 0; i < line.length; i++) { + const charId = ids[i]; + if (charId && anchorIds.has(charId.toKey())) result += `<<${charId.part1},${charId.part2}>>`; + result += line[i]; + } + return result; +} + +function* collectAnchorIds (item) { + if (item && item.anchorId != null) yield item.anchorId.value.toKey(); + if (item && item.children) { + for (const child of item.children.values()) yield* collectAnchorIds(child); + } +} diff --git a/bin/exporters/pdf.js b/bin/exporters/pdf.js new file mode 100644 index 0000000..075fbb3 --- /dev/null +++ b/bin/exporters/pdf.js @@ -0,0 +1,28 @@ +import { execSync } from 'node:child_process'; +import { writeFileSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmToSvg } from './svg.js'; + +export function rmToPdf (data) { + const svg = rmToSvg(data); + const svgTmp = join(tmpdir(), `notable-${Date.now()}.svg`); + const pdfTmp = join(tmpdir(), `notable-${Date.now()}.pdf`); + try { + writeFileSync(svgTmp, svg, 'utf8'); + const inkscape = findInkscape(); + if (!inkscape) throw new Error('Inkscape not found. Install Inkscape to use PDF export.'); + execSync(`"${inkscape}" "${svgTmp}" --export-filename="${pdfTmp}"`, { stdio: 'pipe' }); + return readFileSync(pdfTmp); + } finally { + try { unlinkSync(svgTmp); } catch {} + try { unlinkSync(pdfTmp); } catch {} + } +} + +function findInkscape () { + for (const path of ['inkscape', '/Applications/Inkscape.app/Contents/MacOS/inkscape']) { + try { execSync(`"${path}" --version`, { stdio: 'pipe' }); return path; } catch {} + } + return null; +} diff --git a/bin/exporters/svg.js b/bin/exporters/svg.js new file mode 100644 index 0000000..2397b60 --- /dev/null +++ b/bin/exporters/svg.js @@ -0,0 +1,173 @@ +import { CrdtId } from '../../lib/tagged-block-common.js'; +import { readTree } from '../../lib/scene-stream.js'; +import * as si from '../../lib/scene-items.js'; +import { TextDocument } from '../../lib/text.js'; +import { StrokePen, lookupPenColor } from './writing-tools.js'; + +const SCREEN_WIDTH = 1404; +const SCREEN_HEIGHT = 1872; +const SCREEN_DPI = 226; +const SCALE = 72.0 / SCREEN_DPI; +const PAGE_WIDTH_PT = SCREEN_WIDTH * SCALE; +const PAGE_HEIGHT_PT = SCREEN_HEIGHT * SCALE; + +const xx = v => v * SCALE; +const yy = v => v * SCALE; +const scale = v => v * SCALE; + +const TEXT_TOP_Y = -88; +const LINE_HEIGHTS = new Map([ + [si.ParagraphStyle.PLAIN, 70], + [si.ParagraphStyle.BULLET, 35], + [si.ParagraphStyle.BULLET2, 35], + [si.ParagraphStyle.BOLD, 70], + [si.ParagraphStyle.HEADING, 150], + [si.ParagraphStyle.CHECKBOX, 35], + [si.ParagraphStyle.CHECKBOX_CHECKED, 35], +]); + +export function rmToSvg (data) { + const tree = readTree(data); + return treeToSvg(tree); +} + +export function treeToSvg (tree) { + const parts = []; + const anchorPos = buildAnchorPos(tree.rootText); + const [xMin, xMax, yMin, yMax] = getBoundingBox(tree.root, anchorPos); + const widthPt = xx(xMax - xMin + 1); + const heightPt = yy(yMax - yMin + 1); + + parts.push( + `\n` + + `\n` + ); + + parts.push(`\t\n`); + + if (tree.rootText != null) parts.push(drawText(tree.rootText)); + parts.push(drawGroup(tree.root, anchorPos)); + + parts.push('\t\n\n'); + return parts.join(''); +} + +function buildAnchorPos (text) { + const anchorPos = new Map(); + if (text != null) { + const doc = TextDocument.fromSceneItem(text); + let ypos = text.posY + TEXT_TOP_Y; + const topOfText = ypos; + for (const p of doc.contents) { + anchorPos.set(p.startId.toKey(), ypos); + for (const subp of p.contents) { + for (const k of subp.i) anchorPos.set(k.toKey(), ypos); + } + ypos += LINE_HEIGHTS.get(p.style.value) ?? 70; + } + anchorPos.set(new CrdtId(0, 281474976710654).toKey(), topOfText); + anchorPos.set(new CrdtId(0, 281474976710655).toKey(), ypos); + } else { + anchorPos.set(new CrdtId(0, 281474976710654).toKey(), 0); + anchorPos.set(new CrdtId(0, 281474976710655).toKey(), SCREEN_HEIGHT); + } + return anchorPos; +} + +function getAnchor (item, anchorPos) { + if (item.anchorId != null) { + const anchorX = item.anchorOriginX.value; + const anchorY = anchorPos.get(item.anchorId.value.toKey()) ?? 0; + return [anchorX, anchorY]; + } + return [0, 0]; +} + +function getBoundingBox (item, anchorPos, defaultBox = null) { + const def = defaultBox ?? [-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2, 0, SCREEN_HEIGHT]; + let [xMin, xMax, yMin, yMax] = def; + + for (const child of item.children.values()) { + if (child instanceof si.Group) { + const [ax, ay] = getAnchor(child, anchorPos); + const [x1, x2, y1, y2] = getBoundingBox(child, anchorPos, [0, 0, 0, 0]); + xMin = Math.min(xMin, x1 + ax); xMax = Math.max(xMax, x2 + ax); + yMin = Math.min(yMin, y1 + ay); yMax = Math.max(yMax, y2 + ay); + } else if (child instanceof si.Line) { + for (const p of child.points) { + xMin = Math.min(xMin, p.x); xMax = Math.max(xMax, p.x); + yMin = Math.min(yMin, p.y); yMax = Math.max(yMax, p.y); + } + } + } + return [xMin, xMax, yMin, yMax]; +} + +function drawGroup (item, anchorPos) { + const [ax, ay] = getAnchor(item, anchorPos); + const parts = [`\t\t\n`]; + for (const child of item.children.values()) { + if (child instanceof si.Group) parts.push(drawGroup(child, anchorPos)); + else if (child instanceof si.Line) parts.push(drawStroke(child)); + } + parts.push(`\t\t\n`); + return parts.join(''); +} + +function drawStroke (item) { + const penColor = lookupPenColor(item.color, item.colorRgba); + const pen = StrokePen.create(item.tool, penColor, item.thicknessScale); + + const parts = []; + let lastXpos = -1, lastYpos = -1, lastSegmentWidth = 0, segmentWidth = 0; + + for (let pointId = 0; pointId < item.points.length; pointId++) { + const point = item.points[pointId]; + if (pointId % pen.segmentLength === 0) { + if (lastXpos !== -1) parts.push('"/>\n'); + const segColor = pen.getSegmentColor(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth); + segmentWidth = pen.getSegmentWidth(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth); + const segOpacity = pen.getSegmentOpacity(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth); + parts.push(`\t\t\t\n'); + return parts.join(''); +} + +function drawText (text) { + const parts = [ + '\t\t', + `\n\t\t\t\n`, + ]; + + let yOffset = TEXT_TOP_Y; + const doc = TextDocument.fromSceneItem(text); + for (const p of doc.contents) { + yOffset += LINE_HEIGHTS.get(p.style.value) ?? 70; + const xpos = text.posX; + const ypos = text.posY + yOffset; + const cls = si.ParagraphStyleByValue[p.style.value]?.toLowerCase() ?? 'plain'; + const content = p.toString().trim(); + if (content) { + parts.push(`\t\t\t${escapeXml(content)}\n`); + } + } + parts.push('\t\t\n'); + return parts.join(''); +} + +function escapeXml (str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/bin/exporters/writing-tools.js b/bin/exporters/writing-tools.js new file mode 100644 index 0000000..ae07fad --- /dev/null +++ b/bin/exporters/writing-tools.js @@ -0,0 +1,173 @@ +import { Pen, PenColor } from '../../lib/scene-items.js'; + +export const RM_PALETTE = new Map([ + [PenColor.BLACK, [0, 0, 0]], + [PenColor.GRAY, [144, 144, 144]], + [PenColor.WHITE, [255, 255, 255]], + [PenColor.YELLOW, [251, 247, 25]], + [PenColor.GREEN, [0, 255, 0]], + [PenColor.PINK, [255, 192, 203]], + [PenColor.BLUE, [78, 105, 201]], + [PenColor.RED, [179, 62, 57]], + [PenColor.GRAY_OVERLAP, [125, 125, 125]], + [PenColor.GREEN_2, [161, 216, 125]], + [PenColor.CYAN, [139, 208, 229]], + [PenColor.MAGENTA, [183, 130, 205]], + [PenColor.YELLOW_2, [247, 232, 81]], +]); + +export function clamp (value) { return Math.min(Math.max(value, 0), 1); } + +export function lookupPenColor (penColorValue, explicitColorRgba) { + if (explicitColorRgba != null) return explicitColorRgba; + const color = RM_PALETTE.get(penColorValue) ?? RM_PALETTE.get(PenColor.BLACK); + return color.length === 3 ? [...color, 255] : color; +} + +export class StrokePen { + constructor (name, baseWidth, baseColor) { + this.name = name; + this.baseWidth = baseWidth; + this.baseColor = baseColor; + this.segmentLength = 1000; + this.baseOpacity = 1; + this.strokeLinecap = 'round'; + this.strokeOpacity = 1; + } + + static directionToTilt (direction) { return direction * (Math.PI * 2) / 255; } + + getSegmentWidth (speed, direction, width, pressure, lastWidth) { return this.baseWidth; } + + getSegmentColor (speed, direction, width, pressure, lastWidth) { + return `rgba(${this.baseColor.join(',')})`; + } + + getSegmentOpacity (speed, direction, width, pressure, lastWidth) { return this.baseOpacity; } + + static create (penNr, colorId, width) { + if (penNr === Pen.PAINTBRUSH_1 || penNr === Pen.PAINTBRUSH_2) return new Brush(width, colorId); + if (penNr === Pen.CALIGRAPHY) return new Calligraphy(width, colorId); + if (penNr === Pen.MARKER_1 || penNr === Pen.MARKER_2) return new Marker(width, colorId); + if (penNr === Pen.BALLPOINT_1 || penNr === Pen.BALLPOINT_2) return new Ballpoint(width, colorId); + if (penNr === Pen.FINELINER_1 || penNr === Pen.FINELINER_2) return new Fineliner(width, colorId); + if (penNr === Pen.PENCIL_1 || penNr === Pen.PENCIL_2) return new Pencil(width, colorId); + if (penNr === Pen.MECHANICAL_PENCIL_1 || penNr === Pen.MECHANICAL_PENCIL_2) return new MechanicalPencil(width, colorId); + if (penNr === Pen.HIGHLIGHTER_1 || penNr === Pen.HIGHLIGHTER_2) return new Highlighter(15, colorId); + if (penNr === Pen.SHADER) return new Shader(12, colorId); + if (penNr === Pen.ERASER_AREA) return new EraseArea(width, colorId); + if (penNr === Pen.ERASER) return new Eraser(width, 2); + throw new Error(`Unknown pen_nr: ${penNr}`); + } +} + +class Fineliner extends StrokePen { + constructor (baseWidth, baseColor) { super('Fineliner', baseWidth * 1.8, baseColor); } +} + +class Ballpoint extends StrokePen { + constructor (baseWidth, baseColor) { + super('Ballpoint', baseWidth, baseColor); + this.segmentLength = 5; + } + getSegmentWidth (speed, direction, width, pressure, lastWidth) { + return (0.5 + pressure / 255) + (width / 4) - 0.5 * ((speed / 4) / 50); + } + getSegmentColor (speed, direction, width, pressure, lastWidth) { + const intensity = clamp((0.1 * -((speed / 4) / 35)) + (1.2 * pressure / 255) + 0.5); + const c = Math.min(Math.floor(Math.abs(intensity - 1) * 255), 60); + return `rgb(${c},${c},${c})`; + } +} + +class Marker extends StrokePen { + constructor (baseWidth, baseColor) { + super('Marker', baseWidth, baseColor); + this.segmentLength = 3; + } + getSegmentWidth (speed, direction, width, pressure, lastWidth) { + return 0.9 * ((width / 4) - 0.4 * StrokePen.directionToTilt(direction)) + (0.1 * lastWidth); + } +} + +class Pencil extends StrokePen { + constructor (baseWidth, baseColor) { + super('Pencil', baseWidth, baseColor); + this.segmentLength = 2; + } + getSegmentWidth (speed, direction, width, pressure, lastWidth) { + let sw = 0.7 * ((((0.8 * this.baseWidth) + (0.5 * pressure / 255)) * (width / 4)) + - (0.25 * StrokePen.directionToTilt(direction) ** 1.8) - (0.6 * (speed / 4) / 50)); + return Math.min(sw, this.baseWidth * 10); + } + getSegmentOpacity (speed, direction, width, pressure, lastWidth) { + return clamp((0.1 * -((speed / 4) / 35)) + (1 * pressure / 255)) - 0.1; + } +} + +class MechanicalPencil extends StrokePen { + constructor (baseWidth, baseColor) { + super('Mechanical Pencil', baseWidth ** 2, baseColor); + this.baseOpacity = 0.7; + } +} + +class Brush extends StrokePen { + constructor (baseWidth, baseColor) { + super('Brush', baseWidth, baseColor); + this.segmentLength = 2; + } + getSegmentWidth (speed, direction, width, pressure, lastWidth) { + return 0.7 * (((1 + (1.4 * pressure / 255)) * (width / 4)) + - (0.5 * StrokePen.directionToTilt(direction)) - ((speed / 4) / 50)); + } + getSegmentColor (speed, direction, width, pressure, lastWidth) { + const intensity = clamp(((pressure / 255) ** 1.5 - 0.2 * ((speed / 4) / 50)) * 1.5); + const rev = Math.abs(intensity - 1); + const [r0, g0, b0, a0] = this.baseColor; + return `rgba(${Math.floor(rev * (255 - r0))},${Math.floor(rev * (255 - g0))},${Math.floor(rev * (255 - b0))},${a0})`; + } +} + +class Highlighter extends StrokePen { + constructor (baseWidth, baseColor) { + super('Highlighter', baseWidth, baseColor); + this.strokeLinecap = 'square'; + this.baseOpacity = 0.3; + this.strokeOpacity = 0.2; + } +} + +class Shader extends StrokePen { + constructor (baseWidth, baseColor) { + super('Shader', baseWidth, baseColor); + this.strokeLinecap = 'round'; + this.baseOpacity = 0.1; + } +} + +class Eraser extends StrokePen { + constructor (baseWidth, baseColor) { + super('Eraser', baseWidth * 2, baseColor); + this.strokeLinecap = 'square'; + } +} + +class EraseArea extends StrokePen { + constructor (baseWidth, baseColor) { + super('Erase Area', baseWidth, baseColor); + this.strokeLinecap = 'square'; + this.baseOpacity = 0; + } +} + +class Calligraphy extends StrokePen { + constructor (baseWidth, baseColor) { + super('Calligraphy', baseWidth, baseColor); + this.segmentLength = 2; + } + getSegmentWidth (speed, direction, width, pressure, lastWidth) { + return 0.9 * (((1 + pressure / 255) * (width / 4)) + - 0.3 * StrokePen.directionToTilt(direction)) + (0.1 * lastWidth); + } +} diff --git a/bin/notable.js b/bin/notable.js new file mode 100755 index 0000000..5d4ddf5 --- /dev/null +++ b/bin/notable.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { extname } from 'node:path'; +import { Command } from 'commander'; +import { readBlocks, writeBlocks, simpleTextDocument } from '../lib/scene-stream.js'; +import { rmToMarkdown } from './exporters/markdown.js'; +import { rmToSvg } from './exporters/svg.js'; +import { rmToPdf } from './exporters/pdf.js'; + +const program = new Command(); + +program + .name('notable') + .description('Convert to/from reMarkable v6 .rm files.\n\nAvailable FORMATs: rm, markdown, svg, pdf, blocks, blocks-data') + .version('0.1.0') + .option('-v, --verbose', 'Increase verbosity (repeat for more)', (_, prev) => prev + 1, 0) + .option('-f, --from ', 'Format to convert from (default: guess from filename)') + .option('-t, --to ', 'Format to convert to (default: guess from filename)') + .option('-o, --output ', 'Output filename (default: write to stdout)') + .argument('[input...]', 'Input files') + .action((inputs, opts) => { + let fromFmt = opts.from; + let toFmt = opts.to; + const outputFile = opts.output; + + if (!fromFmt) { + if (!inputs.length) { console.error('Must specify input filename or --from'); process.exit(1); } + fromFmt = guessFormat(inputs[0]); + } + if (!toFmt) { + if (!outputFile) { console.error('Must specify --output or --to'); process.exit(1); } + toFmt = guessFormat(outputFile); + } + + if (fromFmt === 'rm') { + const isBinary = ['pdf', 'rm'].includes(toFmt); + let output = ''; + let outputBuf = null; + + for (const fn of inputs) { + const data = readFileSync(fn); + if (toFmt === 'blocks' || toFmt === 'blocks-data') { + output += pprintBlocks(data, toFmt !== 'blocks-data') + '\n'; + } else if (toFmt === 'markdown') { + output += rmToMarkdown(data); + } else if (toFmt === 'svg') { + output += rmToSvg(data); + } else if (toFmt === 'pdf') { + outputBuf = rmToPdf(data); + } else if (toFmt === 'rm') { + outputBuf = data; // passthrough + } else { + console.error(`Unknown format ${toFmt}`); process.exit(1); + } + } + + const result = isBinary ? (outputBuf ?? Buffer.alloc(0)) : output; + if (outputFile) { + if (isBinary) writeFileSync(outputFile, result); + else writeFileSync(outputFile, result, 'utf8'); + } else { + if (isBinary) process.stdout.write(result); + else process.stdout.write(result); + } + + } else if (fromFmt === 'markdown') { + const text = inputs.map(fn => readFileSync(fn, 'utf8')).join(''); + const buf = writeBlocks(simpleTextDocument(text)); + if (outputFile) writeFileSync(outputFile, buf); + else process.stdout.write(buf); + } else { + console.error(`Source format ${fromFmt} not implemented`); process.exit(1); + } + }); + +program.parse(); + +function guessFormat (p) { + const ext = extname(p).toLowerCase(); + if (ext === '.rm') return 'rm'; + if (ext === '.svg') return 'svg'; + if (ext === '.pdf') return 'pdf'; + if (ext === '.md' || ext === '.markdown') return 'markdown'; + return 'blocks'; +} + +function pprintBlocks (data, withData = true) { + const blocks = [...readBlocks(data)]; + return blocks.map(b => { + if (withData) return JSON.stringify(blockToObj(b), jsonReplacer, 2); + const obj = blockToObj(b); + return JSON.stringify({ type: obj.type }, null, 2); + }).join('\n'); +} + +function blockToObj (b) { + return { type: b.constructor.name, ...Object.fromEntries( + Object.entries(b).filter(([k]) => !k.startsWith('_')) + )}; +} + +function jsonReplacer (key, value) { + if (typeof value === 'bigint') return value.toString(); + if (Buffer.isBuffer(value)) return value.toString('hex'); + if (value instanceof Map) return Object.fromEntries(value); + return value; +} diff --git a/lib/crdt-sequence.js b/lib/crdt-sequence.js new file mode 100644 index 0000000..c8fc735 --- /dev/null +++ b/lib/crdt-sequence.js @@ -0,0 +1,133 @@ +import { CrdtId } from './tagged-block-common.js'; + +export const END_MARKER = new CrdtId(0, 0); + +export class CrdtSequenceItem { + constructor (itemId, leftId, rightId, deletedLength, value) { + this.itemId = itemId; + this.leftId = leftId; + this.rightId = rightId; + this.deletedLength = deletedLength; + this.value = value; + } +} + +export class CrdtSequence { + constructor (items = []) { + this._items = new Map(); + for (const item of items) this._items.set(item.itemId.toKey(), item); + } + + [Symbol.iterator] () { return this.keys()[Symbol.iterator](); } + + keys () { return [...toposortItems([...this._items.values()])]; } + values () { return this.keys().map(id => this._items.get(id.toKey()).value); } + items () { return this.keys().map(id => [id, this._items.get(id.toKey()).value]); } + + get (id) { const item = this._items.get(id.toKey()); return item ? item.value : undefined; } + + sequenceItems () { return [...this._items.values()]; } + + add (item) { + const key = item.itemId.toKey(); + if (this._items.has(key)) throw new Error(`Already have item ${item.itemId}`); + this._items.set(key, item); + } + + get size () { return this._items.size; } +} + +function sortKey (node) { + if (node === '__start') return [0, 0, 0]; + if (node === '__end') return [2, 0, 0]; + return [1, -node.part1, node.part2]; +} + +function compareKeys (a, b) { + const ka = sortKey(a), kb = sortKey(b); + for (let i = 0; i < 3; i++) { + if (ka[i] < kb[i]) return -1; + if (ka[i] > kb[i]) return 1; + } + return 0; +} + +export function toposortItems (items) { + const itemDict = new Map(); + for (const item of items) itemDict.set(item.itemId.toKey(), item); + if (!itemDict.size) return []; + + const endMarkerKey = END_MARKER.toKey(); + + function sideId (item, side) { + const sideIdVal = side === 'left' ? item.leftId : item.rightId; + if (sideIdVal.toKey() === endMarkerKey || !itemDict.has(sideIdVal.toKey())) { + return side === 'left' ? '__start' : '__end'; + } + return sideIdVal; + } + + const inDegree = new Map(); + const dependents = new Map(); + const allNodes = new Set(['__start', '__end']); + + function nodeKey (n) { return typeof n === 'string' ? n : n.toKey(); } + + function getInDegree (n) { return inDegree.get(nodeKey(n)) || 0; } + function addInDegree (n, d) { inDegree.set(nodeKey(n), getInDegree(n) + d); } + function getDependents (n) { return dependents.get(nodeKey(n)) || []; } + function addDependent (n, dep) { + const k = nodeKey(n); + if (!dependents.has(k)) dependents.set(k, []); + dependents.get(k).push(dep); + } + + for (const item of itemDict.values()) { + const itemId = item.itemId; + const leftId = sideId(item, 'left'); + const rightId = sideId(item, 'right'); + + allNodes.add(itemId); + allNodes.add(leftId); + allNodes.add(rightId); + + addInDegree(itemId, 1); + addDependent(leftId, itemId); + + addInDegree(rightId, 1); + addDependent(itemId, rightId); + } + + for (const node of allNodes) { + if (!inDegree.has(nodeKey(node))) inDegree.set(nodeKey(node), 0); + } + + // Simple sorted array as priority queue + const ready = []; + const insertReady = (node) => { + ready.push(node); + ready.sort(compareKeys); + }; + + for (const node of allNodes) { + if (getInDegree(node) === 0) insertReady(node); + } + + const result = []; + while (ready.length) { + const node = ready.shift(); + + if (typeof node !== 'string' && itemDict.has(nodeKey(node))) { + result.push(node); + } + + if (node === '__end') break; + + for (const dep of getDependents(node)) { + addInDegree(dep, -1); + if (getInDegree(dep) === 0) insertReady(dep); + } + } + + return result; +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..c4e07a6 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,9 @@ +export * from './tagged-block-common.js'; +export * from './tagged-block-reader.js'; +export * from './tagged-block-writer.js'; +export * from './crdt-sequence.js'; +export * as sceneItems from './scene-items.js'; +export * from './scene-items.js'; +export * from './scene-stream.js'; +export * from './scene-tree.js'; +export * from './text.js'; diff --git a/lib/scene-items.js b/lib/scene-items.js new file mode 100644 index 0000000..e5dfa7b --- /dev/null +++ b/lib/scene-items.js @@ -0,0 +1,99 @@ +import { CrdtId, LwwValue } from './tagged-block-common.js'; +import { CrdtSequence } from './crdt-sequence.js'; + +export class SceneItem {} + +export const PenColor = Object.freeze({ + BLACK: 0, GRAY: 1, WHITE: 2, YELLOW: 3, GREEN: 4, PINK: 5, + BLUE: 6, RED: 7, GRAY_OVERLAP: 8, HIGHLIGHT: 9, + GREEN_2: 10, CYAN: 11, MAGENTA: 12, YELLOW_2: 13, +}); +export const PenColorByValue = Object.fromEntries( + Object.entries(PenColor).map(([k, v]) => [v, k]) +); + +export const Pen = Object.freeze({ + BALLPOINT_1: 2, BALLPOINT_2: 15, CALIGRAPHY: 21, + ERASER: 6, ERASER_AREA: 8, + FINELINER_1: 4, FINELINER_2: 17, + HIGHLIGHTER_1: 5, HIGHLIGHTER_2: 18, + MARKER_1: 3, MARKER_2: 16, + MECHANICAL_PENCIL_1: 7, MECHANICAL_PENCIL_2: 13, + PAINTBRUSH_1: 0, PAINTBRUSH_2: 12, + PENCIL_1: 1, PENCIL_2: 14, + SHADER: 23, + isHighlighter (value) { return value === 5 || value === 18; }, +}); +export const PenByValue = Object.fromEntries( + Object.entries(Pen).filter(([, v]) => typeof v === 'number').map(([k, v]) => [v, k]) +); + +export const ParagraphStyle = Object.freeze({ + BASIC: 0, PLAIN: 1, HEADING: 2, BOLD: 3, + BULLET: 4, BULLET2: 5, CHECKBOX: 6, CHECKBOX_CHECKED: 7, +}); +export const ParagraphStyleByValue = Object.fromEntries( + Object.entries(ParagraphStyle).map(([k, v]) => [v, k]) +); + +export const END_MARKER = new CrdtId(0, 0); + +export class Point extends SceneItem { + constructor (x, y, speed, direction, width, pressure) { + super(); + this.x = x; this.y = y; this.speed = speed; + this.direction = direction; this.width = width; this.pressure = pressure; + } +} + +export class Line extends SceneItem { + constructor (color, tool, points, thicknessScale, startingLength, moveId = null, colorRgba = null) { + super(); + this.color = color; this.tool = tool; this.points = points; + this.thicknessScale = thicknessScale; this.startingLength = startingLength; + this.moveId = moveId; this.colorRgba = colorRgba; + } +} + +export class Group extends SceneItem { + constructor ({ + nodeId, + children = null, + label = null, + visible = null, + anchorId = null, + anchorType = null, + anchorThreshold = null, + anchorOriginX = null, + } = {}) { + super(); + this.nodeId = nodeId; + this.children = children ?? new CrdtSequence(); + this.label = label ?? new LwwValue(new CrdtId(0, 0), ''); + this.visible = visible ?? new LwwValue(new CrdtId(0, 0), true); + this.anchorId = anchorId; + this.anchorType = anchorType; + this.anchorThreshold = anchorThreshold; + this.anchorOriginX = anchorOriginX; + } +} + +export class Text extends SceneItem { + constructor (items, styles, posX, posY, width) { + super(); + this.items = items; this.styles = styles; + this.posX = posX; this.posY = posY; this.width = width; + } +} + +export class Rectangle { + constructor (x, y, w, h) { this.x = x; this.y = y; this.w = w; this.h = h; } +} + +export class GlyphRange extends SceneItem { + constructor (start, length, text, color, rectangles, colorRgba = null) { + super(); + this.start = start; this.length = length; this.text = text; + this.color = color; this.rectangles = rectangles; this.colorRgba = colorRgba; + } +} diff --git a/lib/scene-stream.js b/lib/scene-stream.js new file mode 100644 index 0000000..9536d00 --- /dev/null +++ b/lib/scene-stream.js @@ -0,0 +1,706 @@ +import { Buffer } from 'node:buffer'; +import { CrdtId, LwwValue, uuidFromBytesLE, uuidToBytesLE, versionGTE, versionGT } from './tagged-block-common.js'; +import { TaggedBlockReader, UnexpectedBlockError } from './tagged-block-reader.js'; +import { TaggedBlockWriter } from './tagged-block-writer.js'; +import { CrdtSequence, CrdtSequenceItem } from './crdt-sequence.js'; +import { SceneTree } from './scene-tree.js'; +import * as si from './scene-items.js'; + +// Registry mapping block type number → class +const BLOCK_REGISTRY = new Map(); +function reg (cls) { if (cls.BLOCK_TYPE !== undefined) BLOCK_REGISTRY.set(cls.BLOCK_TYPE, cls); return cls; } + +export class Block { + constructor () { this.extraData = Buffer.alloc(0); } + + versionInfo (writer) { return [1, 1]; } // [minVersion, currentVersion] + getBlockType () { return this.constructor.BLOCK_TYPE; } + + static lookup (blockType) { return BLOCK_REGISTRY.get(blockType) || null; } + + static read (reader) { + const blockInfo = reader.beginBlock(); + if (blockInfo === null) return null; + + let block; + try { + const BlockClass = Block.lookup(blockInfo.blockType); + if (BlockClass) { + try { + block = BlockClass.fromStream(reader); + } catch (e) { + reader.data.seek(blockInfo.offset); + const data = Buffer.from(reader.data.readBytes(blockInfo.size)); + block = new UnreadableBlock(e.message, data, blockInfo); + } + } else { + const msg = `Unknown block type ${blockInfo.blockType}. Skipping ${blockInfo.size} bytes.`; + const data = Buffer.from(reader.data.readBytes(blockInfo.size)); + block = new UnreadableBlock(msg, data, blockInfo); + } + } finally { + reader.endBlock(); + } + + block.extraData = Buffer.from(blockInfo.extraData); + return block; + } + + write (writer) { + const [minV, curV] = this.versionInfo(writer); + writer.writeBlock(this.getBlockType(), minV, curV, () => { + this.toStream(writer); + writer.data.writeBytes(this.extraData); + }); + } + + static fromStream (reader) { throw new Error('fromStream not implemented'); } + toStream (writer) { throw new Error('toStream not implemented'); } +} + +export class UnreadableBlock extends Block { + constructor (error, data, info) { + super(); + this.error = error; + this.blockData = data; + this.info = info; + } + getBlockType () { return this.info.blockType; } + static fromStream () { throw new Error('not implemented'); } + toStream (writer) { writer.data.writeBytes(this.blockData); } +} + +export class SceneInfo extends Block { + static BLOCK_TYPE = 0x0D; + constructor (currentLayer, backgroundVisible, rootDocumentVisible, paperSize) { + super(); + this.currentLayer = currentLayer; + this.backgroundVisible = backgroundVisible; + this.rootDocumentVisible = rootDocumentVisible; + this.paperSize = paperSize; + } + versionInfo () { return [0, 1]; } + + static fromStream (stream) { + const currentLayer = stream.readLwwId(1); + const backgroundVisible = stream.bytesRemainingInBlock() > 0 ? stream.readLwwBool(2) : null; + const rootDocumentVisible = stream.bytesRemainingInBlock() > 0 ? stream.readLwwBool(3) : null; + const paperSize = stream.bytesRemainingInBlock() > 0 ? stream.readIntPair(5) : null; + return new SceneInfo(currentLayer, backgroundVisible, rootDocumentVisible, paperSize); + } + + toStream (writer) { + writer.writeLwwId(1, this.currentLayer); + if (this.backgroundVisible) writer.writeLwwBool(2, this.backgroundVisible); + if (this.rootDocumentVisible) writer.writeLwwBool(3, this.rootDocumentVisible); + if (this.paperSize) writer.writeIntPair(5, this.paperSize); + } +} +reg(SceneInfo); + +export class AuthorIdsBlock extends Block { + static BLOCK_TYPE = 0x09; + constructor (authorUuids) { + super(); + this.authorUuids = authorUuids; // Map + } + + static fromStream (stream) { + const numSubblocks = stream.data.readVarint(); + const authorIds = new Map(); + for (let i = 0; i < numSubblocks; i++) { + stream.readSubblock(0, () => { + const uuidLength = stream.data.readVarint(); + if (uuidLength !== 16) throw new Error('Expected UUID length to be 16 bytes'); + const uuid = uuidFromBytesLE(stream.data.readBytes(uuidLength)); + const authorId = stream.data.readUint16(); + authorIds.set(authorId, uuid); + }); + } + return new AuthorIdsBlock(authorIds); + } + + toStream (writer) { + writer.data.writeVarint(this.authorUuids.size); + for (const [authorId, uuid] of this.authorUuids) { + writer.writeSubblock(0, () => { + const bytes = uuidToBytesLE(uuid); + writer.data.writeVarint(bytes.length); + writer.data.writeBytes(bytes); + writer.data.writeUint16(authorId); + }); + } + } +} +reg(AuthorIdsBlock); + +export class MigrationInfoBlock extends Block { + static BLOCK_TYPE = 0x00; + constructor (migrationId, isDevice, unknown = false) { + super(); + this.migrationId = migrationId; + this.isDevice = isDevice; + this._unknown = unknown; + } + + static fromStream (stream) { + const migrationId = stream.readId(1); + const isDevice = stream.readBool(2); + const unknown = stream.bytesRemainingInBlock() > 0 ? stream.readBool(3) : false; + return new MigrationInfoBlock(migrationId, isDevice, unknown); + } + + toStream (writer) { + const version = writer.options.version || '9.9.9'; + writer.writeId(1, this.migrationId); + writer.writeBool(2, this.isDevice); + if (versionGTE(version, '3.2.2')) writer.writeBool(3, this._unknown); + } +} +reg(MigrationInfoBlock); + +export class TreeNodeBlock extends Block { + static BLOCK_TYPE = 0x02; + constructor (group) { super(); this.group = group; } + + versionInfo (writer) { + const version = writer.options.version || '9999'; + return versionGTE(version, '3.4') ? [1, 2] : [1, 1]; + } + + static fromStream (stream) { + const group = new si.Group({ + nodeId: stream.readId(1), + label: stream.readLwwString(2), + visible: stream.readLwwBool(3), + }); + if (stream.bytesRemainingInBlock() > 0) { + group.anchorId = stream.readLwwId(7); + group.anchorType = stream.readLwwByte(8); + group.anchorThreshold = stream.readLwwFloat(9); + group.anchorOriginX = stream.readLwwFloat(10); + } + return new TreeNodeBlock(group); + } + + toStream (writer) { + const g = this.group; + writer.writeId(1, g.nodeId); + writer.writeLwwString(2, g.label); + writer.writeLwwBool(3, g.visible); + if (g.anchorId != null) { + writer.writeLwwId(7, g.anchorId); + writer.writeLwwByte(8, g.anchorType); + writer.writeLwwFloat(9, g.anchorThreshold); + writer.writeLwwFloat(10, g.anchorOriginX); + } + } +} +reg(TreeNodeBlock); + +export class PageInfoBlock extends Block { + static BLOCK_TYPE = 0x0A; + constructor (loadsCount, mergesCount, textCharsCount, textLinesCount, typeFolioUseCount = 0) { + super(); + this.loadsCount = loadsCount; this.mergesCount = mergesCount; + this.textCharsCount = textCharsCount; this.textLinesCount = textLinesCount; + this.typeFolioUseCount = typeFolioUseCount; + } + versionInfo () { return [0, 1]; } + + static fromStream (stream) { + const info = new PageInfoBlock( + stream.readInt(1), stream.readInt(2), stream.readInt(3), stream.readInt(4) + ); + if (stream.bytesRemainingInBlock() > 0) info.typeFolioUseCount = stream.readInt(5); + return info; + } + + toStream (writer) { + const version = writer.options.version || '9999'; + writer.writeInt(1, this.loadsCount); writer.writeInt(2, this.mergesCount); + writer.writeInt(3, this.textCharsCount); writer.writeInt(4, this.textLinesCount); + if (versionGTE(version, '3.2.2')) writer.writeInt(5, this.typeFolioUseCount); + } +} +reg(PageInfoBlock); + +export class SceneTreeBlock extends Block { + static BLOCK_TYPE = 0x01; + constructor (treeId, nodeId, isUpdate, parentId) { + super(); + this.treeId = treeId; this.nodeId = nodeId; + this.isUpdate = isUpdate; this.parentId = parentId; + } + + static fromStream (stream) { + const treeId = stream.readId(1); + const nodeId = stream.readId(2); + const isUpdate = stream.readBool(3); + const parentId = stream.readSubblock(4, () => stream.readId(1)); + return new SceneTreeBlock(treeId, nodeId, isUpdate, parentId); + } + + toStream (writer) { + writer.writeId(1, this.treeId); writer.writeId(2, this.nodeId); + writer.writeBool(3, this.isUpdate); + writer.writeSubblock(4, () => writer.writeId(1, this.parentId)); + } +} +reg(SceneTreeBlock); + +// Point serialization helpers +function pointFromStream (stream, version = 2) { + const d = stream.data; + const x = d.readFloat32(), y = d.readFloat32(); + let speed, direction, width, pressure; + if (version === 1) { + speed = d.readFloat32() * 4; + direction = 255 * d.readFloat32() / (Math.PI * 2); + width = Math.round(d.readFloat32() * 4); + pressure = d.readFloat32() * 255; + } else { + speed = d.readUint16(); width = d.readUint16(); + direction = d.readUint8(); pressure = d.readUint8(); + } + return new si.Point(x, y, speed, direction, width, pressure); +} + +function pointSerializedSize (version = 2) { + if (version === 1) return 0x18; + if (version === 2) return 0x0E; + throw new Error(`Unknown version ${version}`); +} + +function pointToStream (point, writer, version = 2) { + const d = writer.data; + d.writeFloat32(point.x); d.writeFloat32(point.y); + if (version === 1) { + d.writeFloat32(point.speed / 4); + d.writeFloat32(point.direction * (2 * Math.PI) / 255); + d.writeFloat32(point.width / 4); + d.writeFloat32(point.pressure / 255); + } else { + d.writeUint16(point.speed); d.writeUint16(point.width); + d.writeUint8(point.direction); d.writeUint8(point.pressure); + } +} + +function lineFromStream (stream, version = 2) { + const toolId = stream.readInt(1); + const colorId = stream.readInt(2); + const thicknessScale = stream.readDouble(3); + const startingLength = stream.readFloat(4); + + const points = stream.readSubblock(5, (blockInfo) => { + const dataLength = blockInfo.size; + const pointSize = pointSerializedSize(version); + if (dataLength % pointSize !== 0) + throw new Error(`Point data size mismatch: ${dataLength} is not multiple of ${pointSize}`); + const num = dataLength / pointSize; + const pts = []; + for (let i = 0; i < num; i++) pts.push(pointFromStream(stream, version)); + return pts; + }); + + stream.readId(6); // timestamp, unused + + let moveId = null; + if (stream.bytesRemainingInBlock() >= 3) { + try { moveId = stream.readId(7); } catch (e) { if (!(e instanceof UnexpectedBlockError)) throw e; } + } + + const colorRgba = stream.readColorOptional(8); + return new si.Line(colorId, toolId, points, thicknessScale, startingLength, moveId, colorRgba); +} + +function lineToStream (line, writer, version = 2) { + writer.writeInt(1, line.tool); writer.writeInt(2, line.color); + writer.writeDouble(3, line.thicknessScale); writer.writeFloat(4, line.startingLength); + writer.writeSubblock(5, () => { for (const p of line.points) pointToStream(p, writer, version); }); + writer.writeId(6, new CrdtId(0, 1)); // dummy timestamp + if (line.moveId != null) writer.writeId(7, line.moveId); + if (line.colorRgba != null) writer.writeColor(8, line.colorRgba); +} + +// GlyphRange helpers +function glyphRangeFromStream (stream) { + const start = stream.readIntOptional(2); + const length = stream.readIntOptional(3); + const color = stream.readInt(4); + const text = stream.readString(5); + const effectiveLength = length ?? text.length; + + const rectangles = stream.readSubblock(6, () => { + const num = stream.data.readVarint(); + const rects = []; + for (let i = 0; i < num; i++) { + const x = stream.data.readFloat64(), y = stream.data.readFloat64(); + const w = stream.data.readFloat64(), h = stream.data.readFloat64(); + rects.push(new si.Rectangle(x, y, w, h)); + } + return rects; + }); + + const colorRgba = stream.readColorOptional(10); + return new si.GlyphRange(start, effectiveLength, text, color, rectangles, colorRgba); +} + +function glyphRangeToStream (stream, item) { + if (item.start != null) { + stream.writeInt(2, item.start); + stream.writeInt(3, item.length); + } + stream.writeInt(4, item.color); + stream.writeString(5, item.text); + stream.writeSubblock(6, () => { + stream.data.writeVarint(item.rectangles.length); + for (const r of item.rectangles) { + stream.data.writeFloat64(r.x); stream.data.writeFloat64(r.y); + stream.data.writeFloat64(r.w); stream.data.writeFloat64(r.h); + } + }); + if (item.colorRgba != null) stream.writeColor(10, item.colorRgba); +} + +export class SceneItemBlock extends Block { + static ITEM_TYPE = 0; + constructor (parentId, item, extraValueData = Buffer.alloc(0)) { + super(); + this.parentId = parentId; + this.item = item; + this.extraValueData = extraValueData; + } + + static fromStream (stream) { + const blockType = stream.currentBlock.blockType; + let SubClass; + if (blockType === SceneGlyphItemBlock.BLOCK_TYPE) SubClass = SceneGlyphItemBlock; + else if (blockType === SceneGroupItemBlock.BLOCK_TYPE) SubClass = SceneGroupItemBlock; + else if (blockType === SceneLineItemBlock.BLOCK_TYPE) SubClass = SceneLineItemBlock; + else if (blockType === SceneTextItemBlock.BLOCK_TYPE) SubClass = SceneTextItemBlock; + else if (blockType === SceneTombstoneItemBlock.BLOCK_TYPE) SubClass = SceneTombstoneItemBlock; + else throw new Error(`Unknown scene type ${blockType}`); + + const parentId = stream.readId(1); + const itemId = stream.readId(2); + const leftId = stream.readId(3); + const rightId = stream.readId(4); + const deletedLength = stream.readInt(5); + + let value = null, extraValueData = Buffer.alloc(0); + if (stream.hasSubblock(6)) { + let capturedBlockInfo; + stream.readSubblock(6, (blockInfo) => { + capturedBlockInfo = blockInfo; + const itemType = stream.data.readUint8(); + if (itemType !== SubClass.ITEM_TYPE) throw new Error(`Expected item type ${SubClass.ITEM_TYPE}, got ${itemType}`); + value = SubClass.valueFromStream(stream); + }); + // _checkPosition runs after callback returns, so extraData is populated now + extraValueData = Buffer.from(capturedBlockInfo.extraData); + } + + return new SubClass( + parentId, + new CrdtSequenceItem(itemId, leftId, rightId, deletedLength, value), + extraValueData + ); + } + + toStream (writer) { + writer.writeId(1, this.parentId); + writer.writeId(2, this.item.itemId); + writer.writeId(3, this.item.leftId); + writer.writeId(4, this.item.rightId); + writer.writeInt(5, this.item.deletedLength); + if (this.item.value != null) { + writer.writeSubblock(6, () => { + writer.data.writeUint8(this.constructor.ITEM_TYPE); + this.valueToStream(writer, this.item.value); + writer.data.writeBytes(this.extraValueData); + }); + } + } + + static valueFromStream (stream) { return null; } + valueToStream (writer, value) {} +} + +export class SceneTombstoneItemBlock extends SceneItemBlock { + static BLOCK_TYPE = 0x08; + static ITEM_TYPE = 0; + static valueFromStream () { return null; } + valueToStream () {} +} +reg(SceneTombstoneItemBlock); + +export class SceneGlyphItemBlock extends SceneItemBlock { + static BLOCK_TYPE = 0x03; + static ITEM_TYPE = 0x01; + static valueFromStream (stream) { return glyphRangeFromStream(stream); } + valueToStream (writer, value) { glyphRangeToStream(writer, value); } +} +reg(SceneGlyphItemBlock); + +export class SceneGroupItemBlock extends SceneItemBlock { + static BLOCK_TYPE = 0x04; + static ITEM_TYPE = 0x02; + static valueFromStream (stream) { return stream.readId(2); } + valueToStream (writer, value) { writer.writeId(2, value); } +} +reg(SceneGroupItemBlock); + +export class SceneLineItemBlock extends SceneItemBlock { + static BLOCK_TYPE = 0x05; + static ITEM_TYPE = 0x03; + + versionInfo (writer) { + const version = writer.options.version || '9999'; + return versionGT(version, '3.0') ? [2, 2] : [1, 1]; + } + + static valueFromStream (stream) { + const version = stream.currentBlock.currentVersion; + return lineFromStream(stream, version); + } + + valueToStream (writer, value) { + const version = writer.options.version || '9999'; + const lineVersion = versionGT(version, '3.0') ? 2 : 1; + lineToStream(value, writer, lineVersion); + } +} +reg(SceneLineItemBlock); + +export class SceneTextItemBlock extends SceneItemBlock { + static BLOCK_TYPE = 0x06; + static ITEM_TYPE = 0x05; + static valueFromStream () { return null; } + valueToStream () {} +} +reg(SceneTextItemBlock); + +// Text block helpers +function textItemFromStream (stream) { + return stream.readSubblock(0, () => { + const itemId = stream.readId(2); + const leftId = stream.readId(3); + const rightId = stream.readId(4); + const deletedLength = stream.readInt(5); + let value = ''; + if (stream.hasSubblock(6)) { + const [text, fmt] = stream.readStringWithFormat(6); + value = (fmt != null) ? fmt : text; + } + return new CrdtSequenceItem(itemId, leftId, rightId, deletedLength, value); + }); +} + +function textItemToStream (item, writer) { + writer.writeSubblock(0, () => { + writer.writeId(2, item.itemId); writer.writeId(3, item.leftId); + writer.writeId(4, item.rightId); writer.writeInt(5, item.deletedLength); + if (item.value) { + if (typeof item.value === 'string') writer.writeString(6, item.value); + else if (typeof item.value === 'number') writer.writeStringWithFormat(6, '', item.value); + } + }); +} + +function textFormatFromStream (stream) { + const charId = stream.data.readCrdtId(); + const timestamp = stream.readId(1); + let formatType; + stream.readSubblock(2, () => { + const c = stream.data.readUint8(); // assert c === 17 + const formatCode = stream.data.readUint8(); + const name = si.ParagraphStyleByValue[formatCode]; + formatType = name != null ? si.ParagraphStyle[name] : si.ParagraphStyle.PLAIN; + }); + return [charId, new LwwValue(timestamp, formatType)]; +} + +function textFormatToStream (charId, value, writer) { + writer.data.writeCrdtId(charId); + writer.writeId(1, value.timestamp); + writer.writeSubblock(2, () => { + writer.data.writeUint8(17); + writer.data.writeUint8(value.value); + }); +} + +export class RootTextBlock extends Block { + static BLOCK_TYPE = 0x07; + constructor (blockId, value) { super(); this.blockId = blockId; this.value = value; } + + static fromStream (stream) { + const blockId = stream.readId(1); + let textItems, textFormats, posX, posY, width; + + stream.readSubblock(2, () => { + stream.readSubblock(1, () => { + stream.readSubblock(1, () => { + const num = stream.data.readVarint(); + textItems = []; + for (let i = 0; i < num; i++) textItems.push(textItemFromStream(stream)); + }); + }); + stream.readSubblock(2, () => { + stream.readSubblock(1, () => { + const num = stream.data.readVarint(); + textFormats = new Map(); + for (let i = 0; i < num; i++) { + const [charId, fmt] = textFormatFromStream(stream); + textFormats.set(charId.toKey(), fmt); + } + }); + }); + }); + + stream.readSubblock(3, () => { + posX = stream.data.readFloat64(); + posY = stream.data.readFloat64(); + }); + width = stream.readFloat(4); + + const value = new si.Text(new CrdtSequence(textItems), textFormats, posX, posY, width); + return new RootTextBlock(blockId, value); + } + + toStream (writer) { + writer.writeId(1, this.blockId); + const textItems = this.value.items.sequenceItems(); + const textFormats = this.value.styles; + + writer.writeSubblock(2, () => { + writer.writeSubblock(1, () => { + writer.writeSubblock(1, () => { + writer.data.writeVarint(textItems.length); + for (const item of textItems) textItemToStream(item, writer); + }); + }); + writer.writeSubblock(2, () => { + writer.writeSubblock(1, () => { + writer.data.writeVarint(textFormats.size); + for (const [key, item] of textFormats) { + const parts = key.split(':'); + const charId = new CrdtId(Number(parts[0]), Number(parts[1])); + textFormatToStream(charId, item, writer); + } + }); + }); + }); + + writer.writeSubblock(3, () => { + writer.data.writeFloat64(this.value.posX); + writer.data.writeFloat64(this.value.posY); + }); + writer.writeFloat(4, this.value.width); + } +} +reg(RootTextBlock); + +// Public API + +export function* readBlocks (data) { + const reader = new TaggedBlockReader(data); + reader.readHeader(); + while (true) { + const block = Block.read(reader); + if (block) yield block; + else return; + } +} + +export function writeBlocks (blocks, options = {}) { + const writer = new TaggedBlockWriter(); + writer.options = { ...options }; + writer.writeHeader(); + for (const block of blocks) block.write(writer); + return writer.getBuffer(); +} + +export function buildTree (tree, blocks) { + for (const b of blocks) { + if (b instanceof SceneTreeBlock) { + tree.addNode(b.treeId, b.parentId); + } else if (b instanceof TreeNodeBlock) { + if (!(b.group.nodeId.toKey() in tree._nodeIds || tree._nodeIds.has(b.group.nodeId.toKey()))) { + throw new Error(`Node does not exist for TreeNodeBlock: ${b.group.nodeId}`); + } + const node = tree._nodeIds.get(b.group.nodeId.toKey()); + node.label = b.group.label; + node.visible = b.group.visible; + node.anchorId = b.group.anchorId; + node.anchorType = b.group.anchorType; + node.anchorThreshold = b.group.anchorThreshold; + node.anchorOriginX = b.group.anchorOriginX; + } else if (b instanceof SceneGroupItemBlock) { + const nodeId = b.item.value; + if (nodeId == null) continue; + if (!tree._nodeIds.has(nodeId.toKey())) { + throw new Error(`Node does not exist for SceneGroupItemBlock: ${nodeId}`); + } + const item = new CrdtSequenceItem( + b.item.itemId, b.item.leftId, b.item.rightId, + b.item.deletedLength, tree._nodeIds.get(nodeId.toKey()) + ); + tree.addItem(item, b.parentId); + } else if (b instanceof SceneLineItemBlock || b instanceof SceneGlyphItemBlock) { + tree.addItem(b.item, b.parentId); + } else if (b instanceof SceneInfo) { + tree.sceneInfo = b; + } else if (b instanceof RootTextBlock) { + tree.rootText = b.value; + } + } +} + +export function readTree (data) { + const tree = new SceneTree(); + buildTree(tree, readBlocks(data)); + return tree; +} + +export function* simpleTextDocument (text, authorUuid = null) { + if (authorUuid == null) { + // generate random UUID + const bytes = Buffer.alloc(16); + for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant + authorUuid = uuidFromBytesLE(bytes); + } + + yield new AuthorIdsBlock(new Map([[1, authorUuid]])); + yield new MigrationInfoBlock(new CrdtId(1, 1), true); + yield new PageInfoBlock(1, 0, text.length + 1, text.split('\n').length); + + yield new SceneTreeBlock(new CrdtId(0, 11), new CrdtId(0, 0), true, new CrdtId(0, 1)); + + const styles = new Map(); + styles.set(new CrdtId(0, 0).toKey(), new LwwValue(new CrdtId(1, 15), si.ParagraphStyle.PLAIN)); + + yield new RootTextBlock( + new CrdtId(0, 0), + new si.Text( + new CrdtSequence([ + new CrdtSequenceItem(new CrdtId(1, 16), new CrdtId(0, 0), new CrdtId(0, 0), 0, text), + ]), + styles, + -468.0, 234.0, 936.0 + ) + ); + + yield new TreeNodeBlock(new si.Group({ nodeId: new CrdtId(0, 1) })); + yield new TreeNodeBlock(new si.Group({ + nodeId: new CrdtId(0, 11), + label: new LwwValue(new CrdtId(0, 12), 'Layer 1'), + })); + + yield new SceneGroupItemBlock( + new CrdtId(0, 1), + new CrdtSequenceItem(new CrdtId(0, 13), new CrdtId(0, 0), new CrdtId(0, 0), 0, new CrdtId(0, 11)) + ); +} diff --git a/lib/scene-tree.js b/lib/scene-tree.js new file mode 100644 index 0000000..b75d758 --- /dev/null +++ b/lib/scene-tree.js @@ -0,0 +1,40 @@ +import { CrdtId } from './tagged-block-common.js'; +import { Group } from './scene-items.js'; + +export const ROOT_ID = new CrdtId(0, 1); + +export class SceneTree { + constructor () { + this.root = new Group({ nodeId: ROOT_ID }); + this._nodeIds = new Map([[ROOT_ID.toKey(), this.root]]); + this.sceneInfo = null; + this.rootText = null; + } + + has (nodeId) { return this._nodeIds.has(nodeId.toKey()); } + + get (nodeId) { return this._nodeIds.get(nodeId.toKey()); } + + addNode (nodeId, parentId) { + if (this._nodeIds.has(nodeId.toKey())) + throw new Error(`Node ${nodeId} already in tree`); + const node = new Group({ nodeId }); + this._nodeIds.set(nodeId.toKey(), node); + } + + addItem (item, parentId) { + if (!this._nodeIds.has(parentId.toKey())) + throw new Error(`Parent id not known: ${parentId}`); + this._nodeIds.get(parentId.toKey()).children.add(item); + } + + * walk () { yield* walkItems(this.root); } +} + +function* walkItems (item) { + if (item && item.children) { + for (const child of item.children.values()) yield* walkItems(child); + } else { + yield item; + } +} diff --git a/lib/tagged-block-common.js b/lib/tagged-block-common.js new file mode 100644 index 0000000..960f456 --- /dev/null +++ b/lib/tagged-block-common.js @@ -0,0 +1,192 @@ +import { Buffer } from 'node:buffer'; + +export const HEADER_V6 = Buffer.from('reMarkable .lines file, version=6 ', 'ascii'); + +export class EOFError extends Error { + constructor (msg = 'Unexpected end of data') { + super(msg); + this.name = 'EOFError'; + } +} + +export class UnexpectedBlockError extends Error { + constructor (msg) { + super(msg); + this.name = 'UnexpectedBlockError'; + } +} + +export const TagType = Object.freeze({ ID: 0xF, Length4: 0xC, Byte8: 0x8, Byte4: 0x4, Byte1: 0x1 }); +const VALID_TAG_TYPES = new Set(Object.values(TagType)); + +export class CrdtId { + constructor (part1, part2) { + this.part1 = part1; + this.part2 = part2; + } + toKey () { return `${this.part1}:${this.part2}`; } + toString () { return `CrdtId(${this.part1}, ${this.part2})`; } +} + +export class LwwValue { + constructor (timestamp, value) { + this.timestamp = timestamp; + this.value = value; + } +} + +export class DataReader { + constructor (buf) { + this._buf = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + this._pos = 0; + } + + tell () { return this._pos; } + seek (pos) { this._pos = pos; } + get length () { return this._buf.length; } + + readHeader () { + const header = this.readBytes(HEADER_V6.length); + if (!header.equals(HEADER_V6)) throw new Error(`Wrong header: ${header.toString('hex')}`); + } + + readBytes (n) { + if (this._pos + n > this._buf.length) throw new EOFError(); + const result = this._buf.slice(this._pos, this._pos + n); + this._pos += n; + return result; + } + + checkTag (expectedIndex, expectedType) { + const pos = this._pos; + try { + const [index, tagType] = this._readTagValues(); + return index === expectedIndex && tagType === expectedType; + } catch { return false; } + finally { this._pos = pos; } + } + + readTag (expectedIndex, expectedType) { + const pos = this._pos; + let index, tagType; + try { [index, tagType] = this._readTagValues(); } + catch (e) { this._pos = pos; throw e; } + + if (index !== expectedIndex) { + this._pos = pos; + throw new UnexpectedBlockError(`Expected index ${expectedIndex}, got ${index}, at position ${this._pos}`); + } + if (tagType !== expectedType) { + this._pos = pos; + throw new UnexpectedBlockError( + `Expected tag type ${expectedType} (0x${expectedType.toString(16).toUpperCase()}), got 0x${tagType.toString(16).toUpperCase()} at position ${this._pos}` + ); + } + return [index, tagType]; + } + + _readTagValues () { + const x = this.readVarint(); + const index = Math.floor(x / 16); + const tagTypeVal = x % 16; + if (!VALID_TAG_TYPES.has(tagTypeVal)) + throw new Error(`Bad tag type 0x${tagTypeVal.toString(16).toUpperCase()} at position ${this._pos}`); + return [index, tagTypeVal]; + } + + readBool () { return this.readBytes(1)[0] !== 0; } + readUint8 () { return this.readBytes(1)[0]; } + readUint16 () { return this.readBytes(2).readUInt16LE(0); } + readUint32 () { return this.readBytes(4).readUInt32LE(0); } + readFloat32 () { return this.readBytes(4).readFloatLE(0); } + readFloat64 () { return this.readBytes(8).readDoubleLE(0); } + + readVarint () { + let result = 0, multiplier = 1; + while (true) { + const byte = this.readBytes(1)[0]; + result += (byte & 0x7F) * multiplier; + multiplier *= 128; + if (!(byte & 0x80)) break; + } + return result; + } + + readCrdtId () { + return new CrdtId(this.readUint8(), this.readVarint()); + } +} + +export class DataWriter { + constructor () { this._chunks = []; this._pos = 0; } + tell () { return this._pos; } + writeHeader () { this.writeBytes(HEADER_V6); } + getBuffer () { return Buffer.concat(this._chunks); } + + writeBytes (buf) { + if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf); + this._chunks.push(buf); + this._pos += buf.length; + } + + writeTag (index, tagType) { this.writeVarint(index * 16 + tagType); } + + writeBool (v) { const b = Buffer.allocUnsafe(1); b[0] = v ? 1 : 0; this.writeBytes(b); } + writeUint8 (v) { const b = Buffer.allocUnsafe(1); b[0] = v & 0xFF; this.writeBytes(b); } + writeUint16 (v) { const b = Buffer.allocUnsafe(2); b.writeUInt16LE(v, 0); this.writeBytes(b); } + writeUint32 (v) { const b = Buffer.allocUnsafe(4); b.writeUInt32LE(v >>> 0, 0); this.writeBytes(b); } + writeFloat32 (v) { const b = Buffer.allocUnsafe(4); b.writeFloatLE(v, 0); this.writeBytes(b); } + writeFloat64 (v) { const b = Buffer.allocUnsafe(8); b.writeDoubleLE(v, 0); this.writeBytes(b); } + + writeVarint (value) { + if (value < 0) throw new Error('value is negative'); + const bytes = []; + while (true) { + const byte = value % 128; + value = Math.floor(value / 128); + bytes.push(value > 0 ? (byte | 0x80) : byte); + if (!value) break; + } + this.writeBytes(Buffer.from(bytes)); + } + + writeCrdtId (value) { this.writeUint8(value.part1); this.writeVarint(value.part2); } +} + +export function uuidFromBytesLE (buf) { + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + return [ + b[3].toString(16).padStart(2, '0'), b[2].toString(16).padStart(2, '0'), + b[1].toString(16).padStart(2, '0'), b[0].toString(16).padStart(2, '0'), '-', + b[5].toString(16).padStart(2, '0'), b[4].toString(16).padStart(2, '0'), '-', + b[7].toString(16).padStart(2, '0'), b[6].toString(16).padStart(2, '0'), '-', + b[8].toString(16).padStart(2, '0'), b[9].toString(16).padStart(2, '0'), '-', + Array.from(b.slice(10, 16)).map(x => x.toString(16).padStart(2, '0')).join(''), + ].join(''); +} + +export function uuidToBytesLE (uuid) { + const hex = uuid.replace(/-/g, ''); + const bytes = Buffer.from(hex, 'hex'); + const r = Buffer.allocUnsafe(16); + r[0] = bytes[3]; r[1] = bytes[2]; r[2] = bytes[1]; r[3] = bytes[0]; + r[4] = bytes[5]; r[5] = bytes[4]; + r[6] = bytes[7]; r[7] = bytes[6]; + bytes.copy(r, 8, 8, 16); + return r; +} + +export function compareVersions (a, b) { + const ap = String(a).split('.').map(Number); + const bp = String(b).split('.').map(Number); + const len = Math.max(ap.length, bp.length); + for (let i = 0; i < len; i++) { + const av = ap[i] || 0, bv = bp[i] || 0; + if (av < bv) return -1; + if (av > bv) return 1; + } + return 0; +} + +export const versionGTE = (a, b) => compareVersions(a, b) >= 0; +export const versionGT = (a, b) => compareVersions(a, b) > 0; diff --git a/lib/tagged-block-reader.js b/lib/tagged-block-reader.js new file mode 100644 index 0000000..3232d88 --- /dev/null +++ b/lib/tagged-block-reader.js @@ -0,0 +1,159 @@ +import { Buffer } from 'node:buffer'; +import { DataReader, TagType, CrdtId, LwwValue, UnexpectedBlockError, EOFError } from './tagged-block-common.js'; + +export { UnexpectedBlockError, EOFError }; + +export class BlockOverflowError extends Error { + constructor (msg) { super(msg); this.name = 'BlockOverflowError'; } +} + +export class BlockInfo { + constructor (offset, size) { + this.offset = offset; + this.size = size; + this.extraData = Buffer.alloc(0); + } +} + +export class MainBlockInfo extends BlockInfo { + constructor (offset, size, blockType, minVersion, currentVersion) { + super(offset, size); + this.blockType = blockType; + this.minVersion = minVersion; + this.currentVersion = currentVersion; + } +} + +export class SubBlockInfo extends BlockInfo {} + +export class TaggedBlockReader { + constructor (buf) { + this.data = buf instanceof DataReader ? buf : new DataReader(buf); + this.currentBlock = null; + this._warnedAboutExtraData = false; + } + + readHeader () { this.data.readHeader(); } + + beginBlock () { + if (this.currentBlock !== null) throw new UnexpectedBlockError('Already in a block'); + let blockLength; + try { blockLength = this.data.readUint32(); } + catch (e) { if (e instanceof EOFError) return null; throw e; } + const unknown = this.data.readUint8(); // eslint-disable-line no-unused-vars + const minVersion = this.data.readUint8(); + const currentVersion = this.data.readUint8(); + const blockType = this.data.readUint8(); + const i0 = this.data.tell(); + this.currentBlock = new MainBlockInfo(i0, blockLength, blockType, minVersion, currentVersion); + return this.currentBlock; + } + + endBlock () { + if (!this.currentBlock) throw new Error('Not in a block'); + this._checkPosition(this.currentBlock); + this.currentBlock = null; + } + + bytesRemainingInBlock () { + if (!this.currentBlock) throw new Error('Not in a block'); + return this.currentBlock.offset + this.currentBlock.size - this.data.tell(); + } + + readSubblock (index, fn) { + this.data.readTag(index, TagType.Length4); + const subblockLength = this.data.readUint32(); + const i0 = this.data.tell(); + const subblock = new SubBlockInfo(i0, subblockLength); + const result = fn(subblock); + this._checkPosition(subblock); + return result; + } + + hasSubblock (index) { + if (this.currentBlock && this.bytesRemainingInBlock() <= 0) return false; + return this.data.checkTag(index, TagType.Length4); + } + + _checkPosition (blockInfo) { + const i0 = blockInfo.offset, length = blockInfo.size, i1 = this.data.tell(); + if (i1 > i0 + length) + throw new BlockOverflowError( + `${blockInfo.constructor.name} starting at ${i0}, length ${length}, read up to ${i1} (overflow by ${i1 - (i0 + length)})` + ); + if (i1 < i0 + length) { + if (!this._warnedAboutExtraData) { + process.stderr.write('Warning: Some data has not been read. The data may have been written using a newer format.\n'); + this._warnedAboutExtraData = true; + } + const remaining = i0 + length - i1; + blockInfo.extraData = Buffer.from(this.data.readBytes(remaining)); + } + } + + readId (index) { this.data.readTag(index, TagType.ID); return this.data.readCrdtId(); } + readBool (index) { this.data.readTag(index, TagType.Byte1); return this.data.readBool(); } + readByte (index) { this.data.readTag(index, TagType.Byte1); return this.data.readUint8(); } + readInt (index) { this.data.readTag(index, TagType.Byte4); return this.data.readUint32(); } + readFloat (index) { this.data.readTag(index, TagType.Byte4); return this.data.readFloat32(); } + readDouble (index) { this.data.readTag(index, TagType.Byte8); return this.data.readFloat64(); } + + _readOptional (fn, defaultValue = null) { + try { return fn(); } + catch (e) { + if (e instanceof UnexpectedBlockError || e instanceof EOFError) return defaultValue; + throw e; + } + } + + readIdOptional (index, def = null) { return this._readOptional(() => this.readId(index), def); } + readBoolOptional (index, def = null) { return this._readOptional(() => this.readBool(index), def); } + readByteOptional (index, def = null) { return this._readOptional(() => this.readByte(index), def); } + readIntOptional (index, def = null) { return this._readOptional(() => this.readInt(index), def); } + readFloatOptional (index, def = null) { return this._readOptional(() => this.readFloat(index), def); } + readDoubleOptional (index, def = null) { return this._readOptional(() => this.readDouble(index), def); } + + readColorOptional (index) { + if (!this.data.checkTag(index, TagType.Byte4)) return null; + const packed = this.readInt(index); + return [(packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF, (packed >>> 24) & 0xFF]; + } + + readLwwBool (index) { + return this.readSubblock(index, () => new LwwValue(this.readId(1), this.readBool(2))); + } + readLwwByte (index) { + return this.readSubblock(index, () => new LwwValue(this.readId(1), this.readByte(2))); + } + readLwwFloat (index) { + return this.readSubblock(index, () => new LwwValue(this.readId(1), this.readFloat(2))); + } + readLwwId (index) { + return this.readSubblock(index, () => new LwwValue(this.readId(1), this.readId(2))); + } + readLwwString (index) { + return this.readSubblock(index, () => new LwwValue(this.readId(1), this.readString(2))); + } + + readString (index) { + return this.readSubblock(index, () => { + const stringLength = this.data.readVarint(); + this.data.readBool(); // isAscii flag + return this.data.readBytes(stringLength).toString('utf8'); + }); + } + + readStringWithFormat (index) { + return this.readSubblock(index, () => { + const stringLength = this.data.readVarint(); + this.data.readBool(); // isAscii flag + const str = this.data.readBytes(stringLength).toString('utf8'); + const fmt = this.data.checkTag(2, TagType.Byte4) ? this.readInt(2) : null; + return [str, fmt]; + }); + } + + readIntPair (index) { + return this.readSubblock(index, () => [this.data.readUint32(), this.data.readUint32()]); + } +} diff --git a/lib/tagged-block-writer.js b/lib/tagged-block-writer.js new file mode 100644 index 0000000..d90a86d --- /dev/null +++ b/lib/tagged-block-writer.js @@ -0,0 +1,97 @@ +import { DataWriter, TagType, LwwValue, UnexpectedBlockError } from './tagged-block-common.js'; + +export class TaggedBlockWriter { + constructor () { + this._data = new DataWriter(); + this._inBlock = false; + this.options = {}; + } + + get data () { return this._data; } + getBuffer () { return this._data.getBuffer(); } + writeHeader () { this._data.writeHeader(); } + + writeBlock (blockType, minVersion, currentVersion, fn) { + if (this._inBlock) throw new UnexpectedBlockError('Already in a block'); + const prev = this._data; + const blockWriter = new DataWriter(); + this._data = blockWriter; + this._inBlock = true; + try { fn(); } + finally { this._data = prev; } + this._inBlock = false; + const buf = blockWriter.getBuffer(); + prev.writeUint32(buf.length); + prev.writeUint8(0); + prev.writeUint8(minVersion); + prev.writeUint8(currentVersion); + prev.writeUint8(blockType); + prev.writeBytes(buf); + } + + writeSubblock (index, fn) { + const prev = this._data; + const sub = new DataWriter(); + this._data = sub; + try { fn(); } + finally { this._data = prev; } + const buf = sub.getBuffer(); + prev.writeTag(index, TagType.Length4); + prev.writeUint32(buf.length); + prev.writeBytes(buf); + } + + writeId (index, value) { this._data.writeTag(index, TagType.ID); this._data.writeCrdtId(value); } + writeBool (index, value) { this._data.writeTag(index, TagType.Byte1); this._data.writeBool(value); } + writeByte (index, value) { this._data.writeTag(index, TagType.Byte1); this._data.writeUint8(value); } + writeInt (index, value) { this._data.writeTag(index, TagType.Byte4); this._data.writeUint32(value); } + writeFloat (index, value) { this._data.writeTag(index, TagType.Byte4); this._data.writeFloat32(value); } + writeDouble (index, value) { this._data.writeTag(index, TagType.Byte8); this._data.writeFloat64(value); } + + writeColor (index, value) { + const [r, g, b, a] = value; + this.writeInt(index, (b | (g << 8) | (r << 16) | (a << 24)) >>> 0); + } + + writeLwwBool (index, value) { + this.writeSubblock(index, () => { this.writeId(1, value.timestamp); this.writeBool(2, value.value); }); + } + writeLwwByte (index, value) { + this.writeSubblock(index, () => { this.writeId(1, value.timestamp); this.writeByte(2, value.value); }); + } + writeLwwFloat (index, value) { + this.writeSubblock(index, () => { this.writeId(1, value.timestamp); this.writeFloat(2, value.value); }); + } + writeLwwId (index, value) { + this.writeSubblock(index, () => { this.writeId(1, value.timestamp); this.writeId(2, value.value); }); + } + writeLwwString (index, value) { + this.writeSubblock(index, () => { this.writeId(1, value.timestamp); this.writeString(2, value.value); }); + } + + writeString (index, value) { + this.writeSubblock(index, () => { + const bytes = Buffer.from(value, 'utf8'); + this._data.writeVarint(bytes.length); + this._data.writeBool(true); + this._data.writeBytes(bytes); + }); + } + + writeStringWithFormat (index, text, fmt) { + this.writeSubblock(index, () => { + const bytes = Buffer.from(text, 'utf8'); + this._data.writeVarint(bytes.length); + this._data.writeBool(true); + this._data.writeBytes(bytes); + this.writeInt(2, fmt); + }); + } + + writeIntPair (index, value) { + this.writeSubblock(index, () => { + this._data.writeUint32(value[0]); + this._data.writeUint32(value[1]); + }); + } +} diff --git a/lib/text.js b/lib/text.js new file mode 100644 index 0000000..5fa141c --- /dev/null +++ b/lib/text.js @@ -0,0 +1,117 @@ +import { CrdtId, LwwValue } from './tagged-block-common.js'; +import { CrdtSequence, CrdtSequenceItem } from './crdt-sequence.js'; +import * as si from './scene-items.js'; + +export function* expandTextItem (item) { + if (item.deletedLength > 0) { + const chars = Array(item.deletedLength).fill(''); + let itemId = item.itemId; + let leftId = item.leftId; + for (let i = 0; i < chars.length - 1; i++) { + const rightId = new CrdtId(itemId.part1, itemId.part2 + 1); + yield new CrdtSequenceItem(itemId, leftId, rightId, 1, ''); + leftId = itemId; + itemId = rightId; + } + yield new CrdtSequenceItem(itemId, leftId, item.rightId, 1, ''); + return; + } + + if (typeof item.value === 'number') { yield item; return; } + + const chars = item.value; + if (!chars) { + process.stderr.write(`Warning: unexpected empty text item\n`); + return; + } + + let itemId = item.itemId; + let leftId = item.leftId; + for (let i = 0; i < chars.length - 1; i++) { + const rightId = new CrdtId(itemId.part1, itemId.part2 + 1); + yield new CrdtSequenceItem(itemId, leftId, rightId, 0, chars[i]); + leftId = itemId; + itemId = rightId; + } + yield new CrdtSequenceItem(itemId, leftId, item.rightId, 0, chars[chars.length - 1]); +} + +export function* expandTextItems (items) { + for (const item of items) yield* expandTextItem(item); +} + +export class CrdtStr { + constructor (s = '', i = [], properties = {}) { + this.s = s; this.i = i; this.properties = properties; + } + toString () { return this.s; } +} + +export class Paragraph { + constructor (contents, startId, style = null) { + this.contents = contents; + this.startId = startId; + this.style = style ?? new LwwValue(new CrdtId(0, 0), si.ParagraphStyle.PLAIN); + } + toString () { return this.contents.map(s => s.s).join(''); } +} + +export class TextDocument { + constructor (contents) { this.contents = contents; } + + static fromSceneItem (text) { + const charFormats = new Map(); + for (const [key, lww] of text.styles) charFormats.set(key, lww.value); + const endKey = new CrdtId(0, 0).toKey(); + if (!charFormats.has(endKey)) charFormats.set(endKey, si.ParagraphStyle.PLAIN); + + const charItems = new CrdtSequence([...expandTextItems(text.items.sequenceItems())]); + const keys = [...charItems]; + let keyIdx = 0; + + const properties = { 'font-weight': 'normal', 'font-style': 'normal' }; + + function handleFormattingCode (code) { + if (code === 1) properties['font-weight'] = 'bold'; + else if (code === 2) properties['font-weight'] = 'normal'; + if (code === 3) properties['font-style'] = 'italic'; + else if (code === 4) properties['font-style'] = 'normal'; + } + + function parseParagraphContents () { + let startId; + if (keyIdx < keys.length && charItems.get(keys[keyIdx]) === '\n') { + startId = keys[keyIdx++]; + } else { + startId = new CrdtId(0, 0); + } + const contents = []; + while (keyIdx < keys.length) { + const char = charItems.get(keys[keyIdx]); + if (typeof char === 'number') { + handleFormattingCode(char); + } else if (char === '\n') { + break; + } else { + if (!contents.length || JSON.stringify(contents[contents.length - 1].properties) !== JSON.stringify(properties)) { + contents.push(new CrdtStr('', [], { ...properties })); + } + contents[contents.length - 1].s += char; + contents[contents.length - 1].i.push(keys[keyIdx]); + } + keyIdx++; + } + return [startId, contents]; + } + + const paragraphs = []; + while (keyIdx < keys.length) { + const [startId, contents] = parseParagraphContents(); + const styleKey = startId.toKey(); + const style = text.styles.get(styleKey) ?? new LwwValue(new CrdtId(0, 0), si.ParagraphStyle.PLAIN); + paragraphs.push(new Paragraph(contents, startId, style)); + } + + return new TextDocument(paragraphs); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a6e4555 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1258 @@ +{ + "name": "notable", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notable", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "commander": "^12.0.0" + }, + "bin": { + "notable": "bin/notable.js" + }, + "devDependencies": { + "eslint": "^8.26.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index f0d8817..1b97ee4 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,26 @@ { "name": "notable", "type": "module", - "version": "0.0.1", - "description": "reMarkable tooling", + "version": "0.1.0", + "description": "Parse and convert reMarkable .rm files", "author": "Robin Berjon ", "license": "Apache-2.0", - "scripts": {}, - "bin": {}, - "repository": { - "type": "git", - "url": "https://github.com/darobin/notable.git" + "main": "lib/index.js", + "exports": "./lib/index.js", + "bin": { + "notable": "./bin/notable.js" + }, + "scripts": { + "test": "node --test test/*.test.js" }, - "eslintConfig": { - "env": { - "browser": true, - "mocha": true, - "es2021": true - }, - "extends": "eslint:recommended", - "overrides": [], - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "rules": {} + "dependencies": { + "commander": "^12.0.0" }, "devDependencies": { "eslint": "^8.26.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/darobin/notable.git" } -} \ No newline at end of file +} diff --git a/test/crdt-sequence.test.js b/test/crdt-sequence.test.js new file mode 100644 index 0000000..a0b88d3 --- /dev/null +++ b/test/crdt-sequence.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CrdtSequence, CrdtSequenceItem } from '../lib/crdt-sequence.js'; +import { CrdtId } from '../lib/tagged-block-common.js'; + +function cid (k) { return new CrdtId(k === 0 ? 0 : 1, k); } +function makeItem (itemId, leftId, rightId, ...args) { + return new CrdtSequenceItem(cid(itemId), cid(leftId), cid(rightId), ...args); +} + +test('empty sequence', () => { + assert.deepStrictEqual([...new CrdtSequence([])], []); +}); + +test('just one item', () => { + const seq = new CrdtSequence([makeItem(1, 0, 0, 0, 'A')]); + assert.deepStrictEqual([...seq], [cid(1)]); +}); + +test('two items', () => { + const items = [makeItem(1, 0, 0, 0, 'A'), makeItem(2, 1, 0, 0, 'B')]; + assert.deepStrictEqual([...new CrdtSequence(items)], [cid(1), cid(2)]); + assert.deepStrictEqual([...new CrdtSequence([...items].reverse())], [cid(1), cid(2)]); +}); + +test('overlapping concurrent inserts - higher author first', () => { + const A = new CrdtSequenceItem(new CrdtId(1, 1), new CrdtId(0, 0), new CrdtId(0, 0), 0, 'A'); + const Z = new CrdtSequenceItem(new CrdtId(1, 2), new CrdtId(1, 1), new CrdtId(0, 0), 0, 'Z'); + const author2 = new CrdtSequenceItem(new CrdtId(2, 1), new CrdtId(1, 1), new CrdtId(1, 2), 0, '12'); + const author1 = new CrdtSequenceItem(new CrdtId(1, 3), new CrdtId(1, 1), new CrdtId(1, 2), 0, '_'); + const items = [A, Z, author2, author1]; + const seq = new CrdtSequence(items); + assert.deepStrictEqual([...seq], [new CrdtId(1, 1), new CrdtId(2, 1), new CrdtId(1, 3), new CrdtId(1, 2)]); + assert.equal(seq.values().join(''), 'A12_Z'); +}); + +test('overlapping sorted by id', () => { + const items = [ + makeItem(8, 0, 0, 0, 'A'), + makeItem(9, 8, 0, 0, 'B'), + makeItem(3, 0, 0, 0, 'C'), + ]; + assert.deepStrictEqual([...new CrdtSequence(items)], [cid(3), cid(8), cid(9)]); + assert.deepStrictEqual([...new CrdtSequence([...items].reverse())], [cid(3), cid(8), cid(9)]); +}); + +test('unknown id', () => { + const items = [ + makeItem(28, 0, 15, 0, 'A'), + makeItem(31, 30, 15, 2, ''), + makeItem(33, 32, 15, 0, 'B'), + makeItem(15, 0, 0, 0, 'C'), + ]; + assert.deepStrictEqual([...new CrdtSequence(items)], [cid(28), cid(31), cid(33), cid(15)]); + assert.deepStrictEqual([...new CrdtSequence([...items].reverse())], [cid(28), cid(31), cid(33), cid(15)]); +}); + +test('unknown id at right', () => { + const items = [makeItem(14, 0, 0, 0, 'A'), makeItem(19, 14, 15, 0, 'V')]; + assert.deepStrictEqual([...new CrdtSequence(items)], [cid(14), cid(19)]); +}); + +test('iterates in order', () => { + const items = [makeItem(1, 0, 0, 0, 'A'), makeItem(2, 1, 0, 0, 'B')]; + for (const testItems of [items, [...items].reverse()]) { + const seq = new CrdtSequence(testItems); + assert.deepStrictEqual(seq.keys(), [cid(1), cid(2)]); + assert.deepStrictEqual(seq.values(), ['A', 'B']); + } +}); diff --git a/test/data/Bold_Heading_Bullet_Normal.rm b/test/data/Bold_Heading_Bullet_Normal.rm new file mode 100644 index 0000000000000000000000000000000000000000..740bf9b46a58f8599b1b0cc381d86b19ea50367d GIT binary patch literal 633 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEDY1_lO3MouOkVFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}Z1BF;JOn(WOzaZ4ArEMBa+e`XjX11m%$gFGXnB7+Es z!@$TT!q33Kpu)(&z+j@yz`)?b0x}aM$H>Si&%mwEz+lMe!_2_IAkV-U4N}I)$S%*o zpkv9%z##LJfq_Bh3j+fK2agy71A{)JpgjYFKLbMu$bcM>E=ER29yyQ{lZ`#2pg*G! zM2ee%fq|2eD=)QN0pwdAagY*5A&?T7ZjeKm7`dEyB*AiG_Kd>*j3N;2YzzzxEQ|`7 z#R@!pU|A`9MoEw?7Xt$W#3@YH_KeE@j4BWf5T_*a=zvwLfTf^jfs7YtRLDs!DM>9- z$WK!!$w*a5%qhr7OiC?*I!pv)IhwpqmVqKm<|&I1A`z7lZ`x+D}>F& zC?d}U6=!5%kY}=nh%?&=fC31F9cmANCXN?GI?81)%=ks}8jy!s5> E09j5>F#rGn literal 0 HcmV?d00001 diff --git a/test/data/Color_and_tool_v3.14.4.rm b/test/data/Color_and_tool_v3.14.4.rm new file mode 100644 index 0000000000000000000000000000000000000000..b00c6da7a455798621025218b7a081d53997ff70 GIT binary patch literal 21855 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 H@);NbZZoXq literal 0 HcmV?d00001 diff --git a/test/data/Lines_v2_updated.rm b/test/data/Lines_v2_updated.rm new file mode 100644 index 0000000000000000000000000000000000000000..47f49717721713cbcaae5d91df16cdfe65316c10 GIT binary patch literal 7755 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 F5&+3&r3L^1 literal 0 HcmV?d00001 diff --git a/test/data/More_color_highlight_shader_v3.15.4.2.rm b/test/data/More_color_highlight_shader_v3.15.4.2.rm new file mode 100644 index 0000000000000000000000000000000000000000..8acd793fcdf2d4542b7916c08afe378f9e557444 GIT binary patch literal 12649 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 Nk<|}yjyw;_kpQ6Cy;1-G literal 0 HcmV?d00001 diff --git a/test/data/Normal_AB.rm b/test/data/Normal_AB.rm new file mode 100644 index 0000000000000000000000000000000000000000..6092ec94674799d8707af96ff5b9939a82dda8d3 GIT binary patch literal 358 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEVe1_lO3MovZ^VFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}yq6{3Jao{>?JQ3S+cVB`{EWME)W0dY*285kH`KwJS3 zmywZCo`GARfx(c`2P7lUz?c9LXP0MS(6M7+V31J)5waj_dBhnQ81xwh>=_vR85lx9 z3UgQ(7#Nrs9i4Q9K+1SQ$`}~r8ToaX7#J7?8EwGUfK)ov9sp4c4!I643=CN=G9Vr! zBa=J>qYf7X0|UrDkRAp`8;~F585k59mEmf*bp*j`cyu@!7#P?YeG)5EixdnIN~Iu5 RS>zcQ^%;0ET&T~$4FH<6CNuy5 literal 0 HcmV?d00001 diff --git a/test/data/Normal_A_stroke_2_layers.rm b/test/data/Normal_A_stroke_2_layers.rm new file mode 100644 index 0000000000000000000000000000000000000000..ef5a02aff744113984a95a4a8a6351a4534dd262 GIT binary patch literal 1196 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEVe1_lO3MovZ^VFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}yq6{3Jao{>?JK?KBMVB`{EVqjoU0dY(~EEh(QUJ#d& zkx`z3SD%5wkkN;kfq_Aufl&Y^&M1gOTm)I17olDXNt{tI7-9~)JOhJ{Ap--0j3S7T z0Xd&XjDdkcpOMd=fx(}FAq1o_2V^@VqoWQRNEOI^3^rhsL6Q!&2S5~qL#~4h14EVz z$PflbMkaX%MjbA&tGRVR{$gOX0eN4Zfk%;18KeWOhF3=rtcG8QlYxPOozW+;GPOv- z5UNxFNhzZsL@A>Xic%w}QU*mvKbWbEB2b$dP;F*VWUK*sL7q`epOLSJ5$qmDaYd#% zj2s|WFiNP%@U=Ouf$~ID7#Ln0Mwlu^$W$35Q)LmR%AuGl1vXU*;#L-U21b1bl(1%D zU|?XN=*Gad)oR}=|49ndLothzMQF3l?mtbWbJWcwBILQV;fsvLXv jvDnKnp9ZV{@GsfkBUZ?X;ctl}#4RAQ!NKI~e~>o;C8Ny+ literal 0 HcmV?d00001 diff --git a/test/data/Normal_A_stroke_2_layers_v3.2.2.rm b/test/data/Normal_A_stroke_2_layers_v3.2.2.rm new file mode 100644 index 0000000000000000000000000000000000000000..b216797a16dabfb4639aef035bf372019d43bf64 GIT binary patch literal 1877 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEVe1_lO3MovZ^VFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}yq9io6io{>?J!H^NGo`I1|gqeYXK?Njd!pXqE;KIbf zzz_oB3V;YkMn-uCUVR1zLq;EvLU{&8xHzLA4sj7=abARaDI{@5L4>$Ek~jlz3B+D@ zc?JfZAO;2o87B~7%fP_E#3RPQz@X2_XV1Xk4|Qb@$W@Gtjyy78DFu5*KDZRfKkSTL zWyX3&dPX{IAY&LohTDLx2C*D!4}d5Jhg=6228JvbkRc3=j7;(jj5=Hl3=HxN+&Z8D zVPLdj1#@{68I?ggz-oAP1i^y*IuM(D5-U@S6bzwC1(1|73PO}J3ZW=9f+}TDWb}iX z$|wT0nE};i21UjikQd|`#q=5ZdKkg(VH8(nn#0Heas{J=iVR}*)21b1blqhBa z#l1cQFR}_oVS5HdOhZ&K3L;eS>NAQWClja&5fl{~_Kc$N)B;tZ4p+e_sLv>k#SE#L zV3#tn$}@`SGfE<>Xl7twU={(T3UH3H04Zc(U;rg928Ng<1_p))+nWvwgnvHBz#zcD z!0jMeb8yA-4+ohT1Q?|EFSuQD&?E56K}H4vhFf4_@&41lGs>lHox0!3vMB5HnNq85j``l+tIEC(VHpy9JyYm{T3W4&+y06mohn zJ<}27q`z|3~iFnAiV!MZAzx!lNoj zkjESzd&CMkG5jrom|36Cz*q{(R7{`{RM%(JL3Us~6SffiVDAD7${0H)28IVe4V@Y` zs~==$;9y8*QfN1DdcZDokdc9dAzbUlX?>>zMfrnF3>*xrEM6EJI634EvIUP7?09L7eMd7BQlY^icn3s7~Vuztq0+SwC zpW_XQO$JU59L8XMJ~tJbjhqq;b-;2GR}J`#og7&8!TQRtaxhvuG1Tomz{jsC&h%D7qpuT#(@cAi9~$mNZ1ZzsP+wpVvZ3m3fr_sa!;dLo zeSOaguKPGKJW>Gb+x{xS*3XIIL^4=T;!T5yuM@+GV6eXSw;Un zurY8joc+k~JI1M@ZP@{K1`dYX9|YzWIXN7cI>^Pq!SLk61>0Ju2gkh*ax-u++9ndjcuSWb6( n;Cd9C3QAvHkecqaVbTe3Jgk4(aAlg4z^udI7-LA!XJ7;XAYosp literal 0 HcmV?d00001 diff --git a/test/data/Normal_A_stroke_2_layers_v3.3.2.rm b/test/data/Normal_A_stroke_2_layers_v3.3.2.rm new file mode 100644 index 0000000000000000000000000000000000000000..5fcee73401296a11e6036a0cce95a30573be4f9a GIT binary patch literal 5403 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 Hd?JQ3S+cVB`{EWME)W0dY(~gbRoe01=Fg zjPeZJ`V0()j6Tc^3=HxNj4}{$CV2)%9WJmCgANl|%m!q(JOhIwqcWJ!$Ry9ets@9l z!=uB=z`(%H=#yBPTBKlzP$~sc$|BFesL#M_&%of%zz_lw%VA+)U|`Z`;4TO87#W#C z4q@a&R*?wutBNTz1A__3@h%`2#;Ae3!Wfc~s(=)1rNybm3aL4%$t6Ykd6~%y1&IZz zMLCKf{X#6)CO!-3Jit(OyZYb076%2b<5L#AZ*gE~D0Tg!=Ht+bY(2j{BOly>#USk} zJ**52CT0u_3=r!r7#JAL7=sWo2DUo0q*x)pJWnCNAhjs5Br`8vp}4ZRBsEt@AulmE zHATTWKP9z758|MpuQ9u(xpW?2IMgJ!s(z-!136a#l|TJ(2lXO5NWh+vAMPMfNHMA$ z0XfJD?jRcm1_moeumuX4#R^G@#i=O@`FRRHnR%rZ3W<3s3i$;knYo!&sVNF+`9-)w z>C(Qk>F%8e7_J<@bIo{`L%@VTj%iF2;Ew7-c9ft!qX67dCE!qEU}In~5r;bpluX4L zQ!QN>YR^>DLO;I*-l+49Xn2r5|QFG?+z;tiMqL nxADoAWEb6tx&sYCV&C_1&URopaqg^+jIRSjf?XodHU|d)(Zsmy literal 0 HcmV?d00001 diff --git a/test/data/Wikipedia_highlighted_p2.rm b/test/data/Wikipedia_highlighted_p2.rm new file mode 100644 index 0000000000000000000000000000000000000000..c74ace5786db9e7778cfe5afadfa910eeaa8a1c0 GIT binary patch literal 524 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEVe1_lO3MovZ^VFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}yq6{3Jao{>?JQ3S+cVB`{EWME)W0dY(~gbRoe01=Fg zjPeZJ`V0()j6Tc^3=HxNj4}{$CV2)%9WJmCgANl|%m!q(JOhIwqcWJ!$Ry9ets@9l z!=uB=z`(%H=#yBPTBKlzP$~sc$|BFesL#M_&%of%zz_lw%VA+)U|`Z`;077Mz{JD~ zatI?IR0YVveIR9wjLcvOetSkfxYi0#AgDMpFff>a0>lMmRE#VG1A{c9p}Dz2Zmz=N z>4z2=nVKo&=7LyedLY3ZkOn51q#G*J73v%q&VKXmWGZxEh*%Q7;%A`)LqJUW{No-D zG9?TQKe_8181@!e=t`G3FnpNs_~z3jxO_OWs|4&B`Qfet8PBM4iH(851mqwWP;kYt zF)%Q&Fec^{=jSMbTy4a#N7m$Q>j8z3N&3=X=Q%KZvvpwHoan%C=Fl}0*-!@ny-Hg_ literal 0 HcmV?d00001 diff --git a/test/data/With_SceneInfo_Block.rm b/test/data/With_SceneInfo_Block.rm new file mode 100644 index 0000000000000000000000000000000000000000..f87cd8a05379603f2fe5141649b1e472c5749f00 GIT binary patch literal 7113 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 literal 0 HcmV?d00001 diff --git a/test/data/test-crdt-ordering.rm b/test/data/test-crdt-ordering.rm new file mode 100644 index 0000000000000000000000000000000000000000..af835a827211931791d48bd834be2ddb88efb104 GIT binary patch literal 636 zcmXR)^-V0wPE5*4RnW`H%u6j+NXyJg)ln!*Eh^5;&$BgCfCEDY1_lO3MouOkVFm^U zf%!|LJ)KX+Xm1HMYx*{&lZ}Z1BL3#UBZrQ;F-9}*9hvt{=F4VA26l)>26;wCMFv9# zNf4KTkxPV`fq_AVk%581gpGlL!3D$$0m=1%2nI%88IZ6%1A{&TgAOYL1A{yRgCe61 zoE5{tz`#($%D@o7&cKkP0a2i%0M?A3s1bn4f0*h3W;)Du=wXzA8i7p~EXK&lD9^yH z&%j{F=mT=RJOkrlkak8!c6kN{oqh%e2AN_Ik;lNmz``TOz`&r-C}7XP;LpGS_E`={ z7bBx1k2pw*Nywg2z@JeNBE`bMz`(?4Xav=UA_vhH50w%GX+t$JN=FD}FE1#D7#QRk z`E{5W7#IYZY{2dYxsjpi0GM*fb#P%|nBWovQY!-rLU{%z6_AJwNS=|2NuGgGhYK1M zAX6BS!dw}yhFeDvtcFL2lYxPOozW+;GPOv-5D`66V5N*K@(hgn47|vJ0QRXq12+I- Cj79DM literal 0 HcmV?d00001 diff --git a/test/scene-stream.test.js b/test/scene-stream.test.js new file mode 100644 index 0000000..e9ad215 --- /dev/null +++ b/test/scene-stream.test.js @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CrdtId, LwwValue, HEADER_V6 } from '../lib/tagged-block-common.js'; +import { TaggedBlockWriter } from '../lib/tagged-block-writer.js'; +import { TaggedBlockReader, MainBlockInfo } from '../lib/tagged-block-reader.js'; +import { CrdtSequence, CrdtSequenceItem } from '../lib/crdt-sequence.js'; +import * as si from '../lib/scene-items.js'; +import { + Block, UnreadableBlock, AuthorIdsBlock, MigrationInfoBlock, PageInfoBlock, + SceneTreeBlock, TreeNodeBlock, SceneGroupItemBlock, SceneGlyphItemBlock, + SceneLineItemBlock, RootTextBlock, SceneItemBlock, + readBlocks, writeBlocks, +} from '../lib/scene-stream.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_PATH = join(__dirname, 'data'); + +function hexLines (buf, n = 32) { + const lines = []; + for (let i = 0; i < buf.length; i += n) lines.push(buf.slice(i, i + n).toString('hex')); + return lines; +} + +function loadRm (name) { return readFileSync(join(DATA_PATH, name)); } + +const TEST_FILES_AND_VERSIONS = [ + ['Normal_AB.rm', '3.0'], + ['Normal_A_stroke_2_layers.rm', '3.0'], + ['Normal_A_stroke_2_layers_v3.2.2.rm', '3.2.2'], + ['Normal_A_stroke_2_layers_v3.3.2.rm', '3.3.2'], + ['Bold_Heading_Bullet_Normal.rm', '3.0'], + ['Lines_v2.rm', '3.1'], + ['Lines_v2_updated.rm', '3.2'], + ['Wikipedia_highlighted_p1.rm', '3.1'], + ['Wikipedia_highlighted_p2.rm', '3.1'], + ['With_SceneInfo_Block.rm', '3.4'], + ['Color_and_tool_v3.14.4.rm', '3.14'], + ['More_color_highlight_shader_v3.15.4.2.rm', '3.15'], +]; + +for (const [testFile, version] of TEST_FILES_AND_VERSIONS) { + test(`full_roundtrip: ${testFile}`, () => { + let data = loadRm(testFile); + if (version === '3.2.2' || version === '3.3.2') { + data = Buffer.from(data.toString('hex').replace(/010205/g, '020205'), 'hex'); + } + const output = writeBlocks(readBlocks(data), { version }); + assert.deepStrictEqual(hexLines(data), hexLines(output)); + }); +} + +for (const [testFile] of TEST_FILES_AND_VERSIONS) { + test(`files_fully_parsed: ${testFile}`, () => { + const data = loadRm(testFile); + const result = [...readBlocks(data)]; + for (const block of result) { + assert.ok(!(block instanceof UnreadableBlock), `Block was unreadable: ${block.error}`); + assert.equal(block.extraData.length, 0, `Block has extra data: ${block.extraData.toString('hex')}`); + if (block instanceof SceneItemBlock) { + assert.equal(block.extraValueData.length, 0); + } + } + }); +} + +test('normal_ab', () => { + const data = loadRm('Normal_AB.rm'); + const result = [...readBlocks(data)]; + + const expected0 = new AuthorIdsBlock(new Map([[1, '495ba59f-c943-2b5c-b455-3682f6948906']])); + assert.equal(result[0].constructor.name, 'AuthorIdsBlock'); + assert.equal(result[0].authorUuids.get(1), expected0.authorUuids.get(1)); + + const expected1 = new MigrationInfoBlock(new CrdtId(1, 1), true); + assert.deepStrictEqual(result[1].migrationId, expected1.migrationId); + assert.equal(result[1].isDevice, true); + + assert.equal(result[2].constructor.name, 'PageInfoBlock'); + assert.equal(result[2].loadsCount, 1); + assert.equal(result[2].textCharsCount, 3); + assert.equal(result[2].textLinesCount, 1); + + assert.equal(result[3].constructor.name, 'SceneTreeBlock'); + assert.deepStrictEqual(result[3].treeId, new CrdtId(0, 11)); + + const rtb = result[4]; + assert.equal(rtb.constructor.name, 'RootTextBlock'); + assert.equal(rtb.value.items.values()[0], 'AB'); + assert.equal(rtb.value.posX, -468.0); + assert.equal(rtb.value.width, 936.0); +}); + +test('read_glyph_range', () => { + const data = loadRm('Wikipedia_highlighted_p1.rm'); + const glyphs = [...readBlocks(data)].filter(b => b instanceof SceneGlyphItemBlock); + assert.equal(glyphs[0].item.value.text, 'The reMarkable uses electronic paper'); +}); + +test('write_blocks header and content', () => { + const blocks = [new MigrationInfoBlock(new CrdtId(1, 1), true)]; + const buf = writeBlocks(blocks, { version: '3.1' }); + assert.ok(buf.slice(0, 43).equals(HEADER_V6)); + assert.equal(buf.slice(43).toString('hex'), '05000000000101001f01012101'); +}); + +test('blocks_keep_unknown_data_in_main_block', () => { + const dataHex = '2E000000 0000010D 1C06000000 1F0000 2F0000 2C05000000 1F0000 2101 3C05000000 1F0000 2101 5C08000000 7C050000 50070000 E1FF'.replace(/\s/g, ''); + const buf = Buffer.concat([HEADER_V6, Buffer.from(dataHex, 'hex')]); + const block = [...readBlocks(buf)][0]; + assert.equal(block.constructor.name, 'SceneInfo'); + assert.equal(block.extraData.toString('hex'), 'e1ff'); +}); + +test('blocks_keep_unknown_data_in_value_subblock', () => { + const dataHex = '59000000 00020205 1f0219 2f021e 3f0000 4f0000 54 0000 0000 6c4300 0000 03 140f000000 2400000000 3800000000 0000f03f 4400000000 5c1c000000 f8fe82c2f42a30c30300080000 00b86983c2622d30c30000080000 00 6f0001 7f010f 8f0101'.replace(/\s/g, ''); + const buf = Buffer.concat([HEADER_V6, Buffer.from(dataHex, 'hex')]); + const block = [...readBlocks(buf)][0]; + assert.equal(block.constructor.name, 'SceneLineItemBlock'); + assert.equal(block.extraValueData.toString('hex'), '8f0101'); +}); + +test('error_in_block_contained', () => { + const dataHex = '06000000 00010103 1f0219 aabbcc 05000000 00010100 1f0219 2101'.replace(/\s/g, ''); + const buf = Buffer.concat([HEADER_V6, Buffer.from(dataHex, 'hex')]); + const blocks = [...readBlocks(buf)]; + assert.equal(blocks.length, 2); + assert.ok(blocks[0] instanceof UnreadableBlock); + assert.ok(blocks[0].error.includes('Bad tag type 0xA')); + assert.equal(blocks[1].constructor.name, 'MigrationInfoBlock'); + assert.deepStrictEqual(blocks[1].migrationId, new CrdtId(0x02, 0x19)); + + const output = writeBlocks(blocks, { version: '3.0' }); + assert.deepStrictEqual(output, buf); +}); + +// Block roundtrip tests +const ROUNDTRIP_BLOCKS = [ + new AuthorIdsBlock(new Map([[1, '495ba59f-c943-2b5c-b455-3682f6948906']])), + new MigrationInfoBlock(new CrdtId(1, 1), true), + new PageInfoBlock(3, 2, 3, 1), + new SceneTreeBlock(new CrdtId(0, 11), new CrdtId(0, 0), true, new CrdtId(0, 1)), + new RootTextBlock( + new CrdtId(0, 0), + new si.Text( + new CrdtSequence([new CrdtSequenceItem(new CrdtId(1, 16), new CrdtId(0, 0), new CrdtId(0, 0), 0, 'AB')]), + new Map([[new CrdtId(0, 0).toKey(), new LwwValue(new CrdtId(1, 15), si.ParagraphStyle.PLAIN)]]), + -468.0, 234.0, 936.0 + ) + ), + new TreeNodeBlock(new si.Group({ nodeId: new CrdtId(0, 11), label: new LwwValue(new CrdtId(0, 12), 'Layer 1') })), + new SceneGroupItemBlock( + new CrdtId(0, 1), + new CrdtSequenceItem(new CrdtId(0, 13), new CrdtId(0, 0), new CrdtId(0, 0), 0, new CrdtId(0, 11)) + ), + new SceneGlyphItemBlock( + new CrdtId(0, 11), + new CrdtSequenceItem( + new CrdtId(1, 17), new CrdtId(1, 16), new CrdtId(0, 0), 0, + new si.GlyphRange(1536, 23, 'display technology.[13]', si.PenColor.YELLOW, [ + new si.Rectangle(-809.061564750815, 1724.1146737357485, 333.5427440226558, 56.30432956921868), + new si.Rectangle(-485.51105154941456, 1730.4364894378523, 58.22011730763188, 33.42225280328421), + ]) + ) + ), +]; + +for (const block of ROUNDTRIP_BLOCKS) { + test(`block_roundtrip: ${block.constructor.name}`, () => { + const writer = new TaggedBlockWriter(); + block.write(writer); + const buf = writer.getBuffer(); + const reader = new TaggedBlockReader(buf); + const block2 = Block.read(reader); + assert.equal(block2.constructor.name, block.constructor.name); + // Re-write and compare bytes + const writer2 = new TaggedBlockWriter(); + block2.write(writer2); + assert.deepStrictEqual( + hexLines(buf), + hexLines(writer2.getBuffer()), + `Roundtrip bytes differ for ${block.constructor.name}` + ); + }); +} diff --git a/test/scene-tree.test.js b/test/scene-tree.test.js new file mode 100644 index 0000000..bf65385 --- /dev/null +++ b/test/scene-tree.test.js @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CrdtId, LwwValue } from '../lib/tagged-block-common.js'; +import { CrdtSequence, CrdtSequenceItem } from '../lib/crdt-sequence.js'; +import * as si from '../lib/scene-items.js'; +import { + SceneTreeBlock, TreeNodeBlock, SceneGroupItemBlock, SceneLineItemBlock, + RootTextBlock, buildTree, readTree, +} from '../lib/scene-stream.js'; +import { SceneTree } from '../lib/scene-tree.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_PATH = join(__dirname, 'data'); + +function treeStructure (item) { + if (item instanceof si.Group) { + return [item.nodeId, item.label.value, [...item.children.values()].map(treeStructure)]; + } + return item; +} + +test('basic_tree_structure', () => { + const blocks = [ + new SceneTreeBlock(new CrdtId(0, 11), new CrdtId(0, 0), true, new CrdtId(0, 1)), + new TreeNodeBlock(new si.Group({ nodeId: new CrdtId(0, 1) })), + new TreeNodeBlock(new si.Group({ nodeId: new CrdtId(0, 11), label: new LwwValue(new CrdtId(0, 12), 'Layer 1') })), + new SceneGroupItemBlock( + new CrdtId(0, 1), + new CrdtSequenceItem(new CrdtId(0, 13), new CrdtId(0, 0), new CrdtId(0, 0), 0, new CrdtId(0, 11)) + ), + ]; + + const tree = new SceneTree(); + buildTree(tree, blocks); + + const [rootId, rootLabel, rootChildren] = treeStructure(tree.root); + assert.deepStrictEqual(rootId, new CrdtId(0, 1)); + assert.equal(rootLabel, ''); + assert.equal(rootChildren.length, 1); + assert.deepStrictEqual(rootChildren[0][0], new CrdtId(0, 11)); + assert.equal(rootChildren[0][1], 'Layer 1'); + assert.deepStrictEqual(rootChildren[0][2], []); +}); + +test('scene_info_paper_size', () => { + const data = readFileSync(join(DATA_PATH, 'Color_and_tool_v3.14.4.rm')); + const tree = readTree(data); + assert.ok(tree.sceneInfo != null); + assert.deepStrictEqual(tree.sceneInfo.paperSize, [1620, 2160]); +}); diff --git a/test/tagged-block-reader.test.js b/test/tagged-block-reader.test.js new file mode 100644 index 0000000..99c234a --- /dev/null +++ b/test/tagged-block-reader.test.js @@ -0,0 +1,147 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TaggedBlockReader, BlockOverflowError, UnexpectedBlockError } from '../lib/tagged-block-reader.js'; +import { CrdtId } from '../lib/tagged-block-common.js'; + +function stream (hexString) { + return new TaggedBlockReader(Buffer.from(hexString.replace(/\s/g, ''), 'hex')); +} + +// Main block tests +const BLOCK_HEX = '04000000' + '00010205' + 'ff000000' + '00000000'; + +test('read_block basic', () => { + const s = stream(BLOCK_HEX); + const bi = s.beginBlock(); + assert.ok(bi !== null); + assert.equal(bi.size, 4); + assert.equal(bi.blockType, 5); + assert.equal(bi.minVersion, 1); + assert.equal(bi.currentVersion, 2); + assert.equal(bi.offset, 8); + const value = s.data.readUint32(); + assert.equal(value, 0xFF); + s.endBlock(); +}); + +test('read_block bytes_remaining', () => { + const s = stream(BLOCK_HEX); + s.beginBlock(); + assert.equal(s.bytesRemainingInBlock(), 4); + s.data.readUint32(); + assert.equal(s.bytesRemainingInBlock(), 0); + s.endBlock(); +}); + +test('read_block error_on_overflow', () => { + const s = stream(BLOCK_HEX); + s.beginBlock(); + s.data.readUint32(); + assert.throws(() => { s.data.readUint32(); s.endBlock(); }, BlockOverflowError); +}); + +test('read_block skips_to_end_of_block_if_not_all_read', () => { + const s = stream(BLOCK_HEX); + s.beginBlock(); + assert.equal(s.data.tell(), 8); + s.endBlock(); + assert.equal(s.data.tell(), 12); +}); + +test('read_block error_if_already_in_block', () => { + const s = stream(BLOCK_HEX); + s.beginBlock(); + assert.throws(() => s.beginBlock(), UnexpectedBlockError); +}); + +// Subblock tests +const SUB_HEX = '5c' + '04000000' + 'ff000000' + '00000000'; + +test('read_subblock basic', () => { + const s = stream(SUB_HEX); + s.readSubblock(5, (bi) => { + assert.equal(bi.size, 4); + const value = s.data.readUint32(); + assert.equal(value, 0xFF); + }); +}); + +test('has_subblock', () => { + const s = stream(SUB_HEX); + assert.equal(s.hasSubblock(5), true); + assert.equal(s.hasSubblock(5), true); + assert.equal(s.hasSubblock(4), false); + assert.equal(s.hasSubblock(5), true); +}); + +test('read_subblock error_on_wrong_index', () => { + const s = stream(SUB_HEX); + assert.throws(() => s.readSubblock(3, () => {}), UnexpectedBlockError); +}); + +test('read_subblock error_on_wrong_tag', () => { + const s = stream(SUB_HEX); + assert.throws(() => s.readInt(5), UnexpectedBlockError); +}); + +test('read_subblock error_on_wrong_index_does_not_consume_tag', () => { + const s = stream(SUB_HEX); + try { s.readSubblock(3, () => {}); } catch {} + s.readSubblock(5, (bi) => { + assert.equal(bi.size, 4); + }); +}); + +test('has_subblock returns False with bad data', () => { + const s = stream('1d000000'); + assert.equal(s.hasSubblock(1), false); + assert.equal(s.hasSubblock(6), false); + assert.equal(s.data.tell(), 0); +}); + +test('has_subblock returns False at end of file', () => { + const s = stream(''); + assert.equal(s.hasSubblock(2), false); +}); + +test('has_subblock checks for end of block', () => { + const s = stream('03000000 00010103 1f0219 2c000000 00010100'.replace(/\s/g, '')); + const bi = s.beginBlock(); + s.readId(1); + assert.equal(s.hasSubblock(2), false); + s.endBlock(); +}); + +test('read_int', () => { + const s = stream('34abcd0000'); + assert.equal(s.readInt(3), 0xCDAB); +}); + +test('read_int_wrong_index', () => { + const s = stream('34abcd0000'); + assert.throws(() => s.readInt(2), UnexpectedBlockError); +}); + +test('read_int_optional', () => { + const s = stream('34abcd0000'); + assert.equal(s.readIntOptional(2, -1), -1); + assert.equal(s.readIntOptional(3), 0xCDAB); + assert.equal(s.readIntOptional(4), null); +}); + +test('read_lww_string', () => { + const s = stream('1c0d000000' + '1f0101' + '2c05000000' + '0301616263'); + const lww = s.readLwwString(1); + assert.deepStrictEqual(lww.timestamp, new CrdtId(1, 1)); + assert.equal(lww.value, 'abc'); +}); + +test('read_string_ascii', () => { + const s = stream('1c05000000' + '0301616263'); + assert.equal(s.readString(1), 'abc'); +}); + +test('read_string_utf', () => { + const s = stream('1c05000000' + '030161c397'); + assert.equal(s.readString(1), 'a×'); +}); diff --git a/test/tagged-block-writer.test.js b/test/tagged-block-writer.test.js new file mode 100644 index 0000000..4b8f8f3 --- /dev/null +++ b/test/tagged-block-writer.test.js @@ -0,0 +1,95 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TaggedBlockWriter } from '../lib/tagged-block-writer.js'; +import { TaggedBlockReader, UnexpectedBlockError } from '../lib/tagged-block-reader.js'; +import { CrdtId, LwwValue } from '../lib/tagged-block-common.js'; + +test('write_id_zero', () => { + const w = new TaggedBlockWriter(); + w.writeId(3, new CrdtId(0, 0)); + assert.equal(w.getBuffer().toString('hex'), '3f0000'); +}); + +test('write_int', () => { + const w = new TaggedBlockWriter(); + w.writeInt(3, 0xCDAB); + assert.equal(w.getBuffer().toString('hex'), '34abcd0000'); +}); + +test('write_block', () => { + const w = new TaggedBlockWriter(); + w.writeBlock(5, 1, 2, () => { w.writeInt(3, 0x1234); }); + assert.equal(w.getBuffer().toString('hex'), '05000000000102053434120000'); +}); + +test('write_block_error_if_nested', () => { + const w = new TaggedBlockWriter(); + assert.throws(() => { + w.writeBlock(5, 1, 2, () => { + w.writeBlock(4, 1, 1, () => { w.writeInt(3, 0x1234); }); + }); + }, UnexpectedBlockError); +}); + +test('write_block_error_recovery', () => { + const w = new TaggedBlockWriter(); + try { + w.writeBlock(5, 1, 2, () => { w.writeInt(3, 0x1234); throw new Error('test'); }); + } catch {} + w.writeBool(7, true); + assert.equal(w.getBuffer().toString('hex'), '7101'); +}); + +test('write_subblock', () => { + const w = new TaggedBlockWriter(); + w.writeSubblock(2, () => { w.writeInt(3, 0x1234); }); + assert.equal(w.getBuffer().toString('hex'), '2c050000003434120000'); +}); + +test('write_subblock_nested', () => { + const w = new TaggedBlockWriter(); + w.writeSubblock(1, () => { w.writeSubblock(2, () => { w.writeInt(3, 0x1234); }); }); + assert.equal(w.getBuffer().toString('hex'), '1c0a0000002c050000003434120000'); +}); + +test('write_subblock_error_recovery', () => { + const w = new TaggedBlockWriter(); + try { + w.writeSubblock(2, () => { w.writeInt(3, 0x1234); throw new Error('test'); }); + } catch {} + w.writeBool(7, true); + assert.equal(w.getBuffer().toString('hex'), '7101'); +}); + +const ROUNDTRIP_CASES = [ + ['id', new CrdtId(1, 5)], + ['id', new CrdtId(0, 0)], + ['bool', true], + ['bool', false], + ['byte', 7], + ['int', 45], + ['float', 8.0], + ['double', 5.4], + ['lwwBool', new LwwValue(new CrdtId(1, 4), true)], + ['lwwByte', new LwwValue(new CrdtId(1, 4), 7)], + ['lwwFloat', new LwwValue(new CrdtId(1, 4), 8.0)], + ['lwwId', new LwwValue(new CrdtId(1, 4), new CrdtId(1, 5))], + ['lwwString', new LwwValue(new CrdtId(1, 4), 'hello')], + ['string', 'abc'], + ['string', 'a×'], +]; + +for (const index of [0, 3, 20]) { + for (const [dataType, value] of ROUNDTRIP_CASES) { + test(`values_roundtrip index=${index} type=${dataType} value=${JSON.stringify(value)}`, () => { + const writer = new TaggedBlockWriter(); + const writeMethod = `write${dataType[0].toUpperCase()}${dataType.slice(1)}`; + writer[writeMethod](index, value); + const buf = writer.getBuffer(); + const reader = new TaggedBlockReader(buf); + const readMethod = `read${dataType[0].toUpperCase()}${dataType.slice(1)}`; + const result = reader[readMethod](index); + assert.deepStrictEqual(result, value); + }); + } +} diff --git a/test/text-files.test.js b/test/text-files.test.js new file mode 100644 index 0000000..ba64472 --- /dev/null +++ b/test/text-files.test.js @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CrdtId, LwwValue } from '../lib/tagged-block-common.js'; +import { readTree, writeBlocks, simpleTextDocument } from '../lib/scene-stream.js'; +import * as si from '../lib/scene-items.js'; +import { TextDocument } from '../lib/text.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_PATH = join(__dirname, 'data'); + +function loadRm (name) { return readFileSync(join(DATA_PATH, name)); } + +function hexLines (buf, n = 32) { + const lines = []; + for (let i = 0; i < buf.length; i += n) lines.push(buf.slice(i, i + n).toString('hex')); + return lines; +} + +function extractDoc (filename) { + const data = loadRm(filename); + const tree = readTree(data); + assert.ok(tree.rootText, `No root text in ${filename}`); + return TextDocument.fromSceneItem(tree.rootText); +} + +function showStrFormatting (x) { + let s = x.s; + if (x.properties['font-weight'] === 'bold') s = `${s}`; + if (x.properties['font-style'] === 'italic') s = `${s}`; + return s; +} + +function formattedLines (doc) { + return doc.contents.map(p => [p.style.value, p.contents.map(showStrFormatting).join('')]); +} + +test('normal_ab', () => { + const lines = formattedLines(extractDoc('Normal_AB.rm')); + assert.deepStrictEqual(lines, [[si.ParagraphStyle.PLAIN, 'AB']]); +}); + +test('list', () => { + const lines = formattedLines(extractDoc('Bold_Heading_Bullet_Normal.rm')); + assert.deepStrictEqual(lines, [ + [si.ParagraphStyle.BOLD, 'A'], + [si.ParagraphStyle.HEADING, 'new line'], + [si.ParagraphStyle.BULLET, 'B is a letter of the alphabet'], + [si.ParagraphStyle.PLAIN, 'C'], + ]); +}); + +test('inline_formats', () => { + const lines = formattedLines(extractDoc('Normal_A_stroke_2_layers_v3.3.2.rm')); + assert.deepStrictEqual(lines, [ + [si.ParagraphStyle.PLAIN, 'A'], + [si.ParagraphStyle.PLAIN, 'v3.2.2'], + [si.ParagraphStyle.PLAIN, 'Normal bold italic'], + [si.ParagraphStyle.PLAIN, 'Bold italic normal'], + [si.ParagraphStyle.BOLD, 'Bold line'], + [si.ParagraphStyle.PLAIN, 'Normal line'], + [si.ParagraphStyle.HEADING, 'Heading line'], + ]); +}); + +test('crdt_concurrent_author_ordering', () => { + const lines = formattedLines(extractDoc('test-crdt-ordering.rm')); + assert.deepStrictEqual(lines, [[si.ParagraphStyle.HEADING, 'A12_Z']]); +}); + +test('simple_text_document', () => { + const expected = loadRm('Normal_AB.rm'); + const authorUuid = '495ba59f-c943-2b5c-b455-3682f6948906'; + const output = writeBlocks(simpleTextDocument('AB', authorUuid), { version: '3.0' }); + assert.deepStrictEqual(hexLines(output), hexLines(expected)); +}); diff --git a/test/text.test.js b/test/text.test.js new file mode 100644 index 0000000..fd0052d --- /dev/null +++ b/test/text.test.js @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CrdtId, LwwValue } from '../lib/tagged-block-common.js'; +import { CrdtSequence, CrdtSequenceItem } from '../lib/crdt-sequence.js'; +import * as si from '../lib/scene-items.js'; +import { expandTextItem, expandTextItems, TextDocument, CrdtStr, Paragraph } from '../lib/text.js'; + +function cid (k) { return new CrdtId(k === 0 ? 0 : 1, k); } +function makeItem (itemId, leftId, rightId, ...args) { + return new CrdtSequenceItem(cid(itemId), cid(leftId), cid(rightId), ...args); +} + +test('expand_text_1', () => { + const result = [...expandTextItem(makeItem(17, 0, 0, 0, 'AAAA'))]; + assert.deepStrictEqual(result, [ + makeItem(17, 0, 18, 0, 'A'), + makeItem(18, 17, 19, 0, 'A'), + makeItem(19, 18, 20, 0, 'A'), + makeItem(20, 19, 0, 0, 'A'), + ]); +}); + +test('expand_text_2', () => { + const result = [...expandTextItem(makeItem(34, 20, 21, 0, 'x'))]; + assert.deepStrictEqual(result, [makeItem(34, 20, 21, 0, 'x')]); +}); + +test('expand_text_3', () => { + const result = [...expandTextItem(makeItem(21, 20, 0, 0, 'A\nB'))]; + assert.deepStrictEqual(result, [ + makeItem(21, 20, 22, 0, 'A'), + makeItem(22, 21, 23, 0, '\n'), + makeItem(23, 22, 0, 0, 'B'), + ]); +}); + +test('expand_text_empty', () => { + const result = [...expandTextItem(makeItem(21, 20, 0, 2, ''))]; + assert.deepStrictEqual(result, [ + makeItem(21, 20, 22, 1, ''), + makeItem(22, 21, 0, 1, ''), + ]); +}); + +const START_BOLD = 1, END_BOLD = 2, START_ITALIC = 3, END_ITALIC = 4; + +function docFromItems (items) { + const rootText = new si.Text(new CrdtSequence(items), new Map(), -468.0, 234.0, 936.0); + return TextDocument.fromSceneItem(rootText); +} + +test('inline_formatting_italic_over_paragraphs', () => { + const doc = docFromItems([ + makeItem(20, 0, 0, 0, 'A'), + makeItem(21, 20, 0, 0, 'B\nC'), + makeItem(24, 23, 0, 0, 'D'), + makeItem(30, 20, 21, 0, START_ITALIC), + makeItem(31, 23, 24, 0, END_ITALIC), + ]); + + assert.equal(doc.contents.length, 2); + assert.equal(doc.contents[0].toString(), 'AB'); + assert.equal(doc.contents[1].toString(), 'CD'); + + assert.equal(doc.contents[0].contents[0].s, 'A'); + assert.deepStrictEqual(doc.contents[0].contents[0].properties, { 'font-weight': 'normal', 'font-style': 'normal' }); + assert.equal(doc.contents[0].contents[1].s, 'B'); + assert.deepStrictEqual(doc.contents[0].contents[1].properties, { 'font-weight': 'normal', 'font-style': 'italic' }); + + assert.equal(doc.contents[1].contents[0].s, 'C'); + assert.deepStrictEqual(doc.contents[1].contents[0].properties, { 'font-weight': 'normal', 'font-style': 'italic' }); + assert.equal(doc.contents[1].contents[1].s, 'D'); + assert.deepStrictEqual(doc.contents[1].contents[1].properties, { 'font-weight': 'normal', 'font-style': 'normal' }); +}); + +test('inline_formatting_bold_italic_interleaved', () => { + const doc = docFromItems([ + makeItem(20, 0, 0, 0, 'ABC\nDEF'), + makeItem(30, 20, 21, 0, START_ITALIC), + makeItem(31, 21, 22, 0, START_BOLD), + makeItem(32, 24, 25, 0, END_ITALIC), + makeItem(33, 25, 26, 0, END_BOLD), + ]); + + assert.equal(doc.contents[0].toString(), 'ABC'); + assert.equal(doc.contents[0].contents[0].s, 'A'); + assert.equal(doc.contents[0].contents[1].s, 'B'); + assert.equal(doc.contents[0].contents[2].s, 'C'); + assert.deepStrictEqual(doc.contents[0].contents[2].properties, { 'font-weight': 'bold', 'font-style': 'italic' }); + assert.equal(doc.contents[1].toString(), 'DEF'); + assert.equal(doc.contents[1].contents[1].s, 'E'); + assert.deepStrictEqual(doc.contents[1].contents[1].properties, { 'font-weight': 'bold', 'font-style': 'normal' }); +});