Files
onenote-tools/tests/content-model.test.ts

316 lines
9.1 KiB
TypeScript

// 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 { parseContentObjects } 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.find((run) => run.text === 'Example')?.href).toBe(
'https://example.com/path'
);
const unsafe = parseSyntheticLink('javascript:alert(1)');
expect(unsafe.text).toBe('Example');
expect(
unsafe.runs.find((run) => run.text === 'Example')?.href
).toBeUndefined();
});
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);
});
});
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 marker = `\ufddfHYPERLINK "${href}"`;
const text = `${marker}Example`;
const objects = [
object(
richId,
JCID.RichTextNode,
{
...emptyProps(),
objectIds: [compact(2), compact(3), compact(4)],
properties: {
entries: [
referenceEntry(PROPERTY.ParagraphStyle, 'one'),
referenceEntry(PROPERTY.TextRunFormatting, 'many', 2),
bytesEntry(PROPERTY.TextRunIndex, u32Vector(marker.length)),
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
),
];
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;
}