From 63dc570c5ce68bfef55a625352e5fb8c789087bb Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Wed, 12 Aug 2026 09:52:26 +0000 Subject: [PATCH] embed fonts when needed --- README.md | 42 +++++++++++++++++++++++++++++++++++++++--- package.json | 7 ++++++- bin/notable.js | 37 ++++++++++++++++++++----------------- fonts/NotoSans-Bold.ttf | 0 fonts/NotoSans-Regular.ttf | 0 fonts/NotoSerif-Regular.ttf | 0 fonts/OFL.txt | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ fonts/README.md | 15 +++++++++++++++ lib/index.js | 6 ++++++ lib/scene-items.js | 15 ++++++++++++++- lib/scene-stream.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- test/exporters.test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/notebook.test.js | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bin/exporters/markdown.js | 63 --------------------------------------------------------------- bin/exporters/pdf.js | 28 ---------------------------- bin/exporters/svg.js | 173 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- bin/exporters/writing-tools.js | 173 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- lib/exporters/markdown.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/exporters/page-paint.js | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/exporters/pdf.js | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/exporters/svg.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/exporters/ttf.js | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/exporters/writing-tools.js | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.content | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.local | 3 +++ test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.metadata | 12 ++++++++++++ test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.thumbnails/21187519-e91e-4c72-b3a1-035fac28e4d3.png | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/0fa00eed-eb69-415e-a0dc-ba685f00e566.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/10535bd2-0039-491c-9891-a8b40606264f.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/1ca221bb-682a-4530-b57d-734a3c556b39.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/21187519-e91e-4c72-b3a1-035fac28e4d3.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/2502a58a-c379-41fb-8e77-ff28ca77da4a.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/25138831-084a-4747-97ce-ebb6aa63854c.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/7e1fd0e7-0b74-428e-b174-e9efc8a22859.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a6818b22-84e2-43d4-a341-59e734b73924.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a94e04f0-3325-47db-a58f-fc8c0b6ebef5.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/bb469f40-467f-45fc-9992-6ca717294638.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/cf241408-d243-42db-b951-3c121edce592.rm | 0 test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/de2d5411-3b4b-47c2-9095-8e2121300319.rm | 0 39 file(s) changed, 1719 insertion(s)(+), 472 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ | `rm` | ✓ | ✓ | reMarkable binary file | | `markdown` | ✓ | ✓ | Text + highlighted passages | | `svg` | — | ✓ | Vector rendering of strokes | -| `pdf` | — | ✓ | Requires [Inkscape](https://inkscape.org/) | +| `pdf` | — | ✓ | Pure JS, no external tools; multiple inputs become pages | | `blocks` | — | — | Debug dump (JSON) | | `blocks-data` | — | — | Debug dump without point data | @@ -85,8 +85,30 @@ import { readBlocks, writeBlocks, readTree, simpleTextDocument, + rmToMarkdown, rmToSvg, rmToPdf, rmsToPdf, } from 'notable'; ``` + +### Converters + +The CLI's converters are part of the library: + +```js +import { readFileSync, writeFileSync } from 'node:fs'; +import { rmToMarkdown, rmToSvg, rmsToPdf } from 'notable'; + +const page = readFileSync('page.rm'); +const markdown = rmToMarkdown(page); // string +const svg = rmToSvg(page); // string + +// one PDF page per .rm buffer, all pure JS +const pdf = rmsToPdf([page1, page2, page3]); // Buffer +writeFileSync('notebook.pdf', pdf); +``` + +`rmToPdf(data)` is the single-page shorthand; `treesToPdf(trees)` and +`treeToSvg(tree)` accept already-parsed scene trees if you need to read the +data once and export it several ways. ### Reading blocks @@ -212,7 +234,19 @@ - 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. +PDF conversion is pure JS with no external dependencies. Passing several `.rm` +files produces a single PDF with one page per input: + +```bash +notable -t pdf -o notebook.pdf page1.rm page2.rm page3.rm +``` + +Typed text uses the standard base-14 PDF fonts (Helvetica/Times, WinAnsi +encoding) when possible. When the text contains characters outside WinAnsi — +Cyrillic, Greek, extended Latin — the bundled Noto Sans/Noto Serif fonts (the +same families the reMarkable itself renders typed text with, see `fonts/`) are +embedded automatically, with a ToUnicode CMap so text extraction and search +keep working. CJK is not covered (the Noto CJK fonts are ~20 MB per face). ## Running tests @@ -220,7 +254,9 @@ 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. +Tests cover binary round-trips of the test `.rm` files and the full test +notebook, block serialisation, CRDT sequence ordering, text extraction, inline +formatting, pen models, and SVG/PDF export (including embedded-font PDFs). ## Acknowledgements diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -7,6 +7,11 @@ "license": "Apache-2.0", "main": "lib/index.js", "exports": "./lib/index.js", + "files": [ + "lib", + "bin", + "fonts" + ], "bin": { "notable": "./bin/notable.js" }, @@ -21,6 +26,6 @@ }, "repository": { "type": "git", - "url": "https://github.com/darobin/notable.git" + "url": "https://tangled.org/robin.berjon.com/notable" } } diff --git a/bin/notable.js b/bin/notable.js --- a/bin/notable.js +++ b/bin/notable.js @@ -3,9 +3,9 @@ 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'; +import { rmToMarkdown } from '../lib/exporters/markdown.js'; +import { rmToSvg } from '../lib/exporters/svg.js'; +import { rmsToPdf } from '../lib/exporters/pdf.js'; const program = new Command(); @@ -37,20 +37,23 @@ 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); + if (toFmt === 'pdf') { + // all inputs become pages of a single PDF + outputBuf = rmsToPdf(inputs.map(fn => readFileSync(fn))); + } else { + 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 === 'rm') { + outputBuf = data; // passthrough + } else { + console.error(`Unknown format ${toFmt}`); process.exit(1); + } } } diff --git a/fonts/NotoSans-Bold.ttf b/fonts/NotoSans-Bold.ttf new file mode 100644 --- /dev/null +++ b/fonts/NotoSans-Bold.ttf diff --git a/fonts/NotoSans-Regular.ttf b/fonts/NotoSans-Regular.ttf new file mode 100644 --- /dev/null +++ b/fonts/NotoSans-Regular.ttf diff --git a/fonts/NotoSerif-Regular.ttf b/fonts/NotoSerif-Regular.ttf new file mode 100644 --- /dev/null +++ b/fonts/NotoSerif-Regular.ttf diff --git a/fonts/OFL.txt b/fonts/OFL.txt new file mode 100644 --- /dev/null +++ b/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/README.md b/fonts/README.md new file mode 100644 --- /dev/null +++ b/fonts/README.md @@ -0,0 +1,15 @@ +# Bundled fonts + +Noto Sans and Noto Serif (Latin-Greek-Cyrillic builds), from the +[Noto Fonts project](https://notofonts.github.io/), hinted TTFs fetched from +`https://github.com/notofonts/notofonts.github.io/raw/main/fonts//hinted/ttf/` +(Noto LGC v2.x, copyright 2022 The Noto Project Authors). + +These match the reMarkable's own typed-text fonts (the firmware 3.x font picker +offers EB Garamond, Noto Sans, Noto Mono, and Noto Serif). They are embedded +into exported PDFs only when the text contains characters that the standard +base-14 PDF fonts (WinAnsi encoding) cannot represent. + +Licensed under the SIL Open Font License 1.1 — see `OFL.txt`. The OFL permits +bundling, redistribution, and PDF embedding; the fonts remain under their own +license independent of this repository's Apache-2.0 license. diff --git a/lib/index.js b/lib/index.js --- a/lib/index.js +++ b/lib/index.js @@ -7,3 +7,9 @@ export * from './scene-stream.js'; export * from './scene-tree.js'; export * from './text.js'; +export * from './exporters/markdown.js'; +export * from './exporters/svg.js'; +export * from './exporters/pdf.js'; +export * from './exporters/page-paint.js'; +export * from './exporters/writing-tools.js'; +export * from './exporters/ttf.js'; diff --git a/lib/scene-items.js b/lib/scene-items.js --- a/lib/scene-items.js +++ b/lib/scene-items.js @@ -31,9 +31,19 @@ export const ParagraphStyle = Object.freeze({ BASIC: 0, PLAIN: 1, HEADING: 2, BOLD: 3, BULLET: 4, BULLET2: 5, CHECKBOX: 6, CHECKBOX_CHECKED: 7, + // Value 10 observed in firmware 3.22+ files on sequential list items; + // 3.22 release notes introduce numbered lists. Values 8-9 unobserved so far. + NUMBERED: 10, }); export const ParagraphStyleByValue = Object.fromEntries( Object.entries(ParagraphStyle).map(([k, v]) => [v, k]) +); + +// Per-page typed-text column width (SceneInfo tag 9), firmware 3.14+. +// Values correlate with RootTextBlock widths 740 / 936 / 1152. +export const ColumnWidth = Object.freeze({ NARROW: 0, DEFAULT: 1, WIDE: 2 }); +export const ColumnWidthByValue = Object.fromEntries( + Object.entries(ColumnWidth).map(([k, v]) => [v, k]) ); export const END_MARKER = new CrdtId(0, 0); @@ -79,10 +89,13 @@ } export class Text extends SceneItem { - constructor (items, styles, posX, posY, width) { + constructor (items, styles, posX, posY, width, widthLww = null) { super(); this.items = items; this.styles = styles; this.posX = posX; this.posY = posY; this.width = width; + // LwwValue duplicating width, written by firmware 3.14+ when the + // column width is changed (RootTextBlock tag 5, subfield 1). + this.widthLww = widthLww; } } diff --git a/lib/scene-stream.js b/lib/scene-stream.js --- a/lib/scene-stream.js +++ b/lib/scene-stream.js @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer'; -import { CrdtId, LwwValue, uuidFromBytesLE, uuidToBytesLE, versionGTE, versionGT } from './tagged-block-common.js'; +import { CrdtId, LwwValue, TagType, 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'; @@ -72,12 +72,22 @@ export class SceneInfo extends Block { static BLOCK_TYPE = 0x0D; - constructor (currentLayer, backgroundVisible, rootDocumentVisible, paperSize) { + constructor (currentLayer, backgroundVisible, rootDocumentVisible, paperSize, + customZoom = null, paperSizeFloat = null, paperSizeFloatLww = null, columnWidth = null) { super(); this.currentLayer = currentLayer; this.backgroundVisible = backgroundVisible; this.rootDocumentVisible = rootDocumentVisible; this.paperSize = paperSize; + // Tags 6-9 appear in firmware 3.2x files. + // customZoom: LwwValue — opaque; observed as four float64 zeros, + // likely custom zoom/viewport state (cf. customZoom* keys in .content files). + this.customZoom = customZoom; + // paperSizeFloat: [width, height] as float64; paperSizeFloatLww: same pair as an LWW value. + this.paperSizeFloat = paperSizeFloat; + this.paperSizeFloatLww = paperSizeFloatLww; + // columnWidth: LwwValue, see si.ColumnWidth (typed-text narrow/default/wide). + this.columnWidth = columnWidth; } versionInfo () { return [0, 1]; } @@ -86,7 +96,29 @@ 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); + const customZoom = stream.hasSubblock(6) + ? stream.readSubblock(6, () => new LwwValue( + stream.readId(1), + stream.readSubblock(2, (blockInfo) => Buffer.from(stream.data.readBytes(blockInfo.size))) + )) + : null; + const paperSizeFloat = stream.hasSubblock(7) + ? stream.readSubblock(7, () => [stream.data.readFloat64(), stream.data.readFloat64()]) + : null; + const paperSizeFloatLww = stream.hasSubblock(8) + ? stream.readSubblock(8, () => new LwwValue( + stream.readId(1), + stream.readSubblock(2, () => [stream.data.readFloat64(), stream.data.readFloat64()]) + )) + : null; + const columnWidth = stream.hasSubblock(9) + ? stream.readSubblock(9, () => new LwwValue( + stream.readId(1), + stream.readSubblock(2, () => stream.data.readUint8()) + )) + : null; + return new SceneInfo(currentLayer, backgroundVisible, rootDocumentVisible, paperSize, + customZoom, paperSizeFloat, paperSizeFloatLww, columnWidth); } toStream (writer) { @@ -94,6 +126,33 @@ if (this.backgroundVisible) writer.writeLwwBool(2, this.backgroundVisible); if (this.rootDocumentVisible) writer.writeLwwBool(3, this.rootDocumentVisible); if (this.paperSize) writer.writeIntPair(5, this.paperSize); + if (this.customZoom != null) { + writer.writeSubblock(6, () => { + writer.writeId(1, this.customZoom.timestamp); + writer.writeSubblock(2, () => writer.data.writeBytes(this.customZoom.value)); + }); + } + if (this.paperSizeFloat != null) { + writer.writeSubblock(7, () => { + writer.data.writeFloat64(this.paperSizeFloat[0]); + writer.data.writeFloat64(this.paperSizeFloat[1]); + }); + } + if (this.paperSizeFloatLww != null) { + writer.writeSubblock(8, () => { + writer.writeId(1, this.paperSizeFloatLww.timestamp); + writer.writeSubblock(2, () => { + writer.data.writeFloat64(this.paperSizeFloatLww.value[0]); + writer.data.writeFloat64(this.paperSizeFloatLww.value[1]); + }); + }); + } + if (this.columnWidth != null) { + writer.writeSubblock(9, () => { + writer.writeId(1, this.columnWidth.timestamp); + writer.writeSubblock(2, () => writer.data.writeUint8(this.columnWidth.value)); + }); + } } } reg(SceneInfo); @@ -511,22 +570,31 @@ 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; + let formatType, extra = null; + stream.readSubblock(2, (blockInfo) => { + const end = blockInfo.offset + blockInfo.size; + formatType = stream.readByte(1); + // Firmware 3.2x can append attributes to a format entry (semantics unknown); + // keep them so writing is lossless. + let unknown2 = null, unknown3 = null, raw = null; + if (stream.data.tell() < end && stream.data.checkTag(2, TagType.Byte1)) unknown2 = stream.readByte(2); + if (stream.data.tell() < end && stream.data.checkTag(3, TagType.Byte4)) unknown3 = stream.readInt(3); + if (stream.data.tell() < end) raw = Buffer.from(stream.data.readBytes(end - stream.data.tell())); + if (unknown2 != null || unknown3 != null || raw != null) extra = { unknown2, unknown3, raw }; }); - return [charId, new LwwValue(timestamp, formatType)]; + const fmt = new LwwValue(timestamp, formatType); + if (extra) fmt.extra = extra; + return [charId, fmt]; } 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); + writer.writeByte(1, value.value); + if (value.extra?.unknown2 != null) writer.writeByte(2, value.extra.unknown2); + if (value.extra?.unknown3 != null) writer.writeInt(3, value.extra.unknown3); + if (value.extra?.raw != null) writer.data.writeBytes(value.extra.raw); }); } @@ -563,8 +631,11 @@ posY = stream.data.readFloat64(); }); width = stream.readFloat(4); + const widthLww = stream.hasSubblock(5) + ? stream.readSubblock(5, () => stream.readLwwFloat(1)) + : null; - const value = new si.Text(new CrdtSequence(textItems), textFormats, posX, posY, width); + const value = new si.Text(new CrdtSequence(textItems), textFormats, posX, posY, width, widthLww); return new RootTextBlock(blockId, value); } @@ -597,6 +668,9 @@ writer.data.writeFloat64(this.value.posY); }); writer.writeFloat(4, this.value.width); + if (this.value.widthLww != null) { + writer.writeSubblock(5, () => writer.writeLwwFloat(1, this.value.widthLww)); + } } } reg(RootTextBlock); diff --git a/test/exporters.test.js b/test/exporters.test.js new file mode 100644 --- /dev/null +++ b/test/exporters.test.js @@ -0,0 +1,170 @@ +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 { readTree, writeBlocks, simpleTextDocument } from '../lib/scene-stream.js'; +import { Pen } from '../lib/scene-items.js'; +import { StrokePen } from '../lib/exporters/writing-tools.js'; +import { pagePaint } from '../lib/exporters/page-paint.js'; +import { rmToSvg } from '../lib/exporters/svg.js'; +import { rmsToPdf, treesToPdf } from '../lib/exporters/pdf.js'; +import { TtfFont } from '../lib/exporters/ttf.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NOTEBOOK_ID = 'f63a4691-25eb-43c5-95b0-a20c2dbeb80b'; +const NOTEBOOK_PATH = join(__dirname, 'data', 'notebook'); + +const content = JSON.parse(readFileSync(join(NOTEBOOK_PATH, `${NOTEBOOK_ID}.content`), 'utf8')); +const pages = content.cPages.pages.map(p => p.id); +function loadPage (id) { + return readFileSync(join(NOTEBOOK_PATH, NOTEBOOK_ID, `${id}.rm`)); +} + +// --- pen models --- + +test('ballpoint color follows the base color', () => { + const red = StrokePen.create(Pen.BALLPOINT_2, [179, 62, 57, 255], 2); + const [r, g, b, a] = red.getSegmentColor(10, 0, 8, 255, 0); + assert.ok(r > g && r > b, `expected reddish, got rgb(${r},${g},${b})`); + assert.equal(a, 255); + // full pressure means no lightening: exactly the base color + assert.deepStrictEqual(red.getSegmentColor(0, 0, 8, 255, 0), [179, 62, 57, 255]); +}); + +test('black ballpoint keeps the historical gray texture', () => { + const black = StrokePen.create(Pen.BALLPOINT_2, [0, 0, 0, 255], 2); + for (const pressure of [0, 64, 128, 200, 255]) { + const [r, g, b] = black.getSegmentColor(10, 0, 8, pressure, 0); + assert.equal(r, g); assert.equal(g, b); + assert.ok(r >= 0 && r <= 60, `gray ${r} out of the 0-60 range`); + } +}); + +test('pencil width and opacity stay in valid ranges', () => { + const pencil = StrokePen.create(Pen.PENCIL_2, [0, 0, 0, 255], 2); + // steep tilt (direction near 255) used to produce negative widths + for (const direction of [0, 128, 200, 255]) { + for (const pressure of [0, 128, 255]) { + const w = pencil.getSegmentWidth(100, direction, 4, pressure, 0); + const o = pencil.getSegmentOpacity(100, direction, 4, pressure, 0); + assert.ok(w > 0, `width ${w} not positive (direction=${direction})`); + assert.ok(o >= 0 && o <= 1, `opacity ${o} out of range`); + } + } +}); + +test('paint layer emits only valid widths and opacities for all notebook pages', () => { + for (const id of pages) { + const { items } = pagePaint(readTree(loadPage(id))); + for (const item of items) { + if (item.kind !== 'segment') continue; + assert.ok(item.width > 0, `non-positive width on page ${id}`); + assert.ok(item.opacity >= 0 && item.opacity <= 1, `bad opacity ${item.opacity} on page ${id}`); + assert.ok(item.color.every(c => c >= 0 && c <= 255), `bad color ${item.color} on page ${id}`); + assert.ok(item.points.length >= 1); + } + } +}); + +// --- SVG --- + +test('SVG still renders strokes and list markers', () => { + const svg = rmToSvg(loadPage(pages[0])); + assert.match(svg, / { + const pdf = rmsToPdf(pages.map(loadPage)); + const str = pdf.toString('latin1'); + assert.ok(str.startsWith('%PDF-1.4\n')); + assert.ok(str.endsWith('%%EOF\n')); + assert.equal(str.match(/\/Type \/Page\b/g).length, pages.length); + assert.match(str, new RegExp(`/Count ${pages.length}`)); + + // startxref points at the xref table + const startxref = Number(str.match(/startxref\n(\d+)\n%%EOF/)[1]); + assert.equal(str.slice(startxref, startxref + 4), 'xref'); + + // every xref offset points at the right object header + const xref = str.slice(startxref); + const entries = [...xref.matchAll(/^(\d{10}) 00000 n /gm)].map(m => Number(m[1])); + for (const [i, off] of entries.entries()) { + assert.equal(str.slice(off, off + `${i + 1} 0 obj`.length), `${i + 1} 0 obj`, `object ${i + 1} offset`); + } + + // stream lengths are accurate + for (const m of str.matchAll(/<< \/Length (\d+) >>\nstream\n/g)) { + const start = m.index + m[0].length; + assert.equal(str.slice(start + Number(m[1]), start + Number(m[1]) + 10), '\nendstream'); + } +}); + +test('PDF pages carry stroke and text content', () => { + const pdf = treesToPdf([readTree(loadPage(pages[9]))]).toString('latin1'); + assert.match(pdf, /BT \/F3 14 Tf 0 g [\d.]+ [\d.]+ Td \(Hello Robin\) Tj ET/); + assert.match(pdf, /\(1\. Aaaaa\) Tj/); + assert.match(pdf, /\(\[ \] Check no\) Tj/); + const strokes = treesToPdf([readTree(loadPage(pages[0]))]).toString('latin1'); + assert.match(strokes, / RG /); + assert.match(strokes, / m [\d.]/); +}); + +// --- embedded fonts --- + +test('TTF parser reads Noto Sans', () => { + const font = new TtfFont(readFileSync(join(__dirname, '..', 'fonts', 'NotoSans-Regular.ttf'))); + assert.ok(font.unitsPerEm === 1000 || font.unitsPerEm === 2048); + const gidA = font.glyphId('A'.codePointAt(0)); + assert.ok(gidA > 0); + assert.ok(font.advanceWidth(gidA) > 300); + assert.ok(font.hasGlyph('Ж'.codePointAt(0)), 'expected Cyrillic coverage'); + assert.ok(font.hasGlyph('λ'.codePointAt(0)), 'expected Greek coverage'); + assert.ok(!font.hasGlyph(0x4E2D), 'CJK should not be covered by the LGC build'); +}); + +function textDocPdf (text) { + return treesToPdf([readTree(writeBlocks(simpleTextDocument(text)))]); +} + +test('Latin text keeps base-14 fonts, including curly quotes', () => { + const pdf = textDocPdf('Hello “world” — it’s fine…').toString('latin1'); + assert.match(pdf, /\/BaseFont \/Helvetica\b/); + assert.doesNotMatch(pdf, /\/Subtype \/Type0/); + assert.doesNotMatch(pdf, /FontFile2/); + assert.match(pdf, /\(Hello \x93world\x94 \x97 it\x92s fine\x85\) Tj/); +}); + +test('non-Latin text triggers Noto embedding', () => { + const pdf = textDocPdf('Привет, мир — γειά σου κόσμε'); + const str = pdf.toString('latin1'); + assert.match(str, /\/Subtype \/Type0/); + assert.match(str, /\/Encoding \/Identity-H/); + assert.match(str, /\/Subtype \/CIDFontType2/); + assert.match(str, /\/CIDToGIDMap \/Identity/); + assert.match(str, /\/FontFile2 \d+ 0 R/); + assert.match(str, /\/Filter \/FlateDecode/); + assert.match(str, /beginbfchar/); + assert.match(str, /<[0-9a-f]+> Tj/); + // xref must still be self-consistent with big binary streams in the file + const startxref = Number(str.match(/startxref\n(\d+)\n%%EOF/)[1]); + assert.equal(str.slice(startxref, startxref + 4), 'xref'); + const entries = [...str.slice(startxref).matchAll(/^(\d{10}) 00000 n /gm)].map(m => Number(m[1])); + for (const [i, off] of entries.entries()) { + assert.equal(str.slice(off, off + `${i + 1} 0 obj`.length), `${i + 1} 0 obj`, `object ${i + 1} offset`); + } +}); + +test('embedded-font PDF maps glyphs back to Unicode', () => { + const str = textDocPdf('Привет').toString('latin1'); + // П is U+041F; the ToUnicode CMap should map some glyph to it + assert.match(str, /<[0-9a-f]{4}> <041f>/); +}); diff --git a/test/notebook.test.js b/test/notebook.test.js new file mode 100644 --- /dev/null +++ b/test/notebook.test.js @@ -0,0 +1,144 @@ +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 { readBlocks, writeBlocks, readTree, UnreadableBlock, SceneItemBlock, SceneLineItemBlock, SceneInfo, RootTextBlock } from '../lib/scene-stream.js'; +import { Pen, PenByValue, PenColorByValue, Line, ParagraphStyle, ColumnWidth } from '../lib/scene-items.js'; +import { StrokePen } from '../lib/exporters/writing-tools.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NOTEBOOK_ID = 'f63a4691-25eb-43c5-95b0-a20c2dbeb80b'; +const NOTEBOOK_PATH = join(__dirname, 'data', 'notebook'); + +const content = JSON.parse(readFileSync(join(NOTEBOOK_PATH, `${NOTEBOOK_ID}.content`), 'utf8')); +const pages = content.cPages.pages.map(p => p.id); + +function loadPage (id) { + return readFileSync(join(NOTEBOOK_PATH, NOTEBOOK_ID, `${id}.rm`)); +} + +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; +} + +test('notebook content lists all pages', () => { + assert.equal(pages.length, content.pageCount); +}); + +for (const [i, id] of pages.entries()) { + test(`page ${i + 1} (${id}) parses fully`, () => { + const data = loadPage(id); + const blocks = [...readBlocks(data)]; + assert.ok(blocks.length > 0, 'No blocks read'); + for (const block of blocks) { + assert.ok(!(block instanceof UnreadableBlock), `Block was unreadable: ${block.error}`); + assert.equal(block.extraData.length, 0, + `${block.constructor.name} has extra data: ${block.extraData.toString('hex')}`); + if (block instanceof SceneItemBlock) { + assert.equal(block.extraValueData.length, 0, + `${block.constructor.name} has extra value data: ${block.extraValueData.toString('hex')}`); + } + } + }); + + test(`page ${i + 1} (${id}) roundtrips byte-for-byte`, () => { + const data = loadPage(id); + const output = writeBlocks(readBlocks(data), { version: '3.15' }); + assert.deepStrictEqual(hexLines(output), hexLines(data)); + }); + + test(`page ${i + 1} (${id}) builds a scene tree`, () => { + const tree = readTree(loadPage(id)); + assert.ok(tree.root, 'Tree has no root'); + }); + + test(`page ${i + 1} (${id}) uses only known pens and colors`, () => { + const data = loadPage(id); + const lines = [...readBlocks(data)] + .filter(b => b instanceof SceneLineItemBlock && b.item.value instanceof Line) + .map(b => b.item.value); + for (const line of lines) { + assert.ok(PenByValue[line.tool] != null, `Unknown pen tool id: ${line.tool}`); + if (line.colorRgba == null) { + assert.ok(PenColorByValue[line.color] != null, `Unknown pen color id: ${line.color}`); + } + assert.doesNotThrow( + () => StrokePen.create(line.tool, [0, 0, 0, 255], line.thicknessScale), + `StrokePen.create failed for tool id ${line.tool} (${PenByValue[line.tool] ?? 'unknown'})` + ); + } + }); +} + +test('notebook exercises the expected set of pens', () => { + const tools = new Set(); + for (const id of pages) { + for (const b of readBlocks(loadPage(id))) { + if (b instanceof SceneLineItemBlock && b.item.value instanceof Line) tools.add(b.item.value.tool); + } + } + assert.deepStrictEqual( + [...tools].sort((a, b) => a - b), + [ + Pen.PAINTBRUSH_2, Pen.MECHANICAL_PENCIL_2, Pen.PENCIL_2, Pen.BALLPOINT_2, + Pen.MARKER_2, Pen.FINELINER_2, Pen.HIGHLIGHTER_2, Pen.CALIGRAPHY, Pen.SHADER, + ].sort((a, b) => a - b) + ); +}); + +function sceneInfoOf (id) { + return [...readBlocks(loadPage(id))].find(b => b instanceof SceneInfo); +} +function rootTextOf (id) { + return [...readBlocks(loadPage(id))].find(b => b instanceof RootTextBlock); +} + +test('SceneInfo new fields parse on every page', () => { + for (const id of pages) { + const info = sceneInfoOf(id); + assert.ok(info, `page ${id} has no SceneInfo`); + assert.deepStrictEqual(info.paperSize, [1404, 1872]); + assert.deepStrictEqual(info.paperSizeFloat, [1404, 1872]); + assert.deepStrictEqual(info.paperSizeFloatLww.value, [1404, 1872]); + assert.equal(info.customZoom.value.length, 32); + assert.ok(info.columnWidth != null, `page ${id} has no columnWidth`); + } +}); + +test('column width narrow/default/wide round-trips through SceneInfo and RootTextBlock', () => { + const expectations = [ + // [page index, column width mode, text width] + [9, ColumnWidth.DEFAULT, 936], + [10, ColumnWidth.NARROW, 740], + [11, ColumnWidth.WIDE, 1152], + ]; + for (const [idx, mode, width] of expectations) { + const id = pages[idx]; + assert.equal(sceneInfoOf(id).columnWidth.value, mode, `page ${idx + 1} column width mode`); + const text = rootTextOf(id).value; + assert.equal(text.width, width, `page ${idx + 1} text width`); + assert.equal(text.posX, -width / 2, `page ${idx + 1} text posX`); + if (text.widthLww != null) assert.equal(text.widthLww.value, width); + } +}); + +test('page 10 uses the numbered-list paragraph style', () => { + const text = rootTextOf(pages[9]).value; + const styles = [...text.styles.values()].map(s => s.value); + assert.equal(styles.filter(s => s === ParagraphStyle.NUMBERED).length, 2); + assert.ok(styles.includes(ParagraphStyle.CHECKBOX)); + assert.ok(styles.includes(ParagraphStyle.CHECKBOX_CHECKED)); + assert.ok(styles.includes(ParagraphStyle.BULLET)); +}); + +test('page 10 preserves extended text-format attributes', () => { + const text = rootTextOf(pages[9]).value; + const withExtra = [...text.styles.values()].filter(s => s.extra != null); + assert.equal(withExtra.length, 1); + assert.equal(withExtra[0].value, ParagraphStyle.BOLD); + assert.deepStrictEqual(withExtra[0].extra, { unknown2: 2, unknown3: 3, raw: null }); +}); diff --git a/bin/exporters/markdown.js b/bin/exporters/markdown.js deleted file mode 100644 --- a/bin/exporters/markdown.js +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 --- a/bin/exporters/pdf.js +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 --- a/bin/exporters/svg.js +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 --- a/bin/exporters/writing-tools.js +++ /dev/null @@ -1,173 +0,0 @@ -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/lib/exporters/markdown.js b/lib/exporters/markdown.js new file mode 100644 --- /dev/null +++ b/lib/exporters/markdown.js @@ -0,0 +1,66 @@ +import { readTree } from '../scene-stream.js'; +import * as si from '../scene-items.js'; +import { TextDocument } from '../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.NUMBERED) lines.push(`1. ${annotated}`); + else if (style === si.ParagraphStyle.CHECKBOX) lines.push(`- [ ] ${annotated}`); + else if (style === si.ParagraphStyle.CHECKBOX_CHECKED) lines.push(`- [x] ${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/lib/exporters/page-paint.js b/lib/exporters/page-paint.js new file mode 100644 --- /dev/null +++ b/lib/exporters/page-paint.js @@ -0,0 +1,151 @@ +import { CrdtId } from '../tagged-block-common.js'; +import * as si from '../scene-items.js'; +import { TextDocument } from '../text.js'; +import { StrokePen, lookupPenColor, clamp } from './writing-tools.js'; + +// Shared page renderer: walks a scene tree and produces flat paint items +// (stroke segments and text lines) in document coordinates. The SVG and PDF +// exporters both consume this so they cannot drift apart. + +export const SCREEN_WIDTH = 1404; +export const SCREEN_HEIGHT = 1872; +export const SCREEN_DPI = 226; +export const SCALE = 72.0 / SCREEN_DPI; + +export const TEXT_TOP_Y = -88; +export const LINE_HEIGHTS = new Map([ + [si.ParagraphStyle.PLAIN, 70], + [si.ParagraphStyle.BULLET, 35], + [si.ParagraphStyle.BULLET2, 35], + [si.ParagraphStyle.NUMBERED, 35], + [si.ParagraphStyle.BOLD, 70], + [si.ParagraphStyle.HEADING, 150], + [si.ParagraphStyle.CHECKBOX, 35], + [si.ParagraphStyle.CHECKBOX_CHECKED, 35], +]); +const LIST_MARKERS = new Map([ + [si.ParagraphStyle.BULLET, '• '], + [si.ParagraphStyle.BULLET2, '◦ '], + [si.ParagraphStyle.CHECKBOX, '☐ '], + [si.ParagraphStyle.CHECKBOX_CHECKED, '☑ '], +]); + +// Returns { bbox: [xMin, xMax, yMin, yMax], items } where items are +// { kind: 'segment', points: [[x, y], ...], color: [r, g, b, a], +// width, opacity, linecap } — absolute document coordinates +// { kind: 'text', x, y, cls, content } +export function pagePaint (tree) { + const anchorPos = buildAnchorPos(tree.rootText); + const bbox = getBoundingBox(tree.root, anchorPos); + const items = []; + if (tree.rootText != null) items.push(...textItems(tree.rootText)); + collectGroup(tree.root, anchorPos, 0, 0, items); + return { bbox, items }; +} + +export 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]; +} + +export 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 collectGroup (item, anchorPos, ox, oy, items) { + const [ax, ay] = getAnchor(item, anchorPos); + const x = ox + ax, y = oy + ay; + for (const child of item.children.values()) { + if (child instanceof si.Group) collectGroup(child, anchorPos, x, y, items); + else if (child instanceof si.Line) items.push(...strokeSegments(child, x, y)); + } +} + +function strokeSegments (item, ox, oy) { + const penColor = lookupPenColor(item.color, item.colorRgba); + const pen = StrokePen.create(item.tool, penColor, item.thicknessScale); + + const segments = []; + let seg = null; + let lastSegmentWidth = 0; + + for (let pointId = 0; pointId < item.points.length; pointId++) { + const point = item.points[pointId]; + if (pointId % pen.segmentLength === 0) { + seg = { + kind: 'segment', + points: seg ? [seg.points[seg.points.length - 1]] : [], + color: pen.getSegmentColor(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth), + // Guard against pen formulas straying out of range: SVG viewers + // silently repair invalid widths/opacities but PDF does not. + width: Math.max(pen.getSegmentWidth(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth), 0.1), + opacity: clamp(pen.getSegmentOpacity(point.speed, point.direction, point.width, point.pressure, lastSegmentWidth)), + linecap: pen.strokeLinecap, + }; + lastSegmentWidth = seg.width; + segments.push(seg); + } + seg.points.push([ox + point.x, oy + point.y]); + } + return segments; +} + +function textItems (text) { + const items = []; + let yOffset = TEXT_TOP_Y; + let listNumber = 0; + const doc = TextDocument.fromSceneItem(text); + for (const p of doc.contents) { + yOffset += LINE_HEIGHTS.get(p.style.value) ?? 70; + const cls = si.ParagraphStyleByValue[p.style.value]?.toLowerCase() ?? 'plain'; + listNumber = p.style.value === si.ParagraphStyle.NUMBERED ? listNumber + 1 : 0; + const marker = listNumber ? `${listNumber}. ` : LIST_MARKERS.get(p.style.value) ?? ''; + const content = p.toString().trim(); + if (content) { + items.push({ kind: 'text', x: text.posX, y: text.posY + yOffset, cls, content: marker + content }); + } + } + return items; +} diff --git a/lib/exporters/pdf.js b/lib/exporters/pdf.js new file mode 100644 --- /dev/null +++ b/lib/exporters/pdf.js @@ -0,0 +1,299 @@ +import { Buffer } from 'node:buffer'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { deflateSync } from 'node:zlib'; +import { readTree } from '../scene-stream.js'; +import { pagePaint, SCALE } from './page-paint.js'; +import { TtfFont } from './ttf.js'; + +// Pure-JS PDF generation: no external tools. Each .rm page becomes one PDF +// page sized to its bounding box, with strokes as stroked paths and typed +// text set either in the standard base-14 fonts (when WinAnsi suffices) or +// in embedded Noto fonts (when the text needs more of Unicode). + +export function rmToPdf (data) { + return rmsToPdf([data]); +} + +export function rmsToPdf (datas) { + return treesToPdf(datas.map(readTree)); +} + +// cls → [base-14 font, size in pt, embedded font file] (mirrors the SVG stylesheet) +const FACES = { + plain: { base14: 'Helvetica', size: 7, file: 'NotoSans-Regular.ttf' }, + bold: { base14: 'Helvetica-Bold', size: 8, file: 'NotoSans-Bold.ttf' }, + heading: { base14: 'Times-Roman', size: 14, file: 'NotoSerif-Regular.ttf' }, +}; +const faceKey = cls => (cls in FACES ? cls : 'plain'); + +// Mappings into WinAnsi for the base-14 path: list markers WinAnsi lacks, +// plus the punctuation WinAnsi encodes in 0x80-0x9F (so a stray curly quote +// does not force font embedding) +const BASE14_SUBSTITUTIONS = new Map([ + ['•', '\x95'], ['◦', '\xB7'], ['☐', '[ ]'], ['☑', '[x]'], + ['€', '\x80'], ['‚', '\x82'], ['ƒ', '\x83'], ['„', '\x84'], ['…', '\x85'], + ['†', '\x86'], ['‡', '\x87'], ['ˆ', '\x88'], ['‰', '\x89'], ['Š', '\x8A'], + ['‹', '\x8B'], ['Œ', '\x8C'], ['Ž', '\x8E'], ['‘', '\x91'], ['’', '\x92'], + ['“', '\x93'], ['”', '\x94'], ['–', '\x96'], ['—', '\x97'], ['˜', '\x98'], + ['™', '\x99'], ['š', '\x9A'], ['›', '\x9B'], ['œ', '\x9C'], ['ž', '\x9E'], + ['Ÿ', '\x9F'], +]); +// Fallbacks when the embedded font has no glyph for a character +const GLYPH_FALLBACKS = new Map([ + ['◦', '·'], ['☐', '[ ]'], ['☑', '[x]'], +]); + +const FONTS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'fonts'); +const ttfCache = new Map(); +function loadTtf (file) { + if (!ttfCache.has(file)) ttfCache.set(file, new TtfFont(readFileSync(join(FONTS_DIR, file)))); + return ttfCache.get(file); +} + +function needsUnicode (pages) { + for (const { items } of pages) { + for (const item of items) { + if (item.kind !== 'text') continue; + for (const ch of item.content) { + if (BASE14_SUBSTITUTIONS.has(ch)) continue; + if (ch.codePointAt(0) >= 256) return true; + } + } + } + return false; +} + +export function treesToPdf (trees) { + const pages = trees.map(tree => pagePaint(tree)); + const embed = needsUnicode(pages); + + // Collect the distinct alpha values used, for ExtGState resources + const alphas = new Map(); // rounded alpha → /GSn name + for (const { items } of pages) { + for (const item of items) { + if (item.kind !== 'segment') continue; + const a = alphaOf(item); + if (a < 1 && !alphas.has(a)) alphas.set(a, `GS${alphas.size}`); + } + } + + const objects = [null, null]; // 1: Catalog, 2: Pages — filled in below + const addObj = body => objects.push(body); // object number is objects.length after push + + // Fonts: one text encoder per face class, writing its objects up front + const faces = new Map(); // face key → { res, size, showText (str → PDF token), finish? } + if (!embed) { + for (const [i, [key, face]] of Object.entries(FACES).entries()) { + addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${face.base14} /Encoding /WinAnsiEncoding >>`); + faces.set(key, { + res: `F${i + 1}`, ref: objects.length, size: face.size, + showText: str => `(${escapeBase14(str)})`, + }); + } + } else { + const usedKeys = new Set(pages.flatMap(p => p.items.filter(i => i.kind === 'text').map(i => faceKey(i.cls)))); + let fi = 0; + for (const key of Object.keys(FACES).filter(k => usedKeys.has(k))) { + const face = FACES[key]; + const font = loadTtf(face.file); + const psName = face.file.replace(/\.ttf$/, ''); + const used = new Map(); // gid → codepoint (for /W and /ToUnicode) + + const raw = font.data; + const compressed = deflateSync(raw); + addObj(Buffer.concat([ + Buffer.from(`<< /Filter /FlateDecode /Length ${compressed.length} /Length1 ${raw.length} >>\nstream\n`, 'latin1'), + compressed, + Buffer.from(`\nendstream`, 'latin1'), + ])); + const fileRef = objects.length; + + const [bx1, by1, bx2, by2] = font.bbox.map(v => font.scaled(v)); + addObj(`<< /Type /FontDescriptor /FontName /${psName} /Flags 4 ` + + `/FontBBox [${bx1} ${by1} ${bx2} ${by2}] /ItalicAngle ${fmt(font.italicAngle)} ` + + `/Ascent ${font.scaled(font.ascent)} /Descent ${font.scaled(font.descent)} ` + + `/CapHeight ${font.scaled(font.capHeight)} /StemV 80 /FontFile2 ${fileRef} 0 R >>`); + const descriptorRef = objects.length; + + addObj(null); // CIDFont — needs the final /W array, filled in by finish() + const cidRef = objects.length; + addObj(null); // ToUnicode + const toUniRef = objects.length; + addObj(`<< /Type /Font /Subtype /Type0 /BaseFont /${psName} /Encoding /Identity-H ` + + `/DescendantFonts [${cidRef} 0 R] /ToUnicode ${toUniRef} 0 R >>`); + + faces.set(key, { + res: `F${++fi}`, ref: objects.length, size: face.size, + showText: str => { + let hex = ''; + for (const ch of str) { + let cp = ch.codePointAt(0); + let seq = [cp]; + if (!font.hasGlyph(cp)) { + seq = [...(GLYPH_FALLBACKS.get(ch) ?? '?')].map(c => c.codePointAt(0)); + } + for (const c of seq) { + const gid = font.glyphId(c) || font.glyphId(0x3F); // '?' + if (!used.has(gid)) used.set(gid, c); + hex += gid.toString(16).padStart(4, '0'); + } + } + return `<${hex}>`; + }, + finish: () => { + const w = [...used.keys()].sort((a, b) => a - b) + .map(gid => `${gid} [${font.advanceWidth(gid)}]`).join(' '); + objects[cidRef - 1] = + `<< /Type /Font /Subtype /CIDFontType2 /BaseFont /${psName} ` + + `/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ` + + `/FontDescriptor ${descriptorRef} 0 R /CIDToGIDMap /Identity /W [ ${w} ] >>`; + objects[toUniRef - 1] = toUnicodeStream(used); + }, + }); + } + } + + for (const [a] of alphas) { + addObj(`<< /Type /ExtGState /CA ${fmt(a)} /ca ${fmt(a)} >>`); + } + + const fontRes = [...faces.values()].map(f => `/${f.res} ${f.ref} 0 R`).join(' '); + const gsRes = [...alphas.values()].map((name, i) => `/${name} ${objects.length - alphas.size + 1 + i} 0 R`).join(' '); + const resources = `<< /Font << ${fontRes} >>${gsRes ? ` /ExtGState << ${gsRes} >>` : ''} >>`; + + const pageRefs = []; + for (const page of pages) { + const [xMin, xMax, yMin, yMax] = page.bbox; + const w = (xMax - xMin + 1) * SCALE; + const h = (yMax - yMin + 1) * SCALE; + const content = pageContent(page, alphas, faces); + addObj(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${fmt(w)} ${fmt(h)}] /Resources ${resources} /Contents ${objects.length + 2} 0 R >>`); + pageRefs.push(objects.length); + addObj(Buffer.concat([ + Buffer.from(`<< /Length ${content.length} >>\nstream\n`, 'latin1'), + content, + Buffer.from(`\nendstream`, 'latin1'), + ])); + } + + for (const face of faces.values()) face.finish?.(); + objects[0] = `<< /Type /Catalog /Pages 2 0 R >>`; + objects[1] = `<< /Type /Pages /Kids [${pageRefs.map(r => `${r} 0 R`).join(' ')}] /Count ${pages.length} >>`; + + // Serialize with xref + const chunks = [Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'latin1')]; + let offset = chunks[0].length; + const offsets = []; + for (const [i, obj] of objects.entries()) { + const body = Buffer.isBuffer(obj) ? obj : Buffer.from(obj, 'latin1'); + const chunk = Buffer.concat([ + Buffer.from(`${i + 1} 0 obj\n`, 'latin1'), + body, + Buffer.from(`\nendobj\n`, 'latin1'), + ]); + offsets.push(offset); + chunks.push(chunk); + offset += chunk.length; + } + const xref = [`xref`, `0 ${objects.length + 1}`, `0000000000 65535 f `]; + for (const o of offsets) xref.push(`${String(o).padStart(10, '0')} 00000 n `); + xref.push( + `trailer`, `<< /Size ${objects.length + 1} /Root 1 0 R >>`, + `startxref`, `${offset}`, `%%EOF`, `` + ); + chunks.push(Buffer.from(xref.join('\n'), 'latin1')); + return Buffer.concat(chunks); +} + +function alphaOf (item) { + return round3(item.opacity * (item.color[3] ?? 255) / 255); +} + +function pageContent (page, alphas, faces) { + const [xMin, , yMin, yMax] = page.bbox; + const h = (yMax - yMin + 1) * SCALE; + const px = x => fmt((x - xMin) * SCALE); + const py = y => fmt(h - (y - yMin) * SCALE); + const ops = []; + + for (const item of page.items) { + if (item.kind === 'text') { + const face = faces.get(faceKey(item.cls)); + ops.push(`BT /${face.res} ${face.size} Tf 0 g ${px(item.x)} ${py(item.y)} Td ${face.showText(item.content)} Tj ET`); + } else if (item.kind === 'segment') { + const [r, g, b] = item.color; + const a = alphaOf(item); + const cap = item.linecap === 'square' ? 2 : 1; + const parts = ['q']; + if (a < 1) parts.push(`/${alphas.get(a)} gs`); + if (a > 0) { + parts.push(`${fmt(r / 255)} ${fmt(g / 255)} ${fmt(b / 255)} RG`); + parts.push(`${fmt(Math.max(item.width * SCALE, 0.05))} w ${cap} J 1 j`); + parts.push(`${px(item.points[0][0])} ${py(item.points[0][1])} m`); + for (let i = 1; i < item.points.length; i++) { + parts.push(`${px(item.points[i][0])} ${py(item.points[i][1])} l`); + } + // single points still get a dot thanks to the round/square cap + if (item.points.length === 1) parts.push(`${px(item.points[0][0])} ${py(item.points[0][1])} l`); + parts.push('S'); + } + parts.push('Q'); + ops.push(parts.join(' ')); + } + } + return Buffer.from(ops.join('\n'), 'latin1'); +} + +function toUnicodeStream (used) { + const entries = [...used.entries()].sort((a, b) => a[0] - b[0]); + const blocks = []; + for (let i = 0; i < entries.length; i += 100) { + const chunk = entries.slice(i, i + 100); + blocks.push( + `${chunk.length} beginbfchar\n` + + chunk.map(([gid, cp]) => `<${gid.toString(16).padStart(4, '0')}> <${utf16Hex(cp)}>`).join('\n') + + `\nendbfchar` + ); + } + const cmap = + `/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n` + + `/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n` + + `/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n` + + `1 begincodespacerange\n<0000> \nendcodespacerange\n` + + blocks.join('\n') + + `\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend`; + return Buffer.concat([ + Buffer.from(`<< /Length ${cmap.length} >>\nstream\n`, 'latin1'), + Buffer.from(cmap, 'latin1'), + Buffer.from(`\nendstream`, 'latin1'), + ]); +} + +function utf16Hex (cp) { + if (cp <= 0xFFFF) return cp.toString(16).padStart(4, '0'); + const v = cp - 0x10000; + const hi = 0xD800 + (v >> 10), lo = 0xDC00 + (v & 0x3FF); + return hi.toString(16).padStart(4, '0') + lo.toString(16).padStart(4, '0'); +} + +function escapeBase14 (str) { + let out = ''; + for (const ch of str) { + const sub = BASE14_SUBSTITUTIONS.get(ch); + if (sub != null) { out += sub; continue; } + const code = ch.codePointAt(0); + if (ch === '(' || ch === ')' || ch === '\\') out += `\\${ch}`; + else if (code >= 32 && code < 256) out += ch; + else out += '?'; + } + return out; +} + +function round3 (n) { return Math.round(n * 1000) / 1000; } + +function fmt (n) { + const s = n.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); + return s === '-0' ? '0' : s; +} diff --git a/lib/exporters/svg.js b/lib/exporters/svg.js new file mode 100644 --- /dev/null +++ b/lib/exporters/svg.js @@ -0,0 +1,62 @@ +import { readTree } from '../scene-stream.js'; +import { pagePaint, SCALE } from './page-paint.js'; + +const xx = v => v * SCALE; +const yy = v => v * SCALE; +const scale = v => v * SCALE; + +export function rmToSvg (data) { + const tree = readTree(data); + return treeToSvg(tree); +} + +export function treeToSvg (tree) { + const { bbox, items } = pagePaint(tree); + const [xMin, xMax, yMin, yMax] = bbox; + const widthPt = xx(xMax - xMin + 1); + const heightPt = yy(yMax - yMin + 1); + + const parts = []; + parts.push( + `\n` + + `\n` + ); + + parts.push(`\t\n`); + + if (items.some(i => i.kind === 'text')) { + parts.push( + '\t\t', + `\n\t\t\t\n` + ); + for (const item of items) { + if (item.kind !== 'text') continue; + parts.push(`\t\t\t${escapeXml(item.content)}\n`); + } + parts.push('\t\t\n'); + } + + for (const item of items) { + if (item.kind !== 'segment') continue; + parts.push(`\t\t\n`); + } + + parts.push('\t\n\n'); + return parts.join(''); +} + +function cssColor ([r, g, b, a]) { + return a === 255 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${(a / 255).toFixed(3)})`; +} + +function escapeXml (str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/lib/exporters/ttf.js b/lib/exporters/ttf.js new file mode 100644 --- /dev/null +++ b/lib/exporters/ttf.js @@ -0,0 +1,113 @@ +import { Buffer } from 'node:buffer'; + +// Minimal TrueType parser: just enough of head/hhea/maxp/hmtx/cmap/OS2/post +// to embed a font in a PDF (glyph lookup, advance widths, and the metrics +// needed for a FontDescriptor). + +export class TtfFont { + constructor (buf) { + this.data = buf; + this.tables = new Map(); + + const numTables = buf.readUInt16BE(4); + for (let i = 0; i < numTables; i++) { + const off = 12 + i * 16; + const tag = buf.toString('ascii', off, off + 4); + this.tables.set(tag, { offset: buf.readUInt32BE(off + 8), length: buf.readUInt32BE(off + 12) }); + } + + const head = this._table('head'); + this.unitsPerEm = head.readUInt16BE(18); + this.bbox = [head.readInt16BE(36), head.readInt16BE(38), head.readInt16BE(40), head.readInt16BE(42)]; + + const hhea = this._table('hhea'); + this.ascent = hhea.readInt16BE(4); + this.descent = hhea.readInt16BE(6); + const numHMetrics = hhea.readUInt16BE(34); + + this.numGlyphs = this._table('maxp').readUInt16BE(4); + + const hmtx = this._table('hmtx'); + this._advances = new Array(this.numGlyphs); + let last = 0; + for (let gid = 0; gid < this.numGlyphs; gid++) { + if (gid < numHMetrics) last = hmtx.readUInt16BE(gid * 4); + this._advances[gid] = last; + } + + const os2 = this.tables.has('OS/2') ? this._table('OS/2') : null; + this.capHeight = os2 && os2.readUInt16BE(0) >= 2 ? os2.readInt16BE(88) : Math.round(this.ascent * 0.7); + this.italicAngle = this.tables.has('post') ? this._table('post').readInt32BE(4) / 65536 : 0; + + this._cmap = this._parseCmap(); + } + + static fromFile (path, readFileSync) { return new TtfFont(readFileSync(path)); } + + _table (tag) { + const t = this.tables.get(tag); + if (!t) throw new Error(`Missing TTF table ${tag}`); + return this.data.subarray(t.offset, t.offset + t.length); + } + + _parseCmap () { + const cmap = this._table('cmap'); + const numSubtables = cmap.readUInt16BE(2); + let best = null; // prefer format 12 (full Unicode), else format 4 (BMP) + for (let i = 0; i < numSubtables; i++) { + const off = 4 + i * 8; + const platform = cmap.readUInt16BE(off); + const encoding = cmap.readUInt16BE(off + 2); + const subOff = cmap.readUInt32BE(off + 4); + const isUnicode = platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10)); + if (!isUnicode) continue; + const format = cmap.readUInt16BE(subOff); + if (format === 12) best = { format, off: subOff }; + else if (format === 4 && best?.format !== 12) best = { format, off: subOff }; + } + if (!best) throw new Error('No usable Unicode cmap subtable'); + + const map = new Map(); + if (best.format === 12) { + const nGroups = cmap.readUInt32BE(best.off + 12); + for (let g = 0; g < nGroups; g++) { + const off = best.off + 16 + g * 12; + const start = cmap.readUInt32BE(off); + const end = cmap.readUInt32BE(off + 4); + const startGid = cmap.readUInt32BE(off + 8); + for (let c = start; c <= end; c++) map.set(c, startGid + (c - start)); + } + } else { + const segCount = cmap.readUInt16BE(best.off + 6) / 2; + const endsOff = best.off + 14; + const startsOff = endsOff + segCount * 2 + 2; + const deltasOff = startsOff + segCount * 2; + const rangesOff = deltasOff + segCount * 2; + for (let s = 0; s < segCount; s++) { + const end = cmap.readUInt16BE(endsOff + s * 2); + const start = cmap.readUInt16BE(startsOff + s * 2); + const delta = cmap.readInt16BE(deltasOff + s * 2); + const rangeOff = cmap.readUInt16BE(rangesOff + s * 2); + if (start === 0xFFFF) continue; + for (let c = start; c <= end; c++) { + let gid; + if (rangeOff === 0) { + gid = (c + delta) & 0xFFFF; + } else { + const gidOff = rangesOff + s * 2 + rangeOff + (c - start) * 2; + gid = cmap.readUInt16BE(gidOff); + if (gid !== 0) gid = (gid + delta) & 0xFFFF; + } + if (gid !== 0) map.set(c, gid); + } + } + } + return map; + } + + glyphId (codepoint) { return this._cmap.get(codepoint) ?? 0; } + hasGlyph (codepoint) { return this._cmap.has(codepoint); } + // advance width in 1000-per-em units (PDF glyph space) + advanceWidth (gid) { return Math.round((this._advances[gid] ?? 0) * 1000 / this.unitsPerEm); } + scaled (v) { return Math.round(v * 1000 / this.unitsPerEm); } +} diff --git a/lib/exporters/writing-tools.js b/lib/exporters/writing-tools.js new file mode 100644 --- /dev/null +++ b/lib/exporters/writing-tools.js @@ -0,0 +1,189 @@ +import { Pen, PenColor } from '../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; } + + // Returns [r, g, b, a] with all channels 0-255. + getSegmentColor (speed, direction, width, pressure, lastWidth) { + return this.baseColor; + } + + 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); + // Lighten the base color as intensity drops, by at most 60/255 (for black + // ink this reproduces the historical 0-60 gray texture exactly). + const lighten = Math.min(Math.abs(intensity - 1), 60 / 255); + const [r, g, b, a] = this.baseColor; + return [ + Math.floor(r + (255 - r) * lighten), + Math.floor(g + (255 - g) * lighten), + Math.floor(b + (255 - b) * lighten), + a, + ]; + } +} + +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)); + // The tilt term can push the width negative for steeply-tilted strokes; + // keep a visible minimum instead of an invalid width. + return Math.max(Math.min(sw, this.baseWidth * 10), 1); + } + getSegmentOpacity (speed, direction, width, pressure, lastWidth) { + return clamp(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 [ + 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/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.content b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.content new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.content @@ -0,0 +1,230 @@ +{ + "cPages": { + "lastOpened": { + "timestamp": "1:14", + "value": "7e1fd0e7-0b74-428e-b174-e9efc8a22859" + }, + "original": { + "timestamp": "0:0", + "value": -1 + }, + "pages": [ + { + "id": "21187519-e91e-4c72-b3a1-035fac28e4d3", + "idx": { + "timestamp": "1:2", + "value": "ba" + }, + "modifed": "1786021920790", + "template": { + "timestamp": "1:1", + "value": "P Grid medium" + } + }, + { + "id": "de2d5411-3b4b-47c2-9095-8e2121300319", + "idx": { + "timestamp": "1:2", + "value": "bb" + }, + "modifed": "1786021959297", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "25138831-084a-4747-97ce-ebb6aa63854c", + "idx": { + "timestamp": "1:2", + "value": "bc" + }, + "modifed": "1786022002650", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "bb469f40-467f-45fc-9992-6ca717294638", + "idx": { + "timestamp": "1:2", + "value": "bd" + }, + "modifed": "1786022068548", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "2502a58a-c379-41fb-8e77-ff28ca77da4a", + "idx": { + "timestamp": "1:2", + "value": "be" + }, + "modifed": "1786022131788", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "cf241408-d243-42db-b951-3c121edce592", + "idx": { + "timestamp": "1:2", + "value": "bf" + }, + "modifed": "1786022226806", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "0fa00eed-eb69-415e-a0dc-ba685f00e566", + "idx": { + "timestamp": "1:2", + "value": "bg" + }, + "modifed": "1786022279519", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "a6818b22-84e2-43d4-a341-59e734b73924", + "idx": { + "timestamp": "1:2", + "value": "bh" + }, + "modifed": "1786022336717", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "1ca221bb-682a-4530-b57d-734a3c556b39", + "idx": { + "timestamp": "1:2", + "value": "bi" + }, + "modifed": "1786022401320", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "10535bd2-0039-491c-9891-a8b40606264f", + "idx": { + "timestamp": "1:2", + "value": "bj" + }, + "modifed": "1786022486855", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "a94e04f0-3325-47db-a58f-fc8c0b6ebef5", + "idx": { + "timestamp": "1:2", + "value": "bk" + }, + "modifed": "1786022537745", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + }, + { + "id": "7e1fd0e7-0b74-428e-b174-e9efc8a22859", + "idx": { + "timestamp": "1:2", + "value": "bl" + }, + "modifed": "1786022558404", + "template": { + "timestamp": "1:2", + "value": "P Grid medium" + } + } + ], + "uuids": [ + { + "first": "1d62d2aa-afb1-5784-874e-4ea9e2470491", + "second": 1 + } + ] + }, + "coverPageNumber": 0, + "customZoomCenterX": 0, + "customZoomCenterY": 936, + "customZoomOrientation": "portrait", + "customZoomPageHeight": 1872, + "customZoomPageWidth": 1404, + "customZoomScale": 1, + "documentMetadata": { + }, + "extraMetadata": { + "LastActiveTool": "secondary", + "LastBallpointColor": "Black", + "LastBallpointSize": "2", + "LastBallpointv2Color": "Red", + "LastBallpointv2Size": "3", + "LastCalligraphyColor": "Red", + "LastCalligraphySize": "3", + "LastEraseSectionColor": "Black", + "LastEraseSectionSize": "1", + "LastEraserColor": "Black", + "LastEraserSize": "2", + "LastEraserTool": "EraseSection", + "LastFinelinerv2Color": "Red", + "LastFinelinerv2Size": "2", + "LastHighlighterv2Color": "ArgbCode", + "LastHighlighterv2ColorCode": "4291282886", + "LastHighlighterv2Size": "1", + "LastMarkerv2Color": "Red", + "LastMarkerv2Size": "3", + "LastPaintbrushv2Color": "Red", + "LastPaintbrushv2Size": "3", + "LastPen": "SharpPencilv2", + "LastPencilColor": "Black", + "LastPencilSize": "2", + "LastPencilv2Color": "Red", + "LastPencilv2Size": "3", + "LastSelectionToolColor": "Black", + "LastSelectionToolSize": "2", + "LastShadingMarkerColor": "ArgbCode", + "LastShadingMarkerColorCode": "1075912220", + "LastShadingMarkerSize": "3", + "LastSharpPencilv2Color": "Red", + "LastSharpPencilv2Size": "3", + "SecondaryFinelinerv2Color": "Black", + "SecondaryFinelinerv2Size": "2", + "SecondaryHighlighterv2Color": "ArgbCode", + "SecondaryHighlighterv2ColorCode": "4294090495", + "SecondaryHighlighterv2Size": "1", + "SecondaryPen": "Highlighterv2", + "SecondarySharpPencilv2Color": "Black", + "SecondarySharpPencilv2Size": "1" + }, + "fileType": "notebook", + "fontName": "", + "formatVersion": 2, + "lineHeight": -1, + "orientation": "portrait", + "pageCount": 12, + "pageTags": [ + ], + "sizeInBytes": "597057", + "tags": [ + ], + "textAlignment": "justify", + "textScale": 1, + "zoomMode": "bestFit" +} diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.local b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.local new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.local @@ -0,0 +1,3 @@ +{ + "contentFormatVersion": 2 +} diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.metadata b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.metadata new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.metadata @@ -0,0 +1,12 @@ +{ + "createdTime": "1786021800751", + "lastModified": "1786022558398", + "lastOpened": "1786021806300", + "lastOpenedPage": 11, + "new": false, + "parent": "", + "pinned": false, + "source": "", + "type": "DocumentType", + "visibleName": "Test" +} diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.thumbnails/21187519-e91e-4c72-b3a1-035fac28e4d3.png b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.thumbnails/21187519-e91e-4c72-b3a1-035fac28e4d3.png new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b.thumbnails/21187519-e91e-4c72-b3a1-035fac28e4d3.png diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/0fa00eed-eb69-415e-a0dc-ba685f00e566.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/0fa00eed-eb69-415e-a0dc-ba685f00e566.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/0fa00eed-eb69-415e-a0dc-ba685f00e566.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/10535bd2-0039-491c-9891-a8b40606264f.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/10535bd2-0039-491c-9891-a8b40606264f.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/10535bd2-0039-491c-9891-a8b40606264f.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/1ca221bb-682a-4530-b57d-734a3c556b39.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/1ca221bb-682a-4530-b57d-734a3c556b39.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/1ca221bb-682a-4530-b57d-734a3c556b39.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/21187519-e91e-4c72-b3a1-035fac28e4d3.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/21187519-e91e-4c72-b3a1-035fac28e4d3.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/21187519-e91e-4c72-b3a1-035fac28e4d3.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/2502a58a-c379-41fb-8e77-ff28ca77da4a.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/2502a58a-c379-41fb-8e77-ff28ca77da4a.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/2502a58a-c379-41fb-8e77-ff28ca77da4a.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/25138831-084a-4747-97ce-ebb6aa63854c.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/25138831-084a-4747-97ce-ebb6aa63854c.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/25138831-084a-4747-97ce-ebb6aa63854c.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/7e1fd0e7-0b74-428e-b174-e9efc8a22859.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/7e1fd0e7-0b74-428e-b174-e9efc8a22859.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/7e1fd0e7-0b74-428e-b174-e9efc8a22859.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a6818b22-84e2-43d4-a341-59e734b73924.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a6818b22-84e2-43d4-a341-59e734b73924.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a6818b22-84e2-43d4-a341-59e734b73924.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a94e04f0-3325-47db-a58f-fc8c0b6ebef5.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a94e04f0-3325-47db-a58f-fc8c0b6ebef5.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/a94e04f0-3325-47db-a58f-fc8c0b6ebef5.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/bb469f40-467f-45fc-9992-6ca717294638.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/bb469f40-467f-45fc-9992-6ca717294638.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/bb469f40-467f-45fc-9992-6ca717294638.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/cf241408-d243-42db-b951-3c121edce592.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/cf241408-d243-42db-b951-3c121edce592.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/cf241408-d243-42db-b951-3c121edce592.rm diff --git a/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/de2d5411-3b4b-47c2-9095-8e2121300319.rm b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/de2d5411-3b4b-47c2-9095-8e2121300319.rm new file mode 100644 --- /dev/null +++ b/test/data/notebook/f63a4691-25eb-43c5-95b0-a20c2dbeb80b/de2d5411-3b4b-47c2-9095-8e2121300319.rm -- tangled.sh