feat: parse rich OneNote content resources
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
204
src/onenote/one/content-model.ts
Normal file
204
src/onenote/one/content-model.ts
Normal 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
926
src/onenote/one/content.ts
Normal 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;
|
||||||
|
}
|
||||||
262
src/onenote/one/property-access.ts
Normal file
262
src/onenote/one/property-access.ts
Normal 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}`);
|
||||||
|
}
|
||||||
294
src/onenote/one/section-content.ts
Normal file
294
src/onenote/one/section-content.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
336
src/onenote/onestore/file-data.ts
Normal file
336
src/onenote/onestore/file-data.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
278
tests/content-model.test.ts
Normal 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
177
tests/file-data.test.ts
Normal 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]!;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user