diff --git a/src/onenote/one/content.ts b/src/onenote/one/content.ts index 32de66e..bb33091 100644 --- a/src/onenote/one/content.ts +++ b/src/onenote/one/content.ts @@ -54,6 +54,7 @@ import { export interface OneNoteContentBudget { blocks: number; textRuns: number; + textCharacters: number; tableCells: number; inkPoints: number; } @@ -72,7 +73,13 @@ interface ContentContext extends OneNoteContentParseOptions { } export function createContentBudget(): OneNoteContentBudget { - return { blocks: 0, textRuns: 0, tableCells: 0, inkPoints: 0 }; + return { + blocks: 0, + textRuns: 0, + textCharacters: 0, + tableCells: 0, + inkPoints: 0, + }; } /** Parse page graph roots into an inert, recursively structured content tree. */ @@ -235,6 +242,7 @@ function parseRichText( let sourceText = optionalString(object, PROPERTY.RichEditTextUnicode); sourceText ??= optionalLatin1(object, PROPERTY.TextExtendedAscii); sourceText = (sourceText ?? '').split('\0').join(''); + incrementTextCharacterBudget(context, sourceText.length); const paragraphStyleId = objectReferences(object, PROPERTY.ParagraphStyle)[0]; const paragraphStyle = paragraphStyleId @@ -1223,6 +1231,22 @@ function incrementTextRunBudget(context: ContentContext, amount: number): void { } } +function incrementTextCharacterBudget( + context: ContentContext, + amount: number +): void { + const next = context.budget.textCharacters + amount; + if ( + !Number.isSafeInteger(next) || + next > context.limits.maxExtractedTextChars + ) { + throw new Error( + `Rich-text character count exceeds ${context.limits.maxExtractedTextChars}` + ); + } + context.budget.textCharacters = next; +} + function incrementTableCellBudget(context: ContentContext): void { context.budget.tableCells += 1; if (context.budget.tableCells > context.limits.maxTableCells) { diff --git a/tests/content-model.test.ts b/tests/content-model.test.ts index a019cb0..b5eaf82 100644 --- a/tests/content-model.test.ts +++ b/tests/content-model.test.ts @@ -14,6 +14,7 @@ import { type OneNoteContentBlock, type OneNoteTextBlock, } from '../src/onenote/index.js'; +import type { ParserDiagnostic } from '../src/onenote/model/diagnostics.js'; import { parseContentObjects } from '../src/onenote/one/content.js'; import { JCID, PROPERTY } from '../src/onenote/one/constants.js'; import type { @@ -207,6 +208,47 @@ describe('OneNote rich-content model', () => { .reduce((total, stroke) => total + stroke.points.length, 0); expect(parsedPoints).toBeLessThanOrEqual(100); }); + + it('bounds retained rich text across content blocks', () => { + const guid = '{33333333-3333-3333-3333-333333333333}'; + const firstId = id(guid, 1); + const secondId = id(guid, 2); + const richTextObject = (objectId: ExGuid, text: string) => + object( + objectId, + JCID.RichTextNode, + props( + bytesEntry(PROPERTY.RichEditTextUnicode, Buffer.from(text, 'utf16le')) + ), + guid + ); + const objects = [ + richTextObject(firstId, 'first'), + richTextObject(secondId, 'second'), + ]; + const diagnostics: ParserDiagnostic[] = []; + const blocks = parseContentObjects([firstId, secondId], { + space: { + id: id(guid, 0), + roots: new Map(), + objects: new Map( + objects.map((value) => [exGuidKey(value.id), value] as const) + ), + }, + limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS, maxExtractedTextChars: 8 }, + diagnostics, + resources: new Map(), + }); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: 'text', text: 'first' }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: 'CONTENT_PARSE_FAILED', + message: expect.stringContaining('Rich-text character count exceeds 8'), + }) + ); + }); }); function parseSyntheticLink(href: string): OneNoteTextBlock {