feat: parse rich OneNote content resources
This commit is contained in:
@@ -15,11 +15,20 @@ export const JCID = {
|
||||
OutlineElementNode: 0x0006000d,
|
||||
RichTextNode: 0x0006000e,
|
||||
ImageNode: 0x00060011,
|
||||
NumberListNode: 0x00060012,
|
||||
InkContainer: 0x00060014,
|
||||
OutlineGroup: 0x00060019,
|
||||
TableNode: 0x00060022,
|
||||
TableRowNode: 0x00060023,
|
||||
TableCellNode: 0x00060024,
|
||||
TitleNode: 0x0006002c,
|
||||
EmbeddedFileNode: 0x00060035,
|
||||
EmbeddedFileContainer: 0x00080036,
|
||||
PictureContainer: 0x00080039,
|
||||
XpsContainer: 0x0008003a,
|
||||
InkDataNode: 0x0002003b,
|
||||
InkStrokeNode: 0x00020047,
|
||||
StrokePropertiesNode: 0x00120048,
|
||||
PageManifestNode: 0x00060037,
|
||||
PageMetadata: 0x00020030,
|
||||
SectionMetadata: 0x00020031,
|
||||
@@ -43,5 +52,71 @@ export const PROPERTY = {
|
||||
TextExtendedAscii: 0x1c003498,
|
||||
TextRunFormatting: 0x24001e13,
|
||||
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,
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user