feat: parse rich OneNote content resources

This commit is contained in:
2026-07-22 19:08:49 +02:00
parent 670310ea9e
commit c99615f5e6
13 changed files with 2671 additions and 6 deletions

View File

@@ -10,6 +10,20 @@ export {
} from './parser/limits.js'; } from './parser/limits.js';
export { export {
parseOneNoteSection, parseOneNoteSection,
parseOneNoteSectionContent,
type ParseOneNoteSectionOptions, type ParseOneNoteSectionOptions,
} from './parser/parse-section.js'; } from './parser/parse-section.js';
export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js'; export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js';
export type {
OneNoteAttachmentBlock,
OneNoteColor,
OneNoteContentBlock,
OneNoteImageBlock,
OneNotePageContentModel,
OneNoteResource,
OneNoteSectionContentModel,
OneNoteTableBlock,
OneNoteTextBlock,
OneNoteTextRun,
OneNoteTextStyle,
} from './one/content-model.js';

View File

@@ -15,11 +15,20 @@ export const JCID = {
OutlineElementNode: 0x0006000d, OutlineElementNode: 0x0006000d,
RichTextNode: 0x0006000e, RichTextNode: 0x0006000e,
ImageNode: 0x00060011, ImageNode: 0x00060011,
NumberListNode: 0x00060012,
InkContainer: 0x00060014, InkContainer: 0x00060014,
OutlineGroup: 0x00060019, OutlineGroup: 0x00060019,
TableNode: 0x00060022, TableNode: 0x00060022,
TableRowNode: 0x00060023,
TableCellNode: 0x00060024,
TitleNode: 0x0006002c, TitleNode: 0x0006002c,
EmbeddedFileNode: 0x00060035, EmbeddedFileNode: 0x00060035,
EmbeddedFileContainer: 0x00080036,
PictureContainer: 0x00080039,
XpsContainer: 0x0008003a,
InkDataNode: 0x0002003b,
InkStrokeNode: 0x00020047,
StrokePropertiesNode: 0x00120048,
PageManifestNode: 0x00060037, PageManifestNode: 0x00060037,
PageMetadata: 0x00020030, PageMetadata: 0x00020030,
SectionMetadata: 0x00020031, SectionMetadata: 0x00020031,
@@ -43,5 +52,71 @@ export const PROPERTY = {
TextExtendedAscii: 0x1c003498, TextExtendedAscii: 0x1c003498,
TextRunFormatting: 0x24001e13, TextRunFormatting: 0x24001e13,
TextRunIndex: 0x1c001e12, TextRunIndex: 0x1c001e12,
ParagraphStyle: 0x2000342c,
ParagraphAlignment: 0x0c003477,
ParagraphSpaceBefore: 0x1400342e,
ParagraphSpaceAfter: 0x1400342f,
ParagraphLineSpacingExact: 0x14003430,
ReadingOrderRtl: 0x08003476,
Bold: 0x08001c04,
Italic: 0x08001c05,
Underline: 0x08001c06,
Strikethrough: 0x08001c07,
Superscript: 0x08001c08,
Subscript: 0x08001c09,
Font: 0x1c001c0a,
FontSize: 0x10001c0b,
FontColor: 0x14001c0c,
Highlight: 0x14001c0d,
LanguageId: 0x14001c3b,
RichEditTextLangId: 0x10001cfe,
Charset: 0x0c001d01,
MathFormatting: 0x08003401,
NextStyle: 0x1c00348a,
ParagraphStyleId: 0x1c00345a,
Hyperlink: 0x08001e14,
HyperlinkProtected: 0x08001e19,
Hidden: 0x08001e16, Hidden: 0x08001e16,
LayoutTightLayout: 0x08001c00,
LayoutAlignmentInParent: 0x14001c3e,
LayoutAlignmentSelf: 0x14001c84,
LayoutMaxWidth: 0x14001c1b,
LayoutMaxHeight: 0x14001c1c,
IsLayoutSizeSetByUser: 0x08001cbd,
OffsetFromParentHoriz: 0x14001c14,
OffsetFromParentVert: 0x14001c15,
PictureContainer: 0x20001c3f,
WebPictureContainer14: 0x200034c8,
ImageAltText: 0x1c001e58,
ImageFilename: 0x1c001dd7,
DisplayedPageNumber: 0x14003480,
PictureWidth: 0x140034cd,
PictureHeight: 0x140034ce,
WzHyperlinkUrl: 0x1c001e20,
IsBackground: 0x08001d13,
EmbeddedFileContainer: 0x20001d9b,
EmbeddedFileName: 0x1c001d9c,
SourceFilepath: 0x1c001d9d,
IRecordMedia: 0x14001d24,
RowCount: 0x14001d57,
ColumnCount: 0x14001d58,
TableBordersVisible: 0x08001d5e,
TableColumnWidths: 0x1c001d66,
TableColumnsLocked: 0x1c001d7d,
CellBackgroundColor: 0x14001e26,
RgOutlineIndentDistance: 0x1c001c12,
OutlineElementChildLevel: 0x0c001c03,
InkData: 0x20003415,
InkStrokes: 0x24003416,
InkScalingX: 0x14001c46,
InkScalingY: 0x14001c47,
InkBoundingBox: 0x1c003418,
InkPath: 0x1c00340b,
InkStrokeProperties: 0x20003409,
InkDimensions: 0x1c00340a,
InkPenTip: 0x0c003412,
InkTransparency: 0x0c003414,
InkHeight: 0x1400340c,
InkWidth: 0x1400340d,
InkColor: 0x1400340f,
} as const; } as const;

View File

@@ -0,0 +1,204 @@
// 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 type { ParserDiagnostic } from '../model/diagnostics.js';
import type { FileDataBlobReference } from '../onestore/types.js';
export interface OneNoteColor {
kind: 'auto' | 'rgb' | 'rgba';
red?: number;
green?: number;
blue?: number;
alpha?: number;
}
/** Values are retained in the half-inch unit used by MS-ONE. */
export interface OneNoteLayout {
maxWidth?: number;
maxHeight?: number;
offsetHorizontal?: number;
offsetVertical?: number;
alignmentInParent?: number;
alignmentSelf?: number;
sizeSetByUser?: boolean;
}
export interface OneNoteTextStyle {
charset?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
superscript?: boolean;
subscript?: boolean;
font?: string;
/** Font size in half-points. */
fontSize?: number;
fontColor?: OneNoteColor;
highlight?: OneNoteColor;
languageCode?: number;
mathFormatting?: boolean;
hyperlink?: boolean;
hyperlinkProtected?: boolean;
hidden?: boolean;
}
export interface OneNoteTextRun {
/** UTF-16 offsets into sourceText, matching MS-ONE character positions. */
start: number;
end: number;
text: string;
style: OneNoteTextStyle;
/** Present only for allow-listed URI schemes. */
href?: string;
}
export interface OneNoteTextBlock {
kind: 'text';
id: string;
sourceText: string;
/** Visible text with hidden hyperlink marker runs and NULs removed. */
text: string;
runs: OneNoteTextRun[];
paragraphStyle: OneNoteTextStyle;
paragraphAlignment?: 'left' | 'center' | 'right' | 'unknown';
paragraphSpaceBefore?: number;
paragraphSpaceAfter?: number;
paragraphLineSpacingExact?: number;
rightToLeft?: boolean;
layout: OneNoteLayout;
}
export interface OneNoteImageBlock {
kind: 'image';
id: string;
resourceId?: string;
filename?: string;
altText?: string;
ocrText?: string;
pictureWidth?: number;
pictureHeight?: number;
displayedPageNumber?: number;
href?: string;
isBackground: boolean;
layout: OneNoteLayout;
}
export interface OneNoteAttachmentBlock {
kind: 'attachment';
id: string;
resourceId?: string;
iconResourceId?: string;
/** Untrusted display metadata. It must not be used as a filesystem path. */
filename: string;
size?: number;
layout: OneNoteLayout;
}
export interface OneNoteTableCell {
id: string;
blocks: OneNoteContentBlock[];
backgroundColor?: OneNoteColor;
maxWidth?: number;
}
export interface OneNoteTableRow {
id: string;
cells: OneNoteTableCell[];
}
export interface OneNoteTableBlock {
kind: 'table';
id: string;
declaredRowCount?: number;
declaredColumnCount?: number;
rows: OneNoteTableRow[];
columnWidths: number[];
lockedColumns: boolean[];
bordersVisible: boolean;
layout: OneNoteLayout;
}
export interface OneNoteOutlineBlock {
kind: 'outline';
id: string;
childLevel?: number;
indents: number[];
layout: OneNoteLayout;
children: OneNoteContentBlock[];
}
export interface OneNoteOutlineElementBlock {
kind: 'outline-element';
id: string;
childLevel?: number;
contents: OneNoteContentBlock[];
children: OneNoteContentBlock[];
}
export interface OneNoteOutlineGroupBlock {
kind: 'outline-group';
id: string;
childLevel?: number;
children: OneNoteContentBlock[];
}
export interface OneNoteInkBlock {
kind: 'ink';
id: string;
supported: false;
description: string;
}
export interface OneNoteUnsupportedBlock {
kind: 'unsupported';
id: string;
sourceJcid: number;
description: string;
}
export type OneNoteContentBlock =
| OneNoteTextBlock
| OneNoteImageBlock
| OneNoteAttachmentBlock
| OneNoteTableBlock
| OneNoteOutlineBlock
| OneNoteOutlineElementBlock
| OneNoteOutlineGroupBlock
| OneNoteInkBlock
| OneNoteUnsupportedBlock;
export interface OneNoteResource {
id: string;
kind: 'image' | 'attachment';
sourceJcid: number;
extension?: string;
filename?: string;
mediaType: string;
browserRenderable: boolean;
size?: number;
/** A bounded view descriptor. Bytes are not copied during parsing. */
blob?: FileDataBlobReference;
availability: 'available' | 'external' | 'missing' | 'invalid';
}
export interface OneNotePageContentModel {
id: string;
title: string;
createdAt?: string;
updatedAt?: string;
level?: number;
text: string;
blocks: OneNoteContentBlock[];
}
/** Internal, non-serializable representation used by the worker integration. */
export interface OneNoteSectionContentModel {
sourceName: string;
sectionName: string;
pages: OneNotePageContentModel[];
resources: Map<string, OneNoteResource>;
diagnostics: ParserDiagnostic[];
}

926
src/onenote/one/content.ts Normal file
View File

@@ -0,0 +1,926 @@
// 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
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{content,image,
// outline,rich_text,table,embedded_file}.rs`. Deviations: this module emits a
// browser-oriented, inert data model; links and renderable image formats are
// allow-listed; file payloads remain zero-copy references.
import { BinaryReader } from '../binary/BinaryReader.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type {
ExGuid,
FileDataBlobReference,
ObjectSpace,
StoreObject,
} from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import type {
OneNoteAttachmentBlock,
OneNoteColor,
OneNoteContentBlock,
OneNoteImageBlock,
OneNoteLayout,
OneNoteResource,
OneNoteTableBlock,
OneNoteTextBlock,
OneNoteTextRun,
OneNoteTextStyle,
} from './content-model.js';
import { JCID, PROPERTY } from './constants.js';
import {
assertJcid,
getObject,
objectReferences,
optionalBool,
optionalBytes,
optionalF32,
optionalLatin1,
optionalString,
optionalU8,
optionalU16,
optionalU32,
optionalU32Vector,
} from './property-access.js';
export interface OneNoteContentBudget {
blocks: number;
textRuns: number;
tableCells: number;
}
export interface OneNoteContentParseOptions {
space: ObjectSpace;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
resources: Map<string, OneNoteResource>;
pageId?: string;
budget?: OneNoteContentBudget;
}
interface ContentContext extends OneNoteContentParseOptions {
budget: OneNoteContentBudget;
}
export function createContentBudget(): OneNoteContentBudget {
return { blocks: 0, textRuns: 0, tableCells: 0 };
}
/** Parse page graph roots into an inert, recursively structured content tree. */
export function parseContentObjects(
ids: ExGuid[],
options: OneNoteContentParseOptions
): OneNoteContentBlock[] {
const context: ContentContext = {
...options,
budget: options.budget ?? createContentBudget(),
};
const result: OneNoteContentBlock[] = [];
for (const id of ids) {
const block = parseSafely(context, id, new Set(), 0);
if (block) result.push(block);
}
return result;
}
export function contentBlocksToPlainText(
blocks: readonly OneNoteContentBlock[]
): string {
return blocks
.map(blockToPlainText)
.filter((value) => value.length > 0)
.join('\n');
}
function parseSafely(
context: ContentContext,
id: ExGuid,
active: Set<string>,
depth: number
): OneNoteContentBlock | undefined {
try {
return parseObject(context, id, active, depth);
} catch (error) {
addDiagnostic(
context,
'CONTENT_PARSE_FAILED',
`Could not parse content ${exGuidKey(id)}: ${error instanceof Error ? error.message : String(error)}`,
'MS-ONE content graph'
);
return undefined;
}
}
function parseObject(
context: ContentContext,
id: ExGuid,
active: Set<string>,
depth: number
): OneNoteContentBlock {
if (depth > context.limits.maxGraphDepth) {
throw new Error(
`Content graph depth exceeds ${context.limits.maxGraphDepth}`
);
}
incrementBlockBudget(context);
const key = exGuidKey(id);
if (active.has(key)) throw new Error(`Content graph cycle at ${key}`);
const object = getObject(context.space, id, 'content');
active.add(key);
try {
switch (object.jcid) {
case JCID.OutlineNode:
return {
kind: 'outline',
id: key,
childLevel: optionalU8(object, PROPERTY.OutlineElementChildLevel),
indents: parseFloatVector(
optionalBytes(object, PROPERTY.RgOutlineIndentDistance)
),
layout: parseLayout(object),
children: parseChildren(
context,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
),
};
case JCID.OutlineGroup:
return {
kind: 'outline-group',
id: key,
childLevel: optionalU8(object, PROPERTY.OutlineElementChildLevel),
children: parseChildren(
context,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
),
};
case JCID.OutlineElementNode:
return {
kind: 'outline-element',
id: key,
childLevel: optionalU8(object, PROPERTY.OutlineElementChildLevel),
contents: parseChildren(
context,
objectReferences(object, PROPERTY.ContentChildNodes),
active,
depth
),
children: parseChildren(
context,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
),
};
case JCID.RichTextNode:
return parseRichText(context, object);
case JCID.ImageNode:
return parseImage(context, object);
case JCID.EmbeddedFileNode:
return parseAttachment(context, object);
case JCID.TableNode:
return parseTable(context, object, active, depth);
case JCID.InkContainer:
addDiagnostic(
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:
addDiagnostic(
context,
'UNSUPPORTED_CONTENT_OBJECT',
`Content object JCID 0x${object.jcid.toString(16)} is not rendered`,
`Object ${key}`
);
return {
kind: 'unsupported',
id: key,
sourceJcid: object.jcid,
description: `Unsupported OneNote object 0x${object.jcid.toString(16)}`,
};
}
} finally {
active.delete(key);
}
}
function parseChildren(
context: ContentContext,
ids: ExGuid[],
active: Set<string>,
depth: number
): OneNoteContentBlock[] {
const result: OneNoteContentBlock[] = [];
for (const id of ids) {
const block = parseSafely(context, id, active, depth + 1);
if (block) result.push(block);
}
return result;
}
function parseRichText(
context: ContentContext,
object: StoreObject
): OneNoteTextBlock {
let sourceText = optionalString(object, PROPERTY.RichEditTextUnicode);
sourceText ??= optionalLatin1(object, PROPERTY.TextExtendedAscii);
sourceText = (sourceText ?? '').split('\0').join('');
const paragraphStyleId = objectReferences(object, PROPERTY.ParagraphStyle)[0];
const paragraphStyle = paragraphStyleId
? parseStyleReference(context, paragraphStyleId)
: {};
const styleIds = objectReferences(object, PROPERTY.TextRunFormatting);
let indices = optionalU32Vector(object, PROPERTY.TextRunIndex) ?? [];
let styles = styleIds.map((id) => parseStyleReference(context, id));
({ sourceText, indices, styles } = fixLeadingVerticalTab(
sourceText,
indices,
styles
));
validateTextRuns(context, sourceText, indices, styles);
const runs = createTextRuns(sourceText, indices, styles, paragraphStyle);
incrementTextRunBudget(context, runs.length);
return {
kind: 'text',
id: exGuidKey(object.id),
sourceText,
text: runs
.filter((run) => run.style.hidden !== true)
.map((run) => run.text)
.join(''),
runs,
paragraphStyle,
paragraphAlignment: paragraphAlignment(
optionalU8(object, PROPERTY.ParagraphAlignment)
),
paragraphSpaceBefore: optionalF32(object, PROPERTY.ParagraphSpaceBefore),
paragraphSpaceAfter: optionalF32(object, PROPERTY.ParagraphSpaceAfter),
paragraphLineSpacingExact: optionalF32(
object,
PROPERTY.ParagraphLineSpacingExact
),
rightToLeft: optionalBool(object, PROPERTY.ReadingOrderRtl),
layout: parseLayout(object),
};
}
function validateTextRuns(
context: ContentContext,
text: string,
indices: readonly number[],
styles: readonly OneNoteTextStyle[]
): void {
if (styles.length > context.limits.maxTextRuns) {
throw new Error(`Text run count exceeds ${context.limits.maxTextRuns}`);
}
if (styles.length === 0) {
if (indices.length !== 0) {
throw new Error('TextRunIndex is present without text-run formatting');
}
return;
}
if (styles.length !== indices.length + 1) {
if (text.length === 0 && indices.length === 0) return;
throw new Error(
`Text-run style/index mismatch (${styles.length} styles, ${indices.length} indices)`
);
}
let previous = 0;
for (const index of indices) {
if (
!Number.isSafeInteger(index) ||
index <= previous ||
index > text.length
) {
throw new Error(
`Invalid TextRunIndex ${index} for text length ${text.length}`
);
}
previous = index;
}
}
function createTextRuns(
text: string,
indices: readonly number[],
styles: readonly OneNoteTextStyle[],
paragraphStyle: OneNoteTextStyle
): OneNoteTextRun[] {
if (text.length === 0) return [];
if (styles.length === 0) {
return [{ start: 0, end: text.length, text, style: paragraphStyle }];
}
const boundaries = [0, ...indices, text.length];
const result: OneNoteTextRun[] = [];
let pendingHref: string | undefined;
for (let index = 0; index < styles.length; index += 1) {
const start = boundaries[index]!;
const end = boundaries[index + 1]!;
const runText = text.slice(start, end);
const style = mergeStyles(paragraphStyle, styles[index]!);
const markerHref = style.hidden ? hyperlinkMarker(runText) : undefined;
if (markerHref !== undefined) pendingHref = safeHref(markerHref);
const run: OneNoteTextRun = { start, end, text: runText, style };
if (
!style.hidden &&
pendingHref &&
(style.hyperlinkProtected === true || style.hyperlink === true)
) {
run.href = pendingHref;
pendingHref = undefined;
}
result.push(run);
}
return result;
}
function parseStyleReference(
context: ContentContext,
id: ExGuid
): OneNoteTextStyle {
const object = context.space.objects.get(exGuidKey(id));
if (!object) {
addDiagnostic(
context,
'MISSING_TEXT_STYLE',
`Text style ${exGuidKey(id)} is missing`,
'RichTextNode'
);
return {};
}
if (object.jcid !== JCID.ParagraphStyleObject) {
addDiagnostic(
context,
'INVALID_TEXT_STYLE',
`Style ${exGuidKey(id)} has JCID 0x${object.jcid.toString(16)}`,
'RichTextNode'
);
return {};
}
return compactObject({
charset: optionalU8(object, PROPERTY.Charset),
bold: optionalBool(object, PROPERTY.Bold),
italic: optionalBool(object, PROPERTY.Italic),
underline: optionalBool(object, PROPERTY.Underline),
strikethrough: optionalBool(object, PROPERTY.Strikethrough),
superscript: optionalBool(object, PROPERTY.Superscript),
subscript: optionalBool(object, PROPERTY.Subscript),
font: cleanMetadataString(optionalString(object, PROPERTY.Font)),
fontSize: optionalU16(object, PROPERTY.FontSize),
fontColor: parseColorRef(optionalU32(object, PROPERTY.FontColor)),
highlight: parseColorRef(optionalU32(object, PROPERTY.Highlight)),
languageCode: optionalU32(object, PROPERTY.LanguageId),
mathFormatting: optionalBool(object, PROPERTY.MathFormatting),
hyperlink: optionalBool(object, PROPERTY.Hyperlink),
hyperlinkProtected: optionalBool(object, PROPERTY.HyperlinkProtected),
hidden: optionalBool(object, PROPERTY.Hidden),
});
}
function parseImage(
context: ContentContext,
object: StoreObject
): OneNoteImageBlock {
const containerId =
objectReferences(object, PROPERTY.PictureContainer)[0] ??
objectReferences(object, PROPERTY.WebPictureContainer14)[0];
const filename = cleanMetadataString(
optionalString(object, PROPERTY.ImageFilename)
);
const resourceId = containerId
? registerResource(context, containerId, 'image', filename)
: undefined;
return compactObject({
kind: 'image' as const,
id: exGuidKey(object.id),
resourceId,
filename,
altText: cleanMetadataString(optionalString(object, PROPERTY.ImageAltText)),
ocrText: cleanMetadataString(
optionalString(object, PROPERTY.RichEditTextUnicode)
),
pictureWidth: optionalF32(object, PROPERTY.PictureWidth),
pictureHeight: optionalF32(object, PROPERTY.PictureHeight),
displayedPageNumber: optionalU32(object, PROPERTY.DisplayedPageNumber),
href: safeHref(optionalString(object, PROPERTY.WzHyperlinkUrl)),
isBackground: optionalBool(object, PROPERTY.IsBackground) ?? false,
layout: parseLayout(object),
});
}
function parseAttachment(
context: ContentContext,
object: StoreObject
): OneNoteAttachmentBlock {
const filename =
cleanMetadataString(optionalString(object, PROPERTY.EmbeddedFileName)) ??
'Unnamed attachment';
const containerId = objectReferences(
object,
PROPERTY.EmbeddedFileContainer
)[0];
const iconId = objectReferences(object, PROPERTY.PictureContainer)[0];
const resourceId = containerId
? registerResource(context, containerId, 'attachment', filename)
: undefined;
const iconResourceId = iconId
? registerResource(context, iconId, 'image')
: undefined;
return compactObject({
kind: 'attachment' as const,
id: exGuidKey(object.id),
resourceId,
iconResourceId,
filename,
size: resourceId ? context.resources.get(resourceId)?.size : undefined,
layout: parseLayout(object),
});
}
function registerResource(
context: ContentContext,
id: ExGuid,
kind: OneNoteResource['kind'],
filename?: string
): string | undefined {
const key = exGuidKey(id);
const existing = context.resources.get(key);
if (existing) {
if (!existing.filename && filename) existing.filename = filename;
return key;
}
const object = context.space.objects.get(key);
if (!object) {
addDiagnostic(
context,
'MISSING_RESOURCE_CONTAINER',
`Resource container ${key} is missing`,
'File data'
);
return undefined;
}
const expected =
kind === 'attachment'
? object.jcid === JCID.EmbeddedFileContainer
: object.jcid === JCID.PictureContainer ||
object.jcid === JCID.XpsContainer;
if (!expected) {
addDiagnostic(
context,
'INVALID_RESOURCE_CONTAINER',
`Resource ${key} has unexpected JCID 0x${object.jcid.toString(16)}`,
'File data'
);
return undefined;
}
const extension = normalizeExtension(
object.fileData?.extension ?? extensionFromFilename(filename)
);
const sniffed = sniffResource(object.fileData?.blob, extension);
const availability = resourceAvailability(object);
context.resources.set(key, {
id: key,
kind,
sourceJcid: object.jcid,
extension,
filename,
mediaType: sniffed.mediaType,
browserRenderable: kind === 'image' && sniffed.browserRenderable,
size: object.fileData?.blob?.size,
blob: object.fileData?.blob,
availability,
});
return key;
}
function parseTable(
context: ContentContext,
object: StoreObject,
active: Set<string>,
depth: number
): OneNoteTableBlock {
const declaredRowCount = optionalU32(object, PROPERTY.RowCount);
const declaredColumnCount = optionalU32(object, PROPERTY.ColumnCount);
const rowIds = objectReferences(object, PROPERTY.ElementChildNodes);
if (declaredRowCount !== undefined && declaredRowCount !== rowIds.length) {
addDiagnostic(
context,
'TABLE_ROW_COUNT_MISMATCH',
`Table declares ${declaredRowCount} rows but references ${rowIds.length}`,
`Table ${exGuidKey(object.id)}`
);
}
const rows = rowIds.map((rowId) => {
const row = getObject(context.space, rowId, 'table row');
assertJcid(row, JCID.TableRowNode, 'TableRowNode');
const cellIds = objectReferences(row, PROPERTY.ElementChildNodes);
if (
declaredColumnCount !== undefined &&
cellIds.length !== declaredColumnCount
) {
addDiagnostic(
context,
'TABLE_COLUMN_COUNT_MISMATCH',
`Table row has ${cellIds.length} cells; expected ${declaredColumnCount}`,
`TableRow ${exGuidKey(row.id)}`
);
}
return {
id: exGuidKey(row.id),
cells: cellIds.map((cellId) => {
incrementTableCellBudget(context);
const cell = getObject(context.space, cellId, 'table cell');
assertJcid(cell, JCID.TableCellNode, 'TableCellNode');
return {
id: exGuidKey(cell.id),
blocks: parseChildren(
context,
objectReferences(cell, PROPERTY.ElementChildNodes),
active,
depth
),
backgroundColor: parseColor(
optionalU32(cell, PROPERTY.CellBackgroundColor)
),
maxWidth: optionalF32(cell, PROPERTY.LayoutMaxWidth),
};
}),
};
});
return {
kind: 'table',
id: exGuidKey(object.id),
declaredRowCount,
declaredColumnCount,
rows,
columnWidths: parseCountedFloatVector(
optionalBytes(object, PROPERTY.TableColumnWidths),
context,
'TableColumnWidths'
),
lockedColumns: parseLockedColumns(
optionalBytes(object, PROPERTY.TableColumnsLocked),
declaredColumnCount,
context
),
bordersVisible: optionalBool(object, PROPERTY.TableBordersVisible) ?? true,
layout: parseLayout(object),
};
}
function parseLayout(object: StoreObject): OneNoteLayout {
return compactObject({
maxWidth: optionalF32(object, PROPERTY.LayoutMaxWidth),
maxHeight: optionalF32(object, PROPERTY.LayoutMaxHeight),
offsetHorizontal: optionalF32(object, PROPERTY.OffsetFromParentHoriz),
offsetVertical: optionalF32(object, PROPERTY.OffsetFromParentVert),
alignmentInParent: optionalU32(object, PROPERTY.LayoutAlignmentInParent),
alignmentSelf: optionalU32(object, PROPERTY.LayoutAlignmentSelf),
sizeSetByUser: optionalBool(object, PROPERTY.IsLayoutSizeSetByUser),
});
}
function parseCountedFloatVector(
bytes: Uint8Array | undefined,
context: ContentContext,
structure: string
): number[] {
if (!bytes || bytes.length === 0) return [];
const count = bytes[0]!;
const expected = 1 + count * 4;
if (bytes.length !== expected) {
addDiagnostic(
context,
'INVALID_TABLE_VECTOR',
`${structure} has ${bytes.length} bytes; expected ${expected}`,
structure
);
}
const available = Math.min(count, Math.floor((bytes.length - 1) / 4));
const reader = new BinaryReader(bytes, 1, available * 4, structure);
const result: number[] = [];
for (let index = 0; index < available; index += 1) {
const raw = reader.u32();
const data = new DataView(new ArrayBuffer(4));
data.setUint32(0, raw, true);
const value = data.getFloat32(0, true);
if (Number.isFinite(value)) result.push(value);
}
return result;
}
function parseFloatVector(bytes: Uint8Array | undefined): number[] {
if (!bytes || bytes.length === 0) return [];
const count = bytes[0]!;
const available = Math.min(count, Math.floor((bytes.length - 1) / 4));
const reader = new BinaryReader(bytes, 1, available * 4, 'float vector');
const result: number[] = [];
for (let index = 0; index < available; index += 1) {
const raw = reader.u32();
const data = new DataView(new ArrayBuffer(4));
data.setUint32(0, raw, true);
const value = data.getFloat32(0, true);
if (Number.isFinite(value)) result.push(value);
}
return result;
}
function parseLockedColumns(
bytes: Uint8Array | undefined,
columnCount: number | undefined,
context: ContentContext
): boolean[] {
if (!bytes || bytes.length === 0) return [];
const bitCount = bytes[0]!;
const byteCount = Math.ceil(bitCount / 8);
if (bytes.length !== byteCount + 1) {
addDiagnostic(
context,
'INVALID_TABLE_VECTOR',
`TableColumnsLocked has ${bytes.length} bytes; expected ${byteCount + 1}`,
'TableColumnsLocked'
);
}
const bitmap = bytes.subarray(1, Math.min(bytes.length, byteCount + 1));
const count = columnCount ?? bitCount;
return Array.from(
{ length: count },
(_, index) =>
(((bitmap[Math.floor(index / 8)] ?? 0) >>> (index % 8)) & 1) !== 0
);
}
function sniffResource(
blob: FileDataBlobReference | undefined,
extension: string | undefined
): { mediaType: string; browserRenderable: boolean } {
if (blob && blob.size >= 8) {
const bytes = blob.source.subarray(blob.offset, blob.offset + 12);
if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return { mediaType: 'image/png', browserRenderable: true };
}
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return { mediaType: 'image/jpeg', browserRenderable: true };
}
}
return {
mediaType: mediaTypeFromExtension(extension),
// Extension metadata is untrusted; only a recognized signature is shown.
browserRenderable: false,
};
}
function mediaTypeFromExtension(extension: string | undefined): string {
switch (extension) {
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'emf':
return 'image/emf';
case 'wmf':
return 'image/wmf';
case 'pdf':
return 'application/pdf';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'txt':
return 'text/plain';
default:
return 'application/octet-stream';
}
}
function resourceAvailability(
object: StoreObject
): OneNoteResource['availability'] {
if (!object.fileData) return 'missing';
if (object.fileData.referenceKind === 'invalid') return 'invalid';
if (object.fileData.referenceKind === 'external') return 'external';
return object.fileData.blob ? 'available' : 'missing';
}
function normalizeExtension(value: string | undefined): string | undefined {
const normalized = value
?.split('\0')
.join('')
.trim()
.replace(/^\.+/, '')
.toLowerCase();
return normalized || undefined;
}
function extensionFromFilename(
filename: string | undefined
): string | undefined {
if (!filename) return undefined;
const basename = filename.split(/[\\/]/).at(-1) ?? filename;
const index = basename.lastIndexOf('.');
return index < 0 ? undefined : basename.slice(index + 1);
}
function cleanMetadataString(value: string | undefined): string | undefined {
const cleaned = value?.split('\0').join('').trim();
return cleaned || undefined;
}
function safeHref(value: string | undefined): string | undefined {
const candidate = cleanMetadataString(value);
if (!candidate || containsControlCharacter(candidate)) return undefined;
try {
const url = new URL(candidate);
return url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'mailto:'
? candidate
: undefined;
} catch {
return undefined;
}
}
function containsControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
function hyperlinkMarker(text: string): string | undefined {
return /\ufddfHYPERLINK\s+"([^"]+)"/iu.exec(text)?.[1];
}
function parseColorRef(value: number | undefined): OneNoteColor | undefined {
if (value === undefined) return undefined;
const marker = value >>> 24;
if (marker === 0xff) return { kind: 'auto' };
if (marker !== 0) return undefined;
return {
kind: 'rgb',
red: value & 0xff,
green: (value >>> 8) & 0xff,
blue: (value >>> 16) & 0xff,
};
}
function parseColor(value: number | undefined): OneNoteColor | undefined {
if (value === undefined) return undefined;
const alpha = 255 - ((value >>> 24) & 0xff);
return {
kind: alpha === 255 ? 'rgb' : 'rgba',
red: value & 0xff,
green: (value >>> 8) & 0xff,
blue: (value >>> 16) & 0xff,
...(alpha === 255 ? {} : { alpha }),
};
}
function paragraphAlignment(
value: number | undefined
): OneNoteTextBlock['paragraphAlignment'] {
if (value === undefined) return undefined;
if (value === 0) return 'left';
if (value === 1) return 'center';
if (value === 2) return 'right';
return 'unknown';
}
function mergeStyles(
base: OneNoteTextStyle,
override: OneNoteTextStyle
): OneNoteTextStyle {
return compactObject({ ...base, ...override });
}
function fixLeadingVerticalTab(
sourceText: string,
indices: number[],
styles: OneNoteTextStyle[]
): {
sourceText: string;
indices: number[];
styles: OneNoteTextStyle[];
} {
if (!sourceText.startsWith('\u000b') || indices[0] !== 1) {
return { sourceText, indices, styles };
}
const difference = styles.length - indices.length;
if (difference !== 0 && difference !== 1) {
return { sourceText, indices, styles };
}
sourceText = sourceText.slice(1);
indices = indices.slice(1).map((index) => index - 1);
if (difference === 1) styles = styles.slice(0, -1);
return { sourceText, indices, styles };
}
function blockToPlainText(block: OneNoteContentBlock): string {
switch (block.kind) {
case 'text':
return block.text.split('\u000b').join('\n').trimEnd();
case 'outline':
case 'outline-group':
return contentBlocksToPlainText(block.children);
case 'outline-element': {
const contents = contentBlocksToPlainText(block.contents);
const children = contentBlocksToPlainText(block.children);
return [contents, children].filter(Boolean).join('\n');
}
case 'table':
return block.rows
.map((row) =>
row.cells
.map((cell) => contentBlocksToPlainText(cell.blocks))
.join('\t')
)
.join('\n');
default:
return '';
}
}
function incrementBlockBudget(context: ContentContext): void {
context.budget.blocks += 1;
if (context.budget.blocks > context.limits.maxContentBlocks) {
throw new Error(
`Content block count exceeds ${context.limits.maxContentBlocks}`
);
}
}
function incrementTextRunBudget(context: ContentContext, amount: number): void {
context.budget.textRuns += amount;
if (context.budget.textRuns > context.limits.maxTextRuns) {
throw new Error(`Text run count exceeds ${context.limits.maxTextRuns}`);
}
}
function incrementTableCellBudget(context: ContentContext): void {
context.budget.tableCells += 1;
if (context.budget.tableCells > context.limits.maxTableCells) {
throw new Error(`Table cell count exceeds ${context.limits.maxTableCells}`);
}
}
function addDiagnostic(
context: ContentContext,
code: string,
message: string,
structure: string
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity: 'warning',
code,
message,
structure: context.pageId
? `${structure} (page ${context.pageId})`
: structure,
recoverable: true,
});
}
function compactObject<T extends object>(value: T): T {
for (const key of Object.keys(value) as (keyof T)[]) {
if (value[key] === undefined) delete value[key];
}
return value;
}

View File

@@ -0,0 +1,262 @@
// 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
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `one/property/{simple,
// object_reference}.rs`. Deviations: values stay in the TypeScript OneStore
// model and malformed property types are rejected rather than coerced.
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import {
countObjectReferences,
countObjectSpaceReferences,
findProperty,
referenceOffset,
} from '../onestore/property-set.js';
import type {
ExGuid,
ObjectSpace,
PropertyEntry,
StoreObject,
} from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
export function objectReferences(
object: StoreObject,
propertyId: number
): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectId') count = 1;
else if (property.value.kind === 'objectIds') count = property.value.count;
else throw propertyTypeError(propertyId, 'an object reference');
const offset = referenceOffset(
object.props,
propertyId,
countObjectReferences
);
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
}
export function objectSpaceReferences(
object: StoreObject,
propertyId: number
): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectSpaceId') count = 1;
else if (property.value.kind === 'objectSpaceIds')
count = property.value.count;
else throw propertyTypeError(propertyId, 'an object-space reference');
const offset = referenceOffset(
object.props,
propertyId,
countObjectSpaceReferences
);
return resolveReferenceSlice(
object,
object.props.objectSpaceIds,
offset,
count
);
}
export function rootObject(
space: ObjectSpace,
role: number,
structure: string
): StoreObject {
const id = space.roots.get(role);
if (!id) throw new Error(`Object space has no ${structure}`);
return getObject(space, id, structure);
}
export function getObject(
space: ObjectSpace,
id: ExGuid,
structure: string
): StoreObject {
const object = space.objects.get(exGuidKey(id));
if (!object)
throw new Error(`${structure} object ${exGuidKey(id)} is missing`);
return object;
}
export function assertJcid(
object: StoreObject,
expected: number,
structure: string
): void {
if (object.jcid !== expected) {
throw new Error(
`${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${expected.toString(16)}`
);
}
}
export function optionalEntry(
object: StoreObject,
propertyId: number
): PropertyEntry | undefined {
return findProperty(object.props, propertyId);
}
export function optionalBytes(
object: StoreObject,
propertyId: number
): Uint8Array | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bytes') throw propertyTypeError(propertyId, 'bytes');
return value.value;
}
export function optionalString(
object: StoreObject,
propertyId: number
): string | undefined {
const bytes = optionalBytes(object, propertyId);
if (!bytes) return undefined;
if (bytes.byteLength % 2 !== 0) {
throw new Error(
`UTF-16 property 0x${propertyId.toString(16)} has odd length`
);
}
return new TextDecoder('utf-16le', { fatal: true }).decode(bytes);
}
export function optionalLatin1(
object: StoreObject,
propertyId: number
): string | undefined {
const bytes = optionalBytes(object, propertyId);
if (!bytes) return undefined;
let result = '';
for (const value of bytes) result += String.fromCharCode(value);
return result;
}
export function requiredGuid(
object: StoreObject,
propertyId: number,
structure: string
): string {
const bytes = optionalBytes(object, propertyId);
if (!bytes || bytes.length !== 16) {
throw new Error(`${structure} is absent or not 16 bytes`);
}
return readGuid(new BinaryReader(bytes, 0, 16, structure));
}
export function optionalBool(
object: StoreObject,
propertyId: number
): boolean | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bool') throw propertyTypeError(propertyId, 'a Boolean');
return value.value;
}
export function optionalU8(
object: StoreObject,
propertyId: number
): number | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u8') throw propertyTypeError(propertyId, 'a u8');
return value.value;
}
export function optionalU16(
object: StoreObject,
propertyId: number
): number | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u16') throw propertyTypeError(propertyId, 'a u16');
return value.value;
}
export function optionalU32(
object: StoreObject,
propertyId: number
): number | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u32') throw propertyTypeError(propertyId, 'a u32');
return value.value;
}
export function optionalU64(
object: StoreObject,
propertyId: number
): bigint | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u64') throw propertyTypeError(propertyId, 'a u64');
return value.value;
}
export function optionalF32(
object: StoreObject,
propertyId: number
): number | undefined {
const raw = optionalU32(object, propertyId);
if (raw === undefined) return undefined;
const data = new DataView(new ArrayBuffer(4));
data.setUint32(0, raw, true);
const value = data.getFloat32(0, true);
if (!Number.isFinite(value)) {
throw new Error(
`Float property 0x${propertyId.toString(16)} is not finite`
);
}
return value;
}
export function optionalU32Vector(
object: StoreObject,
propertyId: number
): number[] | undefined {
const bytes = optionalBytes(object, propertyId);
if (!bytes) return undefined;
if (bytes.byteLength % 4 !== 0) {
throw new Error(
`Property 0x${propertyId.toString(16)} is not a u32 vector`
);
}
const reader = new BinaryReader(bytes, 0, bytes.byteLength, 'u32 vector');
const result: number[] = [];
while (reader.remaining > 0) result.push(reader.u32());
return result;
}
function resolveReferenceSlice(
object: StoreObject,
ids: { guidIndex: number; value: number }[],
offset: number,
count: number
): ExGuid[] {
if (offset + count > ids.length) {
throw new Error(
`Reference range ${offset}+${count} exceeds stream length ${ids.length}`
);
}
return ids.slice(offset, offset + count).map((id) => {
const guid = object.mapping.get(id.guidIndex);
if (!guid) throw new Error(`GUID mapping ${id.guidIndex} is missing`);
return { guid, value: id.value };
});
}
function propertyTypeError(propertyId: number, expected: string): Error {
return new Error(`Property 0x${propertyId.toString(16)} is not ${expected}`);
}

View File

@@ -0,0 +1,294 @@
// 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
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{section,
// page_series,page}.rs`. Deviations: pages retain the internal content/resource
// model needed for incremental worker integration.
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { DesktopOneStore } from '../onestore/desktop-store.js';
import type { ObjectSpace } from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
contentBlocksToPlainText,
createContentBudget,
parseContentObjects,
} from './content.js';
import type {
OneNotePageContentModel,
OneNoteResource,
OneNoteSectionContentModel,
} from './content-model.js';
import { JCID, PROPERTY } from './constants.js';
import {
assertJcid,
getObject,
objectReferences,
objectSpaceReferences,
optionalString,
optionalU32,
optionalU64,
requiredGuid,
rootObject,
} from './property-access.js';
interface SectionContentContext {
store: DesktopOneStore;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
resources: Map<string, OneNoteResource>;
budget: ReturnType<typeof createContentBudget>;
extractedChars: number;
currentPage?: string;
}
export function buildSectionContentModel(
store: DesktopOneStore,
sourceName: string,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): OneNoteSectionContentModel {
if (store.kind !== 'section') {
throw new Error('A table-of-contents store cannot be parsed as a section');
}
const context: SectionContentContext = {
store,
limits,
diagnostics,
resources: new Map(),
budget: createContentBudget(),
extractedChars: 0,
};
const root = store.rootObjectSpace;
const metadata = rootObject(root, 2, 'section metadata root');
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
const displayName = cleanMetadataString(
optionalString(metadata, PROPERTY.SectionDisplayName)
);
const section = rootObject(root, 1, 'section content root');
assertJcid(section, JCID.SectionNode, 'SectionNode');
const pageSeriesIds = objectReferences(section, PROPERTY.ElementChildNodes);
const pages: OneNotePageContentModel[] = [];
for (const seriesId of pageSeriesIds) {
const series = getObject(root, seriesId, 'PageSeriesNode');
assertJcid(series, JCID.PageSeriesNode, 'PageSeriesNode');
const pageSpaceIds = objectSpaceReferences(
series,
PROPERTY.ChildGraphSpaceElementNodes
);
for (const pageSpaceId of pageSpaceIds) {
context.currentPage = undefined;
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
if (!pageSpace) {
addDiagnostic(
context,
'MISSING_PAGE_SPACE',
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
'PageSeriesNode'
);
continue;
}
if (pages.length >= limits.maxPages) {
addDiagnostic(
context,
'PAGE_LIMIT_REACHED',
`Page count exceeds ${limits.maxPages}`,
'PageSeriesNode',
'error'
);
break;
}
try {
pages.push(parsePage(context, pageSpace));
} catch (error) {
addDiagnostic(
context,
'PAGE_PARSE_FAILED',
`Could not parse page ${exGuidKey(pageSpaceId)}: ${error instanceof Error ? error.message : String(error)}`,
'Page',
'error'
);
}
}
if (pages.length >= limits.maxPages) break;
}
return {
sourceName,
sectionName: displayName ?? stripOneExtension(sourceName),
pages,
resources: context.resources,
diagnostics,
};
}
function parsePage(
context: SectionContentContext,
space: ObjectSpace
): OneNotePageContentModel {
const metadata = rootObject(space, 2, 'page metadata root');
assertJcid(metadata, JCID.PageMetadata, 'PageMetadata');
const pageGuid = requiredGuid(
metadata,
PROPERTY.NotebookManagementEntityGuid,
'PageMetadata entity GUID'
);
context.currentPage = pageGuid;
const created = optionalU64(metadata, PROPERTY.TopologyCreationTimeStamp);
const level = optionalU32(metadata, PROPERTY.PageLevel);
const cachedMetadataTitle = cleanMetadataString(
optionalString(metadata, PROPERTY.CachedTitleString)
);
const manifest = rootObject(space, 1, 'page content root');
assertJcid(manifest, JCID.PageManifestNode, 'PageManifestNode');
const pageId = objectReferences(manifest, PROPERTY.ContentChildNodes)[0];
if (!pageId) throw new Error('PageManifestNode has no page reference');
const page = getObject(space, pageId, 'PageNode');
assertJcid(page, JCID.PageNode, 'PageNode');
const updated = optionalU32(page, PROPERTY.LastModifiedTime);
const cachedPageTitle = cleanMetadataString(
optionalString(page, PROPERTY.CachedTitleStringFromPage)
);
const contentOptions = {
space,
limits: context.limits,
diagnostics: context.diagnostics,
resources: context.resources,
pageId: pageGuid,
budget: context.budget,
};
const titleId = objectReferences(
page,
PROPERTY.StructureElementChildNodes
)[0];
let titleText: string | undefined;
if (titleId) {
try {
const title = getObject(space, titleId, 'TitleNode');
assertJcid(title, JCID.TitleNode, 'TitleNode');
const titleBlocks = parseContentObjects(
objectReferences(title, PROPERTY.ElementChildNodes),
contentOptions
);
titleText = cleanTitle(contentBlocksToPlainText(titleBlocks));
} catch (error) {
addDiagnostic(
context,
'TITLE_PARSE_FAILED',
error instanceof Error ? error.message : String(error),
'TitleNode'
);
}
}
const blocks = parseContentObjects(
objectReferences(page, PROPERTY.ElementChildNodes),
contentOptions
);
let text = contentBlocksToPlainText(blocks);
const remaining =
context.limits.maxExtractedTextChars - context.extractedChars;
if (text.length > remaining) {
text = text.slice(0, Math.max(0, remaining));
addDiagnostic(
context,
'TEXT_LIMIT_REACHED',
`Extracted text was truncated at ${context.limits.maxExtractedTextChars} characters`,
`Page ${pageGuid}`
);
}
context.extractedChars += text.length;
const title =
titleText ||
firstNonemptyLine(text) ||
cachedPageTitle ||
cachedMetadataTitle ||
'Untitled page';
return compactObject({
id: pageGuid,
title,
createdAt:
created === undefined ? undefined : safeFiletime(context, created),
updatedAt: updated === undefined ? undefined : oneNoteTimeToIso(updated),
level,
text,
blocks,
});
}
function safeFiletime(
context: SectionContentContext,
value: bigint
): string | undefined {
try {
return filetimeToIso(value, 'PageMetadata creation time');
} catch (error) {
addDiagnostic(
context,
'INVALID_PAGE_TIMESTAMP',
error instanceof Error ? error.message : String(error),
'PageMetadata'
);
return undefined;
}
}
function cleanMetadataString(value: string | undefined): string | undefined {
const result = value?.split('\0').join('').trim();
return result || undefined;
}
function cleanTitle(text: string): string | undefined {
const result = text
.split('\n')
.map((value) => value.trim())
.find(Boolean);
return result || undefined;
}
function firstNonemptyLine(text: string): string | undefined {
return text
.split('\n')
.map((value) => value.trim())
.find(Boolean);
}
function addDiagnostic(
context: SectionContentContext,
code: string,
message: string,
structure: string,
severity: 'warning' | 'error' = 'warning'
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity,
code,
message,
structure: context.currentPage
? `${structure} (page ${context.currentPage})`
: structure,
recoverable: true,
});
}
function stripOneExtension(filename: string): string {
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
}
function compactObject<T extends object>(value: T): T {
for (const key of Object.keys(value) as (keyof T)[]) {
if (value[key] === undefined) delete value[key];
}
return value;
}

View File

@@ -8,7 +8,7 @@
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object, // one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
// object_group_list,object_space,revision,revision_manifest_list, // object_group_list,object_space,revision,revision_manifest_list,
// root_file_node_list}.rs`. Deviations: selected section nodes only, // root_file_node_list}.rs`. Deviations: selected section nodes only,
// serializable diagnostics, explicit limits, and attachment payloads skipped. // serializable diagnostics, explicit limits, and zero-copy file-data ranges.
import { BinaryReader } from '../binary/BinaryReader.js'; import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js'; import { readGuid } from '../binary/guid.js';
@@ -26,6 +26,12 @@ import {
readChunkReference64x32, readChunkReference64x32,
readTransactionNodeCounts, readTransactionNodeCounts,
} from './file-node.js'; } from './file-node.js';
import {
emptyObjectPropSet,
parseFileDataDeclaration,
parseFileDataStore,
type FileDataStore,
} from './file-data.js';
import { parseObjectPropSet } from './property-set.js'; import { parseObjectPropSet } from './property-set.js';
import type { import type {
CompactId, CompactId,
@@ -49,6 +55,7 @@ interface ParseContext {
limits: OneNoteParserLimits; limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[]; diagnostics: ParserDiagnostic[];
objectCount: number; objectCount: number;
fileDataStore: FileDataStore;
} }
export function parseDesktopOneStore( export function parseDesktopOneStore(
@@ -132,11 +139,13 @@ function parseDesktopRevisionStore(
); );
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts); const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
const rootList = fileNodeParser.parseList(rootReference); const rootList = fileNodeParser.parseList(rootReference);
const fileDataStore = parseFileDataStore(bytes, rootList, limits);
const context: ParseContext = { const context: ParseContext = {
bytes, bytes,
limits, limits,
diagnostics, diagnostics,
objectCount: 0, objectCount: 0,
fileDataStore,
}; };
let rootObjectSpaceId: ExGuid | undefined; let rootObjectSpaceId: ExGuid | undefined;
@@ -573,12 +582,40 @@ function parseObjectDeclaration(
objects: Map<string, StoreObject> objects: Map<string, StoreObject>
): void { ): void {
if (node.id === 0x072 || node.id === 0x073) { if (node.id === 0x072 || node.id === 0x073) {
diagnostic( const declaration = parseFileDataDeclaration(
context, context.bytes,
'UNSUPPORTED_ATTACHMENT_DECLARATION', node,
'Attachment object metadata is not extracted in this parser phase', context.fileDataStore,
node context.limits
); );
const id = resolveCompactId(declaration.compactId, mapping);
objects.set(exGuidKey(id), {
id,
jcid: declaration.jcid,
props: emptyObjectPropSet(),
mapping: new Map(mapping),
contextId,
fileData: declaration.fileData,
});
if (
declaration.fileData.referenceKind === 'embedded' &&
!declaration.fileData.blob
) {
diagnostic(
context,
'MISSING_FILE_DATA',
`File-data store does not contain ${declaration.fileData.storageGuid}`,
node
);
} else if (declaration.fileData.referenceKind === 'external') {
diagnostic(
context,
'EXTERNAL_FILE_DATA_UNAVAILABLE',
'The file-data object refers to an external onefiles entry that was not selected',
node
);
}
incrementObjectCount(context, node);
return; return;
} }
if (!node.dataReference) { if (!node.dataReference) {
@@ -665,6 +702,10 @@ function parseObjectDeclaration(
mapping: new Map(mapping), mapping: new Map(mapping),
contextId, contextId,
}); });
incrementObjectCount(context, node);
}
function incrementObjectCount(context: ParseContext, node: FileNode): void {
context.objectCount += 1; context.objectCount += 1;
if (context.objectCount > context.limits.maxObjects) { if (context.objectCount > context.limits.maxObjects) {
throw new OneNoteParserError( throw new OneNoteParserError(

View File

@@ -0,0 +1,336 @@
// 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
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/
// {file_node/shared,objects/file_data_store}.rs`. Deviations: file payloads
// remain bounded zero-copy byte ranges, all string/count arithmetic is checked,
// and unavailable external/invalid references remain explicit metadata.
import { BinaryReader } from '../binary/BinaryReader.js';
import { normalizeGuid, readGuid } from '../binary/guid.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type {
CompactId,
ExGuid,
FileDataBlobReference,
FileNode,
FileNodeList,
ObjectPropSet,
StoreFileData,
} from './types.js';
const FILE_DATA_STORE_HEADER = '{BDE316E7-2665-4511-A4C4-8D4D0B7A9EAC}';
const FILE_DATA_STORE_FOOTER = '{71FBA722-0F79-4A0B-BB13-899256426B24}';
const FILE_DATA_FIXED_PREFIX_BYTES = 36;
export type FileDataStore = ReadonlyMap<string, FileDataBlobReference>;
export interface ParsedFileDataDeclaration {
compactId: CompactId;
jcid: number;
fileData: StoreFileData;
}
export function emptyObjectPropSet(): ObjectPropSet {
return {
objectIds: [],
objectSpaceIds: [],
contextIds: [],
properties: { entries: [] },
};
}
/** Parse the optional root-level FileDataStoreListReferenceFND (0x090). */
export function parseFileDataStore(
bytes: Uint8Array,
rootList: FileNodeList,
limits: OneNoteParserLimits
): FileDataStore {
const storeNodes = rootList.nodes.filter((node) => node.id === 0x090);
if (storeNodes.length > 1) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Root FileNodeList contains more than one file-data store',
{ structure: 'FileDataStoreListReferenceFND' }
);
}
const storeNode = storeNodes[0];
if (!storeNode) return new Map();
if (!storeNode.childList) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'FileDataStoreListReferenceFND has no child FileNodeList',
{ offset: storeNode.offset, structure: 'FileDataStoreListReferenceFND' }
);
}
const result = new Map<string, FileDataBlobReference>();
let totalBytes = 0;
for (const node of storeNode.childList.nodes) {
if (node.id !== 0x094) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in file-data store`,
{ offset: node.offset, structure: 'FileDataStoreList' }
);
}
if (result.size >= limits.maxFileDataObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`File-data object count exceeds ${limits.maxFileDataObjects}`,
{ offset: node.offset, structure: 'FileDataStoreList' }
);
}
if (
!node.dataReference ||
node.dataReference.nil ||
node.dataReference.zero
) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'FileDataStoreObjectReferenceFND has no data reference',
{ offset: node.offset, structure: 'FileDataStoreObjectReferenceFND' }
);
}
const nodeReader = new BinaryReader(
bytes,
node.payloadOffset,
node.payloadLength,
'FileDataStoreObjectReferenceFND'
);
const guid = readGuid(nodeReader);
if (nodeReader.remaining !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileDataStoreObjectReferenceFND has trailing payload bytes',
{
offset: nodeReader.offset,
structure: 'FileDataStoreObjectReferenceFND',
}
);
}
if (result.has(guid)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Duplicate file-data GUID ${guid}`,
{ offset: node.offset, structure: 'FileDataStoreList' }
);
}
const blob = parseFileDataStoreObject(bytes, node, limits);
totalBytes = checkedAdd(totalBytes, blob.size, node.offset);
if (totalBytes > limits.maxTotalFileDataBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`File-data payloads exceed ${limits.maxTotalFileDataBytes} bytes`,
{ offset: node.offset, structure: 'FileDataStoreList' }
);
}
result.set(guid, blob);
}
return result;
}
/** Parse ObjectDeclarationFileData3RefCountFND (0x072/0x073). */
export function parseFileDataDeclaration(
bytes: Uint8Array,
node: FileNode,
store: FileDataStore,
limits: OneNoteParserLimits
): ParsedFileDataDeclaration {
if (node.id !== 0x072 && node.id !== 0x073) {
throw new TypeError('Expected a file-data object declaration');
}
const reader = new BinaryReader(
bytes,
node.payloadOffset,
node.payloadLength,
'ObjectDeclarationFileData3RefCountFND'
);
const compactId = readCompactId(reader);
const jcid = reader.u32();
if (node.id === 0x072) reader.u8();
else reader.u32();
const dataReference = readStorageString(reader, limits);
const extension = readStorageString(reader, limits);
if (reader.remaining !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'File-data declaration has trailing payload bytes',
{
offset: reader.offset,
structure: 'ObjectDeclarationFileData3RefCountFND',
}
);
}
return {
compactId,
jcid,
fileData: resolveFileData(dataReference, extension, store, node),
};
}
export function resolveCompactFileDataId(
compactId: CompactId,
mapping: ReadonlyMap<number, string>
): ExGuid {
const guid = mapping.get(compactId.guidIndex);
if (!guid) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`GlobalIdTable has no GUID at index ${compactId.guidIndex}`,
{ structure: 'CompactId' }
);
}
return { guid, value: compactId.value };
}
function parseFileDataStoreObject(
bytes: Uint8Array,
node: FileNode,
limits: OneNoteParserLimits
): FileDataBlobReference {
const reference = node.dataReference!;
const reader = new BinaryReader(
bytes,
reference.offset,
reference.size,
'FileDataStoreObject'
);
if (readGuid(reader) !== FILE_DATA_STORE_HEADER) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileDataStoreObject header GUID',
{ offset: reference.offset, structure: 'FileDataStoreObject' }
);
}
const rawLength = reader.u64();
if (rawLength > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
'File-data length exceeds the safe integer range',
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
);
}
const size = Number(rawLength);
if (size > limits.maxFileDataBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`File-data payload is ${size} bytes (limit ${limits.maxFileDataBytes})`,
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
);
}
reader.u32(); // unused; consumers MUST ignore it
reader.u64(); // reserved; consumers MUST ignore it
const dataOffset = reader.offset;
reader.skip(size);
const padding = (8 - ((FILE_DATA_FIXED_PREFIX_BYTES + size) % 8)) % 8;
reader.skip(padding);
if (readGuid(reader) !== FILE_DATA_STORE_FOOTER) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileDataStoreObject footer GUID',
{ offset: reader.offset - 16, structure: 'FileDataStoreObject' }
);
}
if (reader.remaining !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileDataStoreObject has trailing bytes',
{ offset: reader.offset, structure: 'FileDataStoreObject' }
);
}
return { source: bytes, offset: dataOffset, size };
}
function readStorageString(
reader: BinaryReader,
limits: OneNoteParserLimits
): string {
const characterCount = reader.u32();
if (characterCount > Math.floor(limits.maxVectorBytes / 2)) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Storage string contains ${characterCount} UTF-16 code units`,
{ offset: reader.offset - 4, structure: 'StringInStorageBuffer' }
);
}
const byteLength = characterCount * 2;
try {
return new TextDecoder('utf-16le', { fatal: true }).decode(
reader.read(byteLength)
);
} catch (error) {
if (error instanceof OneNoteParserError) throw error;
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'StringInStorageBuffer contains invalid UTF-16',
{ offset: reader.offset - byteLength, structure: 'StringInStorageBuffer' }
);
}
}
function resolveFileData(
dataReference: string,
extension: string,
store: FileDataStore,
node: FileNode
): StoreFileData {
if (dataReference.startsWith('<ifndf>')) {
const rawGuid = dataReference.slice('<ifndf>'.length);
let storageGuid: string;
try {
storageGuid = normalizeGuid(rawGuid);
} catch {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid embedded file-data GUID ${rawGuid}`,
{
offset: node.offset,
structure: 'ObjectDeclarationFileData3RefCountFND',
}
);
}
return {
referenceKind: 'embedded',
dataReference,
extension,
storageGuid,
blob: store.get(storageGuid),
};
}
if (dataReference.startsWith('<file>')) {
return { referenceKind: 'external', dataReference, extension };
}
if (dataReference === '<invfdo>') {
return { referenceKind: 'invalid', dataReference, extension };
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unknown file-data reference ${dataReference}`,
{ offset: node.offset, structure: 'ObjectDeclarationFileData3RefCountFND' }
);
}
function readCompactId(reader: BinaryReader): CompactId {
const raw = reader.u32();
return { value: raw & 0xff, guidIndex: raw >>> 8 };
}
function checkedAdd(left: number, right: number, offset: number): number {
const result = left + right;
if (!Number.isSafeInteger(result)) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
'File-data byte count exceeds the safe integer range',
{ offset, structure: 'FileDataStoreList' }
);
}
return result;
}

View File

@@ -76,12 +76,29 @@ export interface ObjectPropSet {
properties: PropertySet; properties: PropertySet;
} }
/** A bounded, zero-copy view of file data retained inside the source file. */
export interface FileDataBlobReference {
source: Uint8Array;
offset: number;
size: number;
}
/** Metadata carried by an ObjectDeclarationFileData3* FileNode. */
export interface StoreFileData {
referenceKind: 'embedded' | 'external' | 'invalid';
dataReference: string;
extension: string;
storageGuid?: string;
blob?: FileDataBlobReference;
}
export interface StoreObject { export interface StoreObject {
id: ExGuid; id: ExGuid;
jcid: number; jcid: number;
props: ObjectPropSet; props: ObjectPropSet;
mapping: ReadonlyMap<number, string>; mapping: ReadonlyMap<number, string>;
contextId: ExGuid; contextId: ExGuid;
fileData?: StoreFileData;
} }
export interface ObjectSpace { export interface ObjectSpace {
@@ -93,3 +110,11 @@ export interface ObjectSpace {
export function exGuidKey(id: ExGuid): string { export function exGuidKey(id: ExGuid): string {
return `${id.guid}:${id.value}`; return `${id.guid}:${id.value}`;
} }
/** Return a view into the retained source without copying its bytes. */
export function fileDataBytes(reference: FileDataBlobReference): Uint8Array {
return reference.source.subarray(
reference.offset,
reference.offset + reference.size
);
}

View File

@@ -15,7 +15,14 @@ export interface OneNoteParserLimits {
maxStreamIds: number; maxStreamIds: number;
maxVectorBytes: number; maxVectorBytes: number;
maxObjectReferences: number; maxObjectReferences: number;
maxFileDataObjects: number;
maxFileDataBytes: number;
maxTotalFileDataBytes: number;
maxGraphDepth: number; maxGraphDepth: number;
maxContentBlocks: number;
maxTextRuns: number;
maxTableCells: number;
maxInkPoints: number;
maxPages: number; maxPages: number;
maxDiagnostics: number; maxDiagnostics: number;
maxExtractedTextChars: number; maxExtractedTextChars: number;
@@ -34,7 +41,14 @@ export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
maxStreamIds: 1_000_000, maxStreamIds: 1_000_000,
maxVectorBytes: 64 * 1024 * 1024, maxVectorBytes: 64 * 1024 * 1024,
maxObjectReferences: 1_000_000, maxObjectReferences: 1_000_000,
maxFileDataObjects: 10_000,
maxFileDataBytes: 256 * 1024 * 1024,
maxTotalFileDataBytes: 512 * 1024 * 1024,
maxGraphDepth: 256, maxGraphDepth: 256,
maxContentBlocks: 1_000_000,
maxTextRuns: 1_000_000,
maxTableCells: 1_000_000,
maxInkPoints: 5_000_000,
maxPages: 100_000, maxPages: 100_000,
maxDiagnostics: 10_000, maxDiagnostics: 10_000,
maxExtractedTextChars: 2_000_000, maxExtractedTextChars: 2_000_000,

View File

@@ -3,7 +3,9 @@
// You can obtain one at https://mozilla.org/MPL/2.0/. // You can obtain one at https://mozilla.org/MPL/2.0/.
import type { OneNoteSectionDto } from '../model/dto.js'; import type { OneNoteSectionDto } from '../model/dto.js';
import type { OneNoteSectionContentModel } from '../one/content-model.js';
import { buildSectionDto } from '../one/section.js'; import { buildSectionDto } from '../one/section.js';
import { buildSectionContentModel } from '../one/section-content.js';
import { parseDesktopOneStore } from '../onestore/desktop-store.js'; import { parseDesktopOneStore } from '../onestore/desktop-store.js';
import { toBytes } from './detect-format.js'; import { toBytes } from './detect-format.js';
import type { OneNoteParserLimits } from './limits.js'; import type { OneNoteParserLimits } from './limits.js';
@@ -30,3 +32,20 @@ export function parseOneNoteSection(
diagnostics diagnostics
); );
} }
/** Parse a section into the internal rich-content and lazy-resource model. */
export function parseOneNoteSectionContent(
input: ArrayBuffer | Uint8Array,
options: ParseOneNoteSectionOptions = {}
): OneNoteSectionContentModel {
const bytes = toBytes(input);
const limits = resolveParserLimits(options.limits);
const diagnostics: OneNoteSectionContentModel['diagnostics'] = [];
const store = parseDesktopOneStore(bytes, limits, diagnostics);
return buildSectionContentModel(
store,
options.sourceName ?? 'Untitled.one',
limits,
diagnostics
);
}

278
tests/content-model.test.ts Normal file
View File

@@ -0,0 +1,278 @@
// 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 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');
});
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();
});
});
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;
}

177
tests/file-data.test.ts Normal file
View File

@@ -0,0 +1,177 @@
// 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 { describe, expect, it } from 'vitest';
import {
parseFileDataDeclaration,
parseFileDataStore,
} from '../src/onenote/onestore/file-data.js';
import type { FileNode, FileNodeList } from '../src/onenote/onestore/types.js';
import { fileDataBytes } from '../src/onenote/onestore/types.js';
import { resolveParserLimits } from '../src/onenote/parser/limits.js';
const HEADER = '{BDE316E7-2665-4511-A4C4-8D4D0B7A9EAC}';
const FOOTER = '{71FBA722-0F79-4A0B-BB13-899256426B24}';
const STORAGE_GUID = '{01234567-89AB-CDEF-8123-456789ABCDEF}';
describe('OneStore file-data objects', () => {
it('indexes bounded payload ranges and resolves 0x072 declarations', () => {
const payload = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10);
const fixture = fileDataFixture(payload);
const store = parseFileDataStore(
fixture.bytes,
fixture.root,
resolveParserLimits()
);
const blob = store.get(STORAGE_GUID)!;
expect(blob).toMatchObject({ offset: 100, size: payload.length });
expect(blob.source.buffer).toBe(fixture.bytes.buffer);
expect([...fileDataBytes(blob)]).toEqual([...payload]);
const declarationBytes = declarationPayload(
0x072,
`<ifndf>${STORAGE_GUID}`,
'.png'
);
const declaration = parseFileDataDeclaration(
declarationBytes,
node(0x072, 0, declarationBytes.length),
store,
resolveParserLimits()
);
expect(declaration).toMatchObject({
compactId: { guidIndex: 3, value: 7 },
jcid: 0x00080039,
fileData: {
referenceKind: 'embedded',
storageGuid: STORAGE_GUID,
extension: '.png',
blob,
},
});
});
it('keeps external references explicit and enforces payload limits', () => {
const externalBytes = declarationPayload(
0x073,
'<file>media/image.onebin',
'.jpg'
);
expect(
parseFileDataDeclaration(
externalBytes,
node(0x073, 0, externalBytes.length),
new Map(),
resolveParserLimits()
).fileData
).toEqual({
referenceKind: 'external',
dataReference: '<file>media/image.onebin',
extension: '.jpg',
});
const fixture = fileDataFixture(Uint8Array.of(1, 2, 3, 4, 5));
expect(() =>
parseFileDataStore(
fixture.bytes,
fixture.root,
resolveParserLimits({ maxFileDataBytes: 4 })
)
).toThrow(/payload is 5 bytes/);
});
});
function fileDataFixture(payload: Uint8Array): {
bytes: Uint8Array;
root: FileNodeList;
} {
const objectOffset = 64;
const padding = (8 - ((36 + payload.length) % 8)) % 8;
const objectSize = 36 + payload.length + padding + 16;
const bytes = Buffer.alloc(objectOffset + objectSize);
writeGuid(bytes, 0, STORAGE_GUID);
writeGuid(bytes, objectOffset, HEADER);
bytes.writeBigUInt64LE(BigInt(payload.length), objectOffset + 16);
bytes.writeUInt32LE(0xfeedbeef, objectOffset + 24);
bytes.writeBigUInt64LE(0x1234n, objectOffset + 28);
bytes.set(payload, objectOffset + 36);
writeGuid(bytes, objectOffset + 36 + payload.length + padding, FOOTER);
const dataNode: FileNode = {
...node(0x094, 0, 16),
dataReference: {
offset: objectOffset,
size: objectSize,
nil: false,
zero: false,
},
};
return {
bytes,
root: {
id: 0,
nodes: [
{
...node(0x090, 0, 0),
childList: { id: 1, nodes: [dataNode] },
},
],
},
};
}
function declarationPayload(
id: 0x072 | 0x073,
dataReference: string,
extension: string
): Uint8Array {
const data = Buffer.from(dataReference, 'utf16le');
const ext = Buffer.from(extension, 'utf16le');
const countSize = id === 0x072 ? 1 : 4;
const result = Buffer.alloc(
4 + 4 + countSize + 4 + data.length + 4 + ext.length
);
let offset = 0;
result.writeUInt32LE((3 << 8) | 7, offset);
offset += 4;
result.writeUInt32LE(0x00080039, offset);
offset += 4;
if (id === 0x072) result.writeUInt8(1, offset);
else result.writeUInt32LE(1, offset);
offset += countSize;
result.writeUInt32LE(dataReference.length, offset);
offset += 4;
result.set(data, offset);
offset += data.length;
result.writeUInt32LE(extension.length, offset);
offset += 4;
result.set(ext, offset);
return result;
}
function node(
id: number,
payloadOffset: number,
payloadLength: number
): FileNode {
return {
id,
declaredSize: payloadLength,
offset: payloadOffset,
payloadOffset,
payloadLength,
};
}
function writeGuid(target: Buffer, offset: number, guid: string): void {
const hex = guid.replace(/[{}-]/g, '');
const bytes = Buffer.from(hex, 'hex');
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
order.forEach((sourceIndex, index) => {
target[offset + index] = bytes[sourceIndex]!;
});
}