// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_ONENOTE_PARSER_LIMITS, parseOneNoteSectionContent, type OneNoteContentBlock, type OneNoteTextBlock, } from '../src/onenote/index.js'; import type { ParserDiagnostic } from '../src/onenote/model/diagnostics.js'; import { parseContentObjects, unionInkBoundingBoxes, } from '../src/onenote/one/content.js'; import { JCID, PROPERTY } from '../src/onenote/one/constants.js'; import type { ExGuid, ObjectPropSet, ObjectSpace, PropertyEntry, StoreObject, } from '../src/onenote/onestore/types.js'; import { exGuidKey } from '../src/onenote/onestore/types.js'; const fixture = Buffer.from( readFileSync( join(process.cwd(), 'tests/fixtures/onenote_desktop.one.b64'), 'utf8' ).replace(/\s/g, ''), 'base64' ); describe('OneNote rich-content model', () => { it('retains structured blocks, formatting, and lazy resources', () => { const model = parseOneNoteSectionContent(fixture, { sourceName: 'onenote_desktop.one', }); const blocks = model.pages.flatMap((page) => collectBlocks(page.blocks)); expect(model.pages).toHaveLength(3); expect(model.pages[0]!.text).toContain('TEST\tD'); expect(blocks.some((block) => block.kind === 'table')).toBe(true); expect(blocks.some((block) => block.kind === 'image')).toBe(true); const ink = blocks.filter((block) => block.kind === 'ink'); expect(ink).toHaveLength(11); expect( ink .flatMap((block) => block.strokes) .reduce((total, stroke) => total + stroke.points.length, 0) ).toBe(1316); expect(ink[0]!.strokes[0]!.points.slice(0, 3)).toEqual([ { x: 3360, y: 5848 }, { x: 3360, y: 5854 }, { x: 3360, y: 5860 }, ]); expect(ink[0]!.boundingBox).toEqual({ x: 2152, y: 5848, width: 1208, height: 3992, }); const testing = textBlock(blocks, 'Testing!'); expect(testing.runs.map((run) => run.text)).toEqual(['Testing', '!']); expect(testing.runs[0]!.style.underline).toBe(true); expect(testing.runs[1]!.style.underline).toBe(false); expect( textBlock(blocks, 'This page is nearly empty').runs[1] ).toMatchObject({ text: 'nearly empty', style: { italic: true }, }); const table = blocks.find((block) => block.kind === 'table'); if (!table || table.kind !== 'table') throw new Error('Table is missing'); expect(table.rows.length).toBeGreaterThan(0); expect(table.rows.flatMap((row) => row.cells).length).toBeGreaterThan(0); expect(table.columnWidths.every(Number.isFinite)).toBe(true); expect(table.lockedColumns).toHaveLength( table.declaredColumnCount ?? table.lockedColumns.length ); expect([...model.resources.values()]).toHaveLength(1); const resource = [...model.resources.values()][0]!; expect(resource).toMatchObject({ kind: 'image', extension: 'png', mediaType: 'image/png', browserRenderable: true, availability: 'available', size: 2639, }); expect(resource.blob?.source).toHaveLength(fixture.length); expect( resource.blob?.source.subarray( resource.blob.offset, resource.blob.offset + 8 ) ).toEqual(Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10)); const diagnosticCodes = model.diagnostics.map(({ code }) => code); expect(diagnosticCodes).not.toContain('CONTENT_PARSE_FAILED'); expect(diagnosticCodes).not.toContain('INVALID_TABLE_VECTOR'); expect(diagnosticCodes).not.toContain('INK_STROKE_PARSE_FAILED'); }); it('associates only allow-listed hyperlink marker targets', () => { const safe = parseSyntheticLink('https://example.com/path'); expect(safe.text).toBe('Example'); expect( safe.runs .filter((run) => !run.style.hidden) .map((run) => [run.text, run.href]) ).toEqual([ ['Exam', 'https://example.com/path'], ['ple', 'https://example.com/path'], ]); const unsafe = parseSyntheticLink('javascript:alert(1)'); expect(unsafe.text).toBe('Example'); expect( unsafe.runs.filter((run) => !run.style.hidden).map((run) => run.href) ).toEqual([undefined, undefined]); }); it('retains an embedded-file node as a downloadable lazy resource', () => { const guid = '{22222222-2222-2222-2222-222222222222}'; const attachmentId = id(guid, 1); const containerId = id(guid, 2); const payload = Uint8Array.of(1, 2, 3, 4); const attachment = object( attachmentId, JCID.EmbeddedFileNode, { ...emptyProps(), objectIds: [compact(2)], properties: { entries: [ referenceEntry(PROPERTY.EmbeddedFileContainer, 'one'), bytesEntry( PROPERTY.EmbeddedFileName, Buffer.from('report.txt', 'utf16le') ), ], }, }, guid ); const container = object( containerId, JCID.EmbeddedFileContainer, emptyProps(), guid ); container.fileData = { referenceKind: 'embedded', dataReference: '{22222222-2222-2222-2222-222222222222}', extension: '.txt', blob: { source: payload, offset: 0, size: payload.length }, }; const resources = new Map(); const space: ObjectSpace = { id: id(guid, 0), roots: new Map(), objects: new Map([ [exGuidKey(attachment.id), attachment], [exGuidKey(container.id), container], ]), }; const blocks = parseContentObjects([attachmentId], { space, limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS }, diagnostics: [], resources, }); expect(blocks).toEqual([ expect.objectContaining({ kind: 'attachment', filename: 'report.txt', size: 4, resourceId: exGuidKey(containerId), }), ]); expect(resources.get(exGuidKey(containerId))).toMatchObject({ kind: 'attachment', extension: 'txt', mediaType: 'text/plain', availability: 'available', size: 4, }); }); it('bounds aggregate ink point expansion', () => { const model = parseOneNoteSectionContent(fixture, { limits: { maxInkPoints: 100 }, }); expect(model.diagnostics).toContainEqual( expect.objectContaining({ code: 'INK_STROKE_PARSE_FAILED', message: expect.stringContaining('Ink point count exceeds 100'), }) ); const blocks = model.pages.flatMap((page) => collectBlocks(page.blocks)); const parsedPoints = blocks .filter((block) => block.kind === 'ink') .flatMap((block) => block.strokes) .reduce((total, stroke) => total + stroke.points.length, 0); expect(parsedPoints).toBeLessThanOrEqual(100); }); it('combines a large number of child ink bounds without argument spreading', () => { const boxes = Array.from({ length: 200_000 }, (_, index) => ({ x: index, y: -index, width: 2, height: 3, })); expect(unionInkBoundingBoxes([undefined, ...boxes])).toEqual({ x: 0, y: -199_999, width: 200_001, height: 200_002, }); }); it('bounds retained rich text across content blocks', () => { const guid = '{33333333-3333-3333-3333-333333333333}'; const firstId = id(guid, 1); const secondId = id(guid, 2); const richTextObject = (objectId: ExGuid, text: string) => object( objectId, JCID.RichTextNode, props( bytesEntry(PROPERTY.RichEditTextUnicode, Buffer.from(text, 'utf16le')) ), guid ); const objects = [ richTextObject(firstId, 'first'), richTextObject(secondId, 'second'), ]; const diagnostics: ParserDiagnostic[] = []; const blocks = parseContentObjects([firstId, secondId], { space: { id: id(guid, 0), roots: new Map(), objects: new Map( objects.map((value) => [exGuidKey(value.id), value] as const) ), }, limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS, maxExtractedTextChars: 8 }, diagnostics, resources: new Map(), }); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ kind: 'text', text: 'first' }); expect(diagnostics).toContainEqual( expect.objectContaining({ code: 'CONTENT_PARSE_FAILED', message: expect.stringContaining('Rich-text character count exceeds 8'), }) ); }); }); function parseSyntheticLink(href: string): OneNoteTextBlock { const guid = '{11111111-1111-1111-1111-111111111111}'; const richId = id(guid, 1); const paragraphId = id(guid, 2); const hiddenId = id(guid, 3); const visibleId = id(guid, 4); const secondVisibleId = id(guid, 5); const marker = `\ufddfHYPERLINK "${href}"`; const text = `${marker}Example`; const objects = [ object( richId, JCID.RichTextNode, { ...emptyProps(), objectIds: [compact(2), compact(3), compact(4), compact(5)], properties: { entries: [ referenceEntry(PROPERTY.ParagraphStyle, 'one'), referenceEntry(PROPERTY.TextRunFormatting, 'many', 3), bytesEntry( PROPERTY.TextRunIndex, u32Vector(marker.length, marker.length + 4) ), bytesEntry( PROPERTY.RichEditTextUnicode, Buffer.from(text, 'utf16le') ), ], }, }, guid ), object(paragraphId, JCID.ParagraphStyleObject, emptyProps(), guid), object( hiddenId, JCID.ParagraphStyleObject, props( boolEntry(PROPERTY.Hidden, true), boolEntry(PROPERTY.Hyperlink, true) ), guid ), object( visibleId, JCID.ParagraphStyleObject, props( boolEntry(PROPERTY.HyperlinkProtected, true), boolEntry(PROPERTY.Underline, true) ), guid ), object( secondVisibleId, JCID.ParagraphStyleObject, props( boolEntry(PROPERTY.HyperlinkProtected, true), boolEntry(PROPERTY.Italic, true) ), guid ), ]; const space: ObjectSpace = { id: id(guid, 0), roots: new Map(), objects: new Map(objects.map((value) => [exGuidKey(value.id), value])), }; const blocks = parseContentObjects([richId], { space, limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS }, diagnostics: [], resources: new Map(), }); expect(blocks[0]?.kind).toBe('text'); return blocks[0] as OneNoteTextBlock; } function collectBlocks( blocks: readonly OneNoteContentBlock[] ): OneNoteContentBlock[] { const result: OneNoteContentBlock[] = []; for (const block of blocks) { result.push(block); if (block.kind === 'outline' || block.kind === 'outline-group') { result.push(...collectBlocks(block.children)); } else if (block.kind === 'outline-element') { result.push(...collectBlocks(block.contents)); result.push(...collectBlocks(block.children)); } else if (block.kind === 'table') { for (const row of block.rows) { for (const cell of row.cells) { result.push(...collectBlocks(cell.blocks)); } } } } return result; } function textBlock( blocks: readonly OneNoteContentBlock[], text: string ): OneNoteTextBlock { const result = blocks.find( (block): block is OneNoteTextBlock => block.kind === 'text' && block.text.includes(text) ); expect(result).toBeDefined(); return result!; } function object( objectId: ExGuid, jcid: number, objectProps: ObjectPropSet, mappedGuid: string ): StoreObject { return { id: objectId, jcid, props: objectProps, mapping: new Map([[1, mappedGuid]]), contextId: id(mappedGuid, 0), }; } function id(guid: string, value: number): ExGuid { return { guid, value }; } function compact(value: number): { guidIndex: number; value: number } { return { guidIndex: 1, value }; } function emptyProps(): ObjectPropSet { return { objectIds: [], objectSpaceIds: [], contextIds: [], properties: { entries: [] }, }; } function props(...entries: PropertyEntry[]): ObjectPropSet { return { ...emptyProps(), properties: { entries } }; } function referenceEntry( rawId: number, cardinality: 'one' | 'many', count = 1 ): PropertyEntry { return { rawId, id: rawId & 0x03ffffff, type: cardinality === 'one' ? 0x8 : 0x9, value: cardinality === 'one' ? { kind: 'objectId' } : { kind: 'objectIds', count }, }; } function bytesEntry(rawId: number, value: Uint8Array): PropertyEntry { return { rawId, id: rawId & 0x03ffffff, type: 0x7, value: { kind: 'bytes', value }, }; } function boolEntry(rawId: number, value: boolean): PropertyEntry { return { rawId, id: rawId & 0x03ffffff, type: 0x2, value: { kind: 'bool', value }, }; } function u32Vector(...values: number[]): Uint8Array { const result = Buffer.alloc(values.length * 4); values.forEach((value, index) => result.writeUInt32LE(value, index * 4)); return result; }