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

@@ -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<OneNoteResourcePayloadDto>;
downloadResource?(resourceId: string, filename?: string): Promise<void>;
}
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 (
<section className="page-reader page-reader--empty" aria-live="polite">
<p>Loading extracted page text</p>
<p>Loading structured page content</p>
</section>
);
}
@@ -38,7 +61,7 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
if (!page) {
return (
<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>
);
}
@@ -71,37 +94,542 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
<div className="page-content">
{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 ? (
<p className="extracted-text">{page.text}</p>
) : (
page.blocks.map((block, index) => {
switch (block.kind) {
case 'text':
return (
<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>
);
}
})
<ContentBlocks
blocks={page.blocks}
loadResource={loadResource}
downloadResource={downloadResource}
/>
)}
</div>
</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`;
}