427 lines
12 KiB
TypeScript
427 lines
12 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 type { ParserDiagnostic } from '../src/onenote/model/diagnostics.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('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: '<ifndf>{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('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 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;
|
|
}
|