From 1750a6b5c346045152ed6cfb3c331234d3190d59 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 19:18:42 +0200 Subject: [PATCH] feat: render rich OneNote pages and resources --- src/App.test.tsx | 19 +- src/App.tsx | 65 ++++ src/components/PageReader.test.tsx | 145 +++++++ src/components/PageReader.tsx | 588 +++++++++++++++++++++++++++-- src/onenote/model/dto.ts | 26 +- src/styles.css | 159 +++++++- src/worker/onenote.client.test.ts | 47 ++- src/worker/onenote.client.ts | 35 +- src/worker/onenote.worker.ts | 58 ++- src/worker/parser-adapter.test.ts | 16 + src/worker/parser-adapter.ts | 138 ++++++- src/worker/worker-protocol.ts | 20 + 12 files changed, 1252 insertions(+), 64 deletions(-) create mode 100644 src/components/PageReader.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index e623f96..e20d377 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -80,7 +80,24 @@ class SuccessfulParserWorker implements WorkerPort { page: { ...page, id: message.pageId, - blocks: [{ kind: 'text', text: page.text }], + blocks: [ + { + kind: 'text', + id: 'text-1', + sourceText: page.text, + text: page.text, + runs: [ + { + start: 0, + end: page.text.length, + text: page.text, + style: {}, + }, + ], + paragraphStyle: {}, + layout: {}, + }, + ], diagnostics: [], }, }); diff --git a/src/App.tsx b/src/App.tsx index ed75957..85f8d89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import type { OneNotePackageDto, OneNotePackagedSectionDto, OneNotePageDto, + OneNoteResourcePayloadDto, OneNoteSectionDto, } from './onenote/model/dto.js'; import type { ParserDiagnostic } from './onenote/model/diagnostics.js'; @@ -302,6 +303,54 @@ export function OneNoteApplication({ } }; + const loadSelectedResource = useCallback( + async (resourceId: string): Promise => { + const client = activeClient.current; + if (!client || state.phase !== 'loaded') { + throw new WorkerClientError( + 'session-not-available', + 'The local parser session is no longer available.' + ); + } + const response = await client.getResource( + state.source.sessionId, + resourceId, + state.source.kind === 'onepkg' ? selectedSectionId : undefined + ); + if (response.type === 'failure') { + throw new WorkerClientError( + response.error.code, + response.error.message + ); + } + return response.resource; + }, + [selectedSectionId, state] + ); + + const downloadSelectedResource = useCallback( + async (resourceId: string, suggestedName?: string): Promise => { + const resource = await loadSelectedResource(resourceId); + const filename = safeDownloadFilename( + resource.filename ?? + suggestedName ?? + `onenote-resource.${resource.extension ?? 'bin'}` + ); + const url = URL.createObjectURL( + new Blob([resource.bytes], { type: resource.mediaType }) + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.rel = 'noopener'; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); + }, + [loadSelectedResource] + ); + const visibleDiagnostics = uniqueDiagnostics( (() => { if (state.phase !== 'loaded') return []; @@ -390,6 +439,8 @@ export function OneNoteApplication({ error={ pageState.phase === 'error' ? pageState.message : undefined } + loadResource={loadSelectedResource} + downloadResource={downloadSelectedResource} /> @@ -420,6 +471,20 @@ export function OneNoteApplication({ ); } +function safeDownloadFilename(value: string): string { + const basename = value.split(/[\\/]/u).at(-1) ?? value; + const withoutUnsafeCharacters = [...basename.normalize('NFC')] + .map((character) => { + const code = character.codePointAt(0) ?? 0; + return code <= 0x1f || code === 0x7f || '<>:"|?*'.includes(character) + ? '_' + : character; + }) + .join(''); + const cleaned = withoutUnsafeCharacters.replace(/[. ]+$/gu, '').trim(); + return (cleaned || 'onenote-resource.bin').slice(0, 180); +} + export function App() { const [helpOpen, setHelpOpen] = useState(false); diff --git a/src/components/PageReader.test.tsx b/src/components/PageReader.test.tsx new file mode 100644 index 0000000..b69d46a --- /dev/null +++ b/src/components/PageReader.test.tsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OneNotePageDto } from '../onenote/model/dto.js'; +import { PageReader } from './PageReader.js'; + +const page: OneNotePageDto = { + id: 'page-1', + title: 'Visual page', + text: 'Styled link\nA\tB', + textPreview: 'Styled link A B', + blocks: [ + { + kind: 'text', + id: 'text-1', + sourceText: 'Styled link', + text: 'Styled link', + paragraphStyle: {}, + layout: {}, + runs: [ + { + start: 0, + end: 7, + text: 'Styled ', + style: { + bold: true, + highlight: { kind: 'rgb', red: 255, green: 255, blue: 0 }, + }, + }, + { + start: 7, + end: 11, + text: 'link', + style: { underline: true }, + href: 'https://example.com/', + }, + ], + }, + { + kind: 'table', + id: 'table-1', + rows: [ + { + id: 'row-1', + cells: [ + { id: 'cell-a', blocks: [textBlock('A', 'text-a')] }, + { id: 'cell-b', blocks: [textBlock('B', 'text-b')] }, + ], + }, + ], + columnWidths: [2, 3], + lockedColumns: [false, false], + bordersVisible: true, + layout: {}, + }, + { + kind: 'image', + id: 'image-1', + resourceId: 'resource-image', + altText: 'Embedded diagram', + isBackground: false, + layout: {}, + }, + { + kind: 'attachment', + id: 'attachment-1', + resourceId: 'resource-file', + filename: 'notes.txt', + size: 12, + layout: {}, + }, + ], + diagnostics: [], +}; + +describe('PageReader structured content', () => { + beforeEach(() => { + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:local-image'), + revokeObjectURL: vi.fn(), + }); + }); + + it('renders semantic formatting, links, tables, and a signed image resource', async () => { + const loadResource = vi.fn(async () => ({ + id: 'resource-image', + kind: 'image' as const, + mediaType: 'image/png', + browserRenderable: true, + size: 8, + bytes: Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10).buffer, + })); + + render( + + ); + + expect(screen.getByText('Styled').parentElement).toHaveStyle({ + fontWeight: '700', + }); + expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute( + 'href', + 'https://example.com/' + ); + expect(screen.getByRole('table')).toHaveTextContent('AB'); + await waitFor(() => + expect( + screen.getByRole('img', { name: 'Embedded diagram' }) + ).toHaveAttribute('src', 'blob:local-image') + ); + }); + + it('downloads an attachment only after an explicit user action', async () => { + const downloadResource = vi.fn(async () => undefined); + render( + { + throw new Error('not renderable'); + }} + downloadResource={downloadResource} + /> + ); + + await userEvent.click( + screen.getByRole('button', { name: 'Download attachment' }) + ); + expect(downloadResource).toHaveBeenCalledWith('resource-file', 'notes.txt'); + }); +}); + +function textBlock(text: string, id: string) { + return { + kind: 'text' as const, + id, + sourceText: text, + text, + runs: [{ start: 0, end: text.length, text, style: {} }], + paragraphStyle: {}, + layout: {}, + }; +} diff --git a/src/components/PageReader.tsx b/src/components/PageReader.tsx index 342e584..4e52098 100644 --- a/src/components/PageReader.tsx +++ b/src/components/PageReader.tsx @@ -1,9 +1,26 @@ -import type { OneNotePageDto } from '../onenote/model/dto.js'; +import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; + +import type { + OneNotePageDto, + OneNoteResourcePayloadDto, +} from '../onenote/model/dto.js'; +import type { + OneNoteColor, + OneNoteContentBlock, + OneNoteImageBlock, + OneNoteInkBlock, + OneNoteInkStroke, + OneNoteLayout, + OneNoteTextRun, + OneNoteTextStyle, +} from '../onenote/one/content-model.js'; interface PageReaderProps { page?: OneNotePageDto; loading: boolean; error?: string; + loadResource?(resourceId: string): Promise; + downloadResource?(resourceId: string, filename?: string): Promise; } function formatDate(value: string | undefined): string | null { @@ -16,11 +33,17 @@ function formatDate(value: string | undefined): string | null { }).format(date); } -export function PageReader({ page, loading, error }: PageReaderProps) { +export function PageReader({ + page, + loading, + error, + loadResource, + downloadResource, +}: PageReaderProps) { if (loading) { return (
-

Loading extracted page text…

+

Loading structured page content…

); } @@ -38,7 +61,7 @@ export function PageReader({ page, loading, error }: PageReaderProps) { if (!page) { return (
-

Select a readable page to inspect its extracted text.

+

Select a readable page to inspect its content.

); } @@ -71,37 +94,542 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
{page.blocks.length === 0 && page.text.length === 0 ? ( -

No readable plain text was extracted.

+

No readable content was extracted.

) : page.blocks.length === 0 ? (

{page.text}

) : ( - page.blocks.map((block, index) => { - switch (block.kind) { - case 'text': - return ( -

- {block.text} -

- ); - case 'line-break': - return
; - case 'link': - return ( -

- {block.text} -

- ); - case 'unsupported': - return ( - - ); - } - }) + )}
); } + +function ContentBlocks({ + blocks, + loadResource, + downloadResource, +}: { + blocks: readonly OneNoteContentBlock[]; + loadResource?: PageReaderProps['loadResource']; + downloadResource?: PageReaderProps['downloadResource']; +}) { + return blocks.map((block) => ( + + )); +} + +function ContentBlockView({ + block, + loadResource, + downloadResource, +}: { + block: OneNoteContentBlock; + loadResource?: PageReaderProps['loadResource']; + downloadResource?: PageReaderProps['downloadResource']; +}) { + switch (block.kind) { + case 'text': + return ( +

+ {block.runs.map((run, index) => ( + + ))} +

+ ); + case 'outline': + return ( +
+ +
+ ); + case 'outline-group': + return ( +
+ +
+ ); + case 'outline-element': + return ( +
+ + +
+ ); + case 'table': + return ( +
+ + {block.columnWidths.length > 0 ? ( + + {block.columnWidths.map((width, index) => ( + + ))} + + ) : null} + + {block.rows.map((row) => ( + + {row.cells.map((cell) => ( + + ))} + + ))} + +
+ +
+
+ ); + case 'image': + return ( + + ); + case 'attachment': + return ( + + ); + case 'ink': + return ; + case 'unsupported': + return ( + + ); + } +} + +function TextRunView({ run }: { run: OneNoteTextRun }) { + if (run.style.hidden) return null; + const content = textWithBreaks(run.text); + const style = textStyle(run.style); + return run.href ? ( + + {content} + + ) : ( + {content} + ); +} + +function textWithBreaks(text: string): ReactNode[] { + return text + .replaceAll('\u000b', '\n') + .split('\n') + .flatMap((part, index, values) => + index + 1 < values.length + ? [ + {part}, +
, + ] + : [{part}] + ); +} + +function ImageView({ + image, + loadResource, + downloadResource, +}: { + image: OneNoteImageBlock; + loadResource?: PageReaderProps['loadResource']; + downloadResource?: PageReaderProps['downloadResource']; +}) { + const [state, setState] = useState< + | { phase: 'loading' } + | { phase: 'ready'; resourceId: string; url: string } + | { phase: 'unavailable'; resourceId: string; message: string } + >({ phase: 'loading' }); + + useEffect(() => { + let disposed = false; + let objectUrl: string | undefined; + if (!image.resourceId || !loadResource) { + return; + } + const resourceId = image.resourceId; + void loadResource(image.resourceId) + .then((resource) => { + if ( + disposed || + !resource.browserRenderable || + (resource.mediaType !== 'image/png' && + resource.mediaType !== 'image/jpeg') + ) { + if (!disposed) { + setState({ + phase: 'unavailable', + resourceId, + message: + 'This image format can be downloaded but not displayed safely.', + }); + } + return; + } + objectUrl = URL.createObjectURL( + new Blob([resource.bytes], { type: resource.mediaType }) + ); + setState({ phase: 'ready', resourceId, url: objectUrl }); + }) + .catch((error: unknown) => { + if (!disposed) { + setState({ + phase: 'unavailable', + resourceId, + message: + error instanceof Error + ? error.message + : 'The image could not be loaded.', + }); + } + }); + return () => { + disposed = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [image.resourceId, loadResource]); + + const visibleState = + !image.resourceId || !loadResource + ? { + phase: 'unavailable' as const, + resourceId: image.resourceId ?? '', + message: 'The image payload is not available.', + } + : state.phase !== 'loading' && state.resourceId === image.resourceId + ? state + : { phase: 'loading' as const }; + + const fallback = + visibleState.phase === 'unavailable' + ? visibleState.message + : 'Loading embedded image…'; + const visual = + visibleState.phase === 'ready' ? ( + {image.altText + ) : ( +
+ {fallback} +
+ ); + + return ( +
+ {image.href && visibleState.phase === 'ready' ? ( + + {visual} + + ) : ( + visual + )} + {image.altText || image.filename ? ( +
{image.altText ?? image.filename}
+ ) : null} + {visibleState.phase === 'unavailable' && + image.resourceId && + downloadResource ? ( + + ) : null} +
+ ); +} + +function InkView({ ink }: { ink: OneNoteInkBlock }) { + const box = ink.boundingBox ?? boundingBoxFromInk(ink.strokes); + const padding = Math.max( + 140, + ...ink.strokes.map((stroke) => stroke.width ?? 0) + ); + const viewBox = box + ? `${box.x - padding / 2} ${box.y - padding / 2} ${Math.max(box.width + padding, 1)} ${Math.max(box.height + padding, 1)}` + : '0 0 1 1'; + const width = box ? Math.max((box.width + padding) / (2540 / 96), 1) : 1; + const height = box ? Math.max((box.height + padding) / (2540 / 96), 1) : 1; + + return ( +
+ {ink.strokes.length > 0 ? ( + + {ink.strokes.map((stroke, index) => ( + + ))} + + ) : null} + {ink.children.map((child) => ( + + ))} +
+ ); +} + +function inkPath(stroke: OneNoteInkStroke): string { + const [first, ...deltas] = stroke.points; + if (!first) return ''; + if (deltas.length === 0) return `M ${first.x} ${first.y} l 0 0`; + return `M ${first.x} ${first.y} l ${deltas + .map((point) => `${point.x} ${point.y}`) + .join(' ')}`; +} + +function boundingBoxFromInk( + strokes: readonly OneNoteInkStroke[] +): { x: number; y: number; width: number; height: number } | undefined { + let xMin = Infinity; + let yMin = Infinity; + let xMax = -Infinity; + let yMax = -Infinity; + for (const stroke of strokes) { + const [first, ...deltas] = stroke.points; + if (!first) continue; + let x = first.x; + let y = first.y; + xMin = Math.min(xMin, x); + yMin = Math.min(yMin, y); + xMax = Math.max(xMax, x); + yMax = Math.max(yMax, y); + for (const delta of deltas) { + x += delta.x; + y += delta.y; + xMin = Math.min(xMin, x); + yMin = Math.min(yMin, y); + xMax = Math.max(xMax, x); + yMax = Math.max(yMax, y); + } + } + return Number.isFinite(xMin) + ? { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin } + : undefined; +} + +function inkColor(value: number | undefined): string { + if (value === undefined) return 'currentColor'; + return `rgb(${value & 0xff}, ${(value >>> 8) & 0xff}, ${(value >>> 16) & 0xff})`; +} + +function AttachmentView({ + resourceId, + filename, + size, + downloadResource, +}: { + resourceId?: string; + filename: string; + size?: number; + downloadResource?: PageReaderProps['downloadResource']; +}) { + const [status, setStatus] = useState(); + const download = async () => { + if (!resourceId || !downloadResource) return; + setStatus('Preparing local download…'); + try { + await downloadResource(resourceId, filename); + setStatus(undefined); + } catch (error) { + setStatus( + error instanceof Error + ? error.message + : 'The attachment could not be downloaded.' + ); + } + }; + return ( + + ); +} + +function paragraphStyle( + block: Extract +): CSSProperties { + return { + textAlign: + block.paragraphAlignment === 'unknown' + ? undefined + : block.paragraphAlignment, + marginTop: halfInches(block.paragraphSpaceBefore), + marginBottom: halfInches(block.paragraphSpaceAfter), + lineHeight: + block.paragraphLineSpacingExact === undefined + ? undefined + : halfInches(block.paragraphLineSpacingExact), + }; +} + +function textStyle(style: OneNoteTextStyle): CSSProperties { + const decorations = [ + style.underline ? 'underline' : undefined, + style.strikethrough ? 'line-through' : undefined, + ].filter(Boolean); + return { + fontFamily: style.font, + fontSize: + style.fontSize === undefined + ? undefined + : `${Math.min(Math.max(style.fontSize / 2, 6), 144)}pt`, + fontWeight: style.bold ? 700 : undefined, + fontStyle: style.italic ? 'italic' : undefined, + textDecoration: decorations.length > 0 ? decorations.join(' ') : undefined, + verticalAlign: style.superscript + ? 'super' + : style.subscript + ? 'sub' + : undefined, + color: colorCss(style.fontColor), + backgroundColor: colorCss(style.highlight), + }; +} + +function colorCss(color: OneNoteColor | undefined): string | undefined { + if (!color || color.kind === 'auto') return undefined; + const red = color.red ?? 0; + const green = color.green ?? 0; + const blue = color.blue ?? 0; + return color.kind === 'rgba' + ? `rgba(${red}, ${green}, ${blue}, ${(color.alpha ?? 255) / 255})` + : `rgb(${red}, ${green}, ${blue})`; +} + +function layoutStyle(layout: OneNoteLayout): CSSProperties { + return { + maxWidth: halfInches(layout.maxWidth), + maxHeight: halfInches(layout.maxHeight), + marginInlineStart: halfInches(layout.offsetHorizontal, -1_000, 10_000), + marginTop: halfInches(layout.offsetVertical, -1_000, 10_000), + }; +} + +function halfInches( + value: number | undefined, + minimum = 0, + maximum = 10_000 +): string | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return `${Math.min(Math.max(value * 48, minimum), maximum)}px`; +} + +function formatBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`; + return `${(size / (1024 * 1024)).toFixed(1)} MiB`; +} diff --git a/src/onenote/model/dto.ts b/src/onenote/model/dto.ts index 3f732e1..5830a30 100644 --- a/src/onenote/model/dto.ts +++ b/src/onenote/model/dto.ts @@ -1,4 +1,5 @@ import type { ParserDiagnostic } from './diagnostics.js'; +import type { OneNoteContentBlock } from '../one/content-model.js'; export interface OneNotePageSummaryDto { id: string; @@ -11,21 +12,28 @@ export interface OneNotePageSummaryDto { textPreview: string; } -export type OneNoteContentBlockDto = - | { kind: 'text'; text: string } - | { kind: 'line-break' } - | { kind: 'link'; text: string; href?: string } - | { - kind: 'unsupported'; - sourceKind: string; - description: string; - }; +/** Serializable, inert page content. Resource bytes remain in the worker. */ +export type OneNoteContentBlockDto = OneNoteContentBlock; export interface OneNotePageDto extends OneNotePageSummaryDto { blocks: OneNoteContentBlockDto[]; diagnostics: ParserDiagnostic[]; } +export interface OneNoteResourceDto { + id: string; + kind: 'image' | 'attachment'; + filename?: string; + extension?: string; + mediaType: string; + browserRenderable: boolean; + size: number; +} + +export interface OneNoteResourcePayloadDto extends OneNoteResourceDto { + bytes: ArrayBuffer; +} + export interface OneNoteSectionDto { format: 'one'; sourceName: string; diff --git a/src/styles.css b/src/styles.css index 4c87814..f4c3dd1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -324,7 +324,9 @@ p { } .page-content { - max-width: 48rem; + position: relative; + width: 100%; + max-width: 64rem; line-height: 1.65; } @@ -333,6 +335,135 @@ p { white-space: pre-wrap; } +.onenote-outline { + min-width: 0; + margin-bottom: 1rem; +} + +.onenote-outline-group, +.onenote-outline-element { + min-width: 0; +} + +.onenote-text { + min-height: 1lh; + margin: 0 0 0.55rem; + white-space: pre-wrap; +} + +.onenote-link { + color: #5b2c83; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.12em; +} + +.onenote-table-scroll { + max-width: 100%; + margin-block: 1rem; + overflow-x: auto; +} + +.onenote-table { + width: max-content; + min-width: min(100%, 28rem); + border-spacing: 0; + border-collapse: collapse; + background: #fff; +} + +.onenote-table td { + min-width: 5rem; + border: 1px solid #cfc5d4; + padding: 0.55rem 0.65rem; + vertical-align: top; +} + +.onenote-table--borderless td { + border-color: transparent; +} + +.onenote-table td > :last-child { + margin-bottom: 0; +} + +.onenote-image { + display: grid; + justify-items: start; + gap: 0.45rem; + max-width: 100%; + margin-block: 1rem; +} + +.onenote-image img { + display: block; + max-width: 100%; + height: auto; + border-radius: 0.3rem; +} + +.onenote-image figcaption { + max-width: 40rem; + color: #716675; + font-size: 0.82rem; +} + +.image-placeholder { + display: grid; + min-width: 12rem; + min-height: 7rem; + max-width: 32rem; + place-items: center; + border: 1px dashed #a58caf; + border-radius: 0.55rem; + padding: 1rem; + color: #716675; + background: #faf7fc; + text-align: center; +} + +.attachment-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-block: 0.8rem; + border: 1px solid #d4c8db; + border-radius: 0.65rem; + padding: 0.8rem; + background: #faf7fc; +} + +.attachment-card > div { + display: grid; + flex: 1 1 12rem; + gap: 0.15rem; +} + +.attachment-card span, +.attachment-card small { + color: #716675; + font-size: 0.8rem; +} + +.attachment-card small { + flex-basis: 100%; +} + +.onenote-ink { + position: relative; + max-width: 100%; + margin-block: 0.75rem; + overflow: visible; + color: #241b2b; +} + +.onenote-ink svg { + display: block; + max-width: 100%; + overflow: visible; + pointer-events: none; +} + .unsupported-block { display: grid; gap: 0.2rem; @@ -640,6 +771,32 @@ p { background: #3a3040; } + .onenote-link { + color: #d7acef; + } + + .onenote-table, + .attachment-card, + .image-placeholder { + color: #f6effa; + background: #302638; + } + + .onenote-table td { + border-color: #62536b; + } + + .onenote-image figcaption, + .attachment-card span, + .attachment-card small, + .image-placeholder { + color: #cfc2d6; + } + + .onenote-ink { + color: #f6effa; + } + .diagnostic { color: #f5e8cd; background: #493b23; diff --git a/src/worker/onenote.client.test.ts b/src/worker/onenote.client.test.ts index b674bd4..6f3abde 100644 --- a/src/worker/onenote.client.test.ts +++ b/src/worker/onenote.client.test.ts @@ -85,7 +85,17 @@ describe('OneNoteWorkerClient', () => { title: 'Page seven', text: 'Body', textPreview: 'Body', - blocks: [{ kind: 'text', text: 'Body' }], + blocks: [ + { + kind: 'text', + id: 'text-1', + sourceText: 'Body', + text: 'Body', + runs: [{ start: 0, end: 4, text: 'Body', style: {} }], + paragraphStyle: {}, + layout: {}, + }, + ], diagnostics: [], }, }); @@ -95,6 +105,41 @@ describe('OneNoteWorkerClient', () => { ); }); + it('requests a lazy resource from the retained parser session', async () => { + const worker = new FakeWorker(); + const client = new OneNoteWorkerClient(() => worker); + const result = client.getResource('session-1', 'resource-2', 'section-3'); + + expect(worker.lastMessage).toEqual({ + type: 'get-resource', + requestId: 'resource-1', + sessionId: 'session-1', + resourceId: 'resource-2', + sectionId: 'section-3', + }); + + const bytes = Uint8Array.of(1, 2, 3).buffer; + worker.respond({ + type: 'resource', + requestId: 'resource-1', + sessionId: 'session-1', + resource: { + id: 'resource-2', + kind: 'attachment', + filename: 'file.bin', + mediaType: 'application/octet-stream', + browserRenderable: false, + size: 3, + bytes, + }, + }); + + await expect(result).resolves.toMatchObject({ + type: 'resource', + resource: { bytes }, + }); + }); + it('rejects outstanding work when the worker is terminated', async () => { const worker = new FakeWorker(); const client = new OneNoteWorkerClient(() => worker); diff --git a/src/worker/onenote.client.ts b/src/worker/onenote.client.ts index e620dea..3235f1c 100644 --- a/src/worker/onenote.client.ts +++ b/src/worker/onenote.client.ts @@ -2,6 +2,7 @@ import type { PageWorkerResponse, ParseWorkerRequest, ParseWorkerResponse, + ResourceWorkerResponse, WorkerRequest, WorkerFailure, WorkerResponse, @@ -17,7 +18,9 @@ export interface WorkerPort { } type PendingRequest = { - resolve: (response: ParseWorkerResponse | PageWorkerResponse) => void; + resolve: ( + response: ParseWorkerResponse | PageWorkerResponse | ResourceWorkerResponse + ) => void; reject: (error: WorkerClientError) => void; }; @@ -123,6 +126,36 @@ export class OneNoteWorkerClient { }); } + getResource( + sessionId: string, + resourceId: string, + sectionId?: string + ): Promise { + if (this.terminated) { + return Promise.reject( + new WorkerClientError( + 'worker-failure', + 'The local parser worker has already been terminated.' + ) + ); + } + + const requestId = `resource-${++this.requestSequence}`; + return new Promise((resolve, reject) => { + this.pending.set(requestId, { + resolve: (response) => resolve(response as ResourceWorkerResponse), + reject, + }); + this.worker.postMessage({ + type: 'get-resource', + requestId, + sessionId, + resourceId, + ...(sectionId === undefined ? {} : { sectionId }), + }); + }); + } + terminate(): void { if (this.terminated) return; this.terminated = true; diff --git a/src/worker/onenote.worker.ts b/src/worker/onenote.worker.ts index 249a289..1aa51a0 100644 --- a/src/worker/onenote.worker.ts +++ b/src/worker/onenote.worker.ts @@ -1,28 +1,35 @@ import type { OneNotePageDto } from '../onenote/model/dto.js'; +import type { OneNoteResource } from '../onenote/one/content-model.js'; import { failureFromUnknown, openPackageForWorker, openSectionForWorker, + resourcePayload, workerPageKey, + workerResourceKey, } from './parser-adapter.js'; import type { WorkerRequest, WorkerResponse } from './worker-protocol.js'; interface ModuleWorkerScope { onmessage: ((event: MessageEvent) => void) | null; - postMessage(response: WorkerResponse): void; + postMessage(response: WorkerResponse, transfer?: Transferable[]): void; } interface ParserSession { pages: Map; + resources: Map; } const workerScope = globalThis as unknown as ModuleWorkerScope; const sessions = new Map(); let sessionSequence = 0; -function createSession(pages: Map): string { +function createSession( + pages: Map, + resources: Map +): string { const id = `session-${++sessionSequence}`; - sessions.set(id, { pages }); + sessions.set(id, { pages, resources }); return id; } @@ -34,7 +41,7 @@ async function handleRequest(data: WorkerRequest): Promise { new Uint8Array(data.bytes), data.fileName ); - const sessionId = createSession(parsed.pages); + const sessionId = createSession(parsed.pages, parsed.resources); workerScope.postMessage({ type: 'section', requestId: data.requestId, @@ -56,7 +63,7 @@ async function handleRequest(data: WorkerRequest): Promise { new Uint8Array(data.bytes), data.fileName ); - const sessionId = createSession(parsed.pages); + const sessionId = createSession(parsed.pages, parsed.resources); workerScope.postMessage({ type: 'package', requestId: data.requestId, @@ -108,6 +115,47 @@ async function handleRequest(data: WorkerRequest): Promise { }); return; } + case 'get-resource': { + const session = sessions.get(data.sessionId); + if (!session) { + workerScope.postMessage({ + type: 'failure', + requestId: data.requestId, + error: { + code: 'session-not-available', + message: 'The parser session is no longer available.', + recoverable: false, + }, + }); + return; + } + const resource = session.resources.get( + workerResourceKey(data.resourceId, data.sectionId) + ); + const payload = resource ? resourcePayload(resource) : undefined; + if (!payload) { + workerScope.postMessage({ + type: 'failure', + requestId: data.requestId, + error: { + code: 'resource-not-available', + message: 'The selected embedded resource is not available.', + recoverable: true, + }, + }); + return; + } + workerScope.postMessage( + { + type: 'resource', + requestId: data.requestId, + sessionId: data.sessionId, + resource: payload, + }, + [payload.bytes] + ); + return; + } case 'dispose': sessions.delete(data.sessionId); workerScope.postMessage({ diff --git a/src/worker/parser-adapter.test.ts b/src/worker/parser-adapter.test.ts index 0a7a6b3..7b86057 100644 --- a/src/worker/parser-adapter.test.ts +++ b/src/worker/parser-adapter.test.ts @@ -6,7 +6,9 @@ import { failureFromUnknown, openPackageForWorker, openSectionForWorker, + resourcePayload, workerPageKey, + workerResourceKey, } from './parser-adapter.js'; function fixtureBytes(path: string): Uint8Array { @@ -28,6 +30,19 @@ describe('worker parser adapter', () => { title: 'Test', text: expect.stringContaining('This notebook should have three pages.'), }); + expect(result.resources).toHaveLength(1); + const resource = [...result.resources.values()][0]!; + expect(result.resources.get(workerResourceKey(resource.id))).toBe(resource); + const payload = resourcePayload(resource); + expect(payload).toMatchObject({ + kind: 'image', + mediaType: 'image/png', + browserRenderable: true, + size: 2639, + }); + expect(new Uint8Array(payload!.bytes).subarray(0, 8)).toEqual( + Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10) + ); }); it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => { @@ -65,6 +80,7 @@ describe('worker parser adapter', () => { ).toBe(page.id); } } + expect(result.resources).toBeInstanceOf(Map); }); it('converts malformed section input into a controlled worker failure', () => { diff --git a/src/worker/parser-adapter.ts b/src/worker/parser-adapter.ts index b90d491..a81eaf0 100644 --- a/src/worker/parser-adapter.ts +++ b/src/worker/parser-adapter.ts @@ -1,28 +1,79 @@ -import { OneNoteParserError, parseOneNoteSection } from '../onenote/index.js'; +import { + detectOneNoteFormat, + OneNoteParserError, + parseOneNoteSectionContent, +} from '../onenote/index.js'; import type { OneNotePackageDto, OneNotePageDto, OneNotePageSummaryDto, + OneNoteResourcePayloadDto, OneNoteSectionDto, } from '../onenote/model/dto.js'; +import type { + OneNotePageContentModel, + OneNoteResource, + OneNoteSectionContentModel, +} from '../onenote/one/content-model.js'; import { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js'; import type { WorkerFailure } from './worker-protocol.js'; export interface ParsedWorkerSection { section: OneNoteSectionDto; pages: Map; + resources: Map; } export interface ParsedWorkerPackage { notebook: OneNotePackageDto; pages: Map; + resources: Map; } -function pageDto(page: OneNotePageSummaryDto): OneNotePageDto { +function pageSummary(page: OneNotePageContentModel): OneNotePageSummaryDto { return { - ...page, - blocks: page.text ? [{ kind: 'text', text: page.text }] : [], - diagnostics: [], + id: page.id, + title: page.title, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + level: page.level, + text: page.text, + textPreview: preview(page.text), + }; +} + +function pageDto( + page: OneNotePageContentModel, + diagnostics: OneNoteSectionContentModel['diagnostics'] +): OneNotePageDto { + return { + ...pageSummary(page), + blocks: page.blocks, + diagnostics: diagnostics.filter((diagnostic) => + diagnostic.structure?.includes(`page ${page.id}`) + ), + }; +} + +function sectionDto( + model: OneNoteSectionContentModel, + sourceBytes: Uint8Array +): OneNoteSectionDto { + const format = detectOneNoteFormat(sourceBytes); + return { + format: 'one', + sourceName: model.sourceName, + sectionName: model.sectionName, + pages: model.pages.map(pageSummary), + diagnostics: model.diagnostics, + parserInfo: { + implementation: 'typescript', + supportedVariant: + format.kind === 'fsshttp-one' + ? 'fsshttp-packaged-revision-store' + : 'desktop-revision-store', + referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60', + }, }; } @@ -30,15 +81,53 @@ export function workerPageKey(pageId: string, sectionId?: string): string { return JSON.stringify([sectionId ?? null, pageId]); } +export function workerResourceKey( + resourceId: string, + sectionId?: string +): string { + return JSON.stringify([sectionId ?? null, resourceId]); +} + +export function resourcePayload( + resource: OneNoteResource +): OneNoteResourcePayloadDto | undefined { + const blob = resource.blob; + if (resource.availability !== 'available' || !blob) return undefined; + const bytes = new ArrayBuffer(blob.size); + new Uint8Array(bytes).set( + blob.source.subarray(blob.offset, blob.offset + blob.size) + ); + return { + id: resource.id, + kind: resource.kind, + filename: resource.filename, + extension: resource.extension, + mediaType: resource.mediaType, + browserRenderable: resource.browserRenderable, + size: blob.size, + bytes, + }; +} + export function openSectionForWorker( bytes: Uint8Array, sourceName: string ): ParsedWorkerSection { - const section = parseOneNoteSection(bytes, { sourceName }); + const model = parseOneNoteSectionContent(bytes, { sourceName }); + const section = sectionDto(model, bytes); return { section, pages: new Map( - section.pages.map((page) => [workerPageKey(page.id), pageDto(page)]) + model.pages.map((page) => [ + workerPageKey(page.id), + pageDto(page, model.diagnostics), + ]) + ), + resources: new Map( + [...model.resources].map(([id, resource]) => [ + workerResourceKey(id), + resource, + ]) ), }; } @@ -47,6 +136,7 @@ export async function openPackageForWorker( bytes: Uint8Array, sourceName: string ): Promise { + const contentBySection = new Map(); const result = await openOneNotePackage(bytes, { sourceName, cancellation: { @@ -54,9 +144,13 @@ export async function openPackageForWorker( }, parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => { try { + const model = parseOneNoteSectionContent(sectionBytes, { + sourceName: sectionName, + }); + contentBySection.set(sectionName, model); return { status: 'parsed', - value: parseOneNoteSection(sectionBytes, { sourceName: sectionName }), + value: sectionDto(model, sectionBytes), }; } catch (error) { if ( @@ -83,16 +177,22 @@ export async function openPackageForWorker( }); const pages = new Map(); + const resources = new Map(); const sections = result.package.sections.map((packagedSection) => { if (!packagedSection.section) return packagedSection; - const section = { - ...packagedSection.section, - pages: packagedSection.section.pages.map((page) => { - pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page)); - return page; - }), - }; - return { ...packagedSection, section }; + const model = contentBySection.get(packagedSection.path); + if (model) { + for (const page of model.pages) { + pages.set( + workerPageKey(page.id, packagedSection.id), + pageDto(page, model.diagnostics) + ); + } + for (const [id, resource] of model.resources) { + resources.set(workerResourceKey(id, packagedSection.id), resource); + } + } + return packagedSection; }); return { @@ -101,9 +201,15 @@ export async function openPackageForWorker( sections, }, pages, + resources, }; } +function preview(text: string): string { + const compact = text.replace(/\s+/gu, ' ').trim(); + return compact.length <= 500 ? compact : `${compact.slice(0, 497)}…`; +} + export function failureFromUnknown(error: unknown): WorkerFailure { if (error instanceof OneNoteParserError) { const code = diff --git a/src/worker/worker-protocol.ts b/src/worker/worker-protocol.ts index 55b4419..08060f6 100644 --- a/src/worker/worker-protocol.ts +++ b/src/worker/worker-protocol.ts @@ -1,6 +1,7 @@ import type { OneNotePackageDto, OneNotePageDto, + OneNoteResourcePayloadDto, OneNoteSectionDto, } from '../onenote/model/dto.js'; @@ -24,6 +25,13 @@ export type WorkerRequest = pageId: string; sectionId?: string; } + | { + type: 'get-resource'; + requestId: string; + sessionId: string; + resourceId: string; + sectionId?: string; + } | { type: 'dispose'; sessionId: string; @@ -35,6 +43,7 @@ export interface WorkerFailure { | 'unsupported-format' | 'limit-exceeded' | 'page-not-available' + | 'resource-not-available' | 'session-not-available' | 'worker-failure'; message: string; @@ -62,6 +71,12 @@ export type WorkerResponse = sessionId: string; page: OneNotePageDto; } + | { + type: 'resource'; + requestId: string; + sessionId: string; + resource: OneNoteResourcePayloadDto; + } | { type: 'failure'; requestId: string; @@ -85,3 +100,8 @@ export type PageWorkerResponse = Extract< WorkerResponse, { type: 'page' | 'failure' } >; + +export type ResourceWorkerResponse = Extract< + WorkerResponse, + { type: 'resource' | 'failure' } +>;