/* This file is part of the Notesnook project (https://notesnook.com/) Copyright (C) 2023 Streetwriters (Private) Limited This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import { ObjectProps } from "../one/props"; import { PropertyType } from "../one/property-type"; import { ParagraphStyleData } from "../one/property-set"; import { ColorRef, FileType, decodeSignedMultiByte, parseEmbeddedFileContainer, parseEmbeddedFileNode, parseEmbeddedInkContainer, parseImageNode, parseInkContainer, parseInkDataNode, parseInkStrokeNode, parseNoteTagContainers, parseNoteTagSharedDefinitionContainer, parseNumberListNode, parseOutlineElementNode, parseOutlineGroup, parseOutlineNode, parsePageManifest, parsePageMetadata, parsePageNode, parsePageSeriesNode, parseParagraphStyleObject, parsePictureContainer, parseRecognizedTextChildren, parseRecognizedTextWord, parseRichTextNode, parseSectionMetadata, parseSectionNode, parseStrokePropertiesNode, parseTableCellNode, parseTableNode, parseTableRowNode, parseTitleNode, parseTocContainer, X_DIMENSION_GUID, Y_DIMENSION_GUID } from "../one/property-set"; import { ObjectSpace } from "../onestore/object-space"; import { OneNoteObject } from "../onestore/object-space"; import { Content, EmbeddedFile, EmbeddedObject, Image, Ink, InkBoundingBox, InkContent, InkPoint, InkRecognition, InkRecognizedLine, InkRecognizedWord, InkStroke, List, NoteTag, NoteTagDefinition, Outline, OutlineElement, OutlineGroup, OutlineItem, Page, PageContent, PageSeries, ParagraphStyling, RichText, Section, Table, TableCell, TableRow, TextHyperlink, Title } from "./types"; export function propsOf(space: ObjectSpace, id: string): ObjectProps { const object = space.getObject(id); if (!object) throw new Error(`object ${id} is missing`); return new ObjectProps(object, object.mapping); } function getObject(space: ObjectSpace, id: string): OneNoteObject { const object = space.getObject(id); if (!object) throw new Error(`object ${id} is missing`); return object; } // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- function parseContent(id: string, space: ObjectSpace): Content { const object = getObject(space, id); switch (object.jcid.id) { case 0x00060011: // ImageNode return { type: "image", image: parseImage(id, space) }; case 0x00060035: // EmbeddedFileNode return { type: "embeddedFile", embeddedFile: parseEmbeddedFile(id, space) }; case 0x0006000e: // RichTextNode return { type: "richText", richText: parseRichText(id, space) }; case 0x00060022: // TableNode return { type: "table", table: parseTable(id, space) }; case 0x00060014: // InkContainer return { type: "ink", ink: parseInk(id, space) }; default: return { type: "unknown" }; } } // --------------------------------------------------------------------------- // Rich text // --------------------------------------------------------------------------- const INK_SPACE_BLOB = 0x00020026; const INK_END_OF_LINE_BLOB = 0x00020027; const HYPERLINK_MARKER = "\ufddfHYPERLINK \""; const warnedMissingStyles = new Set(); function parseRichText(id: string, space: ObjectSpace): RichText { const props = propsOf(space, id); const data = parseRichTextNode(props); const paragraphStyle = data.paragraphStyle ? parseStyle( parseParagraphStyleObject(propsOf(space, data.paragraphStyle)) ) : defaultStyle(); const styleData = data.textRunFormatting.map((styleId) => { const object = space.getObject(styleId); if (!object) { if (!warnedMissingStyles.has(styleId)) { warnedMissingStyles.add(styleId); console.warn(`missing style for text run formatting: ${styleId}`); } return fallbackStyleData(); } return parseParagraphStyleObject(new ObjectProps(object, object.mapping)); }); // Text run data (sub property sets containing embedded object data) const textRunDataProp = props.propertyValues(PropertyType.TextRunData); const textRunData = textRunDataProp ? textRunDataProp.propertySets.map((set) => props.subObject(PropertyType.TextRunData, textRunDataProp.propertyId, set) ) : []; // Embedded objects const embeddedObjects: EmbeddedObject[] = []; { let objectsWithoutRef = 0; for (let i = 0; i < textRunData.length; ++i) { const embeddedData = textRunData[i]; const style = styleData[i]; if (!style.textRunIsEmbeddedObject) continue; const objectRefIndex = i - objectsWithoutRef; const objectRef = data.textRunDataObject[objectRefIndex]; if (style.textRunObjectType === INK_END_OF_LINE_BLOB) { embeddedObjects.push({ type: "inkLineBreak" }); continue; } if (style.textRunObjectType === INK_SPACE_BLOB) { const embedded = parseEmbeddedInkContainer(embeddedData); const width = embedded.spaceWidth ?? 0; const height = embedded.spaceHeight ?? 0; embeddedObjects.push({ type: "inkSpace", width, height }); objectsWithoutRef += 1; continue; } if (objectRef) { const ink = parseInkData( objectRef, space, undefined, undefined ); const embedded = parseEmbeddedInkContainer(embeddedData); const boundingBox = embedded.startX !== undefined && embedded.startY !== undefined && embedded.height !== undefined && embedded.width !== undefined ? { x: embedded.startX, y: embedded.startY, height: embedded.height, width: embedded.width } : undefined; embeddedObjects.push({ type: "ink", ink: { content: { type: "strokes", strokes: ink.strokes }, boundingBox: ink.boundingBox, offsetHorizontal: embedded.offsetHoriz, offsetVertical: embedded.offsetVert }, boundingBox }); continue; } } } const text = embeddedObjects.length > 0 ? "" : data.text ?? ""; const textRunIndices = [...data.textRunIndices]; const styles = styleData.map(parseStyle); fixLeadingVtMisalignment(textRunIndices, styles); const noteTags = parseNoteTags(data.noteTags, space); const hyperlinks = extractHyperlinks(text, textRunIndices, styles); return { text, textRunFormatting: styles, textRunIndices, paragraphStyle, paragraphSpaceBefore: data.paragraphSpaceBefore, paragraphSpaceAfter: data.paragraphSpaceAfter, paragraphLineSpacingExact: data.paragraphLineSpacingExact, paragraphAlignment: data.paragraphAlignment, layoutAlignmentInParent: data.layoutAlignmentInParent, layoutAlignmentSelf: data.layoutAlignmentSelf, noteTags, embeddedObjects, hyperlinks }; } function fixLeadingVtMisalignment( indices: number[], styles: ParagraphStyling[] ) { if (indices[0] !== 1) return; const diff = styles.length - indices.length; if (diff !== 0 && diff !== 1) return; indices.shift(); for (let i = 0; i < indices.length; ++i) indices[i] -= 1; if (diff === 1) styles.pop(); } function extractHyperlinks( text: string, indices: number[], styles: ParagraphStyling[] ): TextHyperlink[] { const total = toUtf16Length(text); const runs = textRuns(total, indices, styles); const links: TextHyperlink[] = []; let searchFrom = 0; while (true) { const relativeStart = text.indexOf(HYPERLINK_MARKER, searchFrom); if (relativeStart === -1) break; const markerStart = relativeStart; const targetStart = markerStart + HYPERLINK_MARKER.length; const relativeEnd = text.indexOf('"', targetStart); if (relativeEnd === -1) break; const targetEnd = relativeEnd; const markerEnd = targetEnd + 1; searchFrom = markerEnd; const markerStartUtf16 = toUtf16Length(text.slice(0, markerStart)); const markerEndUtf16 = toUtf16Length(text.slice(0, markerEnd)); const overlapping = runs.filter( (run) => run.end > markerStartUtf16 && run.start < markerEndUtf16 ); const markerIsHiddenLink = overlapping.length > 0 && overlapping.every((run) => run.style.hyperlink && run.style.hidden); if (!markerIsHiddenLink) continue; let first: number | undefined; for (let index = 0; index < runs.length; ++index) { const run = runs[index]; if (run.end <= markerEndUtf16) continue; if (run.style.hidden && run.style.hyperlink) continue; if (run.style.hyperlink && !run.style.hidden) { first = index; } break; } if (first === undefined) continue; const start = Math.max(runs[first].start, markerEndUtf16); let end = runs[first].end; for (const run of runs.slice(first + 1)) { if (run.start !== end || !run.style.hyperlink || run.style.hidden) break; end = run.end; } if (start < end && targetStart < targetEnd) { links.push({ target: text.slice(targetStart, targetEnd), start, end }); } } return links; } type TextRun = { start: number; end: number; style: ParagraphStyling; }; function textRuns( total: number, indices: number[], styles: ParagraphStyling[] ): TextRun[] { let start = 0; return styles.map((style, index) => { const end = Math.max( Math.min(indices[index] ?? total, total), start ); const run = { start, end, style }; start = end; return run; }); } function toUtf16Length(text: string): number { let length = 0; for (let i = 0; i < text.length; ++i) { const code = text.charCodeAt(i); length += code >= 0xd800 && code <= 0xdbff ? 2 : 1; } return length; } function fallbackStyleData(): ParagraphStyleData { return { charset: undefined, bold: false, italic: false, underline: false, strikethrough: false, superscript: false, subscript: false, mathFormatting: false, hyperlink: false, hyperlinkProtected: false, hidden: false, textRunIsEmbeddedObject: false }; } function defaultStyle(): ParagraphStyling { return { bold: false, italic: false, underline: false, strikethrough: false, superscript: false, subscript: false, mathFormatting: false, hyperlink: false, hyperlinkProtected: false, hidden: false }; } function parseStyle(data: ParagraphStyleData): ParagraphStyling { return { charset: data.charset, bold: data.bold, italic: data.italic, underline: data.underline, strikethrough: data.strikethrough, superscript: data.superscript, subscript: data.subscript, font: data.font, fontSize: data.fontSize, fontColor: data.fontColor, highlight: data.highlight, nextStyle: data.nextStyle, styleId: data.styleId, paragraphAlignment: data.paragraphAlignment, paragraphSpaceBefore: data.paragraphSpaceBefore, paragraphSpaceAfter: data.paragraphSpaceAfter, paragraphLineSpacingExact: data.paragraphLineSpacingExact, languageCode: data.languageCode, mathFormatting: data.mathFormatting, hyperlink: data.hyperlink, hyperlinkProtected: data.hyperlinkProtected, hidden: data.hidden }; } // --------------------------------------------------------------------------- // Note tags // --------------------------------------------------------------------------- function parseNoteTags( containers: ReturnType, space: ObjectSpace ): NoteTag[] { return containers.map((container) => ({ completedAt: container.completedAtMs, itemStatus: container.itemStatus, definition: container.definition ? parseNoteTagDefinition(container.definition, space) : undefined })); } function parseNoteTagDefinition( definitionId: string, space: ObjectSpace ): NoteTagDefinition { const data = parseNoteTagSharedDefinitionContainer( propsOf(space, definitionId) ); return { label: data.label, status: data.status, shape: data.shape, highlightColor: data.highlightColor, textColor: data.textColor, actionItemType: data.actionItemType }; } // --------------------------------------------------------------------------- // Outline // --------------------------------------------------------------------------- function parseOutline(id: string, space: ObjectSpace): Outline { const data = parseOutlineNode(propsOf(space, id)); return { childLevel: data.childLevel, listSpacing: data.listSpacing, indents: data.indents, alignmentInParent: data.alignmentInParent, alignmentSelf: data.alignmentSelf, layoutMaxHeight: data.layoutMaxHeight, layoutMaxWidth: data.layoutMaxWidth, layoutReservedWidth: data.layoutReservedWidth, layoutMinimumOutlineWidth: data.layoutMinimumOutlineWidth, isLayoutSizeSetByUser: data.isLayoutSizeSetByUser, offsetHorizontal: data.offsetHorizontal, offsetVertical: data.offsetVertical, items: data.children.map((itemId) => parseOutlineItem(itemId, space)) }; } function parseOutlineItem(id: string, space: ObjectSpace): OutlineItem { const object = getObject(space, id); switch (object.jcid.id) { case 0x00060019: // OutlineGroup return { type: "group", group: parseOutlineGroupObject(id, space) }; case 0x0006000d: // OutlineElementNode return { type: "element", element: parseOutlineElement(id, space) }; default: throw new Error(`invalid outline item type: 0x${object.jcid.id.toString(16)}`); } } function parseOutlineGroupObject(id: string, space: ObjectSpace): OutlineGroup { const data = parseOutlineGroup(propsOf(space, id)); return { childLevel: data.childLevel, outlines: data.children.map((itemId) => parseOutlineItem(itemId, space)) }; } function parseOutlineElement(id: string, space: ObjectSpace): OutlineElement { const data = parseOutlineElementNode(propsOf(space, id)); return { childLevel: data.childLevel, listSpacing: data.listSpacing, children: data.children.map((itemId) => parseOutlineItem(itemId, space)), contents: data.contents.map((contentId) => parseContent(contentId, space)), listContents: data.listContents.map((listId) => parseList(listId, space)) }; } function parseList(id: string, space: ObjectSpace): List { const data = parseNumberListNode(propsOf(space, id)); return { listFont: data.listFont, listRestart: data.listRestart, listFormat: data.listFormat, bold: data.bold, italic: data.italic, font: data.font, fontSize: data.fontSize, fontColor: data.fontColor }; } // --------------------------------------------------------------------------- // Table // --------------------------------------------------------------------------- function parseTable(id: string, space: ObjectSpace): Table { const data = parseTableNode(propsOf(space, id)); return { rows: data.rowCount, cols: data.colCount, contents: data.rows.map((rowId) => parseRow(rowId, space)), colsLocked: data.colsLocked, colWidths: data.colWidths, bordersVisible: data.bordersVisible, layoutAlignmentInParent: data.layoutAlignmentInParent, layoutAlignmentSelf: data.layoutAlignmentSelf, noteTags: parseNoteTags(data.noteTags, space) }; } function parseRow(id: string, space: ObjectSpace): TableRow { const data = parseTableRowNode(propsOf(space, id)); return { contents: data.cells.map((cellId) => parseCell(cellId, space)) }; } function parseCell(id: string, space: ObjectSpace): TableCell { const data = parseTableCellNode(propsOf(space, id)); return { contents: data.contents.map((elementId) => parseOutlineElement(elementId, space)), backgroundColor: data.backgroundColor, layoutMaxWidth: data.layoutMaxWidth, indents: data.indents }; } // --------------------------------------------------------------------------- // Image // --------------------------------------------------------------------------- function parseImage(id: string, space: ObjectSpace): Image { const props = propsOf(space, id); const node = parseImageNode(props); let data: Uint8Array | undefined; let extension: string | undefined; if (node.pictureContainer) { const container = parsePictureContainer(propsOf(space, node.pictureContainer)); data = container.data; extension = container.extension; } return { data, extension, layoutMaxWidth: node.layoutMaxWidth, layoutMaxHeight: node.layoutMaxHeight, altText: node.altText, layoutAlignmentInParent: node.layoutAlignmentInParent, layoutAlignmentSelf: node.layoutAlignmentSelf, imageFilename: node.imageFilename, displayedPageNumber: node.displayedPageNumber, text: node.text, pictureWidth: node.pictureWidth, pictureHeight: node.pictureHeight, hyperlinkUrl: node.hyperlinkUrl, offsetHorizontal: node.offsetHorizontal, offsetVertical: node.offsetVertical, isBackground: node.isBackground, noteTags: parseNoteTags(node.noteTags, space) }; } // --------------------------------------------------------------------------- // Embedded file // --------------------------------------------------------------------------- function parseEmbeddedFile(id: string, space: ObjectSpace): EmbeddedFile { const props = propsOf(space, id); const node = parseEmbeddedFileNode(props); const filename = node.embeddedFileName ?? ""; let data: Uint8Array = new Uint8Array(0); if (node.embeddedFileContainer) { const containerProps = propsOf(space, node.embeddedFileContainer); if (containerProps.fileData) { data = parseEmbeddedFileContainer(containerProps); } } return { filename, fileType: node.fileType, data, layoutMaxWidth: node.layoutMaxWidth, layoutMaxHeight: node.layoutMaxHeight, offsetHorizontal: node.offsetHorizontal, offsetVertical: node.offsetVertical, noteTags: parseNoteTags(node.noteTags, space) }; } // --------------------------------------------------------------------------- // Ink // --------------------------------------------------------------------------- const MAX_INK_NESTING_DEPTH = 16; function parseInk(id: string, space: ObjectSpace): Ink { return parseInkRec(id, space, 0); } function parseInkRec(id: string, space: ObjectSpace, depth: number): Ink { if (depth > MAX_INK_NESTING_DEPTH) { throw new Error("maximum ink nesting depth exceeded"); } const container = parseInkContainer(propsOf(space, id)); if (!container.inkData) { const children = container.children ?? []; const content: InkContent = { type: "group", children: [] }; let boundingBox: InkBoundingBox | undefined; for (const childId of children) { const child = parseInkRec(childId, space, depth + 1); if (child.boundingBox) { boundingBox = boundingBox ? unionBoundingBoxes(boundingBox, child.boundingBox) : child.boundingBox; } (content.children as Ink[]).push(child); } return { content, boundingBox, offsetHorizontal: container.offsetHorizontal, offsetVertical: container.offsetVertical }; } const { strokes, boundingBox } = parseInkData( container.inkData, space, container.inkScalingX, container.inkScalingY ); return { content: { type: "strokes", strokes }, boundingBox, offsetHorizontal: container.offsetHorizontal, offsetVertical: container.offsetVertical }; } function unionBoundingBoxes(a: InkBoundingBox, b: InkBoundingBox): InkBoundingBox { const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y); const x2 = Math.max(a.x + a.width, b.x + b.width); const y2 = Math.max(a.y + a.height, b.y + b.height); return { x, y, width: x2 - x, height: y2 - y }; } function parseInkData( inkDataId: string, space: ObjectSpace, scaleX?: number, scaleY?: number ): { strokes: InkStroke[]; boundingBox?: InkBoundingBox } { if (!space.getObject(inkDataId)) { return { strokes: [] }; } const inkData = parseInkDataNode(propsOf(space, inkDataId)); const strokes = inkData.strokes.map((strokeId) => parseInkStroke(strokeId, space, scaleX, scaleY) ); scaleX = scaleX ?? 1.0; scaleY = scaleY ?? 1.0; const boundingBox = inkData.boundingBox ? { x: inkData.boundingBox.xMin * scaleX, y: inkData.boundingBox.yMin * scaleY, width: (inkData.boundingBox.xMax - inkData.boundingBox.xMin) * scaleX, height: (inkData.boundingBox.yMax - inkData.boundingBox.yMin) * scaleY } : undefined; return { strokes, boundingBox }; } function parseInkStroke( strokeId: string, space: ObjectSpace, scaleX?: number, scaleY?: number ): InkStroke { const data = parseInkStrokeNode(propsOf(space, strokeId)); const props = parseStrokePropertiesNode(propsOf(space, data.properties)); const idxX = props.dimensions.findIndex((d) => d.id === X_DIMENSION_GUID); const idxY = props.dimensions.findIndex((d) => d.id === Y_DIMENSION_GUID); if (idxX === -1) throw new Error("ink stroke properties has no x dimension"); if (idxY === -1) throw new Error("ink stroke properties has no y dimension"); const dimensionOffset = props.dimensions.length === 0 ? 0 : Math.floor(data.path.length / props.dimensions.length); const startX = dimensionOffset * idxX; const startY = dimensionOffset * idxY; const x = data.path.slice(startX, startX + dimensionOffset); const y = data.path.slice(startY, startY + dimensionOffset); scaleX = scaleX ?? 1.0; scaleY = scaleY ?? 1.0; const path: InkPoint[] = []; for (let i = 0; i < x.length; ++i) { path.push({ x: scaleX * x[i], y: scaleY * y[i] }); } return { path, penTip: props.penTip, transparency: props.transparency, height: props.inkHeight, width: props.inkWidth, color: props.color }; } // --------------------------------------------------------------------------- // Ink recognition // --------------------------------------------------------------------------- function parseInkRecognition(id: string, space: ObjectSpace): InkRecognition | undefined { const lines: InkRecognizedLine[] = []; let wordId = 0; for (const lineId of parseRecognizedTextChildren(propsOf(space, id))) { const words: InkRecognizedWord[] = []; for (const blockId of parseRecognizedTextChildren(propsOf(space, lineId))) { for (const wordRefId of parseRecognizedTextChildren(propsOf(space, blockId))) { const word = parseRecognizedTextWord(propsOf(space, wordRefId)); words.push({ id: wordId++, alternatives: word.alternatives, languageId: word.languageId }); } } if (words.length) lines.push({ words }); } if (!lines.length) return undefined; return { lines, text: lines .map((line) => line.words .map((word) => word.alternatives[0] ?? "") .filter((text) => text.length > 0) .join(" ") ) .join("\n") }; } // --------------------------------------------------------------------------- // Page & section // --------------------------------------------------------------------------- function parseTitle(id: string, space: ObjectSpace): Title { const data = parseTitleNode(propsOf(space, id)); return { contents: data.children.map((outlineId) => parseOutline(outlineId, space)), offsetHorizontal: data.offsetHorizontal, offsetVertical: data.offsetVertical, layoutAlignmentInParent: data.layoutAlignmentInParent, layoutAlignmentSelf: data.layoutAlignmentSelf }; } function outlineText(outline: Outline): string | undefined { const element = outline.items[0]; if (!element || element.type !== "element") return undefined; const richText = element.element.contents[0]; if (!richText || richText.type !== "richText") return undefined; const visible = visibleText(richText.richText); return visible.length > 0 ? visible : undefined; } function visibleText(richText: RichText): string { const formatting = richText.textRunFormatting; if (formatting.length === 0) return richText.text; const utf16 = toUtf16Array(richText.text); const indices = richText.textRunIndices; const bounds: number[] = [0, ...indices]; if (bounds.length === formatting.length) bounds.push(utf16.length); let output = ""; for (let i = 0; i < formatting.length; ++i) { if (formatting[i].hidden) continue; const start = Math.min(bounds[i] ?? 0, utf16.length); const end = Math.min(bounds[i + 1] ?? utf16.length, utf16.length); if (start < end) { const chunk = utf16.slice(start, end); const CHUNK_SIZE = 0x8000; for (let i = 0; i < chunk.length; i += CHUNK_SIZE) { output += String.fromCharCode(...chunk.slice(i, i + CHUNK_SIZE)); } } } return output; } function toUtf16Array(text: string): number[] { const result: number[] = []; for (let i = 0; i < text.length; ++i) { const code = text.charCodeAt(i); if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) { const next = text.charCodeAt(i + 1); if (next >= 0xdc00 && next <= 0xdfff) { const full = (code - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000; result.push(0xd800 + Math.floor(full / 0x400), 0xdc00 + (full % 0x400)); i += 1; continue; } } result.push(code); } return result; } function removeHyperlink(title: string): string { const HYPERLINK = "\ufddfHYPERLINK \""; let cleanTitle = title; while (true) { const markerStart = cleanTitle.indexOf(HYPERLINK); if (markerStart === -1) break; const hyperlinkPart = cleanTitle.slice(markerStart + HYPERLINK.length); const quoteEnd = hyperlinkPart.indexOf('"'); if (quoteEnd !== -1) { cleanTitle = cleanTitle.slice(0, markerStart) + hyperlinkPart.slice(quoteEnd + 1); } else { cleanTitle = cleanTitle.slice(0, markerStart); break; } } return cleanTitle; } export function parsePage(pageSpaceId: string, store: { objectSpace(id: string): ObjectSpace | undefined; }): Page { const space = store.objectSpace(pageSpaceId); if (!space) throw new Error(`page space ${pageSpaceId} is missing`); const metadata = parsePageMetadata(propsOf(space, space.metadataRoot() ?? "")); const manifest = parsePageManifest(propsOf(space, space.contentRoot() ?? "")); const pageProps = propsOf(space, manifest.page); const data = parsePageNode(pageProps); const title = data.title ? parseTitle(data.title, space) : undefined; const titleText = title ? outlineText(title.contents[0]) : undefined; const inkRecognition = data.recognizedText ? parseInkRecognition(data.recognizedText, space) : undefined; const contents: PageContent[] = data.content.map((contentId) => { const object = getObject(space, contentId); switch (object.jcid.id) { case 0x00060011: // ImageNode return { type: "image", image: parseImage(contentId, space) }; case 0x00060035: // EmbeddedFileNode return { type: "embeddedFile", embeddedFile: parseEmbeddedFile(contentId, space) }; case 0x0006000c: // OutlineNode return { type: "outline", outline: parseOutline(contentId, space) }; case 0x00060014: // InkContainer return { type: "ink", ink: parseInk(contentId, space) }; default: return { type: "unknown" as const }; } }); return { linkTargetId: metadata.entityGuid, title, titleText: titleText ? removeHyperlink(titleText) : contents .filter((c): c is Extract => c.type === "outline") .map((c) => outlineText(c.outline)) .find((text) => text !== undefined), level: metadata.pageLevel, createdAt: metadata.createdAtMs, updatedAt: data.lastModifiedMs, author: data.author, height: data.pageHeight, contents, inkRecognition }; } export function parseSection( store: { dataRoot: ObjectSpace; objectSpace(id: string): ObjectSpace | undefined }, filename: string ): Section { const space = store.dataRoot; const metadata = parseSectionMetadata(propsOf(space, space.metadataRoot() ?? "")); const content = parseSectionNode(propsOf(space, space.contentRoot() ?? "")); const displayName = metadata.displayName ?? filename.replace(/\.one$/, ""); return { displayName, pageSeries: content.pageSeries.map((pageSeriesId) => parsePageSeries(pageSeriesId, store) ), color: metadata.color }; } function parsePageSeries(id: string, store: { dataRoot: ObjectSpace; objectSpace(id: string): ObjectSpace | undefined; }): PageSeries { const space = store.dataRoot; const data = parsePageSeriesNode(propsOf(space, id)); return { pages: data.pageSpaces.map((pageSpaceId) => parsePage(pageSpaceId, store)) }; } // --------------------------------------------------------------------------- // Notebook // --------------------------------------------------------------------------- export function parseNotebookToc(space: ObjectSpace): { entries: string[]; color?: { alpha: number; r: number; g: number; b: number }; } { const contentRoot = space.contentRoot(); if (!contentRoot) throw new Error("notebook has no content root"); const entry = parseTocEntry(contentRoot, space); return { entries: orderedTocEntries(entry.entries), color: entry.color }; } type TocEntry = { entries: { orderingId: number; name: string }[]; color?: { alpha: number; r: number; g: number; b: number }; }; function parseTocEntry(id: string, space: ObjectSpace): TocEntry { const toc = parseTocContainer(propsOf(space, id)); if (toc.filename !== undefined) { if (toc.orderingId === undefined) throw new Error("section has no order id"); return { entries: [{ orderingId: toc.orderingId, name: toc.filename }], color: toc.color }; } const entries = toc.children.map((childId) => parseTocEntry(childId, space)); return { entries: entries.flatMap((entry) => entry.entries), color: toc.color }; } function orderedTocEntries(entries: { orderingId: number; name: string }[]): string[] { // OneNote can retain older ordering snapshots in the same TOC. Later // references to a filename are authoritative. const seen = new Set(); const latest = entries .map((entry, index) => ({ entry, index })) .filter(({ entry }) => { if (seen.has(entry.name)) return false; seen.add(entry.name); return true; }); latest.reverse(); latest.sort((a, b) => a.entry.orderingId - b.entry.orderingId); return latest.map(({ entry }) => entry.name); }