feat: render rich OneNote pages and resources

This commit is contained in:
2026-07-22 19:18:42 +02:00
parent 141be03f5e
commit 1750a6b5c3
12 changed files with 1252 additions and 64 deletions

View File

@@ -80,7 +80,24 @@ class SuccessfulParserWorker implements WorkerPort {
page: { page: {
...page, ...page,
id: message.pageId, 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: [], diagnostics: [],
}, },
}); });

View File

@@ -16,6 +16,7 @@ import type {
OneNotePackageDto, OneNotePackageDto,
OneNotePackagedSectionDto, OneNotePackagedSectionDto,
OneNotePageDto, OneNotePageDto,
OneNoteResourcePayloadDto,
OneNoteSectionDto, OneNoteSectionDto,
} from './onenote/model/dto.js'; } from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js'; import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
@@ -302,6 +303,54 @@ export function OneNoteApplication({
} }
}; };
const loadSelectedResource = useCallback(
async (resourceId: string): Promise<OneNoteResourcePayloadDto> => {
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<void> => {
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( const visibleDiagnostics = uniqueDiagnostics(
(() => { (() => {
if (state.phase !== 'loaded') return []; if (state.phase !== 'loaded') return [];
@@ -390,6 +439,8 @@ export function OneNoteApplication({
error={ error={
pageState.phase === 'error' ? pageState.message : undefined pageState.phase === 'error' ? pageState.message : undefined
} }
loadResource={loadSelectedResource}
downloadResource={downloadSelectedResource}
/> />
</div> </div>
@@ -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() { export function App() {
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);

View File

@@ -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(
<PageReader page={page} loading={false} loadResource={loadResource} />
);
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(
<PageReader
page={page}
loading={false}
loadResource={async () => {
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: {},
};
}

View File

@@ -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 { interface PageReaderProps {
page?: OneNotePageDto; page?: OneNotePageDto;
loading: boolean; loading: boolean;
error?: string; error?: string;
loadResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
downloadResource?(resourceId: string, filename?: string): Promise<void>;
} }
function formatDate(value: string | undefined): string | null { function formatDate(value: string | undefined): string | null {
@@ -16,11 +33,17 @@ function formatDate(value: string | undefined): string | null {
}).format(date); }).format(date);
} }
export function PageReader({ page, loading, error }: PageReaderProps) { export function PageReader({
page,
loading,
error,
loadResource,
downloadResource,
}: PageReaderProps) {
if (loading) { if (loading) {
return ( return (
<section className="page-reader page-reader--empty" aria-live="polite"> <section className="page-reader page-reader--empty" aria-live="polite">
<p>Loading extracted page text</p> <p>Loading structured page content</p>
</section> </section>
); );
} }
@@ -38,7 +61,7 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
if (!page) { if (!page) {
return ( return (
<section className="page-reader page-reader--empty"> <section className="page-reader page-reader--empty">
<p>Select a readable page to inspect its extracted text.</p> <p>Select a readable page to inspect its content.</p>
</section> </section>
); );
} }
@@ -71,37 +94,542 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
<div className="page-content"> <div className="page-content">
{page.blocks.length === 0 && page.text.length === 0 ? ( {page.blocks.length === 0 && page.text.length === 0 ? (
<p className="empty-message">No readable plain text was extracted.</p> <p className="empty-message">No readable content was extracted.</p>
) : page.blocks.length === 0 ? ( ) : page.blocks.length === 0 ? (
<p className="extracted-text">{page.text}</p> <p className="extracted-text">{page.text}</p>
) : ( ) : (
page.blocks.map((block, index) => { <ContentBlocks
switch (block.kind) { blocks={page.blocks}
case 'text': loadResource={loadResource}
return ( downloadResource={downloadResource}
<p className="extracted-text" key={index}> />
{block.text}
</p>
);
case 'line-break':
return <br key={index} />;
case 'link':
return (
<p className="extracted-text" key={index}>
{block.text}
</p>
);
case 'unsupported':
return (
<aside className="unsupported-block" key={index}>
<strong>{block.sourceKind}</strong>
<span>{block.description}</span>
</aside>
);
}
})
)} )}
</div> </div>
</article> </article>
); );
} }
function ContentBlocks({
blocks,
loadResource,
downloadResource,
}: {
blocks: readonly OneNoteContentBlock[];
loadResource?: PageReaderProps['loadResource'];
downloadResource?: PageReaderProps['downloadResource'];
}) {
return blocks.map((block) => (
<ContentBlockView
key={block.id}
block={block}
loadResource={loadResource}
downloadResource={downloadResource}
/>
));
}
function ContentBlockView({
block,
loadResource,
downloadResource,
}: {
block: OneNoteContentBlock;
loadResource?: PageReaderProps['loadResource'];
downloadResource?: PageReaderProps['downloadResource'];
}) {
switch (block.kind) {
case 'text':
return (
<p
className="onenote-text"
dir={block.rightToLeft ? 'rtl' : undefined}
style={{
...layoutStyle(block.layout),
...paragraphStyle(block),
}}
>
{block.runs.map((run, index) => (
<TextRunView key={`${run.start}-${run.end}-${index}`} run={run} />
))}
</p>
);
case 'outline':
return (
<section className="onenote-outline" style={layoutStyle(block.layout)}>
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
downloadResource={downloadResource}
/>
</section>
);
case 'outline-group':
return (
<div className="onenote-outline-group">
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
downloadResource={downloadResource}
/>
</div>
);
case 'outline-element':
return (
<div
className="onenote-outline-element"
style={{
marginInlineStart: `${Math.min(block.childLevel ?? 0, 12) * 1.1}rem`,
}}
>
<ContentBlocks
blocks={block.contents}
loadResource={loadResource}
downloadResource={downloadResource}
/>
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
downloadResource={downloadResource}
/>
</div>
);
case 'table':
return (
<div className="onenote-table-scroll" style={layoutStyle(block.layout)}>
<table
className={
block.bordersVisible
? 'onenote-table'
: 'onenote-table onenote-table--borderless'
}
>
{block.columnWidths.length > 0 ? (
<colgroup>
{block.columnWidths.map((width, index) => (
<col key={index} style={{ width: halfInches(width) }} />
))}
</colgroup>
) : null}
<tbody>
{block.rows.map((row) => (
<tr key={row.id}>
{row.cells.map((cell) => (
<td
key={cell.id}
style={{
backgroundColor: colorCss(cell.backgroundColor),
maxWidth: halfInches(cell.maxWidth),
}}
>
<ContentBlocks
blocks={cell.blocks}
loadResource={loadResource}
downloadResource={downloadResource}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case 'image':
return (
<ImageView
image={block}
loadResource={loadResource}
downloadResource={downloadResource}
/>
);
case 'attachment':
return (
<AttachmentView
resourceId={block.resourceId}
filename={block.filename}
size={block.size}
downloadResource={downloadResource}
/>
);
case 'ink':
return <InkView ink={block} />;
case 'unsupported':
return (
<aside className="unsupported-block">
<strong>Unsupported object 0x{block.sourceJcid.toString(16)}</strong>
<span>{block.description}</span>
</aside>
);
}
}
function TextRunView({ run }: { run: OneNoteTextRun }) {
if (run.style.hidden) return null;
const content = textWithBreaks(run.text);
const style = textStyle(run.style);
return run.href ? (
<a
className="onenote-link"
href={run.href}
target="_blank"
rel="noopener noreferrer"
title={run.href}
style={style}
>
{content}
</a>
) : (
<span style={style}>{content}</span>
);
}
function textWithBreaks(text: string): ReactNode[] {
return text
.replaceAll('\u000b', '\n')
.split('\n')
.flatMap((part, index, values) =>
index + 1 < values.length
? [
<span key={`${index}-text`}>{part}</span>,
<br key={`${index}-br`} />,
]
: [<span key={`${index}-text`}>{part}</span>]
);
}
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' ? (
<img
src={visibleState.url}
alt={image.altText ?? image.filename ?? ''}
style={{
width: halfInches(image.pictureWidth),
height: halfInches(image.pictureHeight),
}}
/>
) : (
<div className="image-placeholder" role="img" aria-label={image.altText}>
{fallback}
</div>
);
return (
<figure className="onenote-image" style={layoutStyle(image.layout)}>
{image.href && visibleState.phase === 'ready' ? (
<a
href={image.href}
target="_blank"
rel="noopener noreferrer"
title={image.href}
>
{visual}
</a>
) : (
visual
)}
{image.altText || image.filename ? (
<figcaption>{image.altText ?? image.filename}</figcaption>
) : null}
{visibleState.phase === 'unavailable' &&
image.resourceId &&
downloadResource ? (
<button
type="button"
onClick={() =>
void downloadResource(image.resourceId!, image.filename)
}
>
Download original image
</button>
) : null}
</figure>
);
}
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 (
<div className="onenote-ink" style={layoutStyle(ink.layout)}>
{ink.strokes.length > 0 ? (
<svg
viewBox={viewBox}
width={width}
height={height}
role="img"
aria-label={`${ink.strokes.length} handwritten or drawn stroke${ink.strokes.length === 1 ? '' : 's'}`}
>
{ink.strokes.map((stroke, index) => (
<path
key={index}
d={inkPath(stroke)}
fill="none"
stroke={inkColor(stroke.color)}
strokeWidth={Math.max(stroke.width ?? 140, 1)}
strokeOpacity={
stroke.transparency === undefined
? undefined
: Math.max(0, Math.min(1, (255 - stroke.transparency) / 256))
}
strokeLinecap={stroke.penTip === 0 ? 'round' : 'square'}
strokeLinejoin={stroke.penTip === 0 ? 'round' : 'bevel'}
/>
))}
</svg>
) : null}
{ink.children.map((child) => (
<InkView key={child.id} ink={child} />
))}
</div>
);
}
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<string>();
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 (
<aside className="attachment-card">
<div>
<strong>{filename}</strong>
{size === undefined ? null : <span>{formatBytes(size)}</span>}
</div>
<button
type="button"
disabled={!resourceId || !downloadResource}
onClick={() => void download()}
>
Download attachment
</button>
{status ? <small role="status">{status}</small> : null}
</aside>
);
}
function paragraphStyle(
block: Extract<OneNoteContentBlock, { kind: 'text' }>
): 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`;
}

View File

@@ -1,4 +1,5 @@
import type { ParserDiagnostic } from './diagnostics.js'; import type { ParserDiagnostic } from './diagnostics.js';
import type { OneNoteContentBlock } from '../one/content-model.js';
export interface OneNotePageSummaryDto { export interface OneNotePageSummaryDto {
id: string; id: string;
@@ -11,21 +12,28 @@ export interface OneNotePageSummaryDto {
textPreview: string; textPreview: string;
} }
export type OneNoteContentBlockDto = /** Serializable, inert page content. Resource bytes remain in the worker. */
| { kind: 'text'; text: string } export type OneNoteContentBlockDto = OneNoteContentBlock;
| { kind: 'line-break' }
| { kind: 'link'; text: string; href?: string }
| {
kind: 'unsupported';
sourceKind: string;
description: string;
};
export interface OneNotePageDto extends OneNotePageSummaryDto { export interface OneNotePageDto extends OneNotePageSummaryDto {
blocks: OneNoteContentBlockDto[]; blocks: OneNoteContentBlockDto[];
diagnostics: ParserDiagnostic[]; 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 { export interface OneNoteSectionDto {
format: 'one'; format: 'one';
sourceName: string; sourceName: string;

View File

@@ -324,7 +324,9 @@ p {
} }
.page-content { .page-content {
max-width: 48rem; position: relative;
width: 100%;
max-width: 64rem;
line-height: 1.65; line-height: 1.65;
} }
@@ -333,6 +335,135 @@ p {
white-space: pre-wrap; 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 { .unsupported-block {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
@@ -640,6 +771,32 @@ p {
background: #3a3040; 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 { .diagnostic {
color: #f5e8cd; color: #f5e8cd;
background: #493b23; background: #493b23;

View File

@@ -85,7 +85,17 @@ describe('OneNoteWorkerClient', () => {
title: 'Page seven', title: 'Page seven',
text: 'Body', text: 'Body',
textPreview: '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: [], 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 () => { it('rejects outstanding work when the worker is terminated', async () => {
const worker = new FakeWorker(); const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker); const client = new OneNoteWorkerClient(() => worker);

View File

@@ -2,6 +2,7 @@ import type {
PageWorkerResponse, PageWorkerResponse,
ParseWorkerRequest, ParseWorkerRequest,
ParseWorkerResponse, ParseWorkerResponse,
ResourceWorkerResponse,
WorkerRequest, WorkerRequest,
WorkerFailure, WorkerFailure,
WorkerResponse, WorkerResponse,
@@ -17,7 +18,9 @@ export interface WorkerPort {
} }
type PendingRequest = { type PendingRequest = {
resolve: (response: ParseWorkerResponse | PageWorkerResponse) => void; resolve: (
response: ParseWorkerResponse | PageWorkerResponse | ResourceWorkerResponse
) => void;
reject: (error: WorkerClientError) => void; reject: (error: WorkerClientError) => void;
}; };
@@ -123,6 +126,36 @@ export class OneNoteWorkerClient {
}); });
} }
getResource(
sessionId: string,
resourceId: string,
sectionId?: string
): Promise<ResourceWorkerResponse> {
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 { terminate(): void {
if (this.terminated) return; if (this.terminated) return;
this.terminated = true; this.terminated = true;

View File

@@ -1,28 +1,35 @@
import type { OneNotePageDto } from '../onenote/model/dto.js'; import type { OneNotePageDto } from '../onenote/model/dto.js';
import type { OneNoteResource } from '../onenote/one/content-model.js';
import { import {
failureFromUnknown, failureFromUnknown,
openPackageForWorker, openPackageForWorker,
openSectionForWorker, openSectionForWorker,
resourcePayload,
workerPageKey, workerPageKey,
workerResourceKey,
} from './parser-adapter.js'; } from './parser-adapter.js';
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js'; import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
interface ModuleWorkerScope { interface ModuleWorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null; onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
postMessage(response: WorkerResponse): void; postMessage(response: WorkerResponse, transfer?: Transferable[]): void;
} }
interface ParserSession { interface ParserSession {
pages: Map<string, OneNotePageDto>; pages: Map<string, OneNotePageDto>;
resources: Map<string, OneNoteResource>;
} }
const workerScope = globalThis as unknown as ModuleWorkerScope; const workerScope = globalThis as unknown as ModuleWorkerScope;
const sessions = new Map<string, ParserSession>(); const sessions = new Map<string, ParserSession>();
let sessionSequence = 0; let sessionSequence = 0;
function createSession(pages: Map<string, OneNotePageDto>): string { function createSession(
pages: Map<string, OneNotePageDto>,
resources: Map<string, OneNoteResource>
): string {
const id = `session-${++sessionSequence}`; const id = `session-${++sessionSequence}`;
sessions.set(id, { pages }); sessions.set(id, { pages, resources });
return id; return id;
} }
@@ -34,7 +41,7 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
new Uint8Array(data.bytes), new Uint8Array(data.bytes),
data.fileName data.fileName
); );
const sessionId = createSession(parsed.pages); const sessionId = createSession(parsed.pages, parsed.resources);
workerScope.postMessage({ workerScope.postMessage({
type: 'section', type: 'section',
requestId: data.requestId, requestId: data.requestId,
@@ -56,7 +63,7 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
new Uint8Array(data.bytes), new Uint8Array(data.bytes),
data.fileName data.fileName
); );
const sessionId = createSession(parsed.pages); const sessionId = createSession(parsed.pages, parsed.resources);
workerScope.postMessage({ workerScope.postMessage({
type: 'package', type: 'package',
requestId: data.requestId, requestId: data.requestId,
@@ -108,6 +115,47 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
}); });
return; 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': case 'dispose':
sessions.delete(data.sessionId); sessions.delete(data.sessionId);
workerScope.postMessage({ workerScope.postMessage({

View File

@@ -6,7 +6,9 @@ import {
failureFromUnknown, failureFromUnknown,
openPackageForWorker, openPackageForWorker,
openSectionForWorker, openSectionForWorker,
resourcePayload,
workerPageKey, workerPageKey,
workerResourceKey,
} from './parser-adapter.js'; } from './parser-adapter.js';
function fixtureBytes(path: string): Uint8Array { function fixtureBytes(path: string): Uint8Array {
@@ -28,6 +30,19 @@ describe('worker parser adapter', () => {
title: 'Test', title: 'Test',
text: expect.stringContaining('This notebook should have three pages.'), 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 () => { it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
@@ -65,6 +80,7 @@ describe('worker parser adapter', () => {
).toBe(page.id); ).toBe(page.id);
} }
} }
expect(result.resources).toBeInstanceOf(Map);
}); });
it('converts malformed section input into a controlled worker failure', () => { it('converts malformed section input into a controlled worker failure', () => {

View File

@@ -1,28 +1,79 @@
import { OneNoteParserError, parseOneNoteSection } from '../onenote/index.js'; import {
detectOneNoteFormat,
OneNoteParserError,
parseOneNoteSectionContent,
} from '../onenote/index.js';
import type { import type {
OneNotePackageDto, OneNotePackageDto,
OneNotePageDto, OneNotePageDto,
OneNotePageSummaryDto, OneNotePageSummaryDto,
OneNoteResourcePayloadDto,
OneNoteSectionDto, OneNoteSectionDto,
} from '../onenote/model/dto.js'; } 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 { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js';
import type { WorkerFailure } from './worker-protocol.js'; import type { WorkerFailure } from './worker-protocol.js';
export interface ParsedWorkerSection { export interface ParsedWorkerSection {
section: OneNoteSectionDto; section: OneNoteSectionDto;
pages: Map<string, OneNotePageDto>; pages: Map<string, OneNotePageDto>;
resources: Map<string, OneNoteResource>;
} }
export interface ParsedWorkerPackage { export interface ParsedWorkerPackage {
notebook: OneNotePackageDto; notebook: OneNotePackageDto;
pages: Map<string, OneNotePageDto>; pages: Map<string, OneNotePageDto>;
resources: Map<string, OneNoteResource>;
} }
function pageDto(page: OneNotePageSummaryDto): OneNotePageDto { function pageSummary(page: OneNotePageContentModel): OneNotePageSummaryDto {
return { return {
...page, id: page.id,
blocks: page.text ? [{ kind: 'text', text: page.text }] : [], title: page.title,
diagnostics: [], 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]); 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( export function openSectionForWorker(
bytes: Uint8Array, bytes: Uint8Array,
sourceName: string sourceName: string
): ParsedWorkerSection { ): ParsedWorkerSection {
const section = parseOneNoteSection(bytes, { sourceName }); const model = parseOneNoteSectionContent(bytes, { sourceName });
const section = sectionDto(model, bytes);
return { return {
section, section,
pages: new Map( 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, bytes: Uint8Array,
sourceName: string sourceName: string
): Promise<ParsedWorkerPackage> { ): Promise<ParsedWorkerPackage> {
const contentBySection = new Map<string, OneNoteSectionContentModel>();
const result = await openOneNotePackage(bytes, { const result = await openOneNotePackage(bytes, {
sourceName, sourceName,
cancellation: { cancellation: {
@@ -54,9 +144,13 @@ export async function openPackageForWorker(
}, },
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => { parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
try { try {
const model = parseOneNoteSectionContent(sectionBytes, {
sourceName: sectionName,
});
contentBySection.set(sectionName, model);
return { return {
status: 'parsed', status: 'parsed',
value: parseOneNoteSection(sectionBytes, { sourceName: sectionName }), value: sectionDto(model, sectionBytes),
}; };
} catch (error) { } catch (error) {
if ( if (
@@ -83,16 +177,22 @@ export async function openPackageForWorker(
}); });
const pages = new Map<string, OneNotePageDto>(); const pages = new Map<string, OneNotePageDto>();
const resources = new Map<string, OneNoteResource>();
const sections = result.package.sections.map((packagedSection) => { const sections = result.package.sections.map((packagedSection) => {
if (!packagedSection.section) return packagedSection; if (!packagedSection.section) return packagedSection;
const section = { const model = contentBySection.get(packagedSection.path);
...packagedSection.section, if (model) {
pages: packagedSection.section.pages.map((page) => { for (const page of model.pages) {
pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page)); pages.set(
return page; workerPageKey(page.id, packagedSection.id),
}), pageDto(page, model.diagnostics)
}; );
return { ...packagedSection, section }; }
for (const [id, resource] of model.resources) {
resources.set(workerResourceKey(id, packagedSection.id), resource);
}
}
return packagedSection;
}); });
return { return {
@@ -101,9 +201,15 @@ export async function openPackageForWorker(
sections, sections,
}, },
pages, 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 { export function failureFromUnknown(error: unknown): WorkerFailure {
if (error instanceof OneNoteParserError) { if (error instanceof OneNoteParserError) {
const code = const code =

View File

@@ -1,6 +1,7 @@
import type { import type {
OneNotePackageDto, OneNotePackageDto,
OneNotePageDto, OneNotePageDto,
OneNoteResourcePayloadDto,
OneNoteSectionDto, OneNoteSectionDto,
} from '../onenote/model/dto.js'; } from '../onenote/model/dto.js';
@@ -24,6 +25,13 @@ export type WorkerRequest =
pageId: string; pageId: string;
sectionId?: string; sectionId?: string;
} }
| {
type: 'get-resource';
requestId: string;
sessionId: string;
resourceId: string;
sectionId?: string;
}
| { | {
type: 'dispose'; type: 'dispose';
sessionId: string; sessionId: string;
@@ -35,6 +43,7 @@ export interface WorkerFailure {
| 'unsupported-format' | 'unsupported-format'
| 'limit-exceeded' | 'limit-exceeded'
| 'page-not-available' | 'page-not-available'
| 'resource-not-available'
| 'session-not-available' | 'session-not-available'
| 'worker-failure'; | 'worker-failure';
message: string; message: string;
@@ -62,6 +71,12 @@ export type WorkerResponse =
sessionId: string; sessionId: string;
page: OneNotePageDto; page: OneNotePageDto;
} }
| {
type: 'resource';
requestId: string;
sessionId: string;
resource: OneNoteResourcePayloadDto;
}
| { | {
type: 'failure'; type: 'failure';
requestId: string; requestId: string;
@@ -85,3 +100,8 @@ export type PageWorkerResponse = Extract<
WorkerResponse, WorkerResponse,
{ type: 'page' | 'failure' } { type: 'page' | 'failure' }
>; >;
export type ResourceWorkerResponse = Extract<
WorkerResponse,
{ type: 'resource' | 'failure' }
>;