feat: decode OneNote ink strokes

This commit is contained in:
2026-07-22 19:16:08 +02:00
parent 30b0357530
commit 141be03f5e
5 changed files with 424 additions and 15 deletions

View File

@@ -19,6 +19,10 @@ export type {
OneNoteColor, OneNoteColor,
OneNoteContentBlock, OneNoteContentBlock,
OneNoteImageBlock, OneNoteImageBlock,
OneNoteInkBlock,
OneNoteInkBoundingBox,
OneNoteInkPoint,
OneNoteInkStroke,
OneNotePageContentModel, OneNotePageContentModel,
OneNoteResource, OneNoteResource,
OneNoteSectionContentModel, OneNoteSectionContentModel,

View File

@@ -112,6 +112,7 @@ export const PROPERTY = {
InkScalingY: 0x14001c47, InkScalingY: 0x14001c47,
InkBoundingBox: 0x1c003418, InkBoundingBox: 0x1c003418,
InkPath: 0x1c00340b, InkPath: 0x1c00340b,
InkBias: 0x0c00341c,
InkStrokeProperties: 0x20003409, InkStrokeProperties: 0x20003409,
InkDimensions: 0x1c00340a, InkDimensions: 0x1c00340a,
InkPenTip: 0x0c003412, InkPenTip: 0x0c003412,

View File

@@ -145,11 +145,37 @@ export interface OneNoteOutlineGroupBlock {
children: OneNoteContentBlock[]; children: OneNoteContentBlock[];
} }
export interface OneNoteInkPoint {
x: number;
y: number;
}
export interface OneNoteInkBoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface OneNoteInkStroke {
points: OneNoteInkPoint[];
penTip?: number;
transparency?: number;
width?: number;
height?: number;
color?: number;
bias?: 'handwriting' | 'drawing' | 'both';
languageCode?: number;
}
/** A leaf has strokes; an intermediate grouping container has children. */
export interface OneNoteInkBlock { export interface OneNoteInkBlock {
kind: 'ink'; kind: 'ink';
id: string; id: string;
supported: false; strokes: OneNoteInkStroke[];
description: string; children: OneNoteInkBlock[];
boundingBox?: OneNoteInkBoundingBox;
layout: OneNoteLayout;
} }
export interface OneNoteUnsupportedBlock { export interface OneNoteUnsupportedBlock {

View File

@@ -10,6 +10,7 @@
// allow-listed; file payloads remain zero-copy references. // allow-listed; file payloads remain zero-copy references.
import { BinaryReader } from '../binary/BinaryReader.js'; import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import type { ParserDiagnostic } from '../model/diagnostics.js'; import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNoteParserLimits } from '../parser/limits.js'; import type { OneNoteParserLimits } from '../parser/limits.js';
import type { import type {
@@ -24,6 +25,9 @@ import type {
OneNoteColor, OneNoteColor,
OneNoteContentBlock, OneNoteContentBlock,
OneNoteImageBlock, OneNoteImageBlock,
OneNoteInkBlock,
OneNoteInkBoundingBox,
OneNoteInkStroke,
OneNoteLayout, OneNoteLayout,
OneNoteResource, OneNoteResource,
OneNoteTableBlock, OneNoteTableBlock,
@@ -51,6 +55,7 @@ export interface OneNoteContentBudget {
blocks: number; blocks: number;
textRuns: number; textRuns: number;
tableCells: number; tableCells: number;
inkPoints: number;
} }
export interface OneNoteContentParseOptions { export interface OneNoteContentParseOptions {
@@ -67,7 +72,7 @@ interface ContentContext extends OneNoteContentParseOptions {
} }
export function createContentBudget(): OneNoteContentBudget { export function createContentBudget(): OneNoteContentBudget {
return { blocks: 0, textRuns: 0, tableCells: 0 }; return { blocks: 0, textRuns: 0, tableCells: 0, inkPoints: 0 };
} }
/** Parse page graph roots into an inert, recursively structured content tree. */ /** Parse page graph roots into an inert, recursively structured content tree. */
@@ -189,18 +194,7 @@ function parseObject(
case JCID.TableNode: case JCID.TableNode:
return parseTable(context, object, active, depth); return parseTable(context, object, active, depth);
case JCID.InkContainer: case JCID.InkContainer:
addDiagnostic( return parseInk(context, object, active, depth);
context,
'UNSUPPORTED_INK',
'Ink content is retained as a typed placeholder',
`Object ${key}`
);
return {
kind: 'ink',
id: key,
supported: false,
description: 'Ink parsing is not implemented in this slice',
};
default: default:
addDiagnostic( addDiagnostic(
context, context,
@@ -591,6 +585,342 @@ function parseTable(
}; };
} }
function parseInk(
context: ContentContext,
object: StoreObject,
active: Set<string>,
depth: number
): OneNoteInkBlock {
const inkDataId = objectReferences(object, PROPERTY.InkData)[0];
if (!inkDataId) {
const children: OneNoteInkBlock[] = [];
for (const childId of objectReferences(
object,
PROPERTY.ContentChildNodes
)) {
const child = parseSafely(context, childId, active, depth + 1);
if (child?.kind === 'ink') {
children.push(child);
} else if (child) {
addDiagnostic(
context,
'INVALID_INK_CHILD',
`Ink group child ${exGuidKey(childId)} is not an ink container`,
`InkContainer ${exGuidKey(object.id)}`
);
}
}
return compactObject({
kind: 'ink' as const,
id: exGuidKey(object.id),
strokes: [],
children,
boundingBox: unionBoundingBoxes(
children.map((child) => child.boundingBox)
),
layout: parseLayout(object),
});
}
const inkData = context.space.objects.get(exGuidKey(inkDataId));
if (!inkData) {
addDiagnostic(
context,
'MISSING_INK_DATA',
`Ink data ${exGuidKey(inkDataId)} is missing`,
`InkContainer ${exGuidKey(object.id)}`
);
return {
kind: 'ink',
id: exGuidKey(object.id),
strokes: [],
children: [],
layout: parseLayout(object),
};
}
assertJcid(inkData, JCID.InkDataNode, 'InkDataNode');
const scaleX = optionalF32(object, PROPERTY.InkScalingX) ?? 1;
const scaleY = optionalF32(object, PROPERTY.InkScalingY) ?? 1;
const strokes: OneNoteInkStroke[] = [];
for (const strokeId of objectReferences(inkData, PROPERTY.InkStrokes)) {
const stroke = parseInkStrokeSafely(context, strokeId, scaleX, scaleY);
if (stroke) strokes.push(stroke);
}
const encodedBoundingBox = parseInkBoundingBox(
optionalBytes(inkData, PROPERTY.InkBoundingBox),
scaleX,
scaleY
);
return compactObject({
kind: 'ink' as const,
id: exGuidKey(object.id),
strokes,
children: [],
boundingBox: boundingBoxFromStrokes(strokes) ?? encodedBoundingBox,
layout: parseLayout(object),
});
}
function parseInkStrokeSafely(
context: ContentContext,
id: ExGuid,
scaleX: number,
scaleY: number
): OneNoteInkStroke | undefined {
try {
return parseInkStroke(context, id, scaleX, scaleY);
} catch (error) {
addDiagnostic(
context,
'INK_STROKE_PARSE_FAILED',
`Could not parse ink stroke ${exGuidKey(id)}: ${error instanceof Error ? error.message : String(error)}`,
'InkStrokeNode'
);
return undefined;
}
}
function parseInkStroke(
context: ContentContext,
id: ExGuid,
scaleX: number,
scaleY: number
): OneNoteInkStroke {
const object = getObject(context.space, id, 'ink stroke');
assertJcid(object, JCID.InkStrokeNode, 'InkStrokeNode');
const propertiesId = objectReferences(
object,
PROPERTY.InkStrokeProperties
)[0];
if (!propertiesId) throw new Error('InkStrokeNode has no properties');
const properties = getObject(
context.space,
propertiesId,
'ink stroke properties'
);
assertJcid(properties, JCID.StrokePropertiesNode, 'StrokePropertiesNode');
const dimensions = parseInkDimensions(
optionalBytes(properties, PROPERTY.InkDimensions)
);
if (dimensions.length === 0 || dimensions.length > 64) {
throw new Error(`Invalid ink dimension count ${dimensions.length}`);
}
const xIndex = dimensions.findIndex(
(dimension) => dimension.id === INK_X_DIMENSION
);
const yIndex = dimensions.findIndex(
(dimension) => dimension.id === INK_Y_DIMENSION
);
if (xIndex < 0 || yIndex < 0) {
throw new Error('Ink stroke dimensions do not contain X and Y');
}
const pathBytes = optionalBytes(object, PROPERTY.InkPath);
if (!pathBytes) throw new Error('InkStrokeNode has no path');
const values = decodeSignedMultiByte(
pathBytes,
context.limits.maxInkPoints * dimensions.length
);
if (values.length % dimensions.length !== 0) {
throw new Error(
`Ink path has ${values.length} values for ${dimensions.length} dimensions`
);
}
const pointCount = values.length / dimensions.length;
incrementInkPointBudget(context, pointCount);
const xOffset = pointCount * xIndex;
const yOffset = pointCount * yIndex;
const xCoordinates = restoreInkDeltas(
values.slice(xOffset, xOffset + pointCount)
);
const yCoordinates = restoreInkDeltas(
values.slice(yOffset, yOffset + pointCount)
);
const points = Array.from({ length: pointCount }, (_, index) => ({
x: finiteInkCoordinate(xCoordinates[index]! * scaleX),
y: finiteInkCoordinate(yCoordinates[index]! * scaleY),
}));
return compactObject({
points,
penTip: optionalU8(properties, PROPERTY.InkPenTip),
transparency: optionalU8(properties, PROPERTY.InkTransparency),
// These two undocumented property names are reversed in observed files.
width: optionalF32(properties, PROPERTY.InkHeight),
height: optionalF32(properties, PROPERTY.InkWidth),
color: optionalU32(properties, PROPERTY.InkColor),
bias: inkBias(optionalU8(object, PROPERTY.InkBias)),
languageCode: optionalU32(object, PROPERTY.LanguageId),
});
}
interface InkDimension {
id: string;
lower: number;
upper: number;
}
const INK_X_DIMENSION = '{598A6A8F-52C0-4BA0-93AF-AF357411A561}';
const INK_Y_DIMENSION = '{B53F9F75-04E0-4498-A7EE-C30DBB5A9011}';
function parseInkDimensions(bytes: Uint8Array | undefined): InkDimension[] {
if (!bytes) return [];
if (bytes.length % 32 !== 0) {
throw new Error(
`InkDimensions length ${bytes.length} is not a multiple of 32`
);
}
const result: InkDimension[] = [];
const reader = new BinaryReader(bytes, 0, bytes.length, 'InkDimensions');
while (reader.remaining > 0) {
result.push({
id: readGuid(reader),
lower: signedU32(reader.u32()),
upper: signedU32(reader.u32()),
});
reader.skip(8);
}
return result;
}
function parseInkBoundingBox(
bytes: Uint8Array | undefined,
scaleX: number,
scaleY: number
): OneNoteInkBoundingBox | undefined {
if (!bytes || bytes.length !== 16) return undefined;
const reader = new BinaryReader(bytes, 0, 16, 'InkBoundingBox');
const xMin = signedU32(reader.u32());
const yMin = signedU32(reader.u32());
const xMax = signedU32(reader.u32());
const yMax = signedU32(reader.u32());
return {
x: finiteInkCoordinate(xMin * scaleX),
y: finiteInkCoordinate(yMin * scaleY),
width: finiteInkCoordinate((xMax - xMin) * scaleX),
height: finiteInkCoordinate((yMax - yMin) * scaleY),
};
}
function decodeSignedMultiByte(bytes: Uint8Array, limit: number): number[] {
const lengthValue = decodeMultiByteInteger(bytes, 0);
const countValue = lengthValue.value >> 1n;
if (
countValue > BigInt(limit) ||
countValue > BigInt(Number.MAX_SAFE_INTEGER)
) {
throw new Error(`Ink path value count exceeds ${limit}`);
}
const count = Number(countValue);
const result: number[] = [];
let offset = lengthValue.nextOffset;
for (let index = 0; index < count; index += 1) {
const decoded = decodeMultiByteInteger(bytes, offset);
offset = decoded.nextOffset;
const shifted = decoded.value >> 1n;
const signed = (decoded.value & 1n) === 1n ? -shifted : shifted;
if (
signed < BigInt(Number.MIN_SAFE_INTEGER) ||
signed > BigInt(Number.MAX_SAFE_INTEGER)
) {
throw new Error('Ink coordinate exceeds the safe integer range');
}
result.push(Number(signed));
}
if (offset !== bytes.length) {
throw new Error(`Ink path has ${bytes.length - offset} trailing bytes`);
}
return result;
}
function decodeMultiByteInteger(
bytes: Uint8Array,
start: number
): { value: bigint; nextOffset: number } {
let value = 0n;
let offset = start;
for (let count = 0; count < 10; count += 1) {
if (offset >= bytes.length)
throw new Error('Truncated ink multi-byte integer');
const byte = bytes[offset++]!;
value |= BigInt(byte & 0x7f) << BigInt(count * 7);
if ((byte & 0x80) === 0) return { value, nextOffset: offset };
}
throw new Error('Ink multi-byte integer exceeds 64-bit width');
}
function signedU32(value: number): number {
return value > 0x7fffffff ? value - 0x1_0000_0000 : value;
}
/** OneNote stores the first coordinate followed by signed coordinate deltas. */
function restoreInkDeltas(values: readonly number[]): number[] {
if (values.length === 0) return [];
const result = [values[0]!];
let coordinate = values[0]!;
for (let index = 1; index < values.length; index += 1) {
coordinate = checkedInkInteger(coordinate + values[index]!);
result.push(coordinate);
}
return result;
}
function checkedInkInteger(value: number): number {
if (!Number.isSafeInteger(value)) {
throw new Error(
'Reconstructed ink coordinate exceeds the safe integer range'
);
}
return value;
}
function finiteInkCoordinate(value: number): number {
if (!Number.isFinite(value)) throw new Error('Ink coordinate is not finite');
return value;
}
function inkBias(
value: number | undefined
): OneNoteInkStroke['bias'] | undefined {
if (value === undefined) return 'both';
if (value === 0) return 'handwriting';
if (value === 1) return 'drawing';
if (value === 2) return 'both';
return undefined;
}
function boundingBoxFromStrokes(
strokes: readonly OneNoteInkStroke[]
): OneNoteInkBoundingBox | undefined {
let xMin = Infinity;
let yMin = Infinity;
let xMax = -Infinity;
let yMax = -Infinity;
for (const stroke of strokes) {
for (const point of stroke.points) {
xMin = Math.min(xMin, point.x);
yMin = Math.min(yMin, point.y);
xMax = Math.max(xMax, point.x);
yMax = Math.max(yMax, point.y);
}
}
return Number.isFinite(xMin)
? { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }
: undefined;
}
function unionBoundingBoxes(
boxes: readonly (OneNoteInkBoundingBox | undefined)[]
): OneNoteInkBoundingBox | undefined {
const present = boxes.filter(
(box): box is OneNoteInkBoundingBox => box !== undefined
);
if (present.length === 0) return undefined;
const x = Math.min(...present.map((box) => box.x));
const y = Math.min(...present.map((box) => box.y));
const x2 = Math.max(...present.map((box) => box.x + box.width));
const y2 = Math.max(...present.map((box) => box.y + box.height));
return { x, y, width: x2 - x, height: y2 - y };
}
function parseLayout(object: StoreObject): OneNoteLayout { function parseLayout(object: StoreObject): OneNoteLayout {
return compactObject({ return compactObject({
maxWidth: optionalF32(object, PROPERTY.LayoutMaxWidth), maxWidth: optionalF32(object, PROPERTY.LayoutMaxWidth),
@@ -900,6 +1230,17 @@ function incrementTableCellBudget(context: ContentContext): void {
} }
} }
function incrementInkPointBudget(
context: ContentContext,
amount: number
): void {
const next = context.budget.inkPoints + amount;
if (!Number.isSafeInteger(next) || next > context.limits.maxInkPoints) {
throw new Error(`Ink point count exceeds ${context.limits.maxInkPoints}`);
}
context.budget.inkPoints = next;
}
function addDiagnostic( function addDiagnostic(
context: ContentContext, context: ContentContext,
code: string, code: string,

View File

@@ -44,6 +44,24 @@ describe('OneNote rich-content model', () => {
expect(model.pages[0]!.text).toContain('TEST\tD'); expect(model.pages[0]!.text).toContain('TEST\tD');
expect(blocks.some((block) => block.kind === 'table')).toBe(true); expect(blocks.some((block) => block.kind === 'table')).toBe(true);
expect(blocks.some((block) => block.kind === 'image')).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!'); const testing = textBlock(blocks, 'Testing!');
expect(testing.runs.map((run) => run.text)).toEqual(['Testing', '!']); expect(testing.runs.map((run) => run.text)).toEqual(['Testing', '!']);
@@ -86,6 +104,7 @@ describe('OneNote rich-content model', () => {
const diagnosticCodes = model.diagnostics.map(({ code }) => code); const diagnosticCodes = model.diagnostics.map(({ code }) => code);
expect(diagnosticCodes).not.toContain('CONTENT_PARSE_FAILED'); expect(diagnosticCodes).not.toContain('CONTENT_PARSE_FAILED');
expect(diagnosticCodes).not.toContain('INVALID_TABLE_VECTOR'); expect(diagnosticCodes).not.toContain('INVALID_TABLE_VECTOR');
expect(diagnosticCodes).not.toContain('INK_STROKE_PARSE_FAILED');
}); });
it('associates only allow-listed hyperlink marker targets', () => { it('associates only allow-listed hyperlink marker targets', () => {
@@ -101,6 +120,24 @@ describe('OneNote rich-content model', () => {
unsafe.runs.find((run) => run.text === 'Example')?.href unsafe.runs.find((run) => run.text === 'Example')?.href
).toBeUndefined(); ).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 { function parseSyntheticLink(href: string): OneNoteTextBlock {