diff --git a/package.json b/package.json index 068a68e..d29b250 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,6 @@ "dependencies": { "color": "^4.2.3", "fs-extra": "^10.1.0", - "image-js": "^1.2.0" + "image-js": "1.2.0" } } diff --git a/src/format.ts b/src/format.ts index 3c4d1b4..6340552 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,3 +1,29 @@ +/** Supernote internal link (created via the Supernote link feature). */ +export interface ILink { + /** Link type. 1 = internal note link. */ + LINKTYPE: string; + LINKINOUT: string; + /** Address of the link bitmap. */ + LINKBITMAP: string; + LINKSTYLE: string; + LINKTIMESTAMP: string; + /** Link rectangle coordinates (x, y, w, h as a comma-separated string). */ + LINKRECT: string; + LINKRECTORI: string; + /** Protocol. Usually RATTA_RLE. */ + LINKPROTOCAL: string; + /** Base64-encoded absolute path to the linked .note file on the device. */ + LINKFILE: string; + LINKFILEID: string; + PAGEID: string; + /** 0-indexed page number this link appears on. */ + OBJPAGE: string; + /** Decoded filename (without path or .note extension) ready for use as [[link]]. */ + text: string; + /** Link area bitmap content. */ + bitmapBuffer: Uint8Array | null; +} + /** Supernote note object. */ export interface ISupernote { /** Page width in pixels. */ @@ -22,6 +48,8 @@ export interface ISupernote { keywords: Record; /** Title locations and styles. */ titles: Record; + /** Internal links created via the Supernote link feature. */ + links: Record; /** Pages information. */ pages: IPage[]; /** Cover page. */ @@ -144,6 +172,8 @@ export interface IRecognitionElement { } export interface IPage { + /** Unique page identifier used for linking. */ + PAGEID?: string; /** Page style (template). */ PAGESTYLE: string; /** Page style MD5. */ @@ -220,6 +250,8 @@ export interface IFooter { KEYWORD: Record; /** Title values. Mappings of TITLE_{key}:{value}. */ TITLE: Record; + /** Link values. Mappings of LINKO_{key}:{value}. */ + LINKO: Record; /** Style values. Mappings of STYLE_{key}:{value}. */ STYLE: Record; /** Page values. Mappings of PAGE{key}:{value}. */ diff --git a/src/index.ts b/src/index.ts index b2f31ab..58e6f76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { SupernoteX } from './parsing'; export { toImage } from './conversion'; export { fetchMirrorFrame } from './mirror'; +export type { ILink, IPage } from './format'; diff --git a/src/parsing.ts b/src/parsing.ts index 9067746..75ec61a 100644 --- a/src/parsing.ts +++ b/src/parsing.ts @@ -6,6 +6,7 @@ import { ILayer, ILayerInfo, ILayerNames, + ILink, RecognitionStatuses, IRecognitionElement, IPage, @@ -222,6 +223,7 @@ export class SupernoteX { this._parseCover(buffer); this._parseKeywords(buffer); this._parseTitles(buffer); + this._parseLinks(buffer); return this; } @@ -243,14 +245,28 @@ export class SupernoteX { const address = readUintLE(chunk, 0, this.addressSize); const data = parseKeyValue(buffer, address, this.lengthFieldSize); const nested = extractNestedKeyValue(data, '_', ['PAGE']); + + // KEYWORD and LINKO entries can appear multiple times in the footer (the + // format is journaled/append-only) causing parseKeyValue to store them as + // arrays. extractNestedKeyValue skips array values, so we extract these two + // groups directly from the raw data to preserve every entry. + const keyword: Record = {}; + const linko: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (key.startsWith('KEYWORD_')) + keyword[key.slice('KEYWORD_'.length)] = value; + else if (key.startsWith('LINKO_')) + linko[key.slice('LINKO_'.length)] = value; + } + this.footer = { - FILE: { FEATURE: '24' }, - COVER: { '0': '0' }, - KEYWORD: {}, - TITLE: {}, - STYLE: {}, - PAGE: {}, - ...nested, + FILE: (nested.FILE as Record) ?? { FEATURE: '24' }, + COVER: (nested.COVER as Record) ?? { '0': '0' }, + TITLE: nested.TITLE ?? {}, + STYLE: (nested.STYLE as Record) ?? {}, + PAGE: (nested.PAGE as Record) ?? {}, + KEYWORD: keyword, + LINKO: linko, }; return this.footer; } @@ -480,22 +496,24 @@ export class SupernoteX { /** Parse a single keyword entry at a certain buffer address. */ _parseKeyword(buffer: Uint8Array, address: number): IKeyword { const data = parseKeyValue(buffer, address, this.lengthFieldSize); - const bitmapBuffer = getContentAtAddress( - buffer, - parseInt((data.KEYWORDSITE as string) ?? '0'), - this.lengthFieldSize, - ); - const keyword: IKeyword = { + const siteAddress = parseInt((data.KEYWORDSITE as string) ?? '0'); + // KEYWORDSITE points to a length-prefixed block containing the OCR text + // of the starred word (not a bitmap). + const textContent = getContentAtAddress(buffer, siteAddress, this.lengthFieldSize); + const keywordText = textContent + ? uint8ArrayToString(textContent).replace(/\0/g, '').trim() + : ''; + return { KEYWORDSEQNO: '0', KEYWORDPAGE: '1', KEYWORDRECT: ['0', '0', '0', '0'], KEYWORDRECTORI: ['0', '0', '0', '0'], KEYWORDSITE: '0', KEYWORDLEN: '0', - KEYWORD: '', - bitmapBuffer, + ...data, + KEYWORD: keywordText, + bitmapBuffer: textContent, }; - return keyword; } /** Parse titles from Supernote file's buffer contents. */ @@ -513,6 +531,67 @@ export class SupernoteX { return this.titles; } + /** Parse links from Supernote file's buffer contents. */ + _parseLinks(buffer: Uint8Array): Record { + this.links = {}; + Object.entries(this.footer.LINKO).forEach(([key, value]) => { + if (!(key in this.links)) this.links[key] = []; + if (typeof value === 'string') + this.links[key].push(this._parseLink(buffer, parseInt(value))); + else + value.forEach((address) => + this.links[key].push(this._parseLink(buffer, parseInt(address))), + ); + }); + return this.links; + } + + /** Parse a single link entry at a certain buffer address. */ + _parseLink(buffer: Uint8Array, address: number): ILink { + const data = parseKeyValue(buffer, address, this.lengthFieldSize); + const bitmapBuffer = getContentAtAddress( + buffer, + parseInt((data.LINKBITMAP as string) ?? '0'), + this.lengthFieldSize, + ); + // LINKFILE is a base64-encoded absolute path on the device, e.g.: + // /storage/emulated/0/Note/Folder/MyNote.note + let text = ''; + if (data.LINKFILE) { + try { + const path = atob(data.LINKFILE as string); + text = path.split('/').pop()?.replace(/\.note$/i, '') ?? ''; + } catch { + // ignore malformed base64 + } + } + // Append #PageN when the target page exists in this document. + const pageid = data.PAGEID as string | undefined; + if (pageid && pageid !== '0' && pageid !== 'none') { + const pageIndex = this.pages.findIndex(p => p.PAGEID === pageid); + if (pageIndex >= 0) { + text += `#Page ${pageIndex + 1}`; + } + } + return { + LINKTYPE: '0', + LINKINOUT: '0', + LINKBITMAP: '0', + LINKSTYLE: '0', + LINKTIMESTAMP: '0', + LINKRECT: '0', + LINKRECTORI: '0', + LINKPROTOCAL: 'RATTA_RLE', + LINKFILE: '', + LINKFILEID: '0', + PAGEID: '0', + OBJPAGE: '0', + ...data, + text, + bitmapBuffer, + }; + } + /** Parse a single title entry at a certain buffer address. */ _parseTitle(buffer: Uint8Array, address: number): ITitle { const data = parseKeyValue(buffer, address, this.lengthFieldSize); diff --git a/tests/input/nomad-3.26.40-blank-2p.note b/tests/input/nomad-3.26.40-blank-2p.note new file mode 100644 index 0000000..42e7bed Binary files /dev/null and b/tests/input/nomad-3.26.40-blank-2p.note differ diff --git a/tests/input/nomad-3.26.40-link-tag-3p.note b/tests/input/nomad-3.26.40-link-tag-3p.note new file mode 100644 index 0000000..4951f90 Binary files /dev/null and b/tests/input/nomad-3.26.40-link-tag-3p.note differ diff --git a/tests/main.test.ts b/tests/main.test.ts index 29c94fa..601d1f6 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -47,6 +47,24 @@ describe("image", () => { }, { timeout: 30000 }) }) +describe("links", () => { + test("appends #PageN anchor to same-file links via PAGEID; cross-file and no-page links have no anchor", async () => { + let sn = new SupernoteX(await readFileToUint8Array("nomad-3.26.40-link-tag-3p.note")) + const allLinks = Object.values(sn.links).flat() + // Link to page 1 of the same file should include the page anchor. + const sameFileLink = allLinks.find(l => l.text.startsWith("nomad-3.26.40-link-tag-3p")) + expect(sameFileLink).toBeDefined() + expect(sameFileLink!.text).toBe("nomad-3.26.40-link-tag-3p#Page 1") + // Cross-file link with a PAGEID (target page not in this document) has no anchor. + const crossFileWithPage = allLinks.find(l => l.PAGEID !== '0' && l.PAGEID !== 'none' && l.text.startsWith("nomad-3.26.40-blank-2p") && !l.text.includes('#')) + expect(crossFileWithPage).toBeDefined() + // Cross-file link without a page (PAGEID 'none') has no anchor. + const crossFileNoPage = allLinks.find(l => l.PAGEID === 'none') + expect(crossFileNoPage).toBeDefined() + expect(crossFileNoPage!.text).toBe("nomad-3.26.40-blank-2p") + }) +}) + describe("nomad", () => { test("convert a note from a nomad Chauvet 3.15.27 to png pages", async () => { let sn = new SupernoteX(await readFileToUint8Array("nomad-3.15.27-blank-2p.note")) @@ -260,3 +278,44 @@ describe("mirror", () => { }, { timeout: 30000 }) }) */ + + + + +describe("keywords", () => { + test("parses all keyword stars with correct text and page via key prefix", async () => { + const sn = new SupernoteX(await readFileToUint8Array("nomad-3.26.40-link-tag-3p.note")) + + const allKeywords = Object.entries(sn.keywords).flatMap(([key, kws]) => + kws.map(kw => ({ key, kw })) + ) + + // Three keyword stars on page 3. + expect(allKeywords).toHaveLength(3) + + const texts = allKeywords.map(({ kw }) => kw.KEYWORD) + expect(texts).toContain("Supemote Keyword") + expect(texts).toContain("Multiple keywords") + expect(texts).toContain("New tag") + + // The KEYWORD footer key encodes the source page as its first 4 digits (1-indexed). + // This is the reliable page indicator — KEYWORDPAGE can be '0' (invalid). + for (const { key } of allKeywords) { + expect(parseInt(key.slice(0, 4))).toBe(3) + } + + // "New tag" has KEYWORDPAGE='0' (invalid) but the key prefix is correct. + const newTagEntry = allKeywords.find(({ kw }) => kw.KEYWORD === "New tag") + expect(newTagEntry).toBeDefined() + expect(newTagEntry!.kw.KEYWORDPAGE).toBe('0') + expect(parseInt(newTagEntry!.key.slice(0, 4))).toBe(3) + }) + + test("page 3 OCR text includes the # new tag line that maps to the keyword", async () => { + const sn = new SupernoteX(await readFileToUint8Array("nomad-3.26.40-link-tag-3p.note")) + const page3Text = sn.pages[2].text + expect(page3Text).toContain("# new tag") + expect(page3Text).toContain("# TITLE") + expect(page3Text).toContain("Multiple keywords") + }) +})