From 253234a09da367541b6231918fad8925f14e2a43 Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Wed, 12 Aug 2026 12:14:34 +0200 Subject: [PATCH] eraser and trailing highlights --- lib/exporters/markdown.js | 4 +- lib/exporters/writing-tools.js | 2 +- package.json | 2 +- test/exporters.test.js | 78 ++++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/lib/exporters/markdown.js b/lib/exporters/markdown.js index 6b3abf5..98c7494 100644 --- a/lib/exporters/markdown.js +++ b/lib/exporters/markdown.js @@ -13,17 +13,19 @@ export function rmToMarkdown (data) { } const highlightLines = ['\n\n# Highlights']; + let highlightCount = 0; 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}`); + highlightCount++; lastPos = item.start + item.text.length; } } highlightLines.push(''); - parts.push(highlightLines.join('\n')); + if (highlightCount) parts.push(highlightLines.join('\n')); return parts.join(''); } diff --git a/lib/exporters/writing-tools.js b/lib/exporters/writing-tools.js index 9739321..504473c 100644 --- a/lib/exporters/writing-tools.js +++ b/lib/exporters/writing-tools.js @@ -57,7 +57,7 @@ export class StrokePen { 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); + if (penNr === Pen.ERASER) return new Eraser(width, lookupPenColor(PenColor.WHITE)); throw new Error(`Unknown pen_nr: ${penNr}`); } } diff --git a/package.json b/package.json index c7a0ab3..0b234cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@robin.berjon/notable", "type": "module", - "version": "1.0.0", + "version": "1.0.1", "description": "Parse and convert reMarkable .rm files", "author": "Robin Berjon ", "license": "Apache-2.0", diff --git a/test/exporters.test.js b/test/exporters.test.js index 0b8261f..ac615ee 100644 --- a/test/exporters.test.js +++ b/test/exporters.test.js @@ -4,9 +4,15 @@ import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { readTree, writeBlocks, simpleTextDocument } from '../lib/scene-stream.js'; -import { Pen } from '../lib/scene-items.js'; -import { StrokePen } from '../lib/exporters/writing-tools.js'; +import { + readTree, writeBlocks, SceneTreeBlock, TreeNodeBlock, SceneGroupItemBlock, SceneLineItemBlock, +} from '../lib/scene-stream.js'; +import { simpleTextDocument } from '../lib/scene-stream.js'; +import { CrdtId, LwwValue } from '../lib/tagged-block-common.js'; +import { CrdtSequenceItem } from '../lib/crdt-sequence.js'; +import { Pen, PenColor, Group, Line, Point } from '../lib/scene-items.js'; +import { StrokePen, lookupPenColor } from '../lib/exporters/writing-tools.js'; +import { rmToMarkdown } from '../lib/exporters/markdown.js'; import { pagePaint } from '../lib/exporters/page-paint.js'; import { rmToSvg } from '../lib/exporters/svg.js'; import { rmsToPdf, treesToPdf } from '../lib/exporters/pdf.js'; @@ -68,6 +74,61 @@ test('paint layer emits only valid widths and opacities for all notebook pages', } }); +// A minimal one-layer document containing one stroke per given [tool, color] +function strokeDocument (strokes) { + const blocks = [ + new SceneTreeBlock(new CrdtId(0, 11), new CrdtId(0, 0), true, new CrdtId(0, 1)), + new TreeNodeBlock(new Group({ nodeId: new CrdtId(0, 1) })), + new TreeNodeBlock(new 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)) + ), + ]; + for (const [i, [tool, color]] of strokes.entries()) { + const points = [ + new Point(100 + i * 50, 100, 10, 0, 8, 128), + new Point(120 + i * 50, 140, 12, 10, 8, 160), + new Point(140 + i * 50, 180, 12, 20, 8, 96), + ]; + blocks.push(new SceneLineItemBlock( + new CrdtId(0, 11), + new CrdtSequenceItem(new CrdtId(1, 20 + i), new CrdtId(0, 0), new CrdtId(0, 0), 0, + new Line(color, tool, points, 2, 0)) + )); + } + return writeBlocks(blocks, { version: '3.1' }); +} + +test('eraser strokes render as white without crashing', () => { + // regression: Eraser used to be constructed with the raw color id 2 + // instead of a resolved [r, g, b, a] array, crashing both exporters + const doc = strokeDocument([[Pen.ERASER, PenColor.BLACK], [Pen.ERASER_AREA, PenColor.BLACK]]); + const { items } = pagePaint(readTree(doc)); + const segments = items.filter(i => i.kind === 'segment'); + assert.ok(segments.length >= 2); + for (const seg of segments) { + assert.ok(Array.isArray(seg.color), `segment color is not an array: ${seg.color}`); + } + assert.deepStrictEqual(segments[0].color, [255, 255, 255, 255], 'eraser draws white'); + assert.equal(segments[segments.length - 1].opacity, 0, 'erase-area is invisible'); + + const svg = rmToSvg(doc); + assert.match(svg, /stroke:rgb\(255,255,255\)/); + const pdf = treesToPdf([readTree(doc)]).toString('latin1'); + assert.match(pdf, /1 1 1 RG/); +}); + +test('every known pen renders through both exporters', () => { + const pens = Object.values(Pen).filter(v => typeof v === 'number'); + const doc = strokeDocument(pens.map(tool => [tool, PenColor.BLACK])); + assert.doesNotThrow(() => rmToSvg(doc)); + assert.doesNotThrow(() => treesToPdf([readTree(doc)])); +}); + // --- SVG --- test('SVG still renders strokes and list markers', () => { @@ -168,3 +229,14 @@ test('embedded-font PDF maps glyphs back to Unicode', () => { // П is U+041F; the ToUnicode CMap should map some glyph to it assert.match(str, /<[0-9a-f]{4}> <041f>/); }); + +// --- markdown --- + +test('markdown omits the Highlights section when there are none', () => { + const md = rmToMarkdown(loadPage(pages[9])); + assert.doesNotMatch(md, /# Highlights/); + assert.match(md, /# Hello Robin/); + const highlighted = rmToMarkdown(readFileSync(join(__dirname, 'data', 'Wikipedia_highlighted_p1.rm'))); + assert.match(highlighted, /# Highlights/); + assert.match(highlighted, /> The reMarkable uses electronic paper/); +}); -- 2.51.2