Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"dependencies": {
"color": "^4.2.3",
"fs-extra": "^10.1.0",
"image-js": "^1.2.0"
"image-js": "1.2.0"
}
}
32 changes: 32 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -22,6 +48,8 @@ export interface ISupernote {
keywords: Record<string, IKeyword[]>;
/** Title locations and styles. */
titles: Record<string, ITitle[]>;
/** Internal links created via the Supernote link feature. */
links: Record<string, ILink[]>;
/** Pages information. */
pages: IPage[];
/** Cover page. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -220,6 +250,8 @@ export interface IFooter {
KEYWORD: Record<string, string | string[]>;
/** Title values. Mappings of TITLE_{key}:{value}. */
TITLE: Record<string, string | string[]>;
/** Link values. Mappings of LINKO_{key}:{value}. */
LINKO: Record<string, string | string[]>;
/** Style values. Mappings of STYLE_{key}:{value}. */
STYLE: Record<string, string>;
/** Page values. Mappings of PAGE{key}:{value}. */
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { SupernoteX } from './parsing';
export { toImage } from './conversion';
export { fetchMirrorFrame } from './mirror';
export type { ILink, IPage } from './format';
111 changes: 95 additions & 16 deletions src/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ILayer,
ILayerInfo,
ILayerNames,
ILink,
RecognitionStatuses,
IRecognitionElement,
IPage,
Expand Down Expand Up @@ -222,6 +223,7 @@ export class SupernoteX {
this._parseCover(buffer);
this._parseKeywords(buffer);
this._parseTitles(buffer);
this._parseLinks(buffer);
return this;
}

Expand All @@ -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<string, string | string[]> = {};
const linko: Record<string, string | string[]> = {};
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<string, string>) ?? { FEATURE: '24' },
COVER: (nested.COVER as Record<string, string>) ?? { '0': '0' },
TITLE: nested.TITLE ?? {},
STYLE: (nested.STYLE as Record<string, string>) ?? {},
PAGE: (nested.PAGE as Record<string, string>) ?? {},
KEYWORD: keyword,
LINKO: linko,
};
return this.footer;
}
Expand Down Expand Up @@ -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. */
Expand All @@ -513,6 +531,67 @@ export class SupernoteX {
return this.titles;
}

/** Parse links from Supernote file's buffer contents. */
_parseLinks(buffer: Uint8Array): Record<string, ILink[]> {
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);
Expand Down
Binary file added tests/input/nomad-3.26.40-blank-2p.note
Binary file not shown.
Binary file added tests/input/nomad-3.26.40-link-tag-3p.note
Binary file not shown.
59 changes: 59 additions & 0 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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")
})
})