API reference
aared previews real PowerPoint, Excel and Word files in the browser, painted to DOM and SVG — never canvas — so text stays selectable and Arabic and other RTL content shapes the way the browser shapes it.
This page defines the supported contracts. New here? Getting started covers installing, picking an entry, copy-ready recipes and the usual first problems, and the playground runs every entry live.
Choosing a layer
Use the integration chooser for a first implementation. This table compares capabilities and ownership:
The element, preview() and the hooks forward a fixed set of options, and
lend out restricted content (viewer.content) for reads and more
views, so thumbnails or a data panel need no second parse. Where they differ
from the low-level open() / render() API:
<aared-viewer> |
preview() / hooks |
open() / render() |
|
|---|---|---|---|
| Format auto-detection | Yes | Generic entry only | No — you choose the import |
appearance beyond theme, fontResolver, mediaResolver |
No | Yes | Yes |
| Moving within a unit: cells, headings, pages | Yes (el.viewer.xlsx, el.viewer.docx) |
Yes (viewer.xlsx, viewer.docx) |
Yes |
search() |
Yes (el.viewer.search()) |
Yes (viewer.search()) |
Yes |
read(), media.get(), the collections; several units at once |
Yes (el.viewer.content) |
Yes (viewer.content) |
Yes |
| Who closes the document | Removing the element | destroy() / unmount |
You (close()) |
The element, preview() and the hooks run on one viewer, so they share its
codes, events and rules: options set where it starts, its methods change it
(see Ownership and updates). A workbook shows its sheet tabs
in all three; the low-level render() leaves them off for an app that draws
its own.
Bundle size. The element, auto-detect preview and React hooks emit lazy renderer chunks for all formats and fetch the chosen one. Direct format entries include only that renderer; a format-specific React hook narrows its types but does not isolate the build. All routes use the same self-contained parse Worker.
Use preview() for custom toolbars, search UI and thumbnails beside the main
view (viewer.content with the format’s render()). Use the low-level API
when you own the paging yourself or keep the document past any viewer.
read() returns supported semantic data: cell values or slide text, not the
renderer representation. DOCX exposes section metadata and headings. There is
no export or conversion, formulas are never recalculated, and nothing edits.
Packages
| Package | Purpose |
|---|---|
@aared/viewer |
Everything below — shared core, per-format entrypoints (lazy-loaded), and <aared-viewer> |
aared-fonts |
Frozen, not published: optional calibrated substitute fonts (see fontResolver); exact Office line breaks are not guaranteed |
@aared/viewer exposes its surface through subpath exports, so a consumer loads only
the formats it uses:
| Import | Contains |
|---|---|
@aared/viewer/core |
Shared grammar: sources, options, collections, appearance, media, warnings and AaredError. Opens nothing itself. |
@aared/viewer/pptx |
Format-local preview(), plus open(), PptxDocument, slide collections/handles and render() |
@aared/viewer/xlsx |
Format-local preview(), plus open(), XlsxDocument, sheet collections/handles with range reads and render() |
@aared/viewer/docx |
Format-local preview(), plus open(), DocxDocument, section/heading collections, render() and SoftBreakPolicy |
@aared/viewer/preview |
preview(), detect(), PreviewHandle — auto-detect preview; lazily import()s only the detected format entrypoint at runtime |
@aared/viewer/react |
usePreview(), usePptx(), useXlsx(), useDocx() — React hooks owning the preview() mount lifecycle; react is an optional peer |
@aared/viewer/element |
<aared-viewer> — registers the custom element |
@aared/viewer/worker |
The self-contained Worker script (dist/core/worker.mjs), for bundler ?url/?raw imports — see Worker override |
Web component
<aared-viewer
src="quarterly-report.pptx"
type="pptx"
theme="system"
controls></aared-viewer>
// Your bundled app code (see Getting started).
import "@aared/viewer/element";
// For content that isn't at a URL (an upload, a drop target). The import
// types the tag, so this is an `AaredViewer` with no cast:
document.querySelector("aared-viewer")!.file = myFile;
The element takes every format, each loaded on first use; an app that shows
one format can use that format’s preview() instead.
Attributes / properties
| Name | Type | Notes |
|---|---|---|
src |
attribute, URL | Fetched and opened automatically |
file |
property only | File | Blob | ArrayBuffer or a typed array, for content that isn’t at a URL (drag-and-drop, upload). Of src and file, the one set last opens |
type |
attribute and property | "pptx" | "xlsx" | "docx" — optional assertion against auto-detection; a mismatch raises error (unsupported-format), at once when a document of another format is open |
index |
attribute and property, number | Zero-based unit to show. A slide and a sheet are each one unit; a DOCX document is one unit. It always names the unit on screen: a sheet tab, the controls and viewer.goTo() write it, and a unit that cannot be shown fires error and puts it back |
zoom |
attribute and property | "fit-width" | "fit-page" | <number> — drives the viewer’s zoom, and viewer.setZoom() writes it back. Unset, each format opens at its default (see Zoom); removing the attribute goes back to it |
controls |
boolean attribute | Built-in nav/zoom bar with a full-screen toggle (hidden where the Fullscreen API is unavailable), in the spirit of <video controls> |
theme |
attribute and property | "light" | "dark" | "system" (default "light") — viewer chrome; document content is always source-faithful (see Dark mode) |
pagination |
attribute and property | "flow" | "layout" (default "flow") — DOCX only: "layout" paints physical pages (viewer.docx.pages, goToPage()), each with the page shadow. Changing it re-opens a DOCX; other formats ignore it |
worker |
property only | WorkerInit — see Worker override |
viewer |
read-only property | The ready viewer (AaredViewerHandle), or null: metadata, navigation, zoom, search, format-specific controls (xlsx, docx), borrowed content and events. No document lifecycle access. See Methods |
Events
Events report changes. The starting state is the load event (or read
index, zoom and viewer.info): opening a file fires no indexchange or
zoomchange, and going to the unit already shown fires nothing.
| Event | detail |
|---|---|
load |
DocumentInfo — the document opened and its first unit painted |
indexchange |
{ index: number } — the unit changed (viewer.goTo(), the index attribute, a sheet tab, the controls) |
slidechange |
{ index: number; title: string } — PPTX, alongside indexchange |
sheetchange |
{ index: number; title: string } — XLSX, alongside indexchange |
progress |
LoadProgress, { loaded: number; total?: number } — bytes read before parsing starts: each streamed chunk for a URL (total when the server sends Content-Length), one event before and one after for a file. An automatic load ends with load or error; explicit open() rejects on failure |
error |
{ message: string; cause: AaredError } — once per failed built-in action, never a duplicate of a public call’s rejection |
zoomchange |
{ zoom: ZoomValue } — the zoom changed (forwarded from the render handle) |
linkactivate |
Forwarded from the render handle — see each format’s hyperlink notes |
cellactivate |
{ sheet: UnitRef; address; row; col; cell: CellData } — XLSX, forwarded |
selectionchange |
{ sheet: { index, title }; selection: { address, row, col } | null; range: { start, end } | null } — XLSX, forwarded |
searchchange |
SearchNavigationState — the active find position or match count changed; see Methods |
Every event is typed (AaredViewerEventMap), so a listener reads detail
with no cast:
import "@aared/viewer/element";
const viewer = document.querySelector("aared-viewer")!;
viewer.addEventListener("load", (event) => console.log(event.detail.count));
viewer.addEventListener("error", (event) => console.warn(event.detail.cause.code));
In TSX, <aared-viewer> needs an entry in JSX.IntrinsicElements, which
you declare yourself: React is an optional peer, so aared adds none.
Methods
import "@aared/viewer/element";
const element = document.querySelector("aared-viewer")!;
await element.open(); // (re)load whichever of src and file was set last
element.index = 3; // show unit 3 (zero-based); or, to wait until painted:
await element.viewer?.goTo(3); // element.index follows
if (element.viewer?.format === "xlsx") await element.viewer.xlsx.goToCell("D14");
type ZoomValue = "fit-width" | "fit-page" | number;
class AaredViewer extends HTMLElement {
readonly viewer: AaredViewerHandle | null; // null until a document opens
open(): Promise<void>;
}
// The preview() handle without what the element owns: `theme` stands in for
// setAppearance(), and removing the element for destroy(). Checking `format`
// narrows it the same way.
type AaredViewerHandle =
| Omit<PptxPreview, "setAppearance" | "destroy">
| Omit<XlsxPreview, "setAppearance" | "destroy">
| Omit<DocxPreview, "setAppearance" | "destroy">;
The attributes and viewer are one state. Setting index or zoom
calls the viewer. The viewer’s indexchange and zoomchange (from
viewer.goTo(), viewer.setZoom(), a sheet tab or the controls) write the
attribute back, the way <input value> follows typing. So index and
zoom always say what is on screen, whichever way it got there.
Failures are AaredErrors with the codes preview() uses for the same
situation (see Errors). Calls to open() or viewer methods reject
only to their caller. Automatic loads (changing src/file), attribute-driven
navigation and built-in controls report once through error, with the error
as detail.cause. An open() that a newer one replaced rejects with
aborted and fires nothing.
Styling
The element uses shadow DOM. Structural parts are exposed for external styling rather than requiring a full shadow-DOM override:
aared-viewer::part(stage) { background: #1a1a1a; }
aared-viewer::part(page) { outline: 1px solid #ccc; }
aared-viewer::part(controls) { font-family: system-ui; }
aared-viewer::part(button) { border-radius: 999px; }
aared-viewer::part(label) { color: #666; }
toolbar is an alias of controls. page is the box the render paints
into: the page column for DOCX, the grid for XLSX; for PPTX it stretches to
the stage and the slide inside it carries the shadow. Change the shadow with
--aared-page-shadow (see Dark mode) rather than
::part(page), so it lands on the right box in every format. Sheet tabs and
row and column headings are inside the shadow root, so page CSS can’t reach
them; style those through a low-level render() into your own container.
The chrome is deliberately minimal: build custom toolbars and search UI on
preview(), or use open() + render() for semantic data and manual rendering.
theme="system" follows prefers-color-scheme, OS changes
included; the host carries data-aared-theme, and data-aared-format once a
document is open.
Lifecycle and cleanup
The element has no destroy(): removing it from the document is its
teardown. It aborts pending reads, tears down the render (observers, image
object URLs) and closes the Worker session. Putting it back, or changing
src or file, opens the file again; so do changing type while nothing
is open and changing pagination on a DOCX. An open that a newer one
replaced stops before it paints, so it never covers the newer one.
Clearing the source (file = null with no src, or removing src with no
file) empties the viewer quietly, even mid-load: the page is cleared,
viewer goes back to null, and no event fires, since an empty viewer is
not a failure. Calling open() with nothing to open is one: it rejects with
invalid-state, without an error event.
Single-format preview
Each format entry offers the same complete viewer without including sibling
renderers. The import determines the format, skips a separate detection probe and narrows the
returned handle. It owns its document: destroy() tears down rendering, search
state and the Worker session together. A wrong file rejects unsupported-format.
import { preview } from "@aared/viewer/pptx";
const viewer = await preview({ source: file, target: container });
await viewer.next();
await viewer.goTo(3);
const matches = await viewer.search("budget");
viewer.destroy();
import { preview } from "@aared/viewer/xlsx";
const viewer = await preview({ source: file, target: container });
await viewer.goTo(1); // Switch sheets
await viewer.xlsx.goToCell("D14");
viewer.destroy();
import { preview } from "@aared/viewer/docx";
const viewer = await preview({
source: file,
target: container,
pagination: { mode: "layout" },
});
await viewer.docx.goToPage(2);
viewer.destroy();
// Exported from each format entry, alongside its matching preview handle:
interface PptxPreviewInput extends Omit<PreviewInput, "type" | "pagination"> {}
interface XlsxPreviewInput extends Omit<PreviewInput, "type" | "pagination"> {}
interface DocxPreviewInput extends Omit<PreviewInput, "type"> {}
The handles are the same PptxPreview, XlsxPreview and DocxPreview described
below, with the same navigation, zoom, appearance, search, events and teardown.
Each format also re-exports PreviewSearchOptions, PreviewEventMap,
SearchNavigation and SearchNavigationState. XLSX preview shows sheet tabs by
default; its low-level render() leaves them off. DOCX preview defaults to flow
mode; page navigation needs layout mode. No type option is needed or accepted
by the single-format inputs.
For thumbnails, range reads or a second view of the same file, use the
viewer’s viewer.content with the same entry’s render(): one parse serves
both, without exposing the document’s lifecycle. The viewer owns the document;
destroy additional render handles before it. Content operations fail closed
after teardown. No preview({ document }) option is needed or provided.
import { preview, render } from "@aared/viewer/pptx";
import { messageFor } from "@aared/viewer/core";
const viewer = await preview({ source: file, target: container });
const strip = document.querySelector<HTMLElement>("#thumbnails")!;
const thumbnails = await Promise.all(viewer.content.slides.list().map(async (slide) => {
const target = strip.appendChild(document.createElement("button"));
target.onclick = () => void viewer.goTo(slide.info.index).catch((error: unknown) => {
strip.title = messageFor(error);
});
return render({ slide, target });
}));
// On unmount:
for (const thumbnail of thumbnails) thumbnail.destroy();
viewer.destroy();
Each format lends its own units through the same grammar, so the two others need no separate mechanism:
| PPTX | XLSX | DOCX | |
|---|---|---|---|
| Read a unit | viewer.content.slides.get(i) → read() |
viewer.content.sheets.get(i) → read({ range }) |
viewer.content.sections.get(i) (already resolved; viewer.content.sections.list() for all) |
| Render one | render({ slide, target }) |
render({ sheet, target }) |
render({ document: viewer.content, target }) |
| Outline | — | — | viewer.content.headings.get(title) |
DOCX takes the whole content facade, so a second view’s pictures resolve
through the same session and goToHeading() navigates it as any other render.
import { preview, render } from "@aared/viewer/xlsx";
const viewer = await preview({ source: file, target: container });
const panel = document.querySelector<HTMLElement>("#cells")!;
const cells = await viewer.content.sheets.get("Revenue").read({ range: "A1:D20" });
const second = await render({ sheet: viewer.content.sheets.get(0), target: panel });
// On unmount:
second.destroy();
viewer.destroy();
To keep the document past any one viewer, or page through it yourself, use
open() + render() instead: render destroy() and document close() stay
separate there. The Worker still contains all three parsers; only renderer
code is isolated. The auto-detect entry below still emits all format chunks
and fetches only the chosen one.
Auto-detect preview (@aared/viewer/preview)
import { preview, detect, type PreviewHandle, type PreviewInput, type PreviewOptions } from "@aared/viewer/preview";
const viewer: PreviewHandle = await preview({ source: file, target: container });
await viewer.goTo(2);
viewer.setZoom(1.5);
viewer.setAppearance({ theme: "dark" }); // in place: index, zoom, and session survive
if (viewer.format === "xlsx") await viewer.xlsx.goToCell("D14"); // narrowed: xlsx is non-null
viewer.destroy();
One call for embeds that take any file: the format is detected in the
Worker, only that format’s code loads, and the returned PreviewHandle owns
navigation, zoom, events and teardown.
interface PreviewOptions {
type?: DocumentFormat; // assert a format; mismatch rejects `unsupported-format` before its renderer loads
index?: number; // starting unit index, default 0; goTo() changes it
signal?: AbortSignal;
fontResolver?: FontResolver; // opt-in, forwarded to open() (see aared-fonts)
worker?: WorkerInit;
appearance?: Appearance; // starting appearance; setAppearance() changes it
zoom?: ZoomValue; // starting zoom, default per format (see Zoom); setZoom() changes it
mediaResolver?: MediaResolver; // override; defaults to the owning session
pagination?: DocxPagination; // DOCX flow or physical pages; ignored for other formats
}
interface PreviewInput extends PreviewOptions {
source: Source;
target: HTMLElement;
}
function preview(input: PreviewInput): Promise<PreviewHandle>;
// One handle type per format; checking `format` narrows to it.
type PreviewHandle = PptxPreview | XlsxPreview | DocxPreview;
interface PptxPreview extends PreviewBase {
readonly format: "pptx"; readonly info: PptxInfo; readonly xlsx: null; readonly docx: null;
readonly content: PptxContent; // borrowed reads/render inputs; no close(), session or search()
search(query: string, options?: PreviewSearchOptions): Promise<PptxSearchMatch[]>;
}
interface XlsxPreview extends PreviewBase {
readonly format: "xlsx"; readonly info: XlsxInfo; readonly xlsx: XlsxPreviewControls; readonly docx: null;
readonly content: XlsxContent;
search(query: string, options?: PreviewSearchOptions): Promise<XlsxSearchMatch[]>;
}
interface DocxPreview extends PreviewBase {
readonly format: "docx"; readonly info: DocxInfo; readonly xlsx: null; readonly docx: DocxPreviewControls;
readonly content: DocxContent;
search(query: string, options?: PreviewSearchOptions): Promise<DocxSearchMatch[]>;
}
// One result of any format's search(): what goToSearchMatch() takes.
type PreviewSearchMatch = PptxSearchMatch | XlsxSearchMatch | DocxSearchMatch;
// What every format's handle has (not exported: use PreviewHandle).
// addEventListener is typed by PreviewEventMap: indexchange, slidechange,
// sheetchange, zoomchange, error, and the forwarded linkactivate,
// cellactivate, selectionchange, plus searchchange for find position updates.
interface PreviewBase extends EventTarget {
readonly searchNavigation: SearchNavigation;
readonly index: number;
readonly zoom: ZoomValue; // the requested zoom, kept across goTo()
readonly defaultZoom: ZoomValue; // the zoom this format opens at (see Zoom)
readonly scale: number; // the scale actually painted
goTo(index: number): Promise<void>; // emits `indexchange` + `slidechange`/`sheetchange`
next(): Promise<void>; // one unit on; nothing at the last
previous(): Promise<void>; // one unit back; nothing at the first
goToSearchMatch(match: PreviewSearchMatch): Promise<void>; // any format's result: its slide, cell or place
setZoom(zoom: ZoomValue): void; // emits `zoomchange` when the value changes
zoomIn(step?: number): void; // zoom + step (default 0.1 = 10 points), at most 300%
zoomOut(step?: number): void; // zoom - step (default 0.1), at least 25%
resetZoom(): void; // back to defaultZoom
setAppearance(appearance: Appearance): void; // changes only what it sets, in place: index and zoom survive
destroy(): void; // full teardown: render + worker + painted DOM
}
interface XlsxPreviewControls {
readonly selection: CellRef | null;
goToCell(cell: string): Promise<void>;
scrollTo(target: { cell?: string; range?: string; align?: "start" | "center" | "end" }): void;
focus(cell: string): void;
}
interface PreviewSearchOptions extends ReadOptions {
highlight?: boolean; // default true; false clears previous highlights
}
interface SearchNavigationState {
readonly index: number | null; // active position, zero-based; null when inactive
readonly count: number;
}
interface SearchNavigation extends SearchNavigationState {
next(): Promise<void>; // select, reveal and outline the next match; wraps
previous(): Promise<void>; // same, in reverse
}
interface DocxPreviewControls {
readonly pageCount: number; // 0 in flow mode
readonly pages: readonly DocxPageInfo[]; // [] in flow mode
readonly headings: readonly HeadingInfo[]; // body outline, in document order
goToHeading(target: string | number): Promise<void>;
goToPage(index: number): Promise<void>;
}
index/goTo() switch units — slides, sheets, or the one DOCX document.
Moving within the unit on screen is format-specific, so it hangs off
viewer.xlsx (cells) and viewer.docx (headings and pages), with the same
methods the format’s own render handle has (see Navigation).
They are null for the other formats, so viewer.format === "xlsx" makes
viewer.xlsx non-null. Each is one object for the viewer’s whole life,
acting on whichever unit is on screen: one kept across goTo() keeps
working. After destroy(), goTo() and search() reject with closed,
goToCell() rejects too, and scrollTo() and focus() do nothing. PPTX has no
places inside a slide, so there is no viewer.pptx. The DocxPageInfo,
DocxPagination, HeadingInfo and SoftBreakPolicy types, and the three
search match types, are re-exported from @aared/viewer/preview for convenience.
Borrowed content
viewer.content is a restricted runtime facade, narrowed by format like
the rest. It exposes reads, metadata and media, not the document object,
close(), session, or another search API. Search stays on viewer.search().
The content interfaces are exported from their format entries and re-exported
from @aared/viewer/preview:
interface SlideContent {
readonly info: SlideInfo;
read(options?: ReadOptions): Promise<SlideText>;
}
interface PptxContent {
readonly info: PptxInfo;
readonly slides: Collection<SlideContent, number>;
readonly media: MediaCollection;
}
interface SheetContent {
readonly info: SheetInfo;
read(options?: SheetReadOptions): Promise<SheetData>;
}
interface XlsxContent {
readonly info: XlsxInfo;
readonly sheets: Collection<SheetContent, number | string>;
readonly media: MediaCollection;
}
interface DocxContent {
readonly info: DocxInfo;
readonly sections: Collection<SectionInfo, number>;
readonly headings: Collection<HeadingInfo, number | string>;
readonly media: MediaCollection;
}
Slide/sheet content inputs come from the viewer, not a user-constructed object.
Their read() follows the ordinary handle contract; DOCX section metadata is
available synchronously through sections.list() / get(). Media access is
content.media.get(mediaId, options) for an already-known media identifier.
The ordinary format render() accepts these inputs and defaults media to the
owning session, with the same render options and output handles as the low-level
route. DOCX accepts the whole content facade so media and headings work together:
import { preview, render } from "@aared/viewer/docx";
const viewer = await preview({ source: file, target: container });
const secondary = document.querySelector<HTMLElement>("#secondary")!;
const otherView = await render({ document: viewer.content, target: secondary,
pagination: { mode: "layout" } });
await otherView.goToHeading(0);
otherView.destroy();
viewer.destroy();
Use distinct attached targets for additional views; do not replace the viewer’s
own target with a separate render. Destroy those render handles before the
viewer. Removing the element, unmounting a hook or changing its source also
ends the borrowed lifetime. Pending secondary renders are cancelled (aborted);
new content reads, collections, media and renders then fail closed. Metadata
and resolved data already returned remain readable: they are read-only by
contract, not an editing API or a security sandbox. To retain the document past
the viewer, use open() + render() and own its close() yourself.
search(query) runs the format’s search (Search grammar)
over the document the viewer already has open, so a search box needs no
second open(). It returns results and highlights painted matches by default
in every format. Each new search replaces the active query; an empty query
clears it. The application owns the search input, visibility toggle and results
list; these controls are not built into the package. Each match is a place to go:
For a compact find bar, use viewer.searchNavigation.index and .count to
display “2 of 4”, and call .next() / .previous() for the arrows. Listen to
searchchange for counter updates. All occurrences get a soft highlight;
the current match gets a stronger fill and outline, without changing text layout.
Steps wrap at both ends. PPTX navigates occurrences across slides; XLSX navigates
matching cells on the active sheet, including virtualized rows; DOCX navigates
painted occurrences, including each displayed header/footer copy. Its navigation
count can therefore exceed the deduplicated source results returned by search().
Empty or data-only search resets navigation to { index: null, count: 0 }.
import { preview } from "@aared/viewer/preview";
const viewer = await preview({ source: file, target: container });
await viewer.search("nile");
await viewer.searchNavigation.next();
const { index, count } = viewer.searchNavigation;
console.log(index === null ? "No active match" : `${index + 1} of ${count}`);
await viewer.searchNavigation.previous();
viewer.goToSearchMatch(match) goes to any result, so a results list needs no
branch per format:
| Format | A match | goToSearchMatch(match) |
|---|---|---|
| PPTX | slide (UnitRef), text |
Shows its slide |
| XLSX | address, text (on the sheet on screen; sheet names it) |
Shows its sheet if another is on screen, then selects its cell |
| DOCX | kind, heading (nearest above, or null), text, optional story |
Outlines one painted copy (below); with no highlights, scrolls to a header or footer’s first displayed copy, else its nearest heading |
A result of the newest highlighted search also becomes the active one, so
searchNavigation.index and the stronger outline follow the click. A result
from elsewhere (an older search, a low-level document’s search()) only moves. In
DOCX one result can be painted several times, so it becomes one painted copy:
- A header or footer result becomes the copy on the first page still in view, so a click while reading page 3 outlines page 3’s header. If no page in view or below carries that header, it becomes the first copy.
- A body result becomes the first occurrence in its own paragraph. Of
several paragraphs with identical text, the n-th result takes the n-th
paragraph.
Switching sheets clears a workbook’s search, because it is the sheet on screen’s
search. So a cell result from another sheet moves there without staying active.
A result of another format rejects
invalid-argument. LikegoTo(), a failure rejects only to its caller, without anerrorevent.
import { preview } from "@aared/viewer/preview";
import { messageFor } from "@aared/viewer/core";
const viewer = await preview({ source: file, target: container });
const list = document.querySelector<HTMLUListElement>("#results")!;
for (const match of await viewer.search("revenue")) {
const item = list.appendChild(document.createElement("li"));
item.textContent = match.text;
item.onclick = () => void viewer.goToSearchMatch(match).catch((error: unknown) => {
list.textContent = messageFor(error);
});
}
import { preview } from "@aared/viewer/preview";
const viewer = await preview({ source: file, target: container });
// Results and highlights together:
const matches = await viewer.search("revenue");
// Results only; also clears previous highlights:
await viewer.search("revenue", { highlight: false });
// Clear the active search:
await viewer.search("");
const [first] = await viewer.search("revenue");
if (first !== undefined) await viewer.goToSearchMatch(first);
Behavior notes:
- A URL is fetched once for detection and opening together. Preview sends one
file open; detection does not download or open the file a second time.
detect(source, { signal })runs the probe alone, opening nothing. - Cancellation during lazy route loading closes the opened session and rejects without waiting for the renderer download; the browser’s import may finish later.
- A document with nothing to show (no slides, sheets or sections) rejects
with
invalid-document; there is never a handle over an empty document. - A workbook shows its sheet tabs unless
appearance.spreadsheet.sheetTabsisfalse.setAppearance()changes only what it sets, sosetAppearance({ theme: "dark" })keeps hidden tabs hidden. A knob goes back to its default only when you pass the default ({ spreadsheet: { sheetTabs: true } }). - The newest
goTo()wins: an overtaken one resolves having changed nothing. A failed one rejects only to its caller; the unit on screen stays. Afterdestroy()it rejects withclosed. Built-in actions such as a sheet-tab click report througherrorinstead. - A toolbar is the viewer’s own methods behind buttons (
viewer.next(),viewer.previous(),viewer.zoomIn(),viewer.zoomOut(),viewer.resetZoom(), readingviewer.defaultZoom), as the element’s built-in bar is.next()andprevious()step on from where the newest move is heading, so three quick presses move three units, and do nothing at either end (a DOCX is one unit).zoomIn(step)andzoomOut(step)add or take awaystep, where1is 100% (default0.1, so 100% to 110%; a step that is not above 0 throwsinvalid-argument). From a fit mode they start at the scale on screen, so a slide fitted at 62% goes to 72%. They round to whole percents and stay within 25%–300%.resetZoom()goes back todefaultZoom, the zoom the format opens at; for a slide or a document that is a fit mode, notsetZoom(1), which pins the natural size. - A failed
search()only rejects, just like navigation.signalcancels it; afterdestroy()it rejects withclosed. The first PPTX search reads every slide’s text through the Worker; a later one reuses the text kept. DOCX searches the document already on the page, and a workbook search covers the sheet on screen. destroy()is the whole teardown (render, Worker session, painted DOM); there is no separateclose(). The target element stays yours.- The events are the element’s (it runs on this same viewer):
indexchangewithslidechangeorsheetchangebeside it (a sheet-tab click included), plus the render handle’s, forwarded.
React hooks (@aared/viewer/react)
import { useState } from "react";
import { isAbort, messageFor } from "@aared/viewer/core";
import { usePptx } from "@aared/viewer/react";
function SlidePreview({ file }: { file: File }) {
const { ref, status, viewer, index, error, retry } = usePptx({ source: file });
const [actionError, setActionError] = useState<string | null>(null);
return (
<>
<div ref={ref} />
{status === "loading" && <p>Loading…</p>}
{status === "error" && <p>{messageFor(error)} <button onClick={retry}>Retry</button></p>}
{status === "ready" && (
<p>
{viewer.info.titles[index]}{" "}
<button disabled={index >= viewer.info.count - 1} onClick={() => {
void viewer.next().then(() => setActionError(null), (failure: unknown) => {
if (!isAbort(failure)) setActionError(messageFor(failure));
});
}}>Next</button>
</p>
)}
{status === "ready" && error !== null && <p>{messageFor(error)}</p>}
{actionError !== null && <p>{actionError}</p>}
</>
);
}
usePreview() mounts preview({ source, target }) inside an effect and owns
what every embed otherwise rewrites: the AbortController, the
loading/ready/error state, index/zoom mirroring from handle events, and
full teardown on unmount or source change. usePptx(), useXlsx(), and
useDocx() are the same hook with type asserted — detection is skipped,
only that format’s chunk is fetched at runtime (bundlers still emit all
three), and viewer is a PptxPreview / XlsxPreview / DocxPreview. The
wrappers add no lifecycle of their own.
The result is one shape per status. Checking it tells TypeScript what is
there, destructured or not: once "ready", viewer, index and zoom are
set (in the example, viewer.info needs no null check); once "error",
error is an AaredError; before either, all of them are null. The
document’s info and format are read from viewer.
Render ref unconditionally. The hook mounts on its first effect, so the
host <div ref={ref} /> must exist then. A host that appears later — inside
{open && …}, behind a tab that is not the first rendered, after a suspense
boundary — leaves the result idle until source changes or retry() runs.
Render the div on every render and hide it with CSS, or mount the component
under the condition that shows it.
index and zoom are start values; the viewer’s methods change them, and the
result follows the screen. Buttons call the viewer directly (as viewer.goTo()
above). appearance is reactive: a dark-mode switch updates the same viewer
without another effect or reopening the document:
import { usePreview } from "@aared/viewer/react";
function ThemedPreview({ file, theme }: { file: File; theme: "light" | "dark" }) {
const { ref } = usePreview({ source: file, appearance: { theme } });
return <div ref={ref} />;
}
interface UsePreviewOptions extends Omit<PreviewOptions, "signal"> {
source: Source; // must be referentially stable: pass the bytes you hold, not a copy per render
// type, index, zoom, appearance, fontResolver, mediaResolver, worker,
// pagination — as on preview(); index and zoom are start values, appearance is reactive
}
type UsePreviewPhase = "idle" | "loading" | "ready" | "error";
type UsePreviewResult<V extends PreviewHandle = PreviewHandle> =
UsePreviewPending | UsePreviewReady<V> | UsePreviewFailed;
// Every state also has: ref (attach: <div ref={ref} />) and retry (re-run
// the mount, from the latest start values). Changes go through viewer.
interface UsePreviewPending { status: "idle" | "loading"; viewer: null; index: null; zoom: null; error: null }
interface UsePreviewReady<V extends PreviewHandle = PreviewHandle> {
status: "ready";
viewer: UsePreviewViewer<V>; // viewer.info, viewer.format, viewer.xlsx / viewer.docx
index: number; // mirrored from `indexchange`
zoom: ZoomValue; // mirrored from `zoomchange`; the painted scale is viewer.scale
error: AaredError | null; // last failed built-in action; the view stays ready
}
interface UsePreviewFailed { status: "error"; viewer: null; index: null; zoom: null; error: AaredError }
// The preview() handle without destroy(): teardown is the hook's (unmount,
// a new source, retry()). Per format, so checking `format` still narrows it.
type UsePreviewViewer<V extends PreviewHandle = PreviewHandle> = V extends PreviewHandle ? Omit<V, "destroy"> : never;
// usePptx → UsePreviewResult<PptxPreview>, useXlsx → <XlsxPreview>, useDocx → <DocxPreview>
interface UsePptxOptions extends Omit<UsePreviewOptions, "type" | "pagination"> {}
interface UseXlsxOptions extends Omit<UsePreviewOptions, "type" | "pagination"> {}
interface UseDocxOptions extends Omit<UsePreviewOptions, "type"> {}
Behavior notes:
- The hook owns the host element’s contents while mounted and clears them
on teardown, so its
viewerhas nodestroy(). Swappingsourcemid-flight cancels the stale mount, which never reaches"error"and never paints over the new one. - Hook-owned failures land in
error. A failed mount setsstatus: "error". A failed built-in action (such as a sheet-tab click) setserrorwhilestatusstays"ready"and the unit on screen stays; the next unit change or mount clears it. Public calls, including navigation and search, reject to the application and never duplicate their failure in hook state. indexandzoomare start values, read when the document mounts. Later changes take effect on the next mount (sourcechange orretry());viewer.goTo()andviewer.setZoom()change what is on screen now.appearanceupdates in place when its field values change, preserving the viewer, Worker session, unit, zoom and selection. Removing it resets the defaults. The option is the whole appearance: a knob removed from it goes back to its default, unlikeviewer.setAppearance(), which changes only what it sets. Inline objects and property order do not trigger extra updates. An imperativeviewer.setAppearance()remains effective until the option’s values change or the viewer remounts.typeandpaginationre-open the document when their value changes. They are compared by their JSON serialization, so equal inline literals don’t remount — but apaginationobject with the same fields in a different order does.sourceis compared by identity: hold the sameFile/Blob/URL or bytes across renders (aUint8Arraykept in state is stable; a copy made per render is not).- The function options never re-open it on their own, so inline functions
are fine: a
mediaResolveris called through the latest one given (adding or removing one re-opens), andfontResolverandworkerare read at the next mount (asourcechange orretry()). - The result object keeps its identity until something in it changes, so it can be an effect dependency.
- StrictMode-safe: mount, cleanup and remount resolve once, leaking nothing.
- Search is
viewer.search(), as onpreview(); semantic reads and secondary renders useviewer.content(see Borrowed content). A DOCX document is one unit, so itsviewer.goTo()takes only0. react(>=18) is an optional peer.
Ownership and updates
The viewer owns the file: remove an element, unmount a hook or call
viewer.destroy() to close it. With manual document operations, destroy the
renders first, then call the document’s close(). Document, handle and
collection types are interfaces, not public constructors.
The following shared rules apply across formats:
- Units and places.
countandgoTo(i)switch units: a slide, a sheet, or the one DOCX document. Moving inside the unit on screen isgoTo<Place>()(see Navigation). - Every
goTo…returnsPromise<void>. A target that doesn’t exist rejectsnot-found; an index that is not a whole number,invalid-argument. - A unit is
{ index, title }(UnitRef) and a cell{ address, row, col }(CellRef, zero-based), everywhere. - A title lookup matches exactly, then ignoring case; never a substring
(that is
search()). - Events describe changes. The ready signal (
load,preview()resolving, the hook’s"ready") carries the starting state. - Start values and updates are explicit.
indexandzoomsay where a viewer starts; changing these hook options later waits for the next mount. Reactappearancechanges apply in place; removing it resets defaults.goTo(),setZoom()andsetAppearance()(which changes only what it sets) change the mounted viewer, events report the change, and the state a layer shows (viewer.index, the hook’sindex, the element’s attributes) is a copy of the screen. Structural options such aspaginationre-open the document and have the same name inrender(),preview()and the hooks. - One error model: every failure is an
AaredError, with the samecodefor the same situation in every layer (Errors). - After
destroy()viewer navigation and search reject withclosed; zoom and appearance setters do nothing, and repeated cleanup is harmless. Low-level render handles exposedestroyedand stop changing their output. A render that a newerrender()into the same target replaced destroys itself.
Navigation
| PPTX | XLSX | DOCX | |
|---|---|---|---|
Unit (count, goTo(i)) |
a slide | a sheet | the document (count 1) |
| Places in a unit | — | cells: goToCell, scrollTo, focus, selection |
headings: goToHeading (both modes); pages: goToPage, pages, pageCount (layout mode) |
| Switching units on the render handle | render another slide | goToSheet(key), the sheet tabs |
— |
Word works the same way: headings are document structure and work in every
view (the Navigation pane); pages are layout and exist only in Print Layout.
So DOCX goToHeading works in flow and layout mode, while goToPage rejects
with unsupported-feature in flow mode, where pageCount is 0 and pages
is empty.
Zoom
Every render handle and viewer has the same zoom: setZoom(zoom), the
requested zoom, and the scale it actually painted. zoom can also be
passed to render() and preview() (and set as the element’s attribute) to
start there without an event.
ZoomValue |
Meaning |
|---|---|
"fit-width" |
Scale so the content’s width fills the container |
"fit-page" |
Scale so the whole content fits the container’s width and height. A container that only grows with its content offers no height, so this falls back to fit-width |
| a number | An explicit scale — 1 is 100% (96 CSS px per inch) |
| Format | Default | What is scaled |
|---|---|---|
| PPTX | "fit-page" |
the slide |
| XLSX | 1 — 100%, like Excel |
the grid, headers included. Fit modes fit the sheet’s used area (never the fill cells), with a 25% floor so a huge sheet stays readable and virtualized |
| DOCX | "fit-width" |
layout mode: the pages — fit-width fits the widest, fit-page also keeps the tallest in view. Flow mode: fit modes are 100%, since the column already flows to its container; a number scales it |
Fit modes re-fit when the container resizes. Zoom belongs to the viewer: it
survives goTo() in preview(), the hooks and the element. Their viewer
also reads the default back (defaultZoom) and steps it the way a toolbar
does (zoomIn(), zoomOut(), resetZoom(); see the
auto-detect preview). How much room the host must
give is in Getting started: Sizing.
PPTX (@aared/viewer/pptx)
import {
open,
render,
type PptxDocument,
type PptxInfo,
type PptxRenderEventMap,
type PptxRenderHandle,
type PptxRenderInput,
type PptxSearchMatch,
type SlideCollection,
type SlideHandle,
type SlideInfo,
} from "@aared/viewer/pptx";
const deck = await open(file); // File | Blob | ArrayBuffer | Uint8Array | URL string
deck.info; // PptxInfo — format narrowed to "pptx"
deck.slides; // SlideCollection
deck.media; // MediaCollection
deck.close(); // end the document's lifetime
open() fails with an unsupported-format AaredError when the file is
not actually a presentation.
Slides
Collection entries are lightweight handles: opening lists slides without reading their content. Read paragraphs/notes or render a slide on demand:
interface SlideInfo extends UnitRef { // { index, title }
size: { width: number; height: number }; // inches
}
interface SlideHandle {
readonly info: SlideInfo;
read(options?: ReadOptions): Promise<SlideText>;
media(mediaId: string, options?: MediaOptions): Promise<ArrayBuffer>;
}
for (const slide of deck.slides.list()) {
console.log(slide.info.index, slide.info.title);
}
const slide = deck.slides.get(0);
const text: SlideText = await slide.read();
A slide’s metadata title is "Slide N" (1-based). slide.read() returns
paragraphs and optional notes, not an identified title placeholder or geometry.
Slide text
interface SlideText {
paragraphs: string[];
notes?: string;
}
slide.read() reads nonempty trimmed paragraphs from shapes and table cells,
walking groups in element order. Chart title, axis, series and category words
appear at their chart’s position in that order; this is source element order,
not a spatial reading-order heuristic. Shared layout/master graphics are
excluded. notes is the notes-page body as plain text, absent when it has no
body. Reads do not load rendering models or request fonts. Search neither
parses nor searches speaker notes. Returned arrays are isolated copies;
see Reading data.
Rendering a slide
interface PptxRenderInput {
slide: SlideHandle | SlideContent;
target: HTMLElement;
appearance?: Appearance;
zoom?: ZoomValue; // default "fit-page"
mediaResolver?: MediaResolver;
signal?: AbortSignal; // cancels before the paint (see Cancellation)
}
// addEventListener is typed by PptxRenderEventMap: zoomchange, linkactivate.
interface PptxRenderHandle extends EventTarget {
readonly slide: SlideInfo;
readonly zoom: ZoomValue;
readonly scale: number;
readonly destroyed: boolean;
readonly warnings: readonly RenderWarning[]; // elements with no id, and font fidelity, for PPTX
setZoom(zoom: ZoomValue): void;
setAppearance(appearance: Appearance): void;
destroy(): void;
}
interface RenderWarning {
code: string; // e.g. "font-unavailable", "pptx-missing-id"; stable, safe to switch on
message: string; // for a human
}
const slideView = await render({ slide, target: container });
slideView.setZoom(1.5); // emits `zoomchange`
slideView.setAppearance({ theme: "dark" }); // theme marker only; slides stay source-faithful
slideView.destroy();
slideView.warnings is empty unless the render noticed something off. Today that
is a font it could not paint with (font-unavailable: neither the requested
family nor a substitute, so the browser’s own fallback painted), and a slide
whose file gave some shapes, pictures, charts, tables or groups no id
(pptx-missing-id, once per slide; the file is damaged, but PowerPoint’s own
repair only adds the ids, so aared does the same and draws the slide in full).
DOCX and XLSX give the same kind of warning (docx-missing-id, xlsx-missing-id:
Word and Excel also call a drawing with no id damaged and offer to repair it,
and aared draws it in full). The slide shows as soon
as it is painted; render() resolves once its fonts arrive (at most two
seconds), since it reads which font painted.
render() takes a registered handle or borrowed content input, resolves the
private painting data on demand and loads media through its owning session.
Semantic data, detached painting payloads and user-constructed handle-like
objects reject with invalid-argument.
Backgrounds and master graphics
A slide paints its background, then its layout’s and master’s graphics (a
logo, a header box; placeholders never paint), then its own elements.
An unsupported background stays white and marks the slide
data-aared-background="unsupported". Layout graphics are shared internally;
their structure is not public data. Painting rules live in ARCHITECTURE.
Searching a deck
interface PptxSearchMatch {
slide: UnitRef; // { index, title } — goTo(slide.index)
text: string; // this occurrence's paragraph text (at most 120 characters)
}
const matches = await deck.search("revenue", { signal });
One result per non-overlapping text occurrence, in slide/paragraph order. Several results can name the same slide, including repeated occurrences in one paragraph. Each result navigates to its slide; highlighting marks every painted occurrence. Cancellation is checked between slides. See Search grammar.
Hyperlinks, on a run or a whole shape, paint as real anchors that notify
instead of navigating: linkactivate with { target }, the URL, or for a
jump inside the deck a #-prefixed part path for the host to decide.
XLSX (@aared/viewer/xlsx)
import {
open,
render,
type SheetCollection,
type SheetHandle,
type SheetInfo,
type SheetReadOptions,
type XlsxDocument,
type XlsxInfo,
type XlsxRenderEventMap,
type XlsxRenderHandle,
type XlsxRenderInput,
type XlsxSearchMatch,
} from "@aared/viewer/xlsx";
const workbook = await open(file);
try {
workbook.info; // XlsxInfo
workbook.sheets; // SheetCollection
workbook.media; // MediaCollection
const sheet = workbook.sheets.get("Revenue"); // by title, or get(1) by index
const data = await sheet.read({ range: "A1:M200" }); // SheetData
} finally {
workbook.close(); // after the last read: a closed document rejects
}
Sheets and range reading
interface SheetInfo extends UnitRef { // { index, title } — title is the tab name
state: "visible" | "hidden" | "veryHidden";
}
interface SheetReadOptions extends ReadOptions {
range?: string | CellRange; // omitted: the whole sheet
}
interface SheetHandle {
readonly info: SheetInfo;
read(options?: SheetReadOptions): Promise<SheetData>;
search(query: string, options?: ReadOptions): Promise<XlsxSearchMatch[]>; // this sheet only
media(mediaId: string, options?: MediaOptions): Promise<ArrayBuffer>;
}
A range is an A1 string ("A1:M200", or one cell "D14") or a structured
CellRange. Reversed A1 corners are put in order; a reversed CellRange, or
a malformed reference, rejects with invalid-range.
const data = await sheet.read({ range: "A1:M200" });
const cell = await sheet.read({ range: "D14" });
const block = await sheet.read({ range: { start: { row: 0, col: 0 }, end: { row: 199, col: 12 } } });
read() returns SheetData: sheet name, direction, full
extent and frozen panes, sparse rows within the window, and intersecting merges
clipped to it. Rows outside the window are not returned, not evidence of empty
cells. A range limits the returned data, not the source parsing: the full sheet
may be parsed. Reads do not request fonts.
Sizing. zoom: "fit-width" or "fit-page" fits the sheet’s used area
for you (see Sizing). Track dimensions are renderer-private.
Spreadsheet data
interface CellRange {
start: { row: number; col: number };
end: { row: number; col: number };
}
interface CellData {
value: string | number | boolean | null;
formatted: string;
type: "string" | "number" | "boolean" | "error" | "empty";
formula?: string;
hyperlink?: { target: string; display?: string };
}
interface RowData {
index: number;
cells: Array<{ col: number; cell: CellData }>;
}
interface SheetData {
name: string;
rightToLeft: boolean;
extent: { cols: number; rows: number };
range: CellRange;
merges: CellRange[];
frozen: { rows: number; cols: number };
rows: RowData[];
}
Coordinates are zero-based, logical A1 order, and range ends are inclusive.
Rows and cells are sparse and ascending; an absent cell inside the read window
is empty. extent and frozen describe the whole sheet; merges intersect the
window and are clipped to it. Omitting range reads the whole extent (an empty
sheet uses A1 as its range). Dates remain numeric serials with a date-formatted
formatted string. Formula results are saved values, never recalculated;
formula is source text. String storage details are not public types.
Styles, conditional painting, drawings, diagnostics, hidden tracks and track
dimensions are not returned. cellactivate uses this same CellData under
detail.cell for double-click and Enter activation. Nested data is copied;
see Reading data.
Rendering a sheet
interface XlsxRenderInput {
sheet: SheetHandle | SheetContent;
target: HTMLElement;
appearance?: Appearance;
zoom?: ZoomValue; // default 1 (100%, like Excel)
mediaResolver?: MediaResolver;
signal?: AbortSignal; // cancels before the paint (see Cancellation)
}
// addEventListener is typed by XlsxRenderEventMap (the events table below).
interface XlsxRenderHandle extends EventTarget {
readonly sheet: SheetInfo;
readonly selection: CellRef | null;
readonly zoom: ZoomValue;
readonly scale: number;
readonly destroyed: boolean;
readonly warnings: readonly RenderWarning[]; // drawings with no id (`xlsx-missing-id`), and font fidelity
setZoom(zoom: ZoomValue): void;
setAppearance(appearance: Appearance): void;
goToCell(cell: string): Promise<void>;
scrollTo(target: { cell?: string; range?: string; align?: "start" | "center" | "end" }): void;
focus(cell: string): void;
goToSheet(key: string | number): Promise<void>;
destroy(): void;
}
const sheetView = await render({ sheet, target: container });
await sheetView.goToCell("D14"); // select + scroll + focus
sheetView.scrollTo({ range: "F120:J140", align: "center" });
sheetView.focus("F120"); // select + focus, no scrolling
await sheetView.goToSheet("Revenue"); // switch tabs by title or index; stale reads cancel
sheetView.destroy();
goToCell and focus take one cell; scrollTo takes exactly one of cell
(one cell) or range. Anything else — a range where a cell belongs, an
empty target, a malformed address — is an invalid-range AaredError
(goToCell rejects with it; focus and scrollTo throw it). goToSheet
works on a render from a sheet handle or borrowed sheet content. Through a preview viewer the cell
operations are the xlsx place: viewer.xlsx.goToCell,
viewer.xlsx.scrollTo, and viewer.xlsx.focus. Switch preview sheets with
viewer.goTo(index) or a sheet tab; there is no viewer.xlsx.goToSheet().
Title-based goToSheet() is only on the low-level render handle.
render() takes a registered sheet handle or borrowed sheet content, never
the result of read(). Rows are virtualized for you; columns paint in full.
The default render looks like a spreadsheet: headings and the read-only selection are on, sheet tabs are off (the host owns tab switching). Turn pieces off explicitly:
await render({
sheet,
target: container,
appearance: { spreadsheet: { headers: false, selection: false, gridlines: "hide" } },
});
The selection is read-only but interactive: a click or drag selects, the
arrow keys move, Home, End and the page keys move along the data, and Enter or
a double-click activates (cellactivate), with role="grid" semantics and one
tab stop. The empty cells past the data select like the rest, numbered on from
the sheet’s last row and column (row 12 is the first one past a sheet of eleven
rows); the arrow keys reach as many of them as the viewer paints. Hyperlinks
notify instead of navigating. The events (XlsxRenderEventMap) name the
sheet as a unit and the cell as a cell location:
| Event | detail |
|---|---|
selectionchange |
{ sheet: UnitRef; selection: CellRef | null; range: { start: CellRef; end: CellRef } | null } — the cursor, plus the Shift/drag-extended range when one stands |
cellactivate |
{ sheet: UnitRef; address; row; col; cell: CellData } |
linkactivate |
{ target: string; sheet: UnitRef; address; row; col } |
sheetchange |
UnitRef — { index, title } of the sheet now shown |
zoomchange |
{ zoom: ZoomValue } |
error |
{ message: string; cause: AaredError } — a built-in sheet-tab action failed; a public goToSheet() call rejects instead |
sheetView.addEventListener("cellactivate", (event) => console.log(event.detail.address));
sheetView.addEventListener("linkactivate", (event) => { /* host decides */ });
Searching a workbook
interface XlsxSearchMatch extends CellRef { // { address, row, col }
sheet: UnitRef; // { index, title }
text: string;
chart?: { name: string }; // a chart's word, at the chart's top-left cell
}
const matches = await sheet.search("revenue", { signal });
A workbook is searched one sheet at a time, as Excel’s Find searches the
sheet you are on: sheet.search() covers that sheet alone, and the viewer’s
search() covers the sheet on screen. For a workbook-wide search, loop over
workbook.sheets.list() (hidden sheets included).
Choose the sheet scope explicitly: use each handle’s info.state to exclude
hidden or very-hidden sheets if that is your app’s policy. Pass the same
signal to each sheet search to cancel a workbook-wide operation. No separate
workbook search method or formula evaluation is provided.
One match per matching cell, against the cached, formatted value (formulas
are never evaluated). A chart’s words (its title, axis titles, series names
and categories) match too, reported at the cell the chart is anchored to,
with chart set. See Search grammar.
DOCX (@aared/viewer/docx)
import {
open,
render,
type DocxDocument,
type DocxInfo,
type DocxPageInfo,
type DocxPagination,
type DocxRenderEventMap,
type DocxRenderHandle,
type DocxRenderInput,
type DocxSearchMatch,
type HeadingCollection,
type HeadingInfo,
type SectionCollection,
type SoftBreakPolicy,
} from "@aared/viewer/docx";
const doc = await open(file);
doc.info; // DocxInfo
doc.sections; // SectionCollection — the source/document structure
doc.headings; // HeadingCollection — outline in document order
doc.media; // MediaCollection
doc.close();
A section is source structure, not a page: all of them arrive with open(),
so there is no section.read(). Pages are layout, and exist only on a
layout-mode render (pages).
const sections = doc.sections.list();
const section = doc.sections.get(0);
interface HeadingInfo {
index: number; // position in headings.list(); goToHeading(index)
title: string; // the heading's text
level: number; // 1-based outline level
section: number; // zero-based section index
}
const headings = doc.headings.list();
const intro = doc.headings.get("Introduction"); // by title, or get(0) by index
The outline is the document’s body headings, in order, as Word’s
Navigation pane lists them; a heading in a header, footer, text box or table
cell is not in it. The same index means the same heading in
headings.list(), goToHeading(), viewer.docx.headings and search matches.
info.titles has one entry, the title from the file’s properties or
"Document"; info.sizes is omitted (see DocumentInfo).
Section metadata
interface SectionInfo {
index: number;
breakType: "continuous" | "nextPage" | "evenPage" | "oddPage";
page: {
width: number;
height: number;
orientation: "portrait" | "landscape";
margins: {
top: number; right: number; bottom: number; left: number;
header: number; footer: number; gutter: number;
};
};
}
All lengths are inches. doc.sections.list() / get() return isolated source
section metadata available after open(), not rendered pages or rich flow
nodes. HeadingInfo, search and navigation are separate supported APIs.
General rich-content traversal and style inspection are not provided;
see Reading data.
Rendering a document
Flow is the default; physical pages are opt-in:
type SoftBreakPolicy = "ignore" | "prefer" | "force";
interface DocxPagination {
mode?: "flow" | "layout"; // default "flow"
softBreaks?: SoftBreakPolicy; // default "prefer"
}
interface DocxRenderInput {
document: DocxDocument | DocxContent;
target: HTMLElement;
pagination?: DocxPagination;
appearance?: Appearance;
zoom?: ZoomValue; // default "fit-width"
mediaResolver?: MediaResolver;
signal?: AbortSignal; // cancels before the paint (see Cancellation)
}
interface DocxPageInfo {
index: number; // zero-based; goToPage(index)
width: number; // inches
height: number;
orientation: "portrait" | "landscape";
}
// addEventListener is typed by DocxRenderEventMap: zoomchange, linkactivate.
interface DocxRenderHandle extends EventTarget {
readonly pageCount: number; // 0 in flow mode
readonly pages: readonly DocxPageInfo[]; // [] in flow mode
readonly zoom: ZoomValue;
readonly scale: number;
readonly destroyed: boolean;
readonly warnings: readonly RenderWarning[]; // layout-mode pagination edge cases, drawings with no id (`docx-missing-id`), and font fidelity in either mode
setZoom(zoom: ZoomValue): void;
setAppearance(appearance: Appearance): void;
goToHeading(target: string | number): Promise<void>;
goToPage(index: number): Promise<void>;
goToSearchMatch(match: DocxSearchMatch): Promise<void>;
destroy(): void;
}
const docView = await render({ document: doc, target: container }); // flow: pageCount 0
await docView.goToHeading("Introduction"); // by title, or goToHeading(2) by index
docView.destroy();
const pagedDocView = await render({
document: doc,
target: container,
pagination: { mode: "layout", softBreaks: "prefer" }, // physical pages
});
await pagedDocView.goToPage(3); // layout mode only
Flow is one continuous article (with the last section’s header and footer);
layout paints one box per page. softBreaks is what to do with the page
breaks Word stored at its last save (w:lastRenderedPageBreak): "ignore",
"prefer" (default) or "force". Every page stays in the DOM, so
find-in-page, selection and links reach all of it.
goToHeading works in both modes; goToPage rejects unsupported-feature
in flow mode and not-found outside the pages (see
Navigation). Through a preview viewer this is the docx
place: viewer.docx.goToHeading. Passing the DocxDocument loads pictures through
its session; pass a mediaResolver only for a bare section list (without
one, its pictures paint as placeholders).
DOCX pages are laid out like Word’s, but pixel-identical Word pagination is not part of the contract.
Searching a document
Sections are already in memory, so it resolves quickly; the blocks are indexed on the first call:
interface DocxSearchMatch {
kind: "heading" | "paragraph" | "list" | "table" | "footnote" | "endnote" | "chart" | "textBox" | "header" | "footer";
section: number;
heading: { index: number; title: string } | null; // nearest preceding body heading
text: string;
table?: { index: number; row: number; col: number }; // kind "table"
note?: { id: string; label: string }; // notes
textBox?: { index: number }; // kind "textBox", document order
story?: { part: string; variant: "default" | "first" | "even" }; // headers/footers
}
const matches = await doc.search("revenue", { signal });
Covers heading, paragraph, list, table-cell, text-box, and footnote/endnote text, one
match per matching block in document order, notes last, and a chart’s words
(kind: "chart", with the section and heading the chart sits under).
- Tables.
table.indexcounts tables in the section in document order (nested tables included);rowandcollocate the innermost cell (colis the logical cell order, not the visual position in an RTL table). A heading inside a table cell does not changeheading. - Headings.
heading.indexis the outline index, sodocView.goToHeading(match.heading.index)jumps to the match’s heading. - Notes. A note match reports the
sectionandheadingof the paragraph that first references it, where a reader meets the mark. - Headers and footers. One result per matching block in a distinct source part,
with
kind: "header"or"footer",heading: null, andstoryidentifying its part and first indexed variant. Inherited copies do not repeat results; separate parts with identical text remain separate. Image alt text is not searched.
Text inside body text boxes is searched under kind: "textBox"; the
textBox.index identifies the box in section document order.
Viewer searches highlight by default; document searches return data only.
The common query/highlight rules are in Search grammar.
goToSearchMatch(match) (on the DOCX render handle, and on every viewer) scrolls
a DOCX result to the first displayed header/footer copy or nearest body heading.
An undisplayed header/footer rejects with not-found; flow mode shows only the
final default pair.
Search includes saved field text; highlighted page fields use displayed values.
The app owns the search input and results list. The package handles highlighting and navigation. For example, with the preview API:
import { preview } from "@aared/viewer/preview";
const viewer = await preview({
source: file,
target: container,
pagination: { mode: "layout" },
});
// Get results and highlight every occurrence, including repeated headers.
const matches = await viewer.search("nile");
// When the user clicks a result (any format):
if (matches[0]) {
await viewer.goToSearchMatch(matches[0]);
}
// When the user clears the search:
await viewer.search("");
// When the viewer is no longer needed:
viewer.destroy();
Shared options and behavior (@aared/viewer/core)
import type {
Appearance,
AaredErrorCode,
CellRef,
Collection,
DocumentFormat,
DocumentInfo,
FontResolver,
GridlineMode,
LoadProgress,
MediaCollection,
MediaOptions,
MediaResolver,
OpenOptions,
ReadOptions,
RenderWarning,
Source,
UnitRef,
ViewerTheme,
WorkerInit,
ZoomValue,
} from "@aared/viewer/core";
import { AaredError, fontAlias, isAaredError, isAbort, messageFor } from "@aared/viewer/core";
Sources and options
type DocumentFormat = "pptx" | "xlsx" | "docx";
type Source = File | Blob | ArrayBuffer | ArrayBufferView | string; // a view (Uint8Array, Node Buffer) is read over its own range
type WorkerInit =
| { url: string | URL; crossOrigin?: "blob"; source?: never; create?: never }
| { source: string; url?: never; crossOrigin?: never; create?: never }
| { create: () => Worker; url?: never; source?: never; crossOrigin?: never };
interface OpenOptions {
fontResolver?: FontResolver;
signal?: AbortSignal;
worker?: WorkerInit;
}
/** Options for lazy content reads and search (`slide.read`, `search`; `sheet.read` adds `range`). */
interface ReadOptions {
signal?: AbortSignal;
}
interface MediaOptions {
signal?: AbortSignal;
}
type FontResolver = (requestedFamily: string) => Promise<FontFace | undefined> | undefined;
type MediaResolver = (mediaId: string) => Promise<ArrayBuffer>;
/** The name a `FontResolver` must register a delivered substitute under (see below). */
function fontAlias(family: string): string;
/** A unit — slide, sheet — by position and label. */
interface UnitRef {
index: number; // zero-based; the goTo() key
title: string;
}
/** A spreadsheet cell location. */
interface CellRef {
address: string; // A1, e.g. "D14"
row: number; // zero-based
col: number; // zero-based
}
fontResolver is optional, and leaving it out is the recommended setup:
nothing downloads, and a family the reader lacks paints in the browser’s own
fonts (some lines then break a word differently from Office). A resolver is
called for families needed by rendering (and eager DOCX opening). XLSX data
reads and PPTX text reads do not request fonts. A resolver that throws or
rejects does not fail the document: the family falls back, the render’s
warnings names it (font-unavailable), and a later render can retry.
A resolver registers its FontFace under fontAlias(requestedFamily),
never the requested name itself (that would shadow a reader who has the real
font) and never the substitute’s own name (the renderer would not select
it). The repo’s frozen, unpublished aared-fonts is such a resolver, for an
app that needs closer Office typography; substitute fonts can still wrap differently.
import { fontAlias, type FontResolver } from "@aared/viewer/core";
const resolver: FontResolver = async (requestedFamily) => {
if (requestedFamily !== "Calibri") return undefined;
const face = new FontFace(fontAlias(requestedFamily), "url(/fonts/carlito.woff2)");
document.fonts.add(face); // the browser fetches it when text first needs it
return face;
};
Styling and CSP
Renderer defaults (grid cells, paragraph and page boxes) are rules in one
constructed stylesheet on the root the render lives in, the document or a
shadow root; inline style carries only what the file authors, set through
the CSSOM. Nothing writes a <style> element or a style attribute,
<aared-viewer>’s chrome and search highlighting included: the ::highlight
rule lives in a constructed stylesheet adopted onto the viewer’s scope. So a
style-src without 'unsafe-inline' works, rendering and highlighted search
alike (checked under style-src 'self' with every format, including a search
run). The aared-* class names are internal; the styling hooks are the
data-aared-* attributes, ::part() and the --aared-* tokens. Your CSS
beats a directly applied renderer default only with !important or a selector
with two IDs and a class. Render into an attached element; DOM serialised out of the page
(outerHTML) doesn’t carry the defaults.
Content roots reset inherited emphasis, tracking and casing, while preserving
the document’s resolved fonts, ink and line spacing. This is not full CSS
encapsulation: avoid application selectors targeting preview descendants, or
use <aared-viewer>’s shadow root. Generated resource IDs, like class names,
are internal and can change on each render.
Collections
The shared grammar behind deck.slides, workbook.sheets,
doc.sections, and doc.headings:
interface Collection<TItem, TKey> {
list(): readonly TItem[];
get(key: TKey): TItem;
}
list()/get() are synchronous over what open() resolved; get() throws
not-found for an unknown key. Sheets and headings also take a title
(workbook.sheets.get("Revenue")), matched as in rule 4 above.
Media
MediaCollection fetches bytes for an already-known media identifier:
interface MediaCollection {
get(mediaId: string, options?: MediaOptions): Promise<ArrayBuffer>;
}
const bytes = await deck.media.get(mediaId); // → ArrayBuffer, transferred from the worker
A render from a handle loads its pictures through the document’s session.
mediaResolver optionally overrides that source. Public data reads do not
enumerate pictures or expose painting media references; a picture-gallery API
is not provided. media.get() rejects with closed after close().
Search grammar
A PPTX or DOCX document, and each XLSX sheet (sheet.search()), has an async
search(query, options?) for your own search box: each match is a location to
jump to (goTo(match.slide.index), goToCell(match.address),
goToHeading(match.heading.index)). These low-level searches return data only.
Viewer searches highlight matching painted text by default; pass
{ highlight: false } for results only, or search an empty string to clear.
A workbook is searched one sheet at a time, as Excel’s Find is.
Switching sheets keeps the highlight query active; an application that displays
results should run viewer.search(query) again on indexchange to get the new
sheet’s matches. The built-in viewers have no search
UI; viewer.search() on preview(), the hooks and the element runs the same
search on the viewer’s own document, and on the sheet on screen for a workbook.
Highlights follow newly painted text, including virtualized XLSX rows and
repeated DOCX headers, without changing layout. A browser without CSS Custom
Highlight support rejects nonempty highlighted search with unsupported-feature;
{ highlight: false } remains available and clears previous highlights. Using
that option from the start also skips loading the highlighting code.
- A case-insensitive substring match over text, never formulas; an empty
query returns
[], and each match’stextis cut to 120 characters (the text as written, not as compared). - Arabic spellings that readers treat as one word match each other: tashkeel
and tatweel are ignored, every alef form (
أ إ آ ٱ) matchesا,ىmatchesي, and Arabic-Indic and Persian digits match Western ones, soالانتاجfindsالإنتاجand٢٤finds24. Ta marbuta against ha, and the hamza on waw or ya, stay different letters. A query of only marks (a lone fatha) matches nothing. Title lookups (sheets.get("Q1")) stay case-only. - The first PPTX search reads every slide from the Worker, so it grows with
the file (timings in
BENCHMARKS.md);
signalcancels it between slides. The first search of an XLSX sheet reads that sheet only. Later searches reuse the text index. DOCX is already in memory. - PPTX searches shape and table text; XLSX the sheet’s formatted cells; DOCX headings, paragraphs, lists, table cells, notes, text boxes, headers and footers.
- After
close()it rejects withclosed.
Appearance
The viewer’s own look, passed to render(), preview() and the hooks
(structural styling is CSS: tokens and ::part()):
type ViewerTheme = "light" | "dark" | "system";
type GridlineMode = "source" | "show" | "hide";
interface Appearance {
theme?: ViewerTheme;
spreadsheet?: {
headers?: boolean;
sheetTabs?: boolean;
fill?: boolean; // default true
selection?: boolean;
gridlines?: GridlineMode;
formulaBar?: boolean; // default false: read-only name box + active-cell formula/value
};
}
type ZoomValue = "fit-width" | "fit-page" | number;
theme (default "light") styles the viewer’s chrome only, never the
document; see Dark mode.
spreadsheet.sheetTabs defaults by layer. The embed layers
(<aared-viewer>, preview(), the hooks) show a workbook’s tabs unless it
is false, since they have no other way to switch sheets. The low-level
render() leaves them off unless it is true, for an app that draws its
own. setAppearance(), at every layer, changes only the knobs it sets: the
theme and the spreadsheet knobs it leaves out keep their current values. To
go back to a default, pass it.
import { preview } from "@aared/viewer/xlsx";
const viewer = await preview({
source: file,
target: container,
appearance: { spreadsheet: { sheetTabs: false } },
});
viewer.setAppearance({ theme: "dark" }); // the tabs stay hidden
viewer.setAppearance({ spreadsheet: { sheetTabs: true } }); // back to the embed default
spreadsheet.fill (default true) continues the grid past the data with
empty cells and headings until the viewport is full, the way Excel’s grid
never ends at your last row. Extra cells are not part of the parsed extent or
model, but the painted fill area supports clicking, drag/Shift selection and
arrow-key movement by geometry. Home, End and page keys stay on the file’s
data. A sheet that already fills the viewer gets none; fill: false paints
only the file’s cells.
spreadsheet.formulaBar (default false) adds a read-only formula bar above
the grid, for low-level render() and preview() alike. It shows the cursor
cell’s address and its formula (or formatted value). For a multi-cell
selection, a dropdown in that same bar offers Sum, Average, Min, Max and Count
over cached cell values; Count counts nonempty cells. Formulas are never
recalculated. Display-only: a viewer never edits, so there is no input to type
into. The selection the bar reads is the cursor plus its Shift/drag-extended
range, reported on selectionchange. When the cursor is on a formula, visible
references to cells on that same sheet are highlighted. No bottom
status/footer bar is added.
Dark mode
Theme changes the viewer, never the document. theme defaults to "light".
"light" and "dark" are fixed; "system" follows the OS. Dark chrome does
not invert document colours: sheets/pages remain as authored, and automatic
text colour follows its fill.
Elements, preview viewers and hooks follow system-theme changes in place,
preserving navigation and zoom. Explicitly choosing light/dark stops following
the OS. Low-level render() resolves system once; its owner must call
setAppearance() when the OS changes.
The element supports these CSS variables; an authored value wins in both themes:
aared-viewer {
--aared-background: #f2f2f2;
--aared-text: inherit;
--aared-page-shadow: 0 1px 5px rgb(0 0 0 / 0.25);
}
Other renderer tokens are internal, not supported inputs. For structural element parts, see Styling; for option updates, see Appearance.
Errors
One error model across all entrypoints:
class AaredError extends Error {
code: AaredErrorCode;
cause?: unknown; // the underlying error, when there is one (see below)
}
type AaredErrorCode =
| "unsupported-format"
| "invalid-document"
| "encrypted-document"
| "unsupported-feature"
| "network-error"
| "media-error"
| "not-found"
| "invalid-range"
| "invalid-argument"
| "invalid-state"
| "worker-error"
| "internal"
| "closed"
| "aborted";
function isAaredError(error: unknown): error is AaredError;
function isAbort(error: unknown): error is Error;
function messageFor(error: unknown): string;
Every failure a public API reports is an AaredError. What each code means,
and where it comes from:
| Code | Meaning | Raised by |
|---|---|---|
invalid-document |
The file is not a readable document: not a ZIP, past the ZIP safety limits, not an Office package, malformed XML, a required part or element missing, an empty document | open(), read(), search(), detect(), preview(), the element |
encrypted-document |
Password-protected (an OLE container, not a ZIP) | open(), detect(), preview(), the element |
unsupported-format |
Not the format this entrypoint (or type) opens, or a file aared cannot identify as an Office format |
open(), detect(), preview(), the element |
unsupported-feature |
A call this format, mode or environment does not have: a range read outside XLSX, goToPage() in flow mode, no Worker in the environment |
read(), goToPage(), open() |
network-error |
A URL source failed to fetch, returned a non-2xx status, or stopped partway | open(), detect(), preview(), the element |
media-error |
media.get() for a part the document does not have |
media.get() |
not-found |
A whole-number index, title, heading or layout that does not exist | get(), goTo(), goToHeading(), goToPage() |
invalid-range |
A malformed A1 range, cell or sheet window | sheet.read({ range }), goToCell(), scrollTo(), focus() |
invalid-argument |
A value that is not what the call takes: an index that is not a whole number, a source that is not a file, bytes or a URL | get(), goTo(), goToPage(), the element’s index; open(), detect(), preview(), the element |
invalid-state |
A call the viewer is not ready for: the element’s open() with no source |
the element |
worker-error |
The Worker failed to start (including a browser refusing a Worker script from another origin), stopped, or a request could not be posted; every later call on that document reports it too | any call that reaches the Worker |
internal |
aared failed on this file: a defect in the viewer, not in the file. cause is the original error with its stack: include it in a bug report |
any call that reaches the Worker |
closed |
Called after close() or destroy(), where the call rejects (reads, search(), media.get(), a preview viewer’s goTo(); see Methods for the no-op moves) |
the closed document or destroyed handle |
aborted |
Cancelled by your signal (a superseded viewer move resolves instead; see Methods) |
anything that takes a signal |
import { isAaredError } from "@aared/viewer/core";
import { open } from "@aared/viewer/xlsx";
try {
const workbook = await open(file);
} catch (error) {
if (isAaredError(error) && error.code === "encrypted-document") {
// show a password-protected-file message
}
}
Messages start with the entrypoint that raised them (@aared/viewer/pptx: …, or
@aared/viewer/core: … from shared code), say what
failed, and say what to do when you can (a damaged file suggests re-saving
it in Office). They are English and for developers and logs; the wording may
change, code does not. messageFor() gives one short generic sentence per
code for a UI; a UI in another language maps code itself. cause is the
underlying error when there is one (for internal, the original error with
its stack: include it in a bug report).
One reporting rule. A call you make throws or rejects to you. An action
aared performs itself reports through error (detail: { message, cause }).
The same failure is never sent through both channels.
| Action | Failure goes to |
|---|---|
preview(), open(), detect(), render(), document reads |
The caller’s promise |
| Viewer navigation, search, search-result activation, format controls | The caller’s promise (or a synchronous throw for synchronous methods) |
Explicit element.open() |
The caller’s promise |
| Element attribute-triggered load/navigation, built-in toolbar, sheet tabs | One error event on the owning element/render handle/viewer |
| React mount or built-in action | error in the hook result; mount failure changes status, a built-in action leaves it ready |
Handle your button’s promise explicitly; void does not handle rejection:
import { preview } from "@aared/viewer/preview";
import { isAbort, messageFor } from "@aared/viewer/core";
const viewer = await preview({ source: file, target: container });
const status = document.querySelector<HTMLElement>("#status")!;
const report = (error: unknown): void => {
if (!isAbort(error)) status.textContent = messageFor(error);
};
viewer.addEventListener("error", (event) => report(event.detail.cause)); // built-in actions
document.querySelector("#next")!.addEventListener("click", () => {
void viewer.next().catch(report); // this app-owned action
});
Aborts. isAbort() matches every cancellation (aborted, and a
DOMException or Error named "AbortError"); cancellation keeps the name
"AbortError" so existing handling works, and every other AaredError is
named "AaredError", so branch on code. A dead Worker (worker-error)
and a call after close() (closed) are deliberately not aborts: they are
failures to surface, not cancellations to ignore.
An unknown value in an otherwise readable file (an alignment PowerPoint never writes) falls back to the default, as Office shows it.
Cancellation
import { isAbort } from "@aared/viewer/core";
import { open } from "@aared/viewer/xlsx";
const workbook = await open(file);
const controller = new AbortController();
try {
const reading = workbook.sheets.get(0).read({ range: "A1:Z500", signal: controller.signal });
controller.abort();
await reading;
} catch (error) {
if (!isAbort(error)) throw error; // cancelled: nothing to show, nothing to report
} finally {
workbook.close();
}
open(source, { signal }), read({ signal }), search(query, { signal })
and media.get(id, { signal }) reject with aborted when the signal aborts;
a URL’s fetch is cancelled too. The Worker stops at its next safe point (a
decompression or XML parse already running finishes first). In-memory
bytes are copied once before they go to the Worker, so yours stay usable. On a
viewer, a newer navigation cancels the stale move it replaces. Low-level calls
are cancelled only through their own signal.
render({ signal }) cancels a render until it resolves. Before it paints
(reading the unit, or a document waiting for its fonts) it paints nothing;
after (a slide or sheet waiting for its fonts to report warnings) it takes
what it painted back out, and only that. Either way it rejects with
aborted and never hands back a handle, the way an aborted fetch() never
resolves. Once it has resolved the signal is ignored; destroy() is its
teardown. preview(), the hooks and the element do this for you. With low-level
PPTX open() + render(), paging is one render per slide into the same target,
and the recipe aborts the previous one itself, so the handle you keep is always
the newest call’s:
import { isAbort } from "@aared/viewer/core";
import { open, render, type PptxRenderHandle } from "@aared/viewer/pptx";
const deck = await open(file);
let latest: AbortController | undefined;
let shown: PptxRenderHandle | undefined;
async function show(index: number): Promise<void> {
latest?.abort(); // a slower render still on its way never hands back a handle
const mine = (latest = new AbortController());
try {
shown = await render({
slide: deck.slides.get(index),
target: container,
signal: mine.signal,
...(shown === undefined ? {} : { zoom: shown.zoom }), // keep the reader's zoom
});
} catch (error) {
if (!isAbort(error)) throw error; // a newer show() took over
}
}
// Call when the feature unmounts.
function cleanup(): void {
latest?.abort();
shown?.destroy();
deck.close();
}
DocumentInfo
Known right after open(). A PPTX or XLSX open() reads only the manifest:
opening slide 1 never parses slides 2 to 200. A DOCX open() also resolves
its body, since a document has no per-unit reads.
interface DocumentInfo {
format: "pptx" | "xlsx" | "docx";
count: number; // units: slides / sheets / 1 for a document
titles: string[]; // one label per unit
sizes?: { width: number; height: number }[]; // inches, one per unit — PPTX only
sheetStates?: Array<"visible" | "hidden" | "veryHidden">; // XLSX-only
theme: { colors: Record<string, string>; fonts: { major: string; minor: string } };
}
count is the number of units goTo() takes (0 to count - 1): slides,
sheets, or 1 for a document in either DOCX mode. “Unit” is the API’s
neutral word; show people “slides”, “sheets” or “document”. Every per-unit
array is count long, so titles[i] and sizes[i] describe unit i:
| Format | titles |
sizes |
|---|---|---|
| PPTX | "Slide N" (a semantic read returns paragraphs, not an identified title) |
One per slide, for reserving a thumbnail’s aspect ratio before anything renders |
| XLSX | the sheet tab names | Omitted: a sheet has no page; read extent after sheet.read() |
| DOCX | the title from the file’s properties, else "Document" |
Omitted: page setup is per section (doc.sections.list()); laid-out pages are on the render handle’s pages |
theme is the file’s own Office theme (colour scheme, major and minor
fonts), not the viewer’s appearance.theme.
Reading data
Public structured data is a supported, versioned contract. Rendering models
are internal in every format and cannot be supplied to public render().
Compatible additions do not require a breaking release;
incompatible changes follow the versioning policy (minor releases with migration
notes during 0.x).
Data reads return plain projected copies, including nested arrays, ranges and hyperlinks. They are view-only snapshots of the file: changing a local copy does not edit the document, change a later read or affect rendering. Store the data if needed; keep the document open for rendering and media access. Document, handle and collection types are interfaces, not public constructors.
The schemas are next to their format operations: SlideText, SheetData / RowData / CellData / CellRange and SectionInfo. Third-party rendering from aared’s painting representation is not a supported capability.
Deployment
Worker override
Vite, webpack 5 and Next.js emit the default Worker without configuration, including production builds and sub-path deployments. Imports are SSR-safe; opening/rendering still requires a browser (Next.js).
Scripts on a CDN. Direct module Worker construction requires a same-origin URL. Explicitly opt into a Blob bootstrap that imports the CDN’s module:
import { open } from "@aared/viewer/pptx";
import workerUrl from "@aared/viewer/worker?url";
const deck = await open(file, { worker: { url: workerUrl, crossOrigin: "blob" } });
The CDN must send Access-Control-Allow-Origin. The page’s CSP must permit
both blob: and the CDN origin in worker-src; Chrome also checks the
imported module against that directive. Permit the CDN in script-src for
scripts loaded from it. For example:
worker-src 'self' blob: https://cdn.example.com;
script-src 'self' https://cdn.example.com;
There is no automatic URL-to-Blob fallback. Under a policy that forbids Blob Workers, serve the worker on the page’s origin instead:
import { preview } from "@aared/viewer/preview";
const viewer = await preview({ source: file, target: container,
worker: { url: "/aared-worker.mjs" } });
Single-file builds. Include the self-contained Worker as text. Aared creates and releases the Blob URL; the application has no extra cleanup:
import { open } from "@aared/viewer/pptx";
import workerSource from "@aared/viewer/worker?raw";
const deck = await open(file, { worker: { source: workerSource } });
Or a factory, here with Vite’s ?worker import of your own worker entry
(./worker.ts holds one line, import "@aared/viewer/worker";):
import { open } from "@aared/viewer/pptx";
import AaredWorker from "./worker?worker";
const deck = await open(file, { worker: { create: () => new AaredWorker() } });
WorkerInit accepts exactly one of url, source, or create.
Only url accepts crossOrigin: "blob". Invalid inputs reject
invalid-argument; refused or failed Workers reject worker-error.
preview(), detect(), hooks and the element’s worker property accept
the same configuration. The ?url/?raw imports above are Vite syntax;
webpack can use new URL("@aared/viewer/worker", import.meta.url) for an emitted URL
and an asset/source rule for Worker text. For webpack text imports, import
workerSource from "@aared/viewer/worker" without ?raw; scope the rule to
the importing bootstrap so it does not convert the default Worker’s own entry:
// webpack.config.mjs, for a bootstrap named main.js
export default {
module: { rules: [
{ test: /worker[.]mjs$/, issuer: /main[.]js$/, type: "asset/source" },
] },
};
Cleanup. Close the document or destroy its viewer; pending reads reject
with closed. The default Worker lifecycle and generated Blob URLs are
managed by aared. Caller-provided URLs remain caller-owned. A create factory
must return a fresh Worker for each document; aared terminates it on close.
Fidelity is part of the contract
Fidelity is checked against native PowerPoint, Excel and Word output, with real Arabic/RTL fixtures and tables in every format. The browser-library comparisons record the evaluated versions and side-by-side findings; the dated benchmarks measure speed, memory and download size separately from visual fidelity.
The viewer is not a replacement for Office’s editing, calculation or print engine. These boundaries matter when choosing it:
| Boundary | What to expect |
|---|---|
| Formats | PPTX, XLSX and DOCX; not legacy PPT/XLS/DOC, PDF or password-protected files |
| Editing and conversion | View-only: no write-back, file conversion or document export |
| XLSX formulas | Saved results, never recalculation; a missing cache cannot be reconstructed |
| Fonts | Missing fonts can change wrapping; substitutes do not guarantee identical metrics |
| DOCX pages | Flow mode does not paginate. Layout mode is structural, not pixel-identical; long indivisible blocks and table rows can exceed a page |
| Unsupported features | Some graphics and Office-specific features are unpainted or approximated; inspect the output and render warnings for your files |
Detailed format coverage and known gaps live in the fidelity inventory. Test representative files before deployment; a successful open is not a promise that every authored feature paints exactly like Office.
Browser support
Baseline 2023 — Chrome/Edge 110+, Firefox 131+, Safari 16.4+. The floor is
set by the deflate-raw DecompressionStream (parsing runs in a module
Worker), constructed stylesheets, and the CSS zoom the XLSX and DOCX
renderers scale with (Firefox 126, with SVG chart zoom fixed in 131). No polyfills are shipped for
browsers below this floor. Importing any entry, @aared/viewer/element included,
touches no browser global (SSR-safe); the element registers itself only
where the page has a custom-element registry.