diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5d1d3dc..fec43887 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim
## [Unreleased]
+### Added
+
+- Active PR / Proposed: local XLS/XLSX worksheet insertion into the current document. A toolbar control reads a user-selected `.xls` or `.xlsx` file in memory, projects only visible displayed cell text into a heading and table, and announces the imported worksheet/row/cell counts. Formulas, macros, hyperlinks, and hidden sheets receive no editor authority. This is not protected-main behavior until the branch merges.
+
## [0.6.0] — 2026-08-10
### Release
diff --git a/README.md b/README.md
index f2b02332..931e2ba0 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ runtime.
| Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core |
| Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination |
| Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion |
+| Spreadsheet conversion | `@contextualwisdomlab/cwl-editor/spreadsheet` | Active PR: bounded local XLS/XLSX worksheet-to-document conversion used by the editor toolbar |
| Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming |
| Full fonts | `@contextualwisdomlab/cwl-editor/fonts.css` | KR/EN/JP/SC/TC/VI offline font bundle |
| Latin fonts | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese-only bundle |
diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md
index 52ab1bdf..ef7a00e5 100644
--- a/docs/CONTRACTS.md
+++ b/docs/CONTRACTS.md
@@ -103,6 +103,12 @@ Model-assisted authoring is separate from deterministic conversion and validatio
The host owns model/provider selection, credentials, external-data-use approval, redaction, prompt retention, model logging, tenancy, authorization, human approval, and audit. No model may authorize a save, bypass deterministic validation, or redefine a revision/durable-validator contract.
+## Local spreadsheet body-import contract
+
+Active PR / Proposed under ADR 0032. The public `@contextualwisdomlab/cwl-editor/spreadsheet` subpath and the editor toolbar accept local `.xls`/`.xlsx` bytes, project visible displayed cell text into one TipTap insertion batch, and reject hidden sheets, formulas, macros, and hyperlinks as executable authority. Source size is bounded before the file body is read. Genuine `File` values are read through `arrayBuffer()` when present, otherwise `FileReader` or `Response`. Failures are payload-redacted. This contract is not protected-main authority.
+
+Hosts retain transport, authorization, persistence, retention, and any later sharing of the inserted document. Worksheet names remain authoring labels and are not PII-masked in this lane.
+
## Deterministic Office conversion contract
Office rendering accepts versioned bounded JSON and produces supported DOCX/XLSX/PPTX artifacts without model, network, macro, or Desktop Office dependency. Inputs must satisfy XML 1.0, size/depth/container/cycle, spreadsheet, worksheet-name, freeze-pane, supported-structure, and formula-injection rules before publication.
diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md
index cdb52e18..fd09da23 100644
--- a/docs/DOCUMENTATION_FITNESS.md
+++ b/docs/DOCUMENTATION_FITNESS.md
@@ -61,6 +61,7 @@ Document fitness and implementation maturity are independent. A `present_current
| DOCX bounded paragraph alignment | ADR 0024, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | `paragraph` and `rich_paragraph` preserve explicit left/center/right/justify alignment while omission retains inherited/default behavior. |
| DOCX bounded heading alignment | ADR 0025, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | `heading` preserves the same exact left/center/right/justify contract through the shared paragraph-alignment authority while omission retains heading-style/default behavior. |
| DOCX bounded external hyperlinks | ADR 0026, Office schema/renderer/tests, Office guidance and doctoring | `present_current` | `implemented_on_protected_main` | Optional rich-run hyperlinks preserve exact accepted external HTTP(S) targets and existing run emphasis through deterministic relationship-backed OOXML without network, credential, local-file, tenant, persistence, or destination-trust authority. |
+| Bounded local spreadsheet body import | Proposed ADR 0032, spreadsheet subpath, toolbar insertion and known-workbook editor tests | `present_current` | `implemented_on_active_pr` | Local XLS/XLSX files insert visible displayed cells as editable headings/tables without upload, formula execution, or protected-main authority. |
| THREAT_MODEL | `docs/THREAT_MODEL.md` | `present_current` | Covers current trust boundaries and explicitly proposed extensions | Clipboard, evidence, Office, SSR/form, Yjs, model, host-authority and supply-chain threats are reconstructable. |
| TEST_STRATEGY | `docs/TEST_STRATEGY.md` | `present_current` | Protected deterministic/browser/Office evidence plus feature-specific test contracts | Test authority, exact source-head evidence and claim limits are explicit rather than inferred from CI badges. |
| OPERABILITY | `docs/OPERABILITY.md` | `present_current` | Current product responsibilities plus protected browser/release recovery boundaries | Conflict, collaboration, conversion, registry partial-publication recovery and rollback ownership are explicit. |
diff --git a/docs/PRD.md b/docs/PRD.md
index 899086f3..5539da6b 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -145,4 +145,4 @@ Shareable acquisition evidence excludes production tenant content and credential
Protected `main` is the sole implemented baseline. Open PRs may describe Proposed or Active work but are not shipped contracts until protected integration. Canonical documentation must state when a requirement is target architecture rather than current implementation.
-SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`.
+SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. Local XLS/XLSX worksheet insertion into the document body is `implemented_on_active_pr` under Proposed ADR 0032 and is not a shipped claim.
diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md
index 34e903fc..8dc90826 100644
--- a/docs/TEST_STRATEGY.md
+++ b/docs/TEST_STRATEGY.md
@@ -56,6 +56,7 @@ At minimum, maintain regressions for:
- autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence;
- selection/revision races and document movement during asynchronous hashing;
- Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure;
+- local XLS/XLSX body import from a known small workbook File through the real toolbar/editor path, including asserted heading/cell text, hidden-sheet exclusion, formula/link non-execution, FileReader fallback when `arrayBuffer` is absent, and payload-redacted failures;
- package/release stale draft assets, unexpected or non-regular local entries, exact three-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior.
## Concurrency and failure testing
diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md
index e1337651..8a79bc88 100644
--- a/docs/THREAT_MODEL.md
+++ b/docs/THREAT_MODEL.md
@@ -33,6 +33,10 @@ Untrusted clipboard HTML can attempt script execution, external resource fetches
URLs or image-like content can exfiltrate document context, induce unexpected network access, or smuggle executable/active payloads. Inkspan validates only supported local semantics and does not grant network authority. Hosts remain responsible for downstream CSP, fetch policy, proxy/egress controls, content serving, and tenant authorization.
+### Local spreadsheet body import
+
+Untrusted local XLS/XLSX bytes can carry macros, formulas, hyperlinks, hidden sheets, and hostile object graphs. Active-PR import (ADR 0032) must parse only after ZIP/OLE preflight, insert only visible displayed/cached cell text, and keep parser exceptions, file names, formulas, and hidden values out of ordinary status text. Missing `File.arrayBuffer` is not a reason to reject a genuine local workbook when `FileReader` or `Response` can read the same Blob. The parser still has no network, credential, persistence, or model authority.
+
### Spreadsheet formula injection
XLSX cell values beginning with formula-significant prefixes can become executable spreadsheet formulas when opened by a user. Deterministic Office rendering must preserve the documented formula-injection neutralization boundary and never silently convert untrusted strings into formulas. No macro, network, or Desktop Office execution is part of the renderer contract.
diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md
index d120cec7..87b610a9 100644
--- a/docs/TRACEABILITY.md
+++ b/docs/TRACEABILITY.md
@@ -24,6 +24,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p
| Editor integration | Public behavior must exercise the actual TipTap/ProseMirror integration path, not an inert extension field or test-only hook | official TipTap and ProseMirror documentation for the locked dependency line | integration tests and package consumers | Inkspan does not claim compatibility with untested major-version integration semantics |
| Collaboration | Inkspan provides provider-neutral editor/Yjs bindings; host owns provider lifecycle, room authorization, awareness privacy, persistence and audit | official Yjs/provider documentation plus Inkspan public contract | collaboration tests and architecture ownership matrix | No network-provider or tenant-authorization authority is implied |
| Secure development | Security controls are developed test-first, with exact-head scanning/review/package evidence and root-cause regression | NIST SP 800-218 SSDF 1.1 | CI/security/SAST/package/provenance gates, doctoring and regression history | Repository evidence is not a claim of complete SSDF organizational conformance |
+| Local spreadsheet body import | Visible XLS/XLSX worksheet text is inserted locally as inert headings/tables after ZIP/OLE preflight, BIFF8 BoundSheet8 visibility recovery, and File/FileReader/Response body reads | ECMA-376; [MS-XLS]; SheetJS CE; WAI-ARIA 1.2 status/live regions | Proposed ADR 0032, active-PR spreadsheet runtime/editor insertion tests and `./spreadsheet` package surface | `implemented_on_active_pr`; not protected-main authority; no upload, formula execution, macro, network, or destination-trust claim |
| Office rendering | JSON→DOCX/XLSX/PPTX is deterministic, bounded, network-free, macro-free, injection-aware and package-inspected | Office Open XML specifications and relevant Python package contracts | Office renderer tests, Python coverage/docstring/package gates | Format fidelity is limited to explicitly tested supported constructs |
| DOCX informative PNG figures | Informative figures accept only bounded inline PNG data, explicit alt text, bounded dimensions/bytes and deterministic WordprocessingML output | Office Open XML drawing semantics; python-docx public picture APIs | protected-main #121 renderer/schema/tests, ADR 0022 and PNG doctoring | No remote/file/SVG/JPEG fetch, decorative-image claim, arbitrary drawing authority, or image-based model inference is implied |
| DOCX bounded rich-text runs | `rich_paragraph` preserves ordered bold/italic/underline run emphasis through a strict bounded JSON contract | Office Open XML run semantics; python-docx run API | protected-main #124 renderer/schema/tests, ADR 0023 and rich-run doctoring | No arbitrary Word styles, font/color/size, hyperlink, field-code, tracked-change, raw-OOXML or source-format parsing authority is implied |
@@ -46,17 +47,19 @@ Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP Semantics* (RF
MacFarlane, J. (2024, January 28). *CommonMark specification* (Version 0.31.2). CommonMark. https://spec.commonmark.org/0.31.2/
-Microsoft. (n.d.-a). *Browsers*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/browsers
+Microsoft. (n.d.-a). *[MS-XLS]: Excel Binary File Format (.xls) Structure*. Microsoft Learn. Retrieved August 17, 2026, from https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls
-Microsoft. (n.d.-b). *Hyperlink class (DocumentFormat.OpenXml.Wordprocessing)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.hyperlink
+Microsoft. (n.d.-b). *Browsers*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/browsers
-Microsoft. (n.d.-c). *HyperlinkRelationship class (DocumentFormat.OpenXml.Packaging)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.packaging.hyperlinkrelationship
+Microsoft. (n.d.-c). *Hyperlink class (DocumentFormat.OpenXml.Wordprocessing)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.hyperlink
-Microsoft. (n.d.-d). *Projects*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/test-projects
+Microsoft. (n.d.-d). *HyperlinkRelationship class (DocumentFormat.OpenXml.Packaging)*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.packaging.hyperlinkrelationship
-Microsoft. (n.d.-e). *Release notes: Version 1.62*. Playwright. Retrieved August 10, 2026, from https://playwright.dev/docs/release-notes
+Microsoft. (n.d.-e). *Projects*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/test-projects
-Microsoft. (n.d.-f). *Working with paragraphs*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/office/open-xml/word/working-with-paragraphs
+Microsoft. (n.d.-f). *Release notes: Version 1.62*. Playwright. Retrieved August 10, 2026, from https://playwright.dev/docs/release-notes
+
+Microsoft. (n.d.-g). *Working with paragraphs*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/office/open-xml/word/working-with-paragraphs
Node.js contributors. (2026). *Modules: Packages*. Node.js documentation. https://nodejs.org/api/packages.html
@@ -68,6 +71,8 @@ python-docx. (n.d.-b). *Working with text*. Retrieved August 10, 2026, from http
Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785
+SheetJS. (n.d.). *SheetJS CE*. Retrieved August 17, 2026, from https://docs.sheetjs.com/
+
Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218
Web Hypertext Application Technology Working Group. (2026). *HTML Standard: Parsing HTML documents* (Living Standard). Retrieved August 10, 2026, from https://html.spec.whatwg.org/multipage/parsing.html
diff --git a/docs/TRD.md b/docs/TRD.md
index 80cec07a..6776d5d5 100644
--- a/docs/TRD.md
+++ b/docs/TRD.md
@@ -141,4 +141,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu
Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`.
-The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete.
+The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Local XLS/XLSX worksheet body import in #318 is `implemented_on_active_pr` under Proposed ADR 0032. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete.
diff --git a/docs/accessibility.md b/docs/accessibility.md
index 58c29feb..c5bd9708 100644
--- a/docs/accessibility.md
+++ b/docs/accessibility.md
@@ -80,6 +80,8 @@ Disabled table, image, undo, and redo controls are skipped by directional naviga
Toggle controls expose `aria-pressed`; one-shot command buttons do not claim a pressed state. The toolbar declares horizontal orientation and ships visible `:focus-visible` styling, including a forced-colors fallback.
+The **Insert XLS/XLSX spreadsheet** control is a one-shot command. It opens a hidden local file picker, disables itself while the selected workbook is imported, and reports progress plus imported worksheet/row/cell counts through the toolbar's polite `aria-live` status region. Failures announce only `Spreadsheet import failed.` so assistive technology is told the next action — choose another supported file — without hearing file names, worksheet names, or cell text. This control is Active PR / Proposed under ADR 0032 and is not protected-main behavior.
+
After the image file passes the existing local conversion, type, size, and decode policy, the toolbar asks for alternative-text intent before creating the image node. A non-empty response becomes the image alternative text; an explicitly submitted empty response marks the image decorative with `alt=""`; canceling leaves the document unchanged. Conversion failures continue through `onImageError` and do not open the alternative-text prompt. The separate **Alt** control remains available for later corrections when an image is selected.
Shortcuts that the editor already implements are also exposed programmatically with `aria-keyshortcuts` so assistive technology can discover the same commands that appear in the button titles. Inkspan publishes `Control+B Meta+B` for bold, `Control+I Meta+I` for italic, `Control+K Meta+K` for link editing, `Control+Z Meta+Z` for undo, and `Control+Shift+Z Meta+Shift+Z Control+Y Meta+Y` for redo. The redo alternatives reflect Tiptap's configured history and collaboration behavior: both `Ctrl/Cmd+Shift+Z` and `Ctrl/Cmd+Y` invoke redo. `aria-keyshortcuts` describes shortcuts that Inkspan already implements; it does not create keyboard behavior, replace the visible button label, or authorize hosts to intercept those combinations. The explicit values remain `Control` and `Meta` rather than a presentation-only `Ctrl/Cmd` abbreviation because WAI-ARIA defines those modifier tokens and permits a space-separated list of alternatives.
diff --git a/docs/adr/0032-bounded-local-spreadsheet-body-import.md b/docs/adr/0032-bounded-local-spreadsheet-body-import.md
new file mode 100644
index 00000000..988eae5c
--- /dev/null
+++ b/docs/adr/0032-bounded-local-spreadsheet-body-import.md
@@ -0,0 +1,82 @@
+# ADR 0032: Bounded local spreadsheet body import
+
+Status: Proposed
+
+## Context
+
+Buyers need to insert visible worksheet contents from a locally selected `.xls` or `.xlsx` file into the current Inkspan document. The earlier Office renderer contract writes deterministic XLSX artifacts and keeps formula-looking strings inert (ADR 0012). It does not give the editor a local import path. Untrusted workbook bytes can carry macros, formulas, hyperlinks, hidden sheets, and hostile JavaScript object graphs. Those bytes must not gain network, credential, persistence, model, transport, or formula-execution authority.
+
+This decision is Proposed active-PR architecture for PR #318. It is not protected-main implementation authority until the branch merges with the required exact-head evidence.
+
+## Alternatives considered
+
+### Server-side conversion
+
+Rejected. Uploading workbook bytes would move transport, retention, and tenant isolation into Inkspan or force every host to stand up a conversion service for a local authoring action.
+
+### CSV-only or paste-only import
+
+Rejected as the primary contract. Buyers already have Excel workbooks. Paste remains available through SafeClipboard, but it does not preserve worksheet names or hidden-sheet exclusion from a real file.
+
+### Statically bundle the parser into the editor startup graph
+
+Rejected. Ordinary editor startup must not pay for or evaluate a workbook parser before the user selects a file.
+
+### Local preflight, lazy official SheetJS parse, inert TipTap insertion
+
+Selected. Classify ZIP/OLE bytes first, load the pinned official SheetJS CE 0.20.3 tarball only after that preflight, project visible displayed/cached cell text into parser-neutral workbook data, and insert one heading-plus-table batch at the current selection.
+
+## Decision
+
+Inkspan accepts local `.xls` and `.xlsx` bytes through the toolbar file picker and the framework-neutral `@contextualwisdomlab/cwl-editor/spreadsheet` subpath.
+
+The import must:
+
+1. reject source size above 64 MiB before allocating the file body;
+2. classify only ZIP (`xlsx`) and OLE compound-file (`xls`) signatures;
+3. load the parser only after that envelope preflight;
+4. recover BIFF8 worksheet visibility from raw BoundSheet8 records rather than trusting mutable parser-emitted hidden flags;
+5. insert only visible, non-empty worksheets as a level-3 heading, one rectangular table of ordinary cells, and a trailing paragraph;
+6. project displayed or cached cell text only — formulas, macros, and hyperlinks receive no execution or editor authority;
+7. enforce worksheet, row, column, cell, and text ceilings before TipTap materialization;
+8. announce progress and counts through a polite status region, and emit only payload-redacted failures;
+9. read genuine `File`/`Blob` bodies through `arrayBuffer()` when present, otherwise `FileReader` or `Response`, so DOMs that omit `Blob.arrayBuffer` still insert the selected workbook.
+
+Hosts retain transport, authorization, tenant isolation, durable persistence, credentials, retention, and model-use policy. Worksheet names are authoring labels, not tenant identifiers, and are not PII-masked in this lane.
+
+## Consequences
+
+- Authors can place a real worksheet into the document body as editable table cells.
+- Hidden worksheets and formula/link payloads stay out of the inserted document.
+- The spreadsheet subpath remains lazy and package-managed rather than part of ordinary editor startup.
+- jsdom and older DOMs that omit `File.arrayBuffer` no longer fail closed on a genuine local file.
+
+## Failure and recovery
+
+Malformed signatures, hostile descriptors, parser exceptions, resource-limit violations, and rejected editor transactions leave the document unchanged and announce `Spreadsheet import failed.` Recovery is to choose a supported visible workbook within the documented ceilings. Hosts may observe the underlying redacted error through `onSpreadsheetError` without receiving cell text, file names, or parser payloads in the status region.
+
+## Security and privacy impact
+
+Workbook bytes are untrusted local content. The parser receives no network, credential, persistence, or model authority. Formula text, hyperlink targets, hidden-sheet values, and private exception causes must not appear in ordinary diagnostics. This decision does not authorize destination fetching, macro execution, or host-owned audit of workbook contents.
+
+## Compatibility and migration
+
+The change is additive. Existing documents, Office rendering, and ADR 0012 literal-formula export semantics are unchanged. Removing the toolbar control or the `./spreadsheet` subpath after publication would be a public compatibility change.
+
+## Verification
+
+Acceptance requires a known small XLS/XLSX fixture to insert into the document body with asserted heading and cell text, plus 100% owned-production coverage, package-consumer verification of the spreadsheet subpath, and exact-head product CI. Historical or predecessor-head checks do not transfer.
+
+## Rollback or supersession
+
+Rollback removes the toolbar control and spreadsheet subpath from the unmerged branch, or reverts the merged change as one reviewed compatibility decision. A future server converter, CSV-only path, or different parser may supersede this ADR only if it preserves local-only authority, inert cell projection, and redacted failures.
+
+## References — APA 7th
+
+Ecma International. (2021). *ECMA-376: Office Open XML file formats* (5th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-376/
+
+Microsoft. (n.d.). *[MS-XLS]: Excel Binary File Format (.xls) Structure*. Microsoft Learn. Retrieved August 17, 2026, from https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls
+
+SheetJS. (n.d.). *SheetJS CE*. Retrieved August 17, 2026, from https://docs.sheetjs.com/
+
+World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index df8b7b80..d9418f73 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -32,6 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio
| [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output |
| [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output |
| [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text |
+| [0032](0032-bounded-local-spreadsheet-body-import.md) | Proposed | Bounded local XLS/XLSX worksheet insertion into the editor body |
## Decision discipline
diff --git a/docs/package-distribution.md b/docs/package-distribution.md
index ddb4df0e..62f3e0dc 100644
--- a/docs/package-distribution.md
+++ b/docs/package-distribution.md
@@ -18,6 +18,7 @@ integrations.
| `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims |
| `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath |
| `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph |
+| `@contextualwisdomlab/cwl-editor/spreadsheet` | `implemented_on_active_pr` — framework-neutral bounded XLS/XLSX envelope preflight, lazy official SheetJS parse, and parser-neutral worksheet-to-editor JSON conversion used by the toolbar to insert visible worksheet tables; not protected-main authority |
| `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming |
| `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle |
| `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese font bundle |
@@ -58,7 +59,7 @@ embedded in the npm tarball.
dependencies so the consumer's package manager installs and resolves it; it
is not merely a type-only dependency.
- The framework-independent autosave, converter, envelope-identity,
- revision-evidence, text-position-selector, and Markdown entrypoints do not
+ revision-evidence, text-position-selector, Markdown, and spreadsheet entrypoints do not
require React UI, a mounted editor, naruon, contextual-orchestrator, a
database, provider credentials, or host transport. Their individual
package-consumer gates additionally prevent framework dependencies from
@@ -109,7 +110,7 @@ production library build. The verification chain:
4. rejects internal source, tests, demos, Office files, coverage output, and
workflow files from the npm tarball;
5. imports the root, collaboration, converter, autosave, envelope-identity,
- revision-evidence, text-position-selector, and Markdown surfaces through their
+ revision-evidence, text-position-selector, Markdown, and spreadsheet surfaces through their
dedicated packed-consumer checks, including framework-free isolation where
that is part of the public contract;
6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript
diff --git a/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md
new file mode 100644
index 00000000..6bdf9a7a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-13-xls-xlsx-body-import.md
@@ -0,0 +1,212 @@
+# XLS/XLSX Body Import Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans when available to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. If a named skill is unavailable in the current harness, preserve the same test-first/verification discipline rather than treating skill lookup failure as product completion.
+
+**Goal:** Let users insert visible worksheet content from local `.xls` and `.xlsx` files into the current Inkspan editor selection as editable headings and tables.
+
+**Architecture:** Keep binary parsing in a framework-neutral `spreadsheet` package boundary and lazy-load one pinned SheetJS parser only after a user selects a file. Convert parser output into bounded, inert TipTap JSON before one editor transaction; the toolbar owns file selection and accessible progress text, while hosts continue to own transport, authorization, persistence, and retention.
+
+**Tech Stack:** TypeScript 5.9, TipTap/ProseMirror JSON, React 18/19, Vitest, Testing Library, Vite library builds, and SheetJS `xlsx` 0.20.3 from the exact official tarball `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz`.
+
+## Authority corrections
+
+- The earlier plan named `@lokalise/xlsx` 0.20.3, while this branch's existing read-only hosted dependency-provenance workflow installs and verifies the official SheetJS package as `xlsx` 0.20.3 from the pinned SheetJS CDN tarball. Hosted run `31968831519` on exact head `bfd8394687f3fedf969e5ef91ba149b5baab425e` resolved that package successfully and generated lock integrity `sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==`. This plan now follows that demonstrated package identity/provenance rather than the stale mirror claim.
+- ADR 0027 is already the earlier canonical claimant of PR #141 (bounded single-section DOCX page layout). Writing-diagnostics work owns 0028/0029, Hangul authoring owns 0030, and the active design-token/accessibility lane owns 0031. This spreadsheet lane therefore reserves **ADR 0032** rather than colliding with ADR 0027.
+- These corrections change planning authority only. They do not promote the incomplete parser or editor insertion path to protected-main behavior.
+
+## Global Constraints
+
+- Branch from exact protected `main@e8109ec2a17de8bd6594487aa12c8c8a93cb2c03`; do not advance protected `main` while release issue #118 owns the `v0.6.0` publication identity.
+- Work only on `agent/318-spreadsheet-body-import`; do not overlap the active `CwlEditor.tsx` writer or Python Office renderer writers.
+- Parse files locally in browser memory; add no upload, network fetch, credential, tenant, persistence, model, macro, formula-calculation, or durable-audit authority.
+- Accept XLS and XLSX container bytes, but insert only visible worksheet names and displayed/cached cell values as inert text.
+- Apply source, worksheet, row, column, cell, per-cell text, and total text ceilings before proportional editor materialization.
+- Emit stable payload-redacted errors and never reflect file names, worksheet names, formulas, cell contents, parser exceptions, or binary bytes in ordinary failure messages.
+- Preserve exact repository gates: TypeScript, 100% owned-production statement/branch/function/line coverage, package builds and consumers, demo, Chromium/Firefox/WebKit, Office Python 3.11-3.14, security scan, and SAST.
+- Keep the pull request Draft and unmerged until the release freeze and every exact-current-head gate/review condition are resolved.
+
+---
+
+### Task 1: Establish the executable RED contract
+
+**Files:**
+- Create: `src/spreadsheet/spreadsheetImport.ts`
+- Create: `src/spreadsheet/spreadsheetImport.test.ts`
+- Create: `.github/workflows/agent-workspace.yml` (temporary, read-only, removed before handoff)
+
+**Interfaces:**
+- Produces: `SpreadsheetWorkbookData`, `SpreadsheetImportResult`, `SpreadsheetImportError`, and `spreadsheetWorkbookToDocumentJson(workbook)`.
+
+- [x] **Step 1: Write one product-boundary failing test**
+
+```ts
+const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ { name: 'Summary', hidden: false, rows: [['Name', 'Value'], ['매출', '42']] },
+ { name: 'Private', hidden: true, rows: [['secret']] },
+ ],
+});
+expect(result.content.map((node) => node.type)).toEqual([
+ 'heading',
+ 'table',
+ 'paragraph',
+]);
+```
+
+- [x] **Step 2: Commit a compiling placeholder that throws at the product boundary**
+
+```ts
+export function spreadsheetWorkbookToDocumentJson(
+ _workbook: SpreadsheetWorkbookData,
+): SpreadsheetImportResult {
+ throw new SpreadsheetImportError(
+ 'UNSUPPORTED_OR_CORRUPT',
+ 'Spreadsheet import is not implemented.',
+ );
+}
+```
+
+- [x] **Step 3: Open a Draft PR and verify hosted RED**
+
+Run: canonical GitHub `CI` against the exact contributor head.
+
+Expected: dependency setup and TypeScript succeed; the dedicated spreadsheet test fails because conversion is not implemented. Setup, infrastructure, or module-resolution failure is not qualifying RED.
+
+### Task 2: Implement bounded binary parsing and TipTap conversion
+
+**Files:**
+- Create: `src/spreadsheet/sheetJsAdapter.ts`
+- Modify: `src/spreadsheet/spreadsheetImport.ts`
+- Expand: `src/spreadsheet/spreadsheetImport.test.ts`
+- Create: `src/spreadsheet/sheetJsAdapter.test.ts`
+- Modify: `package.json`
+- Modify: `pnpm-lock.yaml`
+
+**Interfaces:**
+- Produces: `DEFAULT_SPREADSHEET_IMPORT_LIMITS`, `SpreadsheetImportLimits`, `SpreadsheetImportErrorCode`, `spreadsheetFileToDocumentJson(source, limits?)`, and parser-neutral workbook conversion.
+- Consumes: package `xlsx` 0.20.3 through a lazy dynamic import after local source preflight; the committed dependency must resolve from the exact official tarball and integrity recorded above.
+
+- [ ] **Step 1: Add failing tests for every public limit and error category**
+
+Cover source size before `arrayBuffer()`, visible worksheet count, decoded range rows/columns, rectangular cell count, per-cell and total text, malformed workbook structures, hidden/empty sheets, and payload-redacted failures.
+
+The first parser-adapter product-boundary RED is being established on this branch before implementation; it must prove the parser receives non-executing options and that only visible displayed text is materialized.
+
+- [ ] **Step 2: Add real XLSX and BIFF8 XLS round trips**
+
+Create in-memory workbooks with Unicode, multiline values, dates, booleans, formulas with cached display values, hidden sheets, and empty sheets; write both `bookType: 'xlsx'` and `bookType: 'biff8'`, then import those exact bytes.
+
+- [ ] **Step 3: Pin the parser and immutable lock**
+
+Add exact dependency:
+
+```json
+"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
+```
+
+Require the pnpm lock entry to preserve exact integrity `sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==` and the same tarball URL. Install with lifecycle scripts disabled during provenance verification. Do not substitute the stale `@lokalise/xlsx` plan value or an npm-registry `xlsx` version.
+
+- [ ] **Step 4: Implement bounded parsing**
+
+Use local binary preflight before lazy `import('xlsx')`, then `read(..., { type: 'array', cellFormula: false, cellHTML: false, cellNF: false, bookVBA: false })`, visible-sheet metadata, decoded-range preflight, and formatted cell text. Do not evaluate formulas or preserve executable links/macros/objects.
+
+- [ ] **Step 5: Build deterministic TipTap JSON**
+
+For each visible non-empty worksheet, emit a level-3 heading, one rectangular table of ordinary `tableCell` nodes, and an empty trailing paragraph. Normalize CRLF to LF and represent internal newlines with `hardBreak` nodes.
+
+- [ ] **Step 6: Run focused tests and exact coverage**
+
+Run: `pnpm vitest run src/spreadsheet/spreadsheetImport.test.ts src/spreadsheet/sheetJsAdapter.test.ts`
+
+Run: `pnpm coverage`
+
+Expected: all tests pass; statements, branches, functions, and lines remain exactly 100% for owned production.
+
+### Task 3: Add accessible toolbar insertion
+
+**Files:**
+- Modify: `src/components/Toolbar.tsx`
+- Modify: `src/components/Toolbar.test.tsx`
+
+**Interfaces:**
+- Consumes: `spreadsheetFileToDocumentJson(file)`.
+- Produces: a keyboard-reachable `Insert XLS/XLSX spreadsheet` control, hidden local file input, one atomic insertion at the current selection, and polite status text.
+
+- [ ] **Step 1: Write failing interaction tests**
+
+Cover accepted MIME/extensions, insertion between existing paragraphs, normal undo, normal transaction/change behavior, same-file reselection, busy disablement, success count, stable failure status, no-file events, and unchanged roving-toolbar navigation.
+
+- [ ] **Step 2: Implement the file picker**
+
+Add a dedicated input accepting `.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`. Clear its value before processing so selecting the same file again emits another change.
+
+- [ ] **Step 3: Insert one validated JSON batch**
+
+Call `editor.chain().focus().insertContent(result.content).run()` exactly once after parsing succeeds. On parse or transaction failure, leave the document unchanged and announce a stable non-content-bearing error.
+
+- [ ] **Step 4: Preserve toolbar accessibility**
+
+Keep one roving tab stop, arrow/Home/End behavior, native disabled semantics while parsing, and a visually unobtrusive `role="status" aria-live="polite"` region.
+
+### Task 4: Publish a framework-neutral spreadsheet subpath and canonical documentation
+
+**Files:**
+- Create: `src/spreadsheet/index.ts`
+- Create: `vite.spreadsheet.config.ts`
+- Modify: `vite.config.ts`
+- Modify: `src/index.ts`
+- Modify: `package.json`
+- Modify: package verification tests/scripts as required
+- Create: `docs/adr/0032-bounded-local-spreadsheet-body-import.md`
+- Modify: `docs/adr/README.md`
+- Modify: `README.md`
+- Modify: `CHANGELOG.md`
+- Modify: `docs/CONTRACTS.md`
+- Modify: `docs/THREAT_MODEL.md`
+- Modify: `docs/TEST_STRATEGY.md`
+- Modify: `docs/TRACEABILITY.md`
+- Modify: `docs/accessibility.md`
+- Add: machine-checkable documentation contract test
+
+**Interfaces:**
+- Produces: package export `@contextualwisdomlab/cwl-editor/spreadsheet` with ESM, CommonJS, and declarations, while keeping React/TipTap runtime code outside that subpath.
+
+- [x] **Step 1: Add package-consumer RED tests**
+
+Require ESM, CommonJS, and strict NodeNext TypeScript consumers to resolve the spreadsheet subpath and its declared public types from the packed tarball.
+
+- [x] **Step 2: Add dedicated Vite build**
+
+Build `src/spreadsheet/index.ts` as `cwl-spreadsheet.js` and `cwl-spreadsheet.cjs`; when the parser dependency is committed, externalize `xlsx` so it remains lazy and package-managed rather than copied into ordinary editor startup.
+
+- [ ] **Step 3: Record ADR 0032**
+
+Document context, decision, alternatives (server conversion, CSV-only, paste-only, static parser bundling), parser provenance, formula/macro non-execution, resource bounds, diagnostic privacy, accessibility, host authority, rollback, and release-freeze integration. ADR 0032 is reserved for this lane because active earlier lanes already own 0027–0031 as described above.
+
+- [ ] **Step 4: Reconcile canonical documentation**
+
+Update product contracts, threat model, test strategy, traceability, accessibility, README, and Unreleased changelog without claiming Draft behavior is protected or shipped.
+
+### Task 5: Remove temporary tooling and acquire exact-head evidence
+
+**Files:**
+- Delete: `.github/workflows/agent-workspace.yml`
+- Update: Draft PR body with exact immutable evidence and limitations
+
+- [ ] **Step 1: Run local complete verification**
+
+Run: `pnpm typecheck && pnpm coverage && pnpm build && pnpm verify:package && pnpm build:demo`
+
+Expected: every command succeeds with exact 100% owned-production coverage.
+
+- [ ] **Step 2: Verify repository hygiene**
+
+Run: `git diff --check`; confirm no temporary workflow, generated coverage, unpacked artifact, secret, credential, or unrelated writer-owned path remains.
+
+- [ ] **Step 3: Acquire hosted exact-head gates**
+
+Require terminal-success CI, Security Scan, and SAST on the unchanged contributor head, including browser and Office matrices. Treat queued, cancelled, predecessor-head, synthetic-only, status-only, and model-only signals as non-passing.
+
+- [ ] **Step 4: Keep the PR Draft and unmerged**
+
+Re-fetch live protected `main`, branch head, reviews, unresolved threads, and issue #118. Do not merge, publish, tag, or move protected release identity while #118 remains open.
diff --git a/package.json b/package.json
index 4e55d924..1cbade53 100644
--- a/package.json
+++ b/package.json
@@ -85,6 +85,11 @@
"import": "./dist/cwl-markdown.js",
"require": "./dist/cwl-markdown.cjs"
},
+ "./spreadsheet": {
+ "types": "./dist/spreadsheet/index.d.ts",
+ "import": "./dist/cwl-spreadsheet.js",
+ "require": "./dist/cwl-spreadsheet.cjs"
+ },
"./styles.css": "./dist/cwl-editor.css",
"./fonts.css": "./src/fonts/fonts.css",
"./fonts-latin.css": "./src/fonts/fonts-latin.css",
@@ -99,7 +104,7 @@
],
"scripts": {
"dev": "vite",
- "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs",
+ "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && vite build --config vite.spreadsheet.config.ts && node ./scripts/copy-styles.mjs",
"build:demo": "vite build --config vite.demo.config.ts",
"fonts": "node ./scripts/fetch-fonts.mjs",
"preview": "vite preview",
@@ -108,7 +113,7 @@
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs",
- "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs"
+ "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs && node ./scripts/verify-spreadsheet-subpath-package.mjs"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
@@ -139,6 +144,7 @@
"marked": "^15.0.6",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"y-prosemirror": "^1.3.7",
"yjs": "^13.6.30"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3a3d3e0e..ea7f0361 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -64,6 +64,9 @@ importers:
turndown-plugin-gfm:
specifier: ^1.0.2
version: 1.0.2
+ xlsx:
+ specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
+ version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
y-prosemirror:
specifier: ^1.3.7
version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.1)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
@@ -2007,6 +2010,12 @@ packages:
utf-8-validate:
optional: true
+ xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz:
+ resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz}
+ version: 0.20.3
+ engines: {node: '>=0.8'}
+ hasBin: true
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -3903,6 +3912,8 @@ snapshots:
ws@8.21.0: {}
+ xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
+
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
diff --git a/scripts/verify-spreadsheet-subpath-package.mjs b/scripts/verify-spreadsheet-subpath-package.mjs
new file mode 100644
index 00000000..fe53ed3d
--- /dev/null
+++ b/scripts/verify-spreadsheet-subpath-package.mjs
@@ -0,0 +1,177 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { findRuntimeModuleAuthority } from './javascript-runtime-authority.mjs';
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const packageJson = JSON.parse(
+ readFileSync(join(repositoryRoot, 'package.json'), 'utf8'),
+);
+const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-spreadsheet-'));
+const extractionDirectory = join(verificationRoot, 'extracted');
+const consumerDirectory = join(verificationRoot, 'consumer');
+const packageDirectory = join(
+ consumerDirectory,
+ 'node_modules',
+ ...packageJson.name.split('/'),
+);
+const ambientAuthorityPattern =
+ /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u;
+const forbiddenProductGraphPattern =
+ /(?:ReactDOM|\bReact\b|react-dom|@tiptap\/react|y-prosemirror|\byjs\b|\bnaruon\b|contextual-orchestrator|NVIDIA_NIM_API_KEY|COPILOT_GITHUB_TOKEN)/iu;
+
+function run(command, argumentsList, cwd = repositoryRoot) {
+ return execFileSync(command, argumentsList, {
+ cwd,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'inherit'],
+ });
+}
+
+function preparePackage() {
+ mkdirSync(extractionDirectory, { recursive: true });
+ mkdirSync(dirname(packageDirectory), { recursive: true });
+ const packResult = JSON.parse(
+ run('npm', [
+ 'pack',
+ '--json',
+ '--ignore-scripts',
+ '--pack-destination',
+ verificationRoot,
+ ]),
+ )[0];
+ assert.equal(packResult.name, packageJson.name);
+ assert.equal(packResult.version, packageJson.version);
+ const tarballPath = join(verificationRoot, packResult.filename);
+ assert.ok(existsSync(tarballPath));
+ run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]);
+ renameSync(join(extractionDirectory, 'package'), packageDirectory);
+ writeFileSync(
+ join(consumerDirectory, 'package.json'),
+ '{"name":"inkspan-spreadsheet-consumer","private":true,"type":"module"}\n',
+ 'utf8',
+ );
+}
+
+function assertAuthorityBoundedSource(source, filename) {
+ assert.doesNotMatch(
+ source,
+ ambientAuthorityPattern,
+ `${filename} must not reference ambient network or credential authority`,
+ );
+ assert.doesNotMatch(
+ source,
+ forbiddenProductGraphPattern,
+ `${filename} must not embed React, Yjs, CWL host, or model authority`,
+ );
+}
+
+function verifyOwnedLazyParserChunk(filename, findings) {
+ assert.equal(
+ findings.length,
+ 1,
+ `${filename} must contain only its single package-owned lazy parser edge`,
+ );
+ const [finding] = findings;
+ assert.ok(
+ finding.kind === 'dynamic-import' || finding.kind === 'commonjs-require',
+ `${filename} lazy parser edge must remain an explicit import or require`,
+ );
+ const extension = filename.endsWith('.cjs') ? 'cjs' : 'js';
+ assert.match(
+ finding.specifier ?? '',
+ new RegExp(`^\\./xlsx-[A-Za-z0-9_-]+\\.${extension}$`, 'u'),
+ `${filename} may lazy-load only its emitted package-owned SheetJS chunk`,
+ );
+
+ const chunkFilename = finding.specifier.slice(2);
+ const chunkPath = join(packageDirectory, 'dist', chunkFilename);
+ assert.ok(
+ existsSync(chunkPath),
+ `${filename} lazy parser chunk must be present in the packed artifact`,
+ );
+ const chunkSource = readFileSync(chunkPath, 'utf8');
+ assert.deepEqual(
+ findRuntimeModuleAuthority(chunkSource, chunkFilename),
+ [],
+ `${chunkFilename} must not delegate further executable module authority`,
+ );
+ assertAuthorityBoundedSource(chunkSource, chunkFilename);
+}
+
+function verifyAuthorityBoundedBundles() {
+ assert.ok(existsSync(join(packageDirectory, 'dist', 'spreadsheet', 'index.d.ts')));
+ for (const filename of ['cwl-spreadsheet.js', 'cwl-spreadsheet.cjs']) {
+ const bundleSource = readFileSync(
+ join(packageDirectory, 'dist', filename),
+ 'utf8',
+ );
+ const findings = findRuntimeModuleAuthority(bundleSource, filename);
+ verifyOwnedLazyParserChunk(filename, findings);
+ assertAuthorityBoundedSource(bundleSource, filename);
+ }
+}
+
+function verifyRuntimeConsumers() {
+ const esmPath = join(consumerDirectory, 'consumer.mjs');
+ writeFileSync(
+ esmPath,
+ `import assert from 'node:assert/strict';
+const spreadsheet = await import('${packageJson.name}/spreadsheet');
+const binary = spreadsheet.preflightSpreadsheetBinarySource(
+ new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
+);
+assert.equal(binary.format, 'xlsx');
+const result = spreadsheet.spreadsheetWorkbookToDocumentJson({
+ worksheets: [{ name: 'Sheet 1', hidden: false, rows: [['Alpha', 'Beta']] }],
+});
+assert.equal(result.worksheetCount, 1);
+assert.equal(result.rowCount, 1);
+assert.equal(result.cellCount, 2);
+assert.equal(result.content[0].type, 'heading');
+assert.equal(result.content[1].type, 'table');
+`,
+ 'utf8',
+ );
+
+ const cjsPath = join(consumerDirectory, 'consumer.cjs');
+ writeFileSync(
+ cjsPath,
+ `const assert = require('node:assert/strict');
+const spreadsheet = require('${packageJson.name}/spreadsheet');
+assert.equal(typeof spreadsheet.preflightSpreadsheetBinarySource, 'function');
+assert.equal(typeof spreadsheet.spreadsheetWorkbookToDocumentJson, 'function');
+assert.throws(
+ () => spreadsheet.preflightSpreadsheetBinarySource(new Uint8Array([1, 2, 3, 4])),
+ (error) => error && error.code === 'UNSUPPORTED_OR_CORRUPT',
+);
+`,
+ 'utf8',
+ );
+
+ run(process.execPath, [esmPath], consumerDirectory);
+ run(process.execPath, [cjsPath], consumerDirectory);
+}
+
+try {
+ preparePackage();
+ verifyAuthorityBoundedBundles();
+ verifyRuntimeConsumers();
+ console.log(
+ `Verified packed ${packageJson.name}/spreadsheet through authority-bounded ESM and CommonJS consumers.`,
+ );
+} finally {
+ rmSync(verificationRoot, { recursive: true, force: true });
+}
diff --git a/src/canonicalProductDocumentation.test.ts b/src/canonicalProductDocumentation.test.ts
index 746ed52e..bf2c18c3 100644
--- a/src/canonicalProductDocumentation.test.ts
+++ b/src/canonicalProductDocumentation.test.ts
@@ -48,6 +48,7 @@ const requiredFiles = [
'docs/adr/0022-informative-docx-png-figures.md',
'docs/adr/0023-bounded-docx-rich-text-runs.md',
'docs/adr/0024-bounded-docx-paragraph-alignment.md',
+ 'docs/adr/0032-bounded-local-spreadsheet-body-import.md',
'src/fonts/OFL.txt',
'src/fonts/NOTICE',
'src/fonts/fonts.css',
diff --git a/src/components/CwlEditor.spreadsheetBodyImport.test.tsx b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx
new file mode 100644
index 00000000..da3f1a80
--- /dev/null
+++ b/src/components/CwlEditor.spreadsheetBodyImport.test.tsx
@@ -0,0 +1,146 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import * as XLSX from 'xlsx';
+import { Editor } from '@tiptap/react';
+import { buildExtensions } from '../extensions/kit.js';
+import { CwlEditor } from './CwlEditor.js';
+import { Toolbar } from './Toolbar.js';
+
+const openEditors: Editor[] = [];
+
+function spreadsheetInput(): HTMLInputElement {
+ return document.querySelector(
+ 'input[data-cwl-spreadsheet-input="true"]',
+ ) as HTMLInputElement;
+}
+
+function serializeWorkbook(bookType: 'xlsx' | 'biff8'): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ const worksheet = XLSX.utils.aoa_to_sheet([
+ ['Product Name', 'Unit Count'],
+ ['Alpha Widget', 12],
+ ['Beta Gadget', 7],
+ ]);
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Quarterly Revenue');
+ const serialized = XLSX.write(workbook, { type: 'array', bookType });
+ return serialized instanceof Uint8Array
+ ? serialized
+ : new Uint8Array(serialized as ArrayBuffer);
+}
+
+function fileFromWorkbookBytes(
+ bytes: Uint8Array,
+ name: string,
+ type: string,
+): File {
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return new File([copy], name, { type });
+}
+
+function quarterlyRevenueFile(bookType: 'xlsx' | 'biff8'): File {
+ return fileFromWorkbookBytes(
+ serializeWorkbook(bookType),
+ 'quarterly-revenue.xlsx',
+ bookType === 'xlsx'
+ ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ : 'application/vnd.ms-excel',
+ );
+}
+
+function expectQuarterlyRevenueTable(root: ParentNode): void {
+ const cells = [...root.querySelectorAll('table td')].map(
+ (cell) => cell.textContent ?? '',
+ );
+ expect(cells).toEqual([
+ 'Product Name',
+ 'Unit Count',
+ 'Alpha Widget',
+ '12',
+ 'Beta Gadget',
+ '7',
+ ]);
+ expect(root).toHaveTextContent('Quarterly Revenue');
+}
+
+function makeEditor(content = '
Before
After
'): Editor {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ const editor = new Editor({
+ element,
+ extensions: buildExtensions({ image: { maxDimension: 0 } }),
+ content,
+ });
+ openEditors.push(editor);
+ return editor;
+}
+
+afterEach(() => {
+ cleanup();
+ for (const editor of openEditors.splice(0)) {
+ if (!editor.isDestroyed) editor.destroy();
+ }
+});
+
+describe('real spreadsheet worksheet insertion into the document body', () => {
+ it.each([
+ ['XLSX', 'xlsx'],
+ ['BIFF8 XLS', 'biff8'],
+ ] as const)(
+ 'inserts a known %s worksheet as an editable heading and table cells',
+ async (_label, bookType) => {
+ const editor = makeEditor();
+ editor.commands.setTextSelection(7);
+ render( );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [quarterlyRevenueFile(bookType)] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Imported 1 worksheet, 3 rows, and 6 cells.',
+ ),
+ );
+
+ const html = editor.getHTML();
+ expect(html.indexOf('Before')).toBeLessThan(html.indexOf('Quarterly Revenue'));
+ expect(html.indexOf('Quarterly Revenue')).toBeLessThan(html.indexOf('After'));
+ expectQuarterlyRevenueTable(editor.view.dom);
+ expect(editor.getJSON().content).toEqual(
+ expect.arrayContaining([
+ {
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Quarterly Revenue' }],
+ },
+ ]),
+ );
+ },
+ );
+
+ it('inserts the same known XLSX fixture through the public CwlEditor toolbar', async () => {
+ render( );
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Insert XLS/XLSX spreadsheet' }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [quarterlyRevenueFile('xlsx')] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Imported 1 worksheet, 3 rows, and 6 cells.',
+ ),
+ );
+
+ const documentRoot = document.querySelector('.ProseMirror');
+ expect(documentRoot).not.toBeNull();
+ expectQuarterlyRevenueTable(documentRoot!);
+ expect(documentRoot).toHaveTextContent('Before');
+ });
+});
diff --git a/src/components/CwlEditor.spreadsheetImport.test.tsx b/src/components/CwlEditor.spreadsheetImport.test.tsx
new file mode 100644
index 00000000..38d2ee11
--- /dev/null
+++ b/src/components/CwlEditor.spreadsheetImport.test.tsx
@@ -0,0 +1,67 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const spreadsheetMocks = vi.hoisted(() => ({
+ importFile: vi.fn(),
+}));
+
+vi.mock('../spreadsheet/index.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ spreadsheetFileToDocumentJson: spreadsheetMocks.importFile,
+ };
+});
+
+import { CwlEditor } from './CwlEditor.js';
+
+function spreadsheetInput(): HTMLInputElement {
+ return document.querySelector(
+ 'input[data-cwl-spreadsheet-input="true"]',
+ ) as HTMLInputElement;
+}
+
+function spreadsheetFile(): File {
+ return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'book.xlsx', {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+}
+
+afterEach(() => {
+ cleanup();
+ spreadsheetMocks.importFile.mockReset();
+});
+
+describe('CwlEditor spreadsheet import integration', () => {
+ it('forwards spreadsheet failures through the public host callback without leaking payload text into status', async () => {
+ const parserFailure = new Error('private workbook payload');
+ spreadsheetMocks.importFile.mockRejectedValue(parserFailure);
+ const onSpreadsheetError = vi.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Insert XLS/XLSX spreadsheet' }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Spreadsheet import failed.',
+ ),
+ );
+ expect(screen.getByRole('status')).not.toHaveTextContent('private workbook');
+ expect(onSpreadsheetError).toHaveBeenCalledTimes(1);
+ expect(onSpreadsheetError).toHaveBeenCalledWith(parserFailure);
+ });
+});
\ No newline at end of file
diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx
index 598ac948..35641c2b 100644
--- a/src/components/CwlEditor.tsx
+++ b/src/components/CwlEditor.tsx
@@ -40,6 +40,7 @@ export const CwlEditor = forwardRef(
onBlur,
onSelectionChange,
onImageError,
+ onSpreadsheetError,
clipboard,
onClipboardError,
placeholder = 'Start writing…',
@@ -243,6 +244,7 @@ export const CwlEditor = forwardRef(
image={image}
className={className}
onImageError={onImageError}
+ onSpreadsheetError={onSpreadsheetError}
formFieldName={formFieldName}
formId={formId}
formFieldDisabled={formFieldDisabled}
@@ -253,4 +255,4 @@ export const CwlEditor = forwardRef(
},
);
-export default CwlEditor;
+export default CwlEditor;
\ No newline at end of file
diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx
index 9cf49c23..8bd937b5 100644
--- a/src/components/EditorFrame.tsx
+++ b/src/components/EditorFrame.tsx
@@ -13,6 +13,7 @@ export interface EditorFrameProps {
image?: ImageConfig;
className?: string;
onImageError?: (error: unknown) => void;
+ onSpreadsheetError?: (error: unknown) => void;
formFieldName?: string;
formId?: string;
formFieldDisabled?: boolean;
@@ -34,6 +35,7 @@ export function EditorFrame({
image,
className,
onImageError,
+ onSpreadsheetError,
formFieldName,
formId,
formFieldDisabled,
@@ -88,6 +90,7 @@ export function EditorFrame({
editor={editor}
image={image}
onImageError={onImageError}
+ onSpreadsheetError={onSpreadsheetError}
/>
) : null}
@@ -97,4 +100,4 @@ export function EditorFrame({
);
}
-export default EditorFrame;
+export default EditorFrame;
\ No newline at end of file
diff --git a/src/components/Toolbar.spreadsheetImport.test.tsx b/src/components/Toolbar.spreadsheetImport.test.tsx
new file mode 100644
index 00000000..9b79e80b
--- /dev/null
+++ b/src/components/Toolbar.spreadsheetImport.test.tsx
@@ -0,0 +1,346 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { Editor } from '@tiptap/react';
+import { buildExtensions } from '../extensions/kit.js';
+
+const spreadsheetMocks = vi.hoisted(() => ({
+ importFile: vi.fn(),
+}));
+
+vi.mock('../spreadsheet/index.js', async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ ...actual,
+ spreadsheetFileToDocumentJson: spreadsheetMocks.importFile,
+ };
+});
+
+import { Toolbar } from './Toolbar.js';
+
+const openEditors: Editor[] = [];
+
+function makeEditor(content = 'Before
After
'): Editor {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ const editor = new Editor({
+ element,
+ extensions: buildExtensions({ image: { maxDimension: 0 } }),
+ content,
+ });
+ openEditors.push(editor);
+ return editor;
+}
+
+function spreadsheetInput(): HTMLInputElement {
+ return document.querySelector(
+ 'input[data-cwl-spreadsheet-input="true"]',
+ ) as HTMLInputElement;
+}
+
+function spreadsheetFile(): File {
+ return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'book.xlsx', {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+}
+
+function importedResult() {
+ return {
+ worksheetCount: 1,
+ rowCount: 1,
+ cellCount: 1,
+ content: [
+ {
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Summary' }],
+ },
+ {
+ type: 'table',
+ content: [
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: '42' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ { type: 'paragraph' },
+ ],
+ } as const;
+}
+
+afterEach(() => {
+ cleanup();
+ spreadsheetMocks.importFile.mockReset();
+ for (const editor of openEditors.splice(0)) {
+ if (!editor.isDestroyed) editor.destroy();
+ }
+});
+
+describe('Toolbar spreadsheet import', () => {
+ it('exposes a keyboard toolbar control and the exact local XLS/XLSX picker contract', async () => {
+ const editor = makeEditor();
+ render( );
+
+ const button = screen.getByRole('button', {
+ name: 'Insert XLS/XLSX spreadsheet',
+ });
+ expect(button).not.toBeDisabled();
+
+ const input = spreadsheetInput();
+ expect(input).toHaveAttribute(
+ 'accept',
+ '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+ expect(input).toHaveAttribute('hidden');
+
+ fireEvent.focus(button);
+ fireEvent.keyDown(button, { key: 'ArrowRight' });
+ expect(document.activeElement).not.toBe(button);
+ });
+
+ it('inserts one validated JSON batch at the active selection and remains normally undoable', async () => {
+ spreadsheetMocks.importFile.mockResolvedValue(importedResult());
+ const editor = makeEditor();
+ editor.commands.setTextSelection(7);
+ const before = editor.getJSON();
+ render( );
+
+ let docChangingTransactions = 0;
+ const countDocChanges = ({ transaction }: { transaction: { docChanged: boolean } }) => {
+ if (transaction.docChanged) docChangingTransactions += 1;
+ };
+ editor.on('transaction', countDocChanges);
+
+ const input = spreadsheetInput();
+ fireEvent.change(input, { target: { files: [spreadsheetFile()] } });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Imported 1 worksheet, 1 row, and 1 cell.',
+ ),
+ );
+ expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1);
+ expect(docChangingTransactions).toBe(1);
+ const html = editor.getHTML();
+ expect(html).toContain(' editor.commands.undo());
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('reports plural zero counts without mutating the document when every sheet is filtered out', async () => {
+ spreadsheetMocks.importFile.mockResolvedValue({
+ worksheetCount: 0,
+ rowCount: 0,
+ cellCount: 0,
+ content: [],
+ });
+ const editor = makeEditor();
+ const before = editor.getJSON();
+ render( );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Imported 0 worksheets, 0 rows, and 0 cells.',
+ ),
+ );
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('fails closed and notifies the host when the editor rejects the insertion transaction', async () => {
+ spreadsheetMocks.importFile.mockResolvedValue(importedResult());
+ const editor = makeEditor();
+ const before = editor.getJSON();
+ const onSpreadsheetError = vi.fn();
+ const rejectedChain = {
+ focus: vi.fn(),
+ insertContent: vi.fn(),
+ run: vi.fn(() => false),
+ };
+ rejectedChain.focus.mockReturnValue(rejectedChain);
+ rejectedChain.insertContent.mockReturnValue(rejectedChain);
+ vi.spyOn(editor, 'chain').mockReturnValue(
+ rejectedChain as unknown as ReturnType,
+ );
+ render(
+ ,
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Spreadsheet import failed.',
+ ),
+ );
+ expect(rejectedChain.run).toHaveBeenCalledTimes(1);
+ expect(onSpreadsheetError).toHaveBeenCalledTimes(1);
+ expect(onSpreadsheetError.mock.calls[0]?.[0]).toMatchObject({
+ message: 'Spreadsheet insertion was rejected.',
+ });
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('fails closed when the editor becomes read-only while parsing', async () => {
+ let resolveImport: ((result: ReturnType) => void) | undefined;
+ spreadsheetMocks.importFile.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveImport = resolve;
+ }),
+ );
+ const editor = makeEditor();
+ const before = editor.getJSON();
+ const onSpreadsheetError = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('Importing spreadsheet…'),
+ );
+
+ act(() => editor.setEditable(false));
+ await act(async () => resolveImport?.(importedResult()));
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Spreadsheet import failed.',
+ ),
+ );
+ expect(onSpreadsheetError).toHaveBeenCalledTimes(1);
+ expect(onSpreadsheetError.mock.calls[0]?.[0]).toMatchObject({
+ message: 'Spreadsheet insertion is unavailable.',
+ });
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('disables import while parsing, ignores duplicate busy events, and permits the same file to be selected again', async () => {
+ let resolveImport: ((result: ReturnType) => void) | undefined;
+ spreadsheetMocks.importFile.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveImport = resolve;
+ }),
+ );
+ const editor = makeEditor();
+ render( );
+
+ const button = screen.getByRole('button', {
+ name: 'Insert XLS/XLSX spreadsheet',
+ });
+ const input = spreadsheetInput();
+ const file = spreadsheetFile();
+ fireEvent.change(input, { target: { files: [file] } });
+
+ await waitFor(() => expect(button).toBeDisabled());
+ expect(screen.getByRole('status')).toHaveTextContent('Importing spreadsheet…');
+ expect(input.value).toBe('');
+
+ fireEvent.change(input, { target: { files: [file] } });
+ expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(1);
+
+ await act(async () => resolveImport?.(importedResult()));
+ await waitFor(() => expect(button).not.toBeDisabled());
+
+ spreadsheetMocks.importFile.mockResolvedValueOnce(importedResult());
+ fireEvent.change(input, { target: { files: [file] } });
+ await waitFor(() => expect(spreadsheetMocks.importFile).toHaveBeenCalledTimes(2));
+ });
+
+ it('announces a stable payload-redacted failure, notifies the host, and leaves the document unchanged', async () => {
+ const parserFailure = new Error('secret workbook cell and local filesystem path');
+ spreadsheetMocks.importFile.mockRejectedValue(parserFailure);
+ const editor = makeEditor();
+ const before = editor.getJSON();
+ const onSpreadsheetError = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Spreadsheet import failed.',
+ ),
+ );
+ expect(screen.getByRole('status')).not.toHaveTextContent('secret workbook');
+ expect(onSpreadsheetError).toHaveBeenCalledWith(parserFailure);
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('contains host spreadsheet-error observer failures without changing the redacted status', async () => {
+ spreadsheetMocks.importFile.mockRejectedValue(
+ new Error('private parser failure'),
+ );
+ const editor = makeEditor();
+ const before = editor.getJSON();
+ const onSpreadsheetError = vi.fn(() => {
+ throw new Error('private host observer failure');
+ });
+ render(
+ ,
+ );
+
+ fireEvent.change(spreadsheetInput(), {
+ target: { files: [spreadsheetFile()] },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Spreadsheet import failed.',
+ ),
+ );
+ expect(onSpreadsheetError).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole('status')).not.toHaveTextContent('private');
+ expect(editor.getJSON()).toEqual(before);
+ });
+
+ it('ignores a picker change with no selected file', async () => {
+ const editor = makeEditor();
+ render( );
+
+ fireEvent.change(spreadsheetInput(), { target: { files: [] } });
+
+ expect(spreadsheetMocks.importFile).not.toHaveBeenCalled();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx
index aeaf6895..fdab45dc 100644
--- a/src/components/Toolbar.test.tsx
+++ b/src/components/Toolbar.test.tsx
@@ -70,8 +70,8 @@ describe('Toolbar', () => {
const buttons = screen.getAllByRole('button');
// Marks(4) + headings(3) + lists/quote/code/hr(5) +
- // link/table/col/row/delCol/delRow/delTable/image/imageAlt(9) + history(2) = 23.
- expect(buttons.length).toBe(23);
+ // link/table/col/row/delCol/delRow/delTable/image/spreadsheet/imageAlt(10) + history(2) = 24.
+ expect(buttons.length).toBe(24);
// Cover onMouseDown preventDefault + every onClick handler.
for (const button of buttons) {
fireEvent.mouseDown(button);
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 55136e55..c73fe389 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -4,11 +4,15 @@ import {
useEffect,
useReducer,
useRef,
+ useState,
type ChangeEvent,
type FocusEvent,
type KeyboardEvent,
} from 'react';
+import { Base64SizeError } from '../converter/base64.js';
import { imageFileToInlineDataUri } from '../extensions/Base64Image.js';
+import { isSafeLinkHref } from '../extensions/SafeLink.js';
+import { spreadsheetFileToDocumentJson } from '../spreadsheet/index.js';
import type { ImageConfig } from '../types.js';
interface ToolbarProps {
@@ -16,6 +20,8 @@ interface ToolbarProps {
image?: ImageConfig;
/** Forwarded from {@link CwlEditor} — image failures must reach the host. */
onImageError?: (error: unknown) => void;
+ /** Optional host observer for payload-redacted spreadsheet import failures. */
+ onSpreadsheetError?: (error: unknown) => void;
}
interface ButtonProps {
@@ -29,6 +35,29 @@ interface ButtonProps {
}
const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]';
+const SPREADSHEET_ACCEPT =
+ '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+/** Read a genuine Blob's byte length without invoking caller-owned accessors. */
+function intrinsicBlobSize(blob: Blob): number {
+ const sizeGetter = Object.getOwnPropertyDescriptor(
+ globalThis.Blob.prototype,
+ 'size',
+ )!.get!;
+ return Reflect.apply(sizeGetter, blob, []) as number;
+}
+
+/** Report a host-observable failure without granting observer code control flow. */
+function reportHostError(
+ observer: ((error: unknown) => void) | undefined,
+ error: unknown,
+): void {
+ try {
+ observer?.(error);
+ } catch {
+ // Host presentation or telemetry observers are best-effort only.
+ }
+}
/** Return every toolbar button in visual and DOM navigation order. */
function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] {
@@ -77,7 +106,8 @@ function ToolbarButton({
/**
* Commercial-grade toolbar covering the common rich-text affordances:
* marks, headings, lists, code, quote, link, horizontal rule, table insert +
- * edit, inline-base64 image upload, and image alternative-text authoring.
+ * edit, inline-base64 image upload, local spreadsheet insertion, and image
+ * alternative-text authoring.
*
* The toolbar follows the WAI-ARIA composite-toolbar keyboard model: it is one
* tab stop, Left/Right arrows move between enabled controls with wrapping, and
@@ -86,10 +116,18 @@ function ToolbarButton({
* already implemented by the editor are exposed with `aria-keyshortcuts` so
* assistive technology receives the same cross-platform commands as tooltips.
*/
-export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
- const fileInputRef = useRef(null);
+export function Toolbar({
+ editor,
+ image,
+ onImageError,
+ onSpreadsheetError,
+}: ToolbarProps) {
+ const imageFileInputRef = useRef(null);
+ const spreadsheetFileInputRef = useRef(null);
const toolbarRef = useRef(null);
const lastFocusedButtonRef = useRef(null);
+ const [spreadsheetBusy, setSpreadsheetBusy] = useState(false);
+ const [spreadsheetStatus, setSpreadsheetStatus] = useState('');
// Re-render on every transaction so active/disabled states (marks, image and
// table selection, undo/redo) stay in sync without host re-renders.
const [, bump] = useReducer((n: number) => n + 1, 0);
@@ -173,6 +211,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
+ if (!isSafeLinkHref(url)) return;
editor
.chain()
.focus()
@@ -203,18 +242,30 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
event.target.value = '';
if (!file) return;
+ const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024;
+ const sourceBytes = intrinsicBlobSize(file);
+ if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) {
+ reportHostError(
+ onImageError,
+ new Base64SizeError(sourceBytes, maxSizeBytes),
+ );
+ return;
+ }
+
let src: string;
try {
src = await imageFileToInlineDataUri(file, {
- maxSizeBytes: image?.maxSizeBytes ?? 10 * 1024 * 1024,
+ maxSizeBytes,
maxDimension: image?.maxDimension ?? 1600,
quality: image?.quality ?? 0.85,
});
- } catch (err) {
- onImageError?.(err);
+ } catch {
+ reportHostError(onImageError, new Error('Image processing failed.'));
return;
}
+ if (editor.isDestroyed || !editor.isEditable) return;
+
const alternativeText = window.prompt(
'Image alternative text. Leave empty only if this image is decorative.',
'',
@@ -226,6 +277,40 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
[editor, image, onImageError],
);
+ const onPickSpreadsheet = useCallback(
+ async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file || spreadsheetBusy) return;
+
+ setSpreadsheetBusy(true);
+ setSpreadsheetStatus('Importing spreadsheet…');
+ try {
+ const result = await spreadsheetFileToDocumentJson(file);
+ if (editor.isDestroyed || !editor.isEditable) {
+ throw new Error('Spreadsheet insertion is unavailable.');
+ }
+ if (result.content.length > 0) {
+ const inserted = editor
+ .chain()
+ .focus()
+ .insertContent(result.content)
+ .run();
+ if (!inserted) throw new Error('Spreadsheet insertion was rejected.');
+ }
+ setSpreadsheetStatus(
+ `Imported ${result.worksheetCount} ${result.worksheetCount === 1 ? 'worksheet' : 'worksheets'}, ${result.rowCount} ${result.rowCount === 1 ? 'row' : 'rows'}, and ${result.cellCount} ${result.cellCount === 1 ? 'cell' : 'cells'}.`,
+ );
+ } catch (error) {
+ reportHostError(onSpreadsheetError, error);
+ setSpreadsheetStatus('Spreadsheet import failed.');
+ } finally {
+ setSpreadsheetBusy(false);
+ }
+ },
+ [editor, onSpreadsheetError, spreadsheetBusy],
+ );
+
return (
fileInputRef.current?.click()}
+ onClick={() => imageFileInputRef.current?.click()}
+ />
+ spreadsheetFileInputRef.current?.click()}
/>
+
@@ -403,8 +503,17 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
onClick={() => editor.chain().focus().redo().run()}
/>
+
+
+ {spreadsheetStatus}
+
);
}
-export default Toolbar;
+export default Toolbar;
\ No newline at end of file
diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx
new file mode 100644
index 00000000..8f43f24c
--- /dev/null
+++ b/src/components/ToolbarImageLifecycle.test.tsx
@@ -0,0 +1,166 @@
+import { act, cleanup, fireEvent, render } from '@testing-library/react';
+import { Editor } from '@tiptap/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { buildExtensions } from '../extensions/kit.js';
+import { Toolbar } from './Toolbar.js';
+
+const PNG_BYTES = new Uint8Array([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
+ 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
+]);
+
+const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = [];
+
+function makeEditor(): Editor {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ const editor = new Editor({
+ element,
+ extensions: buildExtensions({ image: { maxDimension: 0 } }),
+ content: 'before
',
+ });
+ openEditors.push({ editor, element });
+ return editor;
+}
+
+function delayedPngFile(delayMs = 25): File {
+ const file = new File([PNG_BYTES], 'slow.png', { type: 'image/png' });
+ Object.defineProperty(file, 'arrayBuffer', {
+ configurable: true,
+ value: () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve(PNG_BYTES.slice().buffer), delayMs);
+ }),
+ });
+ return file;
+}
+
+function fileInput(): HTMLInputElement {
+ return document.querySelector('input[type="file"]') as HTMLInputElement;
+}
+
+async function settleConversion(): Promise {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 45));
+ });
+}
+
+afterEach(() => {
+ cleanup();
+ for (const { editor, element } of openEditors.splice(0)) {
+ if (!editor.isDestroyed) editor.destroy();
+ element.remove();
+ }
+ vi.restoreAllMocks();
+});
+
+describe('Toolbar asynchronous image-upload lifecycle boundary', () => {
+ it('does not prompt or mutate after the editor becomes read-only', async () => {
+ const editor = makeEditor();
+ const before = editor.getHTML();
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image');
+ render( );
+
+ fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } });
+ editor.setEditable(false);
+ await settleConversion();
+
+ expect(prompt).not.toHaveBeenCalled();
+ expect(editor.getHTML()).toBe(before);
+ expect(editor.getHTML()).not.toContain('data:image/png;base64');
+ });
+
+ it('does not prompt after the editor is destroyed during conversion', async () => {
+ const editor = makeEditor();
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image');
+ render( );
+
+ fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } });
+ editor.destroy();
+ await settleConversion();
+
+ expect(prompt).not.toHaveBeenCalled();
+ expect(editor.isDestroyed).toBe(true);
+ });
+
+ it('does not expose hostile conversion throw values to the host error callback', async () => {
+ const editor = makeEditor();
+ const before = editor.getHTML();
+ const privateSentinel = new Error('private toolbar conversion sentinel');
+ const getPrototypeOf = vi.fn(() => {
+ throw privateSentinel;
+ });
+ const hostileThrownValue = new Proxy({}, { getPrototypeOf });
+ const hostileValues = new WeakSet([hostileThrownValue]);
+ const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' });
+ Object.defineProperty(file, 'arrayBuffer', {
+ configurable: true,
+ value: vi.fn().mockRejectedValue(hostileThrownValue),
+ });
+
+ let leakedHostileValue = false;
+ let observedError: unknown;
+ const onImageError = vi.fn((error: unknown) => {
+ observedError = error;
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ hostileValues.has(error)
+ ) {
+ leakedHostileValue = true;
+ }
+ });
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run');
+ render(
+ ,
+ );
+
+ fireEvent.change(fileInput(), { target: { files: [file] } });
+ await settleConversion();
+
+ expect(onImageError).toHaveBeenCalledOnce();
+ expect(leakedHostileValue).toBe(false);
+ expect(getPrototypeOf).not.toHaveBeenCalled();
+ expect(observedError).toBeInstanceOf(Error);
+ expect((observedError as Error).message).toBe('Image processing failed.');
+ expect(prompt).not.toHaveBeenCalled();
+ expect(editor.getHTML()).toBe(before);
+ expect(editor.getHTML()).not.toContain('data:image');
+ });
+
+ it('contains host image-error observer failures after conversion rejection', async () => {
+ const editor = makeEditor();
+ const before = editor.getHTML();
+ const privateSentinel = new Error('private toolbar observer sentinel');
+ const failedFile = new File([PNG_BYTES], 'failed.png', { type: 'image/png' });
+ Object.defineProperty(failedFile, 'arrayBuffer', {
+ configurable: true,
+ value: vi.fn().mockRejectedValue(new Error('private conversion failure')),
+ });
+ const onImageError = vi.fn(() => {
+ throw privateSentinel;
+ });
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run');
+
+ render(
+ ,
+ );
+
+ fireEvent.change(fileInput(), { target: { files: [failedFile] } });
+ await settleConversion();
+
+ expect(onImageError).toHaveBeenCalledOnce();
+ expect(prompt).not.toHaveBeenCalled();
+ expect(editor.getHTML()).toBe(before);
+ });
+});
diff --git a/src/components/ToolbarLinkPolicy.test.tsx b/src/components/ToolbarLinkPolicy.test.tsx
new file mode 100644
index 00000000..fee56eee
--- /dev/null
+++ b/src/components/ToolbarLinkPolicy.test.tsx
@@ -0,0 +1,50 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { Editor } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Toolbar } from './Toolbar.js';
+
+const openEditors: Editor[] = [];
+
+function makeEditor(): Editor {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ const editor = new Editor({
+ element,
+ extensions: [StarterKit],
+ content: 'link target
',
+ });
+ openEditors.push(editor);
+ return editor;
+}
+
+afterEach(() => {
+ cleanup();
+ for (const editor of openEditors.splice(0)) {
+ if (!editor.isDestroyed) editor.destroy();
+ }
+ vi.restoreAllMocks();
+});
+
+describe('Toolbar link policy boundary', () => {
+ it('rejects an executable URL before issuing an editor command', () => {
+ const editor = makeEditor();
+ const commandChain = {
+ focus: vi.fn(() => commandChain),
+ extendMarkRange: vi.fn(() => commandChain),
+ setLink: vi.fn(() => commandChain),
+ unsetLink: vi.fn(() => commandChain),
+ run: vi.fn(() => true),
+ };
+ vi.spyOn(editor, 'chain').mockReturnValue(
+ commandChain as unknown as ReturnType,
+ );
+ vi.spyOn(window, 'prompt').mockReturnValue('javascript:alert(1)');
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ }));
+
+ expect(commandChain.setLink).not.toHaveBeenCalled();
+ expect(commandChain.run).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/spreadsheet/index.ts b/src/spreadsheet/index.ts
new file mode 100644
index 00000000..86d033f2
--- /dev/null
+++ b/src/spreadsheet/index.ts
@@ -0,0 +1,3 @@
+/** Public package entry for deterministic, local spreadsheet conversion primitives. */
+export * from './spreadsheetImport.js';
+export * from './sheetJsRuntime.js';
diff --git a/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts b/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts
new file mode 100644
index 00000000..a10d7c9e
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.aggregateBudget.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+import { SpreadsheetImportError } from './spreadsheetImport.js';
+
+const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
+
+function decodeRange(range: string): unknown {
+ const match = /^A1:A(\d+)$/.exec(range);
+ if (match === null) throw new Error('unexpected range');
+ return {
+ s: { r: 0, c: 0 },
+ e: { r: Number(match[1]) - 1, c: 0 },
+ };
+}
+
+describe('SheetJS aggregate parser budget', () => {
+ it('discovers sheet names without rows and decreases the parser row ceiling before each selected sheet parse', () => {
+ const readOptions: Record[] = [];
+ const read = vi.fn((_source: Uint8Array, rawOptions: unknown) => {
+ const options = rawOptions as Record;
+ readOptions.push(options);
+
+ if (readOptions.length === 1) {
+ if (options.bookSheets !== true) {
+ throw new Error('the first parser pass materialized worksheet data');
+ }
+ return {
+ SheetNames: ['First', 'Second'],
+ };
+ }
+
+ if (options.sheets === 'First') {
+ return {
+ SheetNames: ['First', 'Second'],
+ Sheets: { First: { '!ref': 'A1:A6000' } },
+ Workbook: { Sheets: [{ Hidden: 0 }, { Hidden: 0 }] },
+ };
+ }
+
+ if (options.sheets === 'Second') {
+ return {
+ SheetNames: ['First', 'Second'],
+ Sheets: { Second: { '!ref': 'A1:A4001' } },
+ Workbook: { Sheets: [{ Hidden: 0 }, { Hidden: 0 }] },
+ };
+ }
+
+ throw new Error('unexpected parser pass');
+ });
+ const sheetToJson = vi.fn(() => []);
+ const parser = {
+ read,
+ utils: {
+ decode_range: decodeRange,
+ sheet_to_json: sheetToJson,
+ },
+ } as unknown as SheetJsParserModule;
+
+ let caught: unknown;
+ try {
+ sheetJsBytesToWorkbookData(XLSX_SOURCE, parser);
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toBeInstanceOf(SpreadsheetImportError);
+ expect(caught).toMatchObject({ code: 'RESOURCE_LIMIT_EXCEEDED' });
+ expect(read).toHaveBeenCalledTimes(3);
+ expect(readOptions[0]).toMatchObject({ bookSheets: true });
+ expect(readOptions[1]).toMatchObject({
+ sheets: 'First',
+ sheetRows: 10_001,
+ });
+ expect(readOptions[2]).toMatchObject({
+ sheets: 'Second',
+ sheetRows: 4_001,
+ });
+ expect(sheetToJson).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.blankRange.test.ts b/src/spreadsheet/sheetJsAdapter.blankRange.test.ts
new file mode 100644
index 00000000..f0ec07b0
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.blankRange.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+
+const BIFF8_SOURCE = new Uint8Array([
+ 0xd0,
+ 0xcf,
+ 0x11,
+ 0xe0,
+ 0xa1,
+ 0xb1,
+ 0x1a,
+ 0xe1,
+]);
+
+describe('sheetJsBytesToWorkbookData blank BIFF8 range normalization', () => {
+ it('treats a parser-synthesized blank-only A1 range as an empty worksheet body', () => {
+ const emptySheet = { '!ref': 'A1' };
+ const read = vi.fn(() => ({
+ SheetNames: ['Empty'],
+ Sheets: { Empty: emptySheet },
+ Workbook: { Sheets: [{ Hidden: 0 }] },
+ }));
+ const sheetToJson = vi.fn(() => [['']]);
+ const parser: SheetJsParserModule = {
+ read,
+ utils: {
+ decode_range: vi.fn(() => ({
+ s: { r: 0, c: 0 },
+ e: { r: 0, c: 0 },
+ })),
+ sheet_to_json: sheetToJson,
+ },
+ };
+
+ expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({
+ worksheets: [{ name: 'Empty', hidden: false, rows: [] }],
+ });
+ expect(read).toHaveBeenCalledTimes(1);
+ expect(sheetToJson).toHaveBeenCalledOnce();
+ expect(sheetToJson).toHaveBeenCalledWith(emptySheet, {
+ header: 1,
+ raw: false,
+ defval: '',
+ blankrows: true,
+ });
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts b/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts
new file mode 100644
index 00000000..ebd20e86
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.outputBounds.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+import { SpreadsheetImportError } from './spreadsheetImport.js';
+
+const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
+
+function parserWithRows(rows: readonly unknown[]): SheetJsParserModule {
+ let readCount = 0;
+ return {
+ read: vi.fn(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return { SheetNames: ['Summary'] };
+ }
+ return {
+ SheetNames: ['Summary'],
+ Sheets: { Summary: { '!ref': 'A1:A1' } },
+ Workbook: { Sheets: [{ Hidden: 0 }] },
+ };
+ }),
+ utils: {
+ decode_range: vi.fn(() => ({
+ s: { r: 0, c: 0 },
+ e: { r: 0, c: 0 },
+ })),
+ sheet_to_json: vi.fn(() => rows),
+ },
+ };
+}
+
+function expectResourceLimit(action: () => unknown): void {
+ let caught: unknown;
+ try {
+ action();
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toBeInstanceOf(SpreadsheetImportError);
+ expect(caught).toMatchObject({ code: 'RESOURCE_LIMIT_EXCEEDED' });
+}
+
+describe('SheetJS materialized output bounds', () => {
+ it('rejects more materialized rows than the decoded range before reading row entries', () => {
+ let rowEntryInspected = false;
+ const rows = new Proxy(new Array(2), {
+ getOwnPropertyDescriptor(target, key) {
+ if (key === '0') {
+ rowEntryInspected = true;
+ throw new Error('row entry must not be inspected');
+ }
+ return Reflect.getOwnPropertyDescriptor(target, key);
+ },
+ });
+ const parser = parserWithRows(rows);
+
+ expectResourceLimit(() => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser));
+ expect(rowEntryInspected).toBe(false);
+ });
+
+ it('rejects a materialized row wider than the decoded range before reading cell entries', () => {
+ let cellEntryInspected = false;
+ const row = new Proxy(new Array(257), {
+ getOwnPropertyDescriptor(target, key) {
+ if (key === '0') {
+ cellEntryInspected = true;
+ throw new Error('cell entry must not be inspected');
+ }
+ return Reflect.getOwnPropertyDescriptor(target, key);
+ },
+ });
+ const parser = parserWithRows([row]);
+
+ expectResourceLimit(() => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser));
+ expect(cellEntryInspected).toBe(false);
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts b/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts
new file mode 100644
index 00000000..ca2f62a1
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.parsedSheetIndex.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+import { SpreadsheetImportError } from './spreadsheetImport.js';
+
+const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
+
+function expectSpreadsheetError(
+ action: () => unknown,
+ code: 'UNSUPPORTED_OR_CORRUPT' | 'RESOURCE_LIMIT_EXCEEDED',
+): void {
+ let caught: unknown;
+ try {
+ action();
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toBeInstanceOf(SpreadsheetImportError);
+ expect(caught).toMatchObject({ code });
+}
+
+function parserWithSelectiveSheetNames(
+ selectiveSheetNames: unknown,
+): SheetJsParserModule {
+ let pass = 0;
+ return {
+ read: vi.fn(() => {
+ pass += 1;
+ if (pass === 1) {
+ return { SheetNames: ['Summary'] };
+ }
+ return {
+ SheetNames: selectiveSheetNames,
+ Sheets: { Summary: {} },
+ };
+ }),
+ utils: {
+ decode_range: vi.fn(),
+ sheet_to_json: vi.fn(),
+ },
+ };
+}
+
+describe('sheetJsBytesToWorkbookData selective sheet-index authority', () => {
+ it('rejects a selective parse whose sheet-name container is not a data array', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames({}),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects a selective parse that reports more worksheet names than the workbook ceiling', () => {
+ const sheetNames = Array.from(
+ { length: 257 },
+ (_, index) => `Sheet ${index}`,
+ );
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames(sheetNames),
+ ),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ });
+
+ it('rejects a selective parse whose sheet names are not strings', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames([123]),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects a selective parse whose sheet name exceeds the code-unit ceiling', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames(['x'.repeat(1_025)]),
+ ),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ });
+
+ it('rejects a selective parse that lists the requested sheet more than once', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames(['Other', 'Summary', 'Summary']),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects a selective parse that omits the requested visible sheet', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserWithSelectiveSheetNames(['Other']),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts
new file mode 100644
index 00000000..79d34657
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.realBiff8.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest';
+import * as XLSX from 'xlsx';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+import {
+ parseSheetJsSpreadsheetBytes,
+ spreadsheetFileToDocumentJson,
+} from './sheetJsRuntime.js';
+import { preflightSpreadsheetBinarySource } from './spreadsheetImport.js';
+
+function realBiff8WithHiddenSheet(): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ const summary = XLSX.utils.aoa_to_sheet([
+ ['Kind', 'Value'],
+ ['Unicode', '매출'],
+ ['Multiline', 'line 1\nline 2'],
+ ['Boolean', true],
+ [
+ 'Date',
+ {
+ t: 'd',
+ v: new Date(Date.UTC(2026, 7, 17)),
+ z: 'yyyy-mm-dd',
+ } satisfies XLSX.CellObject,
+ ],
+ [
+ 'Formula',
+ {
+ t: 'n',
+ v: 42,
+ f: 'SUM(40,2)',
+ } satisfies XLSX.CellObject,
+ ],
+ [
+ 'Hyperlink',
+ {
+ t: 's',
+ v: 'Reference',
+ l: { Target: 'https://secret.invalid/workbook' },
+ } satisfies XLSX.CellObject,
+ ],
+ ]);
+ XLSX.utils.book_append_sheet(workbook, summary, 'Summary');
+ XLSX.utils.book_append_sheet(
+ workbook,
+ XLSX.utils.aoa_to_sheet([['private hidden value']]),
+ 'Hidden',
+ );
+ XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty');
+ workbook.Workbook = {
+ ...(workbook.Workbook ?? {}),
+ Sheets: [{ Hidden: 0 }, { Hidden: 1 }, { Hidden: 0 }],
+ };
+
+ const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' });
+ return serialized instanceof Uint8Array
+ ? serialized
+ : new Uint8Array(serialized as ArrayBuffer);
+}
+
+function hiddenStates(workbook: XLSX.WorkBook) {
+ return workbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0);
+}
+
+function visibilityProjection(workbook: {
+ readonly worksheets: readonly { readonly name: string; readonly hidden: boolean }[];
+}) {
+ return workbook.worksheets.map(({ name, hidden }) => ({ name, hidden }));
+}
+
+function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ ) as ArrayBuffer;
+}
+
+const EXPECTED_VISIBILITY = [
+ { name: 'Summary', hidden: false },
+ { name: 'Hidden', hidden: true },
+ { name: 'Empty', hidden: false },
+] as const;
+
+const VISIBILITY_OPTIONS = {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ sheetRows: 1,
+} as const;
+
+describe('real BIFF8 hidden-sheet metadata', () => {
+ it('survives parser, adapter, runtime, and file-source boundaries', async () => {
+ const bytes = realBiff8WithHiddenSheet();
+ expect(preflightSpreadsheetBinarySource(bytes).format).toBe('xls');
+
+ const lazyXlsx = await import('xlsx');
+ const visibilityWorkbook = lazyXlsx.read(bytes, VISIBILITY_OPTIONS);
+
+ expect(visibilityWorkbook.SheetNames).toEqual([
+ 'Summary',
+ 'Hidden',
+ 'Empty',
+ ]);
+ expect(hiddenStates(visibilityWorkbook)).toEqual([0, 1, 0]);
+ expect(
+ Object.getOwnPropertyDescriptor(
+ visibilityWorkbook.Workbook!.Sheets![1]!,
+ 'Hidden',
+ )?.value,
+ ).toBe(1);
+
+ const copiedBuffer = exactArrayBuffer(bytes);
+ const browserBytes = new Uint8Array(copiedBuffer);
+ expect(Array.from(browserBytes)).toEqual(Array.from(bytes));
+ expect({
+ browserUint8Array: hiddenStates(
+ lazyXlsx.read(browserBytes, VISIBILITY_OPTIONS),
+ ),
+ browserArrayBuffer: hiddenStates(
+ lazyXlsx.read(copiedBuffer, VISIBILITY_OPTIONS),
+ ),
+ }).toEqual({
+ browserUint8Array: [0, 1, 0],
+ browserArrayBuffer: [0, 1, 0],
+ });
+
+ expect(
+ visibilityProjection(
+ sheetJsBytesToWorkbookData(
+ bytes,
+ lazyXlsx as unknown as SheetJsParserModule,
+ ),
+ ),
+ ).toEqual(EXPECTED_VISIBILITY);
+ expect(
+ visibilityProjection(
+ sheetJsBytesToWorkbookData(
+ new Uint8Array(exactArrayBuffer(bytes)),
+ lazyXlsx as unknown as SheetJsParserModule,
+ ),
+ ),
+ ).toEqual(EXPECTED_VISIBILITY);
+
+ expect(
+ visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)),
+ ).toEqual(EXPECTED_VISIBILITY);
+ expect(
+ visibilityProjection(
+ await parseSheetJsSpreadsheetBytes(
+ new Uint8Array(exactArrayBuffer(bytes)),
+ ),
+ ),
+ ).toEqual(EXPECTED_VISIBILITY);
+
+ const fileBuffer = exactArrayBuffer(bytes);
+ const result = await spreadsheetFileToDocumentJson({
+ size: bytes.byteLength,
+ async arrayBuffer() {
+ return fileBuffer;
+ },
+ });
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 7,
+ cellCount: 14,
+ });
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts
new file mode 100644
index 00000000..e1e2aff3
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.selectiveValidation.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+
+const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
+
+function parserForSelectedWorkbook(selectedWorkbook: unknown): SheetJsParserModule {
+ return {
+ read(_source, options) {
+ if (options.bookSheets === true) {
+ return { SheetNames: ['Summary'], Sheets: {} };
+ }
+ return selectedWorkbook;
+ },
+ utils: {
+ decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }),
+ sheet_to_json: () => [],
+ },
+ };
+}
+
+function selectedWorkbook(sheetNames: unknown): object {
+ return {
+ SheetNames: sheetNames,
+ Sheets: { Summary: {} },
+ };
+}
+
+function expectSpreadsheetError(
+ action: () => unknown,
+ code: 'UNSUPPORTED_OR_CORRUPT' | 'RESOURCE_LIMIT_EXCEEDED',
+): void {
+ let caught: unknown;
+ try {
+ action();
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toMatchObject({
+ name: 'SpreadsheetImportError',
+ code,
+ message:
+ code === 'UNSUPPORTED_OR_CORRUPT'
+ ? 'Spreadsheet source is unsupported or corrupt.'
+ : 'Spreadsheet exceeds the configured resource limits.',
+ });
+}
+
+describe('SheetJS selective worksheet identity validation', () => {
+ it('rejects a selected workbook without an array SheetNames identity', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(selectedWorkbook('Summary')),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects a selected workbook with too many SheetNames entries', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(
+ selectedWorkbook(new Array(257).fill('Other')),
+ ),
+ ),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ });
+
+ it('rejects a selected workbook with a non-string sheet identity', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(selectedWorkbook([42])),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects a selected workbook with an oversized sheet identity', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(selectedWorkbook(['x'.repeat(1_025)])),
+ ),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ });
+
+ it('rejects a selected workbook that omits the requested worksheet identity', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(selectedWorkbook(['Other'])),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects an ambiguous selected workbook that repeats the requested identity', () => {
+ expectSpreadsheetError(
+ () =>
+ sheetJsBytesToWorkbookData(
+ XLSX_SOURCE,
+ parserForSelectedWorkbook(selectedWorkbook(['Summary', 'Summary'])),
+ ),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.test.ts b/src/spreadsheet/sheetJsAdapter.test.ts
new file mode 100644
index 00000000..c583a2d0
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.test.ts
@@ -0,0 +1,635 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+import {
+ SpreadsheetImportError,
+ type SpreadsheetImportErrorCode,
+} from './spreadsheetImport.js';
+
+const XLSX_SOURCE = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
+
+interface ParserFixtureOptions {
+ readonly read?: () => unknown;
+ readonly decodeRange?: (range: string) => unknown;
+ readonly sheetToJson?: (sheet: unknown) => unknown;
+}
+
+function parserFixture(
+ workbook: unknown = { SheetNames: [], Sheets: {} },
+ options: ParserFixtureOptions = {},
+) {
+ const read = vi.fn(options.read ?? (() => workbook));
+ const decodeRange = vi.fn(
+ options.decodeRange ??
+ (() => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } })),
+ );
+ const sheetToJson = vi.fn(options.sheetToJson ?? (() => [['value']]));
+
+ return {
+ parser: {
+ read,
+ utils: {
+ decode_range: decodeRange,
+ sheet_to_json: sheetToJson,
+ },
+ } as SheetJsParserModule,
+ read,
+ decodeRange,
+ sheetToJson,
+ };
+}
+
+function expectSpreadsheetError(
+ action: () => unknown,
+ code: SpreadsheetImportErrorCode,
+): void {
+ let caught: unknown;
+ try {
+ action();
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toBeInstanceOf(SpreadsheetImportError);
+ expect(caught).toMatchObject({ code });
+}
+
+function descriptorReportingLength(value: unknown): readonly unknown[] {
+ return new Proxy([], {
+ getOwnPropertyDescriptor(target, key) {
+ if (key === 'length') {
+ return {
+ value,
+ writable: true,
+ enumerable: false,
+ configurable: false,
+ };
+ }
+ return Reflect.getOwnPropertyDescriptor(target, key);
+ },
+ });
+}
+
+function sheetWorkbook(
+ sheet: object,
+ options: {
+ readonly name?: string;
+ readonly hidden?: 0 | 1 | 2;
+ readonly includeMetadata?: boolean;
+ } = {},
+): object {
+ const name = options.name ?? 'Summary';
+ const workbook: Record = {
+ SheetNames: [name],
+ Sheets: { [name]: sheet },
+ };
+ if (options.includeMetadata !== false) {
+ workbook.Workbook = {
+ Sheets:
+ options.hidden === undefined ? [{}] : [{ Hidden: options.hidden }],
+ };
+ }
+ return workbook;
+}
+
+describe('sheetJsBytesToWorkbookData', () => {
+ it('reads local workbook bytes with bounded non-executing options and projects visible displayed text', () => {
+ const visibleSheet = { id: 'visible' };
+ const hiddenSheet = { id: 'hidden' };
+ const read = vi.fn(() => ({
+ SheetNames: ['Summary', 'Private'],
+ Sheets: {
+ Summary: visibleSheet,
+ Private: hiddenSheet,
+ },
+ Workbook: {
+ Sheets: [{ Hidden: 0 }, { Hidden: 1 }],
+ },
+ }));
+ const decodeRange = vi.fn((range: string) => {
+ if (range === 'A1:B2') {
+ return { s: { r: 0, c: 0 }, e: { r: 1, c: 1 } };
+ }
+ return { s: { r: 0, c: 0 }, e: { r: 0, c: 0 } };
+ });
+ const sheetToJson = vi.fn((sheet: unknown) => {
+ if (sheet === visibleSheet) {
+ return [
+ ['Name', 'Value'],
+ ['매출', '42'],
+ ];
+ }
+ return [['secret']];
+ });
+ Object.assign(visibleSheet, { '!ref': 'A1:B2' });
+ Object.assign(hiddenSheet, { '!ref': 'A1' });
+
+ const parser: SheetJsParserModule = {
+ read,
+ utils: {
+ decode_range: decodeRange,
+ sheet_to_json: sheetToJson,
+ },
+ };
+
+ expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({
+ worksheets: [
+ {
+ name: 'Summary',
+ hidden: false,
+ rows: [
+ ['Name', 'Value'],
+ ['매출', '42'],
+ ],
+ },
+ {
+ name: 'Private',
+ hidden: true,
+ rows: [],
+ },
+ ],
+ });
+ expect(read).toHaveBeenCalledTimes(3);
+ expect(read).toHaveBeenNthCalledWith(1, XLSX_SOURCE, {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ bookSheets: true,
+ });
+ expect(read).toHaveBeenNthCalledWith(2, XLSX_SOURCE, {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ sheets: 'Summary',
+ sheetRows: 10_001,
+ });
+ expect(read).toHaveBeenNthCalledWith(3, XLSX_SOURCE, {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ sheets: 'Private',
+ sheetRows: 9_999,
+ });
+ expect(decodeRange).toHaveBeenCalledTimes(1);
+ expect(decodeRange).toHaveBeenCalledWith('A1:B2');
+ expect(sheetToJson).toHaveBeenCalledTimes(1);
+ expect(sheetToJson).toHaveBeenCalledWith(visibleSheet, {
+ header: 1,
+ raw: false,
+ defval: '',
+ blankrows: true,
+ });
+ });
+
+ it('preserves a visible empty sheet without invoking the row materializer', () => {
+ const emptySheet = {};
+ const { parser, sheetToJson } = parserFixture(
+ sheetWorkbook(emptySheet, { includeMetadata: false }),
+ );
+
+ expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({
+ worksheets: [{ name: 'Summary', hidden: false, rows: [] }],
+ });
+ expect(sheetToJson).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['parser exception', { read: (): never => { throw new Error('private'); } }],
+ ['non-object parser result', { read: (): null => null }],
+ ] as const)('normalizes %s as an unsupported source', (_label, overrides) => {
+ const { parser } = parserFixture(undefined, overrides);
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects hostile and accessor-backed required workbook members without evaluating them', () => {
+ const descriptorFailure = new Proxy({}, {
+ getOwnPropertyDescriptor() {
+ throw new Error('private descriptor trap');
+ },
+ });
+ const { parser: hostileParser } = parserFixture(descriptorFailure);
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const getter = vi.fn(() => ['Secret']);
+ const accessorWorkbook = { Sheets: {} } as Record;
+ Object.defineProperty(accessorWorkbook, 'SheetNames', {
+ enumerable: true,
+ get: getter,
+ });
+ const { parser: accessorParser } = parserFixture(accessorWorkbook);
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ expect(getter).not.toHaveBeenCalled();
+
+ const { parser: missingParser } = parserFixture({ Sheets: {} });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, missingParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects invalid workbook container brands and hostile array branding', () => {
+ const { proxy, revoke } = Proxy.revocable([], {});
+ revoke();
+
+ for (const workbook of [
+ { SheetNames: {}, Sheets: {} },
+ { SheetNames: ['Summary'], Sheets: null },
+ { SheetNames: proxy, Sheets: {} },
+ ]) {
+ const { parser } = parserFixture(workbook);
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ });
+
+ it('rejects invalid and negative reported array lengths before iteration', () => {
+ for (const reportedLength of ['not-a-length', -1]) {
+ const { parser } = parserFixture({
+ SheetNames: descriptorReportingLength(reportedLength),
+ Sheets: {},
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ });
+
+ it('rejects more than the bounded total worksheet descriptor count before sheet inspection', () => {
+ const sheetNames = Array.from({ length: 257 }, (_, index) => `Sheet ${index}`);
+ const { parser } = parserFixture({ SheetNames: sheetNames, Sheets: {} });
+
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ });
+
+ it('validates optional workbook sheet metadata without invoking caller accessors', () => {
+ const sheet = {};
+ const workbookGetter = vi.fn(() => ({ Sheets: [] }));
+ const accessorWorkbook = {
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ } as Record;
+ Object.defineProperty(accessorWorkbook, 'Workbook', {
+ enumerable: true,
+ get: workbookGetter,
+ });
+ const { parser: accessorParser } = parserFixture(accessorWorkbook);
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ expect(workbookGetter).not.toHaveBeenCalled();
+
+ const hostileWorkbookMetadata = new Proxy({}, {
+ getOwnPropertyDescriptor() {
+ throw new Error('private metadata trap');
+ },
+ });
+ const { parser: hostileMetadataParser } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook: hostileWorkbookMetadata,
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileMetadataParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ for (const Workbook of [1, {}, { Sheets: {} }]) {
+ const { parser } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook,
+ });
+ if (Workbook && typeof Workbook === 'object' && !('Sheets' in Workbook)) {
+ expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({
+ worksheets: [{ name: 'Summary', hidden: false, rows: [] }],
+ });
+ } else {
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ }
+
+ const { proxy, revoke } = Proxy.revocable([], {});
+ revoke();
+ const { parser: revokedParser } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook: { Sheets: proxy },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, revokedParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('handles absent, short, hidden, very-hidden, and invalid metadata states deterministically', () => {
+ const sheet = {};
+ for (const workbook of [
+ { SheetNames: ['Summary'], Sheets: { Summary: sheet } },
+ {
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook: { Sheets: [] },
+ },
+ sheetWorkbook(sheet, { hidden: 0 }),
+ ]) {
+ const { parser } = parserFixture(workbook);
+ expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({
+ worksheets: [{ name: 'Summary', hidden: false, rows: [] }],
+ });
+ }
+
+ for (const hidden of [1, 2] as const) {
+ const { parser } = parserFixture(sheetWorkbook(sheet, { hidden }));
+ expect(sheetJsBytesToWorkbookData(XLSX_SOURCE, parser)).toEqual({
+ worksheets: [{ name: 'Summary', hidden: true, rows: [] }],
+ });
+ }
+
+ const hiddenGetter = vi.fn(() => 1);
+ const accessorMetadata = {};
+ Object.defineProperty(accessorMetadata, 'Hidden', {
+ enumerable: true,
+ get: hiddenGetter,
+ });
+ const { parser: accessorMetadataParser } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook: { Sheets: [accessorMetadata] },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorMetadataParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ expect(hiddenGetter).not.toHaveBeenCalled();
+
+ for (const metadata of [null, { Hidden: 3 }]) {
+ const { parser } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: sheet },
+ Workbook: { Sheets: [metadata] },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ });
+
+ it('rejects invalid worksheet names, oversized names, and invalid sheet objects', () => {
+ const { parser: nonStringName } = parserFixture({
+ SheetNames: [123],
+ Sheets: {},
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonStringName),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const oversized = 'x'.repeat(1_025);
+ const { parser: oversizedName } = parserFixture({
+ SheetNames: [oversized],
+ Sheets: { [oversized]: {} },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, oversizedName),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+
+ const { parser: invalidSheet } = parserFixture({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: null },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidSheet),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('bounds visible worksheets before range parsing', () => {
+ const names = Array.from({ length: 65 }, (_, index) => `Visible ${index}`);
+ const sheets = Object.fromEntries(names.map((name) => [name, {}]));
+ const { parser, decodeRange } = parserFixture({
+ SheetNames: names,
+ Sheets: sheets,
+ });
+
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ expect(decodeRange).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid range members without evaluating accessor-backed references', () => {
+ const refGetter = vi.fn(() => 'A1');
+ const accessorSheet = {};
+ Object.defineProperty(accessorSheet, '!ref', {
+ enumerable: true,
+ get: refGetter,
+ });
+ const { parser: accessorParser } = parserFixture(sheetWorkbook(accessorSheet));
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessorParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ expect(refGetter).not.toHaveBeenCalled();
+
+ const hostileSheet = new Proxy({}, {
+ getOwnPropertyDescriptor() {
+ throw new Error('private range trap');
+ },
+ });
+ const { parser: hostileParser } = parserFixture(sheetWorkbook(hostileSheet));
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, hostileParser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ for (const reference of [0, '']) {
+ const { parser } = parserFixture(
+ sheetWorkbook({ '!ref': reference } as object),
+ );
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ });
+
+ it('normalizes malformed decoded ranges and hostile range decoders', () => {
+ const cases: readonly (() => unknown)[] = [
+ () => { throw new Error('private'); },
+ () => null,
+ () => ({ s: null, e: { r: 0, c: 0 } }),
+ () => ({ s: { r: 0, c: 0 }, e: null }),
+ () => ({ s: { r: Number.NaN, c: 0 }, e: { r: 0, c: 0 } }),
+ () => ({ s: { r: -1, c: 0 }, e: { r: 0, c: 0 } }),
+ () => ({ s: { r: 1, c: 0 }, e: { r: 0, c: 0 } }),
+ () => ({ s: { r: 0, c: 1 }, e: { r: 0, c: 0 } }),
+ ];
+
+ for (const decodeRange of cases) {
+ const { parser } = parserFixture(
+ sheetWorkbook({ '!ref': 'A1' }),
+ { decodeRange: () => decodeRange() },
+ );
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ }
+ });
+
+ it('preflights decoded column, aggregate-row, and aggregate-cell ceilings before row materialization', () => {
+ const wide = parserFixture(
+ sheetWorkbook({ '!ref': 'wide' }),
+ {
+ decodeRange: () => ({
+ s: { r: 0, c: 0 },
+ e: { r: 0, c: 256 },
+ }),
+ },
+ );
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, wide.parser),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ expect(wide.sheetToJson).not.toHaveBeenCalled();
+
+ const twoSheets = {
+ SheetNames: ['First', 'Second'],
+ Sheets: {
+ First: { '!ref': 'first' },
+ Second: { '!ref': 'second' },
+ },
+ };
+ const rows = parserFixture(twoSheets, {
+ decodeRange: () => ({
+ s: { r: 0, c: 0 },
+ e: { r: 5_999, c: 0 },
+ }),
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, rows.parser),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ expect(rows.sheetToJson).toHaveBeenCalledTimes(1);
+
+ const cells = parserFixture(
+ sheetWorkbook({ '!ref': 'cells' }),
+ {
+ decodeRange: () => ({
+ s: { r: 0, c: 0 },
+ e: { r: 1_024, c: 255 },
+ }),
+ },
+ );
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, cells.parser),
+ 'RESOURCE_LIMIT_EXCEEDED',
+ );
+ expect(cells.sheetToJson).not.toHaveBeenCalled();
+ });
+
+ it('normalizes row materialization failures and hostile array containers', () => {
+ const sheet = { '!ref': 'A1' };
+
+ const throwing = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => { throw new Error('private'); },
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, throwing.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const nonArray = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => ({}),
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonArray.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const { proxy, revoke } = Proxy.revocable([], {});
+ revoke();
+ const revoked = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => proxy,
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, revoked.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const invalidRow = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => [null],
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidRow.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+
+ const invalidRowLength = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => [descriptorReportingLength(-1)],
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, invalidRowLength.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+
+ it('rejects accessor-backed and non-string displayed cells without evaluating caller code', () => {
+ const sheet = { '!ref': 'A1' };
+ const cellGetter = vi.fn(() => 'private');
+ const accessorRow: unknown[] = [];
+ Object.defineProperty(accessorRow, '0', {
+ enumerable: true,
+ configurable: true,
+ get: cellGetter,
+ });
+ accessorRow.length = 1;
+
+ const accessor = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => [accessorRow],
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, accessor.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ expect(cellGetter).not.toHaveBeenCalled();
+
+ const nonString = parserFixture(sheetWorkbook(sheet), {
+ sheetToJson: () => [[42]],
+ });
+ expectSpreadsheetError(
+ () => sheetJsBytesToWorkbookData(XLSX_SOURCE, nonString.parser),
+ 'UNSUPPORTED_OR_CORRUPT',
+ );
+ });
+});
diff --git a/src/spreadsheet/sheetJsAdapter.ts b/src/spreadsheet/sheetJsAdapter.ts
new file mode 100644
index 00000000..441ae10d
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.ts
@@ -0,0 +1,391 @@
+import type {
+ SpreadsheetWorkbookData,
+ SpreadsheetWorksheetData,
+} from './spreadsheetImport.js';
+import {
+ preflightSpreadsheetBinarySource,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+const MAX_VISIBLE_WORKSHEETS = 64;
+const MAX_WORKBOOK_WORKSHEETS = 256;
+const MAX_WORKSHEET_NAME_CODE_UNITS = 1_024;
+const MAX_WORKBOOK_ROWS = 10_000;
+const MAX_WORKSHEET_COLUMNS = 256;
+const MAX_WORKBOOK_CELLS = 262_144;
+const RESOURCE_LIMIT_MESSAGE =
+ 'Spreadsheet exceeds the configured resource limits.';
+const UNSUPPORTED_SOURCE_MESSAGE =
+ 'Spreadsheet source is unsupported or corrupt.';
+
+interface SheetJsReadOptions {
+ readonly type: 'array';
+ readonly cellFormula: false;
+ readonly cellHTML: false;
+ readonly cellNF: false;
+ readonly bookVBA: false;
+ readonly sheetRows?: number;
+ readonly bookSheets?: true;
+ readonly sheets?: string;
+}
+
+/** Minimal SheetJS runtime contract consumed by Inkspan's local adapter. */
+export interface SheetJsParserModule {
+ readonly read: (
+ source: Uint8Array,
+ options: SheetJsReadOptions,
+ ) => unknown;
+ readonly utils: {
+ readonly decode_range: (range: string) => unknown;
+ readonly sheet_to_json: (
+ sheet: unknown,
+ options: {
+ readonly header: 1;
+ readonly raw: false;
+ readonly defval: '';
+ readonly blankrows: true;
+ },
+ ) => unknown;
+ };
+}
+
+function resourceLimitExceeded(): never {
+ throw new SpreadsheetImportError(
+ 'RESOURCE_LIMIT_EXCEEDED',
+ RESOURCE_LIMIT_MESSAGE,
+ );
+}
+
+function unsupportedOrCorruptSource(): never {
+ throw new SpreadsheetImportError(
+ 'UNSUPPORTED_OR_CORRUPT',
+ UNSUPPORTED_SOURCE_MESSAGE,
+ );
+}
+
+function isObject(value: unknown): value is object {
+ return typeof value === 'object' && value !== null;
+}
+
+function isArray(value: unknown): value is readonly unknown[] {
+ try {
+ return Array.isArray(value);
+ } catch {
+ return false;
+ }
+}
+
+function readOwnDataProperty(source: object, key: PropertyKey): unknown {
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(source, key);
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+ if (descriptor === undefined || !('value' in descriptor)) {
+ unsupportedOrCorruptSource();
+ }
+ return descriptor.value;
+}
+
+function readOptionalOwnDataProperty(source: object, key: PropertyKey): unknown {
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(source, key);
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+ if (descriptor === undefined) return undefined;
+ if (!('value' in descriptor)) unsupportedOrCorruptSource();
+ return descriptor.value;
+}
+
+function readArrayLength(source: readonly unknown[]): number {
+ const length = readOwnDataProperty(source, 'length');
+ if (!Number.isSafeInteger(length) || (length as number) < 0) {
+ unsupportedOrCorruptSource();
+ }
+ return length as number;
+}
+
+function readHiddenState(
+ sheetMetadata: readonly unknown[] | undefined,
+ index: number,
+): boolean {
+ if (sheetMetadata === undefined || index >= readArrayLength(sheetMetadata)) {
+ return false;
+ }
+ const metadata = readOwnDataProperty(sheetMetadata, String(index));
+ if (!isObject(metadata)) unsupportedOrCorruptSource();
+ const hidden = readOptionalOwnDataProperty(metadata, 'Hidden');
+ if (hidden === undefined || hidden === 0) return false;
+ if (hidden === 1 || hidden === 2) return true;
+ return unsupportedOrCorruptSource();
+}
+
+function readWorkbookSheetMetadata(workbook: object): readonly unknown[] | undefined {
+ const workbookMetadata = readOptionalOwnDataProperty(workbook, 'Workbook');
+ if (workbookMetadata === undefined) return undefined;
+ if (!isObject(workbookMetadata)) unsupportedOrCorruptSource();
+ const sheetMetadata = readOptionalOwnDataProperty(workbookMetadata, 'Sheets');
+ if (sheetMetadata === undefined) return undefined;
+ if (!isArray(sheetMetadata)) unsupportedOrCorruptSource();
+ return sheetMetadata;
+}
+
+function readParsedSheetIndex(workbook: object, expectedName: string): number {
+ const sheetNames = readOwnDataProperty(workbook, 'SheetNames');
+ if (!isArray(sheetNames)) unsupportedOrCorruptSource();
+ const sheetCount = readArrayLength(sheetNames);
+ if (sheetCount > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded();
+
+ let matchedIndex = -1;
+ for (let index = 0; index < sheetCount; index += 1) {
+ const name = readOwnDataProperty(sheetNames, String(index));
+ if (typeof name !== 'string') unsupportedOrCorruptSource();
+ if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) resourceLimitExceeded();
+ if (name !== expectedName) continue;
+ if (matchedIndex !== -1) unsupportedOrCorruptSource();
+ matchedIndex = index;
+ }
+ if (matchedIndex === -1) unsupportedOrCorruptSource();
+ return matchedIndex;
+}
+
+function decodeRangeDimensions(
+ parser: SheetJsParserModule,
+ reference: string,
+): { readonly rows: number; readonly columns: number } {
+ let decoded: unknown;
+ try {
+ decoded = parser.utils.decode_range(reference);
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+ if (!isObject(decoded)) unsupportedOrCorruptSource();
+ const start = readOwnDataProperty(decoded, 's');
+ const end = readOwnDataProperty(decoded, 'e');
+ if (!isObject(start) || !isObject(end)) unsupportedOrCorruptSource();
+ const startRow = readOwnDataProperty(start, 'r');
+ const startColumn = readOwnDataProperty(start, 'c');
+ const endRow = readOwnDataProperty(end, 'r');
+ const endColumn = readOwnDataProperty(end, 'c');
+ for (const coordinate of [startRow, startColumn, endRow, endColumn]) {
+ if (!Number.isSafeInteger(coordinate) || (coordinate as number) < 0) {
+ unsupportedOrCorruptSource();
+ }
+ }
+ if ((endRow as number) < (startRow as number)) unsupportedOrCorruptSource();
+ if ((endColumn as number) < (startColumn as number)) {
+ unsupportedOrCorruptSource();
+ }
+ return {
+ rows: (endRow as number) - (startRow as number) + 1,
+ columns: (endColumn as number) - (startColumn as number) + 1,
+ };
+}
+
+function readDisplayedRows(
+ parser: SheetJsParserModule,
+ sheet: object,
+ expectedRows: number,
+ expectedColumns: number,
+): readonly (readonly string[])[] {
+ let rawRows: unknown;
+ try {
+ rawRows = parser.utils.sheet_to_json(sheet, {
+ header: 1,
+ raw: false,
+ defval: '',
+ blankrows: true,
+ });
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+ if (!isArray(rawRows)) unsupportedOrCorruptSource();
+ const rowCount = readArrayLength(rawRows);
+ if (rowCount > expectedRows) resourceLimitExceeded();
+ const rows: string[][] = [];
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
+ const rawRow = readOwnDataProperty(rawRows, String(rowIndex));
+ if (!isArray(rawRow)) unsupportedOrCorruptSource();
+ const columnCount = readArrayLength(rawRow);
+ if (columnCount > expectedColumns) resourceLimitExceeded();
+ const row: string[] = [];
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
+ const cell = readOwnDataProperty(rawRow, String(columnIndex));
+ if (typeof cell !== 'string') unsupportedOrCorruptSource();
+ row.push(cell);
+ }
+ rows.push(row);
+ }
+ return rows;
+}
+
+function hasDisplayedCellText(rows: readonly (readonly string[])[]): boolean {
+ return rows.some((row) => row.some((cell) => cell.length > 0));
+}
+
+function readWorkbook(
+ parser: SheetJsParserModule,
+ source: Uint8Array,
+ options: SheetJsReadOptions,
+): object {
+ let parsed: unknown;
+ try {
+ parsed = parser.read(source, options);
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+ if (!isObject(parsed)) unsupportedOrCorruptSource();
+ return parsed;
+}
+
+function baseReadOptions(): Omit<
+ SheetJsReadOptions,
+ 'bookSheets' | 'sheets' | 'sheetRows'
+> {
+ return {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ };
+}
+
+/**
+ * Project locally parsed SheetJS workbook data into Inkspan's parser-neutral
+ * workbook contract without granting formulas, macros, links, or parser output
+ * any editor authority. XLSX first performs a sheet-name-only discovery pass and
+ * then selectively parses individual sheet bodies against the remaining aggregate
+ * row budget. BIFF8 uses one invocation-local whole-workbook snapshot bounded to
+ * `MAX_WORKBOOK_ROWS + 1` rows per worksheet. Real BIFF8 evidence shows that
+ * mixing visibility and selective parser reads can make later hidden-sheet state
+ * depend on parser history, so visibility and displayed bodies must come from the
+ * same parser result. Parser-synthesized blank-only BIFF8 ranges are normalized to
+ * an empty displayed-row projection so an otherwise empty worksheet cannot become
+ * a visible one-cell table merely because the parser reports a degenerate `A1`
+ * range. The source envelope remains bounded before parser loading, and exact
+ * worksheet/count/range/row/column/cell limits are revalidated before displayed
+ * rows are materialized into Inkspan's parser-neutral contract.
+ */
+export function sheetJsBytesToWorkbookData(
+ source: Uint8Array,
+ parser: SheetJsParserModule,
+): SpreadsheetWorkbookData {
+ const boundedSource = preflightSpreadsheetBinarySource(source);
+ const isBiff8 = boundedSource.format === 'xls';
+ const parserSource = isBiff8
+ ? new Uint8Array(boundedSource.bytes)
+ : boundedSource.bytes;
+ const biff8Workbook = isBiff8
+ ? readWorkbook(parser, parserSource, {
+ ...baseReadOptions(),
+ sheetRows: MAX_WORKBOOK_ROWS + 1,
+ })
+ : undefined;
+ const discovery =
+ biff8Workbook ??
+ readWorkbook(parser, parserSource, {
+ ...baseReadOptions(),
+ bookSheets: true,
+ });
+ const sheetNames = readOwnDataProperty(discovery, 'SheetNames');
+ if (!isArray(sheetNames)) unsupportedOrCorruptSource();
+ const sheetCount = readArrayLength(sheetNames);
+ if (sheetCount > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded();
+
+ const worksheetNames: string[] = [];
+ for (let index = 0; index < sheetCount; index += 1) {
+ const name = readOwnDataProperty(sheetNames, String(index));
+ if (typeof name !== 'string') unsupportedOrCorruptSource();
+ if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) resourceLimitExceeded();
+ worksheetNames.push(name);
+ }
+
+ const biff8SheetMetadata =
+ biff8Workbook === undefined
+ ? undefined
+ : readWorkbookSheetMetadata(biff8Workbook);
+ const biff8HiddenStates =
+ biff8Workbook === undefined
+ ? undefined
+ : worksheetNames.map((name) =>
+ readHiddenState(
+ biff8SheetMetadata,
+ readParsedSheetIndex(biff8Workbook, name),
+ ),
+ );
+
+ const worksheets: SpreadsheetWorksheetData[] = [];
+ let visibleCount = 0;
+ let decodedRows = 0;
+ let decodedCells = 0;
+
+ for (const [worksheetIndex, name] of worksheetNames.entries()) {
+ if (biff8HiddenStates?.[worksheetIndex] === true) {
+ worksheets.push({ name, hidden: true, rows: [] });
+ continue;
+ }
+
+ const remainingRows = MAX_WORKBOOK_ROWS - decodedRows;
+ const parsed =
+ biff8Workbook ??
+ readWorkbook(parser, parserSource, {
+ ...baseReadOptions(),
+ sheets: name,
+ sheetRows: remainingRows + 1,
+ });
+ const sheets = readOwnDataProperty(parsed, 'Sheets');
+ if (!isObject(sheets)) unsupportedOrCorruptSource();
+ const sheet = readOwnDataProperty(sheets, name);
+ if (!isObject(sheet)) unsupportedOrCorruptSource();
+
+ if (biff8Workbook === undefined) {
+ const parsedSheetIndex = readParsedSheetIndex(parsed, name);
+ const sheetMetadata = readWorkbookSheetMetadata(parsed);
+ if (readHiddenState(sheetMetadata, parsedSheetIndex)) {
+ worksheets.push({ name, hidden: true, rows: [] });
+ continue;
+ }
+ }
+
+ visibleCount += 1;
+ if (visibleCount > MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded();
+ const reference = readOptionalOwnDataProperty(sheet, '!ref');
+ if (reference === undefined) {
+ worksheets.push({ name, hidden: false, rows: [] });
+ continue;
+ }
+ if (typeof reference !== 'string' || reference.length === 0) {
+ unsupportedOrCorruptSource();
+ }
+ const dimensions = decodeRangeDimensions(parser, reference);
+ if (dimensions.columns > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded();
+ const nextDecodedRows = decodedRows + dimensions.rows;
+ const nextDecodedCells =
+ decodedCells + dimensions.rows * dimensions.columns;
+ if (
+ nextDecodedRows > MAX_WORKBOOK_ROWS ||
+ nextDecodedCells > MAX_WORKBOOK_CELLS
+ ) {
+ resourceLimitExceeded();
+ }
+ decodedRows = nextDecodedRows;
+ decodedCells = nextDecodedCells;
+ const displayedRows = readDisplayedRows(
+ parser,
+ sheet,
+ dimensions.rows,
+ dimensions.columns,
+ );
+ worksheets.push({
+ name,
+ hidden: false,
+ rows: hasDisplayedCellText(displayedRows) ? displayedRows : [],
+ });
+ }
+
+ return { worksheets };
+}
diff --git a/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts
new file mode 100644
index 00000000..bf62a2ac
--- /dev/null
+++ b/src/spreadsheet/sheetJsAdapter.visibilityMetadata.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+
+const BIFF8_SOURCE = new Uint8Array([
+ 0xd0,
+ 0xcf,
+ 0x11,
+ 0xe0,
+ 0xa1,
+ 0xb1,
+ 0x1a,
+ 0xe1,
+]);
+
+const ONE_SNAPSHOT_OPTIONS = {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ sheetRows: 10_001,
+} as const;
+
+describe('sheetJsBytesToWorkbookData BIFF8 visibility authority', () => {
+ it('projects BIFF8 visibility and visible bodies from one bounded workbook snapshot', () => {
+ const summarySheet = { '!ref': 'A1' };
+ const privateSheet = { '!ref': 'A1' };
+ const read = vi.fn(
+ (
+ _source: Uint8Array,
+ options: Parameters[1],
+ ): unknown => {
+ expect(options).toEqual(ONE_SNAPSHOT_OPTIONS);
+ return {
+ SheetNames: ['Summary', 'Private'],
+ Sheets: {
+ Summary: summarySheet,
+ Private: privateSheet,
+ },
+ Workbook: {
+ Sheets: [{ Hidden: 0 }, { Hidden: 1 }],
+ },
+ };
+ },
+ );
+ const sheetToJson = vi.fn((sheet: unknown) =>
+ sheet === summarySheet ? [['public']] : [['private']],
+ );
+ const parser: SheetJsParserModule = {
+ read,
+ utils: {
+ decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }),
+ sheet_to_json: sheetToJson,
+ },
+ };
+
+ expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({
+ worksheets: [
+ { name: 'Summary', hidden: false, rows: [['public']] },
+ { name: 'Private', hidden: true, rows: [] },
+ ],
+ });
+ expect(read).toHaveBeenCalledTimes(1);
+ expect(read.mock.calls[0]?.[0]).not.toBe(BIFF8_SOURCE);
+ expect(read).toHaveBeenCalledWith(expect.any(Uint8Array), ONE_SNAPSHOT_OPTIONS);
+ expect(sheetToJson).toHaveBeenCalledTimes(1);
+ expect(sheetToJson).toHaveBeenCalledWith(summarySheet, {
+ header: 1,
+ raw: false,
+ defval: '',
+ blankrows: true,
+ });
+ });
+
+ it('remains deterministic across repeated BIFF8 imports without selective parser reads', () => {
+ const summarySheet = { '!ref': 'A1' };
+ const privateSheet = { '!ref': 'A1' };
+ let parserHistoryWasPoisoned = false;
+
+ const read = vi.fn(
+ (
+ _source: Uint8Array,
+ options: Parameters[1],
+ ): unknown => {
+ if (options.sheets !== undefined || options.sheetRows === 1) {
+ parserHistoryWasPoisoned = true;
+ }
+ const hidden = parserHistoryWasPoisoned ? 0 : 1;
+ return {
+ SheetNames: ['Summary', 'Private'],
+ Sheets: {
+ Summary: summarySheet,
+ Private: privateSheet,
+ },
+ Workbook: {
+ Sheets: [{ Hidden: 0 }, { Hidden: hidden }],
+ },
+ };
+ },
+ );
+ const parser: SheetJsParserModule = {
+ read,
+ utils: {
+ decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }),
+ sheet_to_json: vi.fn((sheet: unknown) =>
+ sheet === summarySheet ? [['public']] : [['private']],
+ ),
+ },
+ };
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({
+ worksheets: [
+ { name: 'Summary', hidden: false, rows: [['public']] },
+ { name: 'Private', hidden: true, rows: [] },
+ ],
+ });
+ }
+
+ expect(read).toHaveBeenCalledTimes(2);
+ for (const [, options] of read.mock.calls) {
+ expect(options).toEqual(ONE_SNAPSHOT_OPTIONS);
+ }
+ });
+
+ it('does not issue any additional parser reads when a bounded BIFF8 snapshot has no sheets', () => {
+ const read = vi.fn(() => ({ SheetNames: [], Sheets: {} }));
+ const parser: SheetJsParserModule = {
+ read,
+ utils: {
+ decode_range: vi.fn(),
+ sheet_to_json: vi.fn(),
+ },
+ };
+
+ expect(sheetJsBytesToWorkbookData(BIFF8_SOURCE, parser)).toEqual({
+ worksheets: [],
+ });
+ expect(read).toHaveBeenCalledTimes(1);
+ expect(read).toHaveBeenCalledWith(expect.any(Uint8Array), ONE_SNAPSHOT_OPTIONS);
+ });
+});
\ No newline at end of file
diff --git a/src/spreadsheet/sheetJsFileImport.test.ts b/src/spreadsheet/sheetJsFileImport.test.ts
new file mode 100644
index 00000000..bf2e7a23
--- /dev/null
+++ b/src/spreadsheet/sheetJsFileImport.test.ts
@@ -0,0 +1,415 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import * as XLSX from 'xlsx';
+import {
+ SpreadsheetImportError,
+ spreadsheetFileToDocumentJson,
+ type SpreadsheetFileSource,
+} from './index.js';
+
+type WorkbookFormat = 'xlsx' | 'biff8';
+
+function sourceFromBytes(bytes: Uint8Array): SpreadsheetFileSource {
+ return {
+ size: bytes.byteLength,
+ async arrayBuffer() {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ ) as ArrayBuffer;
+ },
+ };
+}
+
+function serializeWorkbook(
+ workbook: XLSX.WorkBook,
+ bookType: WorkbookFormat,
+): Uint8Array {
+ const serialized = XLSX.write(workbook, { type: 'array', bookType });
+ return serialized instanceof Uint8Array
+ ? serialized
+ : new Uint8Array(serialized as ArrayBuffer);
+}
+
+function workbookBytes(bookType: WorkbookFormat): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ const worksheet = XLSX.utils.aoa_to_sheet([
+ ['Name', 'Value'],
+ ['Revenue', 42],
+ ]);
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary');
+ return serializeWorkbook(workbook, bookType);
+}
+
+function richWorkbookBytes(bookType: WorkbookFormat): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ const worksheet = XLSX.utils.aoa_to_sheet([
+ ['Kind', 'Value'],
+ ['Unicode', '매출'],
+ ['Multiline', 'line 1\nline 2'],
+ ['Boolean', true],
+ [
+ 'Date',
+ {
+ t: 'd',
+ v: new Date(Date.UTC(2026, 7, 17)),
+ z: 'yyyy-mm-dd',
+ } satisfies XLSX.CellObject,
+ ],
+ [
+ 'Formula',
+ {
+ t: 'n',
+ v: 42,
+ f: 'SUM(40,2)',
+ } satisfies XLSX.CellObject,
+ ],
+ [
+ 'Hyperlink',
+ {
+ t: 's',
+ v: 'Reference',
+ l: { Target: 'https://secret.invalid/workbook' },
+ } satisfies XLSX.CellObject,
+ ],
+ ]);
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Summary');
+ XLSX.utils.book_append_sheet(
+ workbook,
+ XLSX.utils.aoa_to_sheet([['private hidden value']]),
+ 'Hidden',
+ );
+ XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty');
+
+ /*
+ * Keep a complete metadata array. SheetJS stores visibility by worksheet
+ * index, and its BIFF8 writer traverses workbook metadata as an ordered
+ * sequence. A sparse array can collapse the intended index when serialized,
+ * producing a fixture whose supposedly hidden sheet is actually visible.
+ */
+ workbook.Workbook = {
+ ...(workbook.Workbook ?? {}),
+ Sheets: [{ Hidden: 0 }, { Hidden: 1 }, { Hidden: 0 }],
+ };
+
+ return serializeWorkbook(workbook, bookType);
+}
+
+function fileFromWorkbookBytes(bytes: Uint8Array, name: string, type: string): File {
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return new File([copy], name, { type });
+}
+
+function expectUnsupported(promise: Promise) {
+ return expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ } satisfies Partial);
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('spreadsheetFileToDocumentJson', () => {
+ it('preserves hidden-sheet metadata in the real BIFF8 visibility parse', () => {
+ const workbook = XLSX.read(richWorkbookBytes('biff8'), {
+ type: 'array',
+ cellFormula: false,
+ cellHTML: false,
+ cellNF: false,
+ bookVBA: false,
+ sheetRows: 1,
+ });
+
+ expect(workbook.SheetNames).toEqual(['Summary', 'Hidden', 'Empty']);
+ expect(
+ workbook.Workbook?.Sheets?.map((sheet) => sheet.Hidden ?? 0),
+ ).toEqual([0, 1, 0]);
+ });
+
+ it.each([
+ ['XLSX', 'xlsx'],
+ ['BIFF8 XLS', 'biff8'],
+ ] as const)(
+ 'converts a real local %s source into bounded editable TipTap content',
+ async (_label, bookType) => {
+ const result = await spreadsheetFileToDocumentJson(
+ sourceFromBytes(workbookBytes(bookType)),
+ );
+
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 2,
+ cellCount: 4,
+ });
+ expect(result.content.map((node) => node.type)).toEqual([
+ 'heading',
+ 'table',
+ 'paragraph',
+ ]);
+ expect(result.content[0]).toEqual({
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Summary' }],
+ });
+ },
+ );
+
+ it.each([
+ ['XLSX', 'xlsx'],
+ ['BIFF8 XLS', 'biff8'],
+ ] as const)(
+ 'materializes only inert visible displayed values from a real %s workbook',
+ async (_label, bookType) => {
+ const result = await spreadsheetFileToDocumentJson(
+ sourceFromBytes(richWorkbookBytes(bookType)),
+ );
+ const materialized = JSON.stringify(result.content);
+
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 7,
+ cellCount: 14,
+ });
+ expect(materialized).toContain('매출');
+ expect(materialized).toContain('hardBreak');
+ expect(materialized).toContain('2026-08-17');
+ expect(materialized).toContain('Reference');
+ expect(materialized).toContain('42');
+ expect(materialized).not.toContain('SUM(40,2)');
+ expect(materialized).not.toContain('https://secret.invalid/workbook');
+ expect(materialized).not.toContain('private hidden value');
+ expect(materialized).not.toContain('Hidden');
+ expect(materialized).not.toContain('Empty');
+ },
+ );
+
+ it('rejects an oversized source before reading its bytes', async () => {
+ const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));
+ const source: SpreadsheetFileSource = {
+ size: 64 * 1024 * 1024 + 1,
+ arrayBuffer,
+ };
+
+ await expect(spreadsheetFileToDocumentJson(source)).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'RESOURCE_LIMIT_EXCEEDED',
+ message: 'Spreadsheet exceeds the configured resource limits.',
+ } satisfies Partial);
+ expect(arrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it.each([-1, 1.5])('rejects an invalid declared source size %s', async (size) => {
+ const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));
+ await expectUnsupported(
+ spreadsheetFileToDocumentJson({ size, arrayBuffer }),
+ );
+ expect(arrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('normalizes an unreadable source-size accessor', async () => {
+ const source = {
+ arrayBuffer: vi.fn(async () => new ArrayBuffer(0)),
+ } as unknown as SpreadsheetFileSource;
+ Object.defineProperty(source, 'size', {
+ get() {
+ throw new Error('private local path');
+ },
+ });
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ expect(source.arrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('normalizes unreadable local bytes without leaking parser payload details', async () => {
+ const source: SpreadsheetFileSource = {
+ size: 4,
+ async arrayBuffer() {
+ throw new Error('private local path and workbook payload');
+ },
+ };
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('rejects a non-ArrayBuffer body from a hostile source adapter', async () => {
+ const source = {
+ size: 4,
+ async arrayBuffer() {
+ return 'not bytes';
+ },
+ } as unknown as SpreadsheetFileSource;
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('rejects a source whose declared size changes at the byte boundary', async () => {
+ const source: SpreadsheetFileSource = {
+ size: 4,
+ async arrayBuffer() {
+ return new ArrayBuffer(5);
+ },
+ };
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('reads a genuine browser File through FileReader when arrayBuffer is absent', async () => {
+ const bytes = workbookBytes('xlsx');
+ const file = fileFromWorkbookBytes(
+ bytes,
+ 'quarterly-revenue.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+ expect(typeof file.arrayBuffer).not.toBe('function');
+
+ const result = await spreadsheetFileToDocumentJson(file);
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 2,
+ cellCount: 4,
+ });
+ expect(JSON.stringify(result.content)).toContain('Revenue');
+ expect(JSON.stringify(result.content)).toContain('42');
+ });
+
+ it('normalizes a hostile arrayBuffer accessor without leaking payload text', async () => {
+ const source = {
+ size: 4,
+ } as SpreadsheetFileSource;
+ Object.defineProperty(source, 'arrayBuffer', {
+ enumerable: true,
+ get() {
+ throw new Error('private local path');
+ },
+ });
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('rejects a non-Blob source that cannot supply arrayBuffer', async () => {
+ await expectUnsupported(
+ spreadsheetFileToDocumentJson({ size: 4 } as SpreadsheetFileSource),
+ );
+ });
+
+ it('normalizes FileReader construction and read failures', async () => {
+ const bytes = workbookBytes('xlsx');
+ const file = fileFromWorkbookBytes(
+ bytes,
+ 'quarterly-revenue.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+
+ class ThrowingReader {
+ onload: ((event: ProgressEvent) => void) | null = null;
+ onerror: ((event: ProgressEvent) => void) | null = null;
+ result: ArrayBuffer | null = null;
+ error: DOMException | null = null;
+ constructor() {
+ throw new Error('private FileReader construction');
+ }
+ readAsArrayBuffer(): void {}
+ }
+ vi.stubGlobal('FileReader', ThrowingReader);
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+
+ class ReadThrowingReader {
+ onload: ((event: ProgressEvent) => void) | null = null;
+ onerror: ((event: ProgressEvent) => void) | null = null;
+ result: ArrayBuffer | null = null;
+ error: DOMException | null = null;
+ readAsArrayBuffer(): void {
+ throw new Error('private FileReader read');
+ }
+ }
+ vi.stubGlobal('FileReader', ReadThrowingReader);
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+
+ class ErroringReader {
+ onload: ((event: ProgressEvent) => void) | null = null;
+ onerror: ((event: ProgressEvent) => void) | null = null;
+ result: ArrayBuffer | null = null;
+ error = new DOMException('private FileReader error');
+ readAsArrayBuffer(): void {
+ this.onerror?.(new Event('error') as ProgressEvent);
+ }
+ }
+ vi.stubGlobal('FileReader', ErroringReader);
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+
+ class NonBufferReader {
+ onload: ((event: ProgressEvent) => void) | null = null;
+ onerror: ((event: ProgressEvent) => void) | null = null;
+ result: string | ArrayBuffer | null = 'not bytes';
+ error: DOMException | null = null;
+ readAsArrayBuffer(): void {
+ this.onload?.(new Event('load') as ProgressEvent);
+ }
+ }
+ vi.stubGlobal('FileReader', NonBufferReader);
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+
+ vi.unstubAllGlobals();
+ });
+
+ it('reads a Blob through Response when FileReader is unavailable', async () => {
+ const bytes = workbookBytes('xlsx');
+ const file = fileFromWorkbookBytes(
+ bytes,
+ 'quarterly-revenue.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+ const copied = bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ ) as ArrayBuffer;
+ vi.stubGlobal('FileReader', undefined);
+ vi.stubGlobal(
+ 'Response',
+ class {
+ async arrayBuffer(): Promise {
+ return copied;
+ }
+ },
+ );
+ const result = await spreadsheetFileToDocumentJson(file);
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 2,
+ cellCount: 4,
+ });
+ });
+
+ it('normalizes a failing Response fallback without leaking payload text', async () => {
+ const bytes = workbookBytes('xlsx');
+ const file = fileFromWorkbookBytes(
+ bytes,
+ 'quarterly-revenue.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+ vi.stubGlobal('FileReader', undefined);
+ vi.stubGlobal(
+ 'Response',
+ class {
+ constructor() {
+ throw new Error('private Response payload');
+ }
+ },
+ );
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+ });
+
+ it('rejects a Blob when neither FileReader nor Response can read it', async () => {
+ const bytes = workbookBytes('xlsx');
+ const file = fileFromWorkbookBytes(
+ bytes,
+ 'quarterly-revenue.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ );
+ vi.stubGlobal('FileReader', undefined);
+ vi.stubGlobal('Response', undefined);
+ await expectUnsupported(spreadsheetFileToDocumentJson(file));
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntime.failClosed.test.ts b/src/spreadsheet/sheetJsRuntime.failClosed.test.ts
new file mode 100644
index 00000000..d50ca138
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntime.failClosed.test.ts
@@ -0,0 +1,410 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ parseSheetJsSpreadsheetBytesWithParserLoader,
+} from './sheetJsRuntime.js';
+import type { SheetJsParserModule } from './sheetJsAdapter.js';
+import { SpreadsheetImportError } from './spreadsheetImport.js';
+
+const BIFF8_SIGNATURE = [
+ 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
+] as const;
+
+const BIFF8_BOF = 0x0809;
+const BIFF8_BOUNDSHEET8 = 0x0085;
+const BIFF8_EOF = 0x000a;
+
+function biff8Envelope(minimumLength = 65_536): Uint8Array {
+ const bytes = new Uint8Array(Math.max(minimumLength, 8));
+ bytes.set(BIFF8_SIGNATURE);
+ return bytes;
+}
+
+function writeUint16(target: number[], value: number): void {
+ target.push(value & 0xff, (value >> 8) & 0xff);
+}
+
+function record(type: number, payload: readonly number[]): number[] {
+ const encoded: number[] = [];
+ writeUint16(encoded, type);
+ writeUint16(encoded, payload.length);
+ encoded.push(...payload);
+ return encoded;
+}
+
+function workbookBof(payload: readonly number[] = [0x00, 0x06, 0x05, 0x00]): number[] {
+ return record(BIFF8_BOF, payload);
+}
+
+function boundSheet(visibility = 0): number[] {
+ return record(BIFF8_BOUNDSHEET8, [0, 0, 0, 0, visibility, 0, 0, 0]);
+}
+
+function eof(): number[] {
+ return record(BIFF8_EOF, []);
+}
+
+function validWorkbookStream(): number[] {
+ return [...workbookBof(), ...boundSheet(0), ...eof()];
+}
+
+function expectUnsupported(promise: Promise) {
+ return expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ } satisfies Partial);
+}
+
+function expectResourceLimit(promise: Promise) {
+ return expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'RESOURCE_LIMIT_EXCEEDED',
+ message: 'Spreadsheet exceeds the configured resource limits.',
+ } satisfies Partial);
+}
+
+function parserWithCfb(
+ content: unknown,
+ options: {
+ readonly cfb?: unknown;
+ readonly read?: () => unknown;
+ readonly find?: () => unknown;
+ } = {},
+): SheetJsParserModule {
+ const find =
+ options.find ??
+ (() => ({
+ content,
+ }));
+ const cfb =
+ options.cfb ??
+ {
+ read: options.read ?? (() => ({})),
+ find,
+ };
+ return {
+ CFB: cfb,
+ read: vi.fn(() => ({
+ SheetNames: ['Summary'],
+ Sheets: { Summary: {} },
+ Workbook: { Sheets: [{ Hidden: 0 }] },
+ })),
+ utils: {
+ decode_range: vi.fn(() => ({
+ s: { r: 0, c: 0 },
+ e: { r: 0, c: 0 },
+ })),
+ sheet_to_json: vi.fn(() => []),
+ },
+ } as unknown as SheetJsParserModule;
+}
+
+describe('SheetJS BIFF8 runtime fail-closed boundaries', () => {
+ it('rejects a parser without a usable CFB reader', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([], { cfb: {} }),
+ ),
+ );
+ });
+
+ it('normalizes CFB container and Workbook-entry failures without leaking payload text', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () =>
+ parserWithCfb([], {
+ read: () => {
+ throw new Error('private compound-file payload');
+ },
+ }),
+ ),
+ );
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([], { find: () => null }),
+ ),
+ );
+ });
+
+ it('rejects a Workbook entry whose content descriptor is missing or hostile', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(undefined, { find: () => ({}) }),
+ ),
+ );
+
+ const accessorEntry = {};
+ Object.defineProperty(accessorEntry, 'content', {
+ enumerable: true,
+ get() {
+ throw new Error('private workbook stream');
+ },
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(undefined, { find: () => accessorEntry }),
+ ),
+ );
+
+ const descriptorTrap = new Proxy(
+ {},
+ {
+ getOwnPropertyDescriptor() {
+ throw new Error('private descriptor trap');
+ },
+ },
+ );
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(undefined, { find: () => descriptorTrap }),
+ ),
+ );
+ });
+
+ it('rejects CFB byte views whose length metadata cannot be trusted', async () => {
+ const throwingView = new Uint8Array([1]);
+ Object.defineProperty(throwingView, 'byteLength', {
+ configurable: true,
+ get() {
+ throw new Error('private view length');
+ },
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(throwingView),
+ ),
+ );
+
+ const mismatchedView = new Uint16Array([1]);
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(mismatchedView),
+ ),
+ );
+ });
+
+ it('rejects CFB content that is neither a byte view nor a documented byte array', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb({}),
+ ),
+ );
+ });
+
+ it('rejects CFB byte views whose indexed values are not octets', async () => {
+ const hostileView = Object.create(null) as {
+ byteLength: number;
+ length: number;
+ 0: number;
+ };
+ hostileView.byteLength = 1;
+ hostileView.length = 1;
+ Object.defineProperty(hostileView, '0', {
+ configurable: true,
+ enumerable: true,
+ value: 256,
+ writable: true,
+ });
+ const originalIsView = ArrayBuffer.isView.bind(ArrayBuffer);
+ const isView = vi.spyOn(ArrayBuffer, 'isView').mockImplementation((value) => {
+ return value === hostileView || originalIsView(value);
+ });
+ try {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(hostileView),
+ ),
+ );
+ } finally {
+ isView.mockRestore();
+ }
+ });
+
+ it('rejects a CFB byte array longer than the original source envelope', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(8),
+ async () => parserWithCfb(Array.from({ length: 16 }, () => 0)),
+ ),
+ );
+ });
+
+ it('rejects a CFB byte array whose length metadata is not a safe count', async () => {
+ const content = new Proxy([] as number[], {
+ getOwnPropertyDescriptor(target, property) {
+ if (property === 'length') {
+ return {
+ configurable: true,
+ enumerable: false,
+ value: -1,
+ writable: true,
+ };
+ }
+ return Reflect.getOwnPropertyDescriptor(target, property);
+ },
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb(content),
+ ),
+ );
+ });
+
+ it('rejects a CFB byte array whose indexed values are not octets', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([256, 0, 0, 0]),
+ ),
+ );
+ });
+
+ it('copies a documented CFB byte array and then rejects a truncated BIFF8 record', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([...workbookBof(), 0x85, 0x00, 0xff, 0xff]),
+ ),
+ );
+ });
+
+ it('rejects invalid workbook BOF, short BoundSheet8, and illegal visibility bits', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([...record(0x0000, []), ...eof()]),
+ ),
+ );
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () =>
+ parserWithCfb([
+ ...workbookBof(),
+ ...record(BIFF8_BOUNDSHEET8, [0, 0, 0, 0, 0, 0, 0]),
+ ...eof(),
+ ]),
+ ),
+ );
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () =>
+ parserWithCfb([...workbookBof(), ...boundSheet(0x03), ...eof()]),
+ ),
+ );
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () =>
+ parserWithCfb([...workbookBof(), ...boundSheet(0x04), ...eof()]),
+ ),
+ );
+ });
+
+ it('rejects more BoundSheet8 records than the BIFF8 worksheet ceiling', async () => {
+ const stream = [...workbookBof()];
+ for (let index = 0; index < 257; index += 1) {
+ stream.push(...boundSheet(0));
+ }
+ stream.push(...eof());
+ await expectResourceLimit(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(stream.length),
+ async () => parserWithCfb(stream),
+ ),
+ );
+ });
+
+ it('rejects a workbook stream that never emits EOF', async () => {
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parserWithCfb([...workbookBof(), ...boundSheet(0)]),
+ ),
+ );
+ });
+
+ it('rejects authoritative-visibility wrapping when the parser result is not an object', async () => {
+ const parser = parserWithCfb(validWorkbookStream());
+ (parser.read as ReturnType).mockReturnValue(null);
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parser,
+ ),
+ );
+ });
+
+ it('rejects a parser result whose sheet-name count disagrees with BoundSheet8 visibility', async () => {
+ const parser = parserWithCfb(validWorkbookStream());
+ (parser.read as ReturnType).mockReturnValue({
+ SheetNames: ['Summary', 'Extra'],
+ Sheets: { Summary: {} },
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => parser,
+ ),
+ );
+ });
+
+ it('rejects hostile or non-array sheet-name containers after visibility wrapping', async () => {
+ const nonArray = parserWithCfb(validWorkbookStream());
+ (nonArray.read as ReturnType).mockReturnValue({
+ SheetNames: {},
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => nonArray,
+ ),
+ );
+
+ const originalGetOwnPropertyDescriptor =
+ Object.getOwnPropertyDescriptor.bind(Object);
+
+ for (const hostileLength of [Number.NaN, -1]) {
+ const invalidLength = parserWithCfb(validWorkbookStream());
+ const sheetNames = ['Sheet1'];
+ const descriptorSpy = vi
+ .spyOn(Object, 'getOwnPropertyDescriptor')
+ .mockImplementation((source, key) => {
+ if (source === sheetNames && key === 'length') {
+ return {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: hostileLength,
+ };
+ }
+ return originalGetOwnPropertyDescriptor(source, key);
+ });
+ try {
+ (invalidLength.read as ReturnType).mockReturnValue({
+ SheetNames: sheetNames,
+ });
+ await expectUnsupported(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ biff8Envelope(),
+ async () => invalidLength,
+ ),
+ );
+ } finally {
+ descriptorSpy.mockRestore();
+ }
+ }
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts
new file mode 100644
index 00000000..4f75d097
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntime.sourceBoundary.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import {
+ SpreadsheetImportError,
+ spreadsheetFileToDocumentJson,
+ type SpreadsheetFileSource,
+} from './index.js';
+
+function expectUnsupported(promise: Promise) {
+ return expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ } satisfies Partial);
+}
+
+describe('spreadsheet local source boundary', () => {
+ it('normalizes a hostile returned ArrayBuffer identity trap', async () => {
+ const privatePrototypeError = new Error('private returned buffer prototype');
+ const hostileBuffer = new Proxy(new ArrayBuffer(4), {
+ getPrototypeOf() {
+ throw privatePrototypeError;
+ },
+ });
+ const source = {
+ size: 4,
+ async arrayBuffer() {
+ return hostileBuffer;
+ },
+ } as SpreadsheetFileSource;
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('normalizes a hostile returned ArrayBuffer byteLength accessor', async () => {
+ const hostileBuffer = new ArrayBuffer(4);
+ Object.defineProperty(hostileBuffer, 'byteLength', {
+ configurable: true,
+ get() {
+ throw new Error('private returned buffer length');
+ },
+ });
+ const source = {
+ size: 4,
+ async arrayBuffer() {
+ return hostileBuffer;
+ },
+ } as SpreadsheetFileSource;
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+
+ it('rejects a non-ArrayBuffer body before reading its byteLength member', async () => {
+ let byteLengthReads = 0;
+ const hostileBody = {
+ get byteLength() {
+ byteLengthReads += 1;
+ throw new Error('private non-buffer length');
+ },
+ };
+ const source = {
+ size: 4,
+ async arrayBuffer() {
+ return hostileBody as unknown as ArrayBuffer;
+ },
+ } as SpreadsheetFileSource;
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ expect(byteLengthReads).toBe(0);
+ });
+
+ it('normalizes a hostile non-Blob source prototype trap when arrayBuffer is absent', async () => {
+ const privatePrototypeError = new Error('private source prototype');
+ const source = new Proxy(
+ { size: 4 } as SpreadsheetFileSource,
+ {
+ getPrototypeOf() {
+ throw privatePrototypeError;
+ },
+ },
+ );
+
+ await expectUnsupported(spreadsheetFileToDocumentJson(source));
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntime.test.ts b/src/spreadsheet/sheetJsRuntime.test.ts
new file mode 100644
index 00000000..d9380bd4
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntime.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import * as XLSX from 'xlsx';
+import { parseSheetJsSpreadsheetBytes } from './sheetJsRuntime.js';
+
+function workbookBytes(bookType: 'xlsx' | 'biff8'): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ const summary = XLSX.utils.aoa_to_sheet([
+ ['Metric', 'Value'],
+ ['Revenue', 42],
+ ]);
+ summary.C3 = { t: 'n', f: '1+1', v: 2 };
+ summary['!ref'] = 'A1:C3';
+ XLSX.utils.book_append_sheet(workbook, summary, 'Summary');
+
+ const hidden = XLSX.utils.aoa_to_sheet([['secret']]);
+ XLSX.utils.book_append_sheet(workbook, hidden, 'Hidden');
+ workbook.Workbook ??= {};
+ workbook.Workbook.Sheets ??= [];
+ workbook.Workbook.Sheets[1] = {
+ ...workbook.Workbook.Sheets[1],
+ Hidden: 1,
+ };
+
+ const written = XLSX.write(workbook, {
+ bookType,
+ type: 'array',
+ });
+ return new Uint8Array(written);
+}
+
+describe('real SheetJS spreadsheet runtime', () => {
+ it.each([
+ ['XLSX', 'xlsx'],
+ ['BIFF8 XLS', 'biff8'],
+ ] as const)('parses a real %s workbook through the bounded adapter', async (_label, bookType) => {
+ const workbook = await parseSheetJsSpreadsheetBytes(workbookBytes(bookType));
+
+ expect(workbook).toEqual({
+ worksheets: [
+ {
+ name: 'Summary',
+ hidden: false,
+ rows: [
+ ['Metric', 'Value', ''],
+ ['Revenue', '42', ''],
+ ['', '', '2'],
+ ],
+ },
+ {
+ name: 'Hidden',
+ hidden: true,
+ rows: [],
+ },
+ ],
+ });
+ });
+
+ it('rejects direct parser input above the public source-byte ceiling', async () => {
+ const oversized = new Uint8Array(64 * 1024 * 1024 + 1);
+
+ await expect(parseSheetJsSpreadsheetBytes(oversized)).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'RESOURCE_LIMIT_EXCEEDED',
+ message: 'Spreadsheet exceeds the configured resource limits.',
+ });
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntime.ts b/src/spreadsheet/sheetJsRuntime.ts
new file mode 100644
index 00000000..900d59c2
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntime.ts
@@ -0,0 +1,432 @@
+import {
+ preflightSpreadsheetBinarySource,
+ SpreadsheetImportError,
+ spreadsheetWorkbookToDocumentJson,
+ type SpreadsheetImportResult,
+ type SpreadsheetWorkbookData,
+} from './spreadsheetImport.js';
+import {
+ sheetJsBytesToWorkbookData,
+ type SheetJsParserModule,
+} from './sheetJsAdapter.js';
+
+const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024;
+const MAX_BIFF8_WORKSHEETS = 256;
+const BIFF8_BOF_RECORD = 0x0809;
+const BIFF8_BOUNDSHEET8_RECORD = 0x0085;
+const BIFF8_EOF_RECORD = 0x000a;
+const BIFF8_VERSION = 0x0600;
+const BIFF8_WORKBOOK_GLOBALS = 0x0005;
+
+interface SheetJsCfbEntry {
+ readonly content?: unknown;
+}
+
+interface SheetJsCfbModule {
+ readonly read: (
+ source: Uint8Array,
+ options: { readonly type: 'buffer' },
+ ) => unknown;
+ readonly find: (container: unknown, path: string) => SheetJsCfbEntry | null;
+}
+
+type SheetJsParserWithCfb = SheetJsParserModule & {
+ readonly CFB?: SheetJsCfbModule;
+};
+
+/** Minimal browser-file contract needed by the local spreadsheet import boundary. */
+export interface SpreadsheetFileSource {
+ /** Byte length available before allocating and reading the file body. */
+ readonly size: number;
+ /**
+ * Read the local file body without granting any path, network, or persistence
+ * authority. Genuine `File`/`Blob` values may omit this method; those are
+ * read through `FileReader` or `Response` instead of requiring `arrayBuffer`.
+ */
+ arrayBuffer?(): Promise;
+}
+
+function resourceLimitExceeded(): SpreadsheetImportError {
+ return new SpreadsheetImportError(
+ 'RESOURCE_LIMIT_EXCEEDED',
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+}
+
+function unsupportedOrCorruptSource(): SpreadsheetImportError {
+ return new SpreadsheetImportError(
+ 'UNSUPPORTED_OR_CORRUPT',
+ 'Spreadsheet source is unsupported or corrupt.',
+ );
+}
+
+function isBlobSource(source: SpreadsheetFileSource): source is SpreadsheetFileSource & Blob {
+ try {
+ return typeof Blob !== 'undefined' && source instanceof Blob;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+}
+
+function readBlobViaFileReader(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ let reader: FileReader;
+ try {
+ reader = new FileReader();
+ } catch {
+ reject(unsupportedOrCorruptSource());
+ return;
+ }
+
+ reader.onload = () => {
+ const result = reader.result;
+ if (!(result instanceof ArrayBuffer)) {
+ reject(unsupportedOrCorruptSource());
+ return;
+ }
+ resolve(result);
+ };
+ reader.onerror = () => {
+ reject(unsupportedOrCorruptSource());
+ };
+
+ try {
+ reader.readAsArrayBuffer(blob);
+ } catch {
+ reject(unsupportedOrCorruptSource());
+ }
+ });
+}
+
+async function readSourceArrayBuffer(
+ source: SpreadsheetFileSource,
+): Promise {
+ let arrayBufferMethod: unknown;
+ try {
+ arrayBufferMethod = source.arrayBuffer;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+
+ if (typeof arrayBufferMethod === 'function') {
+ try {
+ return await arrayBufferMethod.call(source);
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ }
+
+ if (isBlobSource(source) && typeof FileReader !== 'undefined') {
+ return readBlobViaFileReader(source);
+ }
+ if (isBlobSource(source) && typeof Response !== 'undefined') {
+ try {
+ return await new Response(source).arrayBuffer();
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ }
+ throw unsupportedOrCorruptSource();
+}
+
+function isObject(value: unknown): value is object {
+ return typeof value === 'object' && value !== null;
+}
+
+function readOwnDataProperty(source: object, key: PropertyKey): unknown {
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(source, key);
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ if (descriptor === undefined || !('value' in descriptor)) {
+ throw unsupportedOrCorruptSource();
+ }
+ return descriptor.value;
+}
+
+function readUint16LittleEndian(source: Uint8Array, offset: number): number {
+ return source[offset]! | (source[offset + 1]! << 8);
+}
+
+function copyCfbEntryBytes(content: unknown, sourceByteLength: number): Uint8Array {
+ if (ArrayBuffer.isView(content)) {
+ let byteLength: number;
+ let elementLength: unknown;
+ try {
+ byteLength = content.byteLength;
+ elementLength = Reflect.get(content, 'length');
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ if (
+ !Number.isSafeInteger(byteLength) ||
+ byteLength < 0 ||
+ byteLength > sourceByteLength ||
+ elementLength !== byteLength
+ ) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ const copy = new Uint8Array(byteLength);
+ for (let index = 0; index < copy.byteLength; index += 1) {
+ const value = readOwnDataProperty(content, String(index));
+ if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 0xff) {
+ throw unsupportedOrCorruptSource();
+ }
+ copy[index] = value as number;
+ }
+ return copy;
+ }
+ if (!Array.isArray(content)) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ const length = readOwnDataProperty(content, 'length');
+ if (
+ !Number.isSafeInteger(length) ||
+ (length as number) < 0 ||
+ (length as number) > sourceByteLength
+ ) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ const copy = new Uint8Array(length as number);
+ for (let index = 0; index < copy.byteLength; index += 1) {
+ const value = readOwnDataProperty(content, String(index));
+ if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 0xff) {
+ throw unsupportedOrCorruptSource();
+ }
+ copy[index] = value as number;
+ }
+ return copy;
+}
+
+function readBiff8WorkbookStream(
+ source: Uint8Array,
+ parser: SheetJsParserModule,
+): Uint8Array {
+ const cfb = (parser as SheetJsParserWithCfb).CFB;
+ if (
+ !isObject(cfb) ||
+ typeof cfb.read !== 'function' ||
+ typeof cfb.find !== 'function'
+ ) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ let container: unknown;
+ let workbookEntry: SheetJsCfbEntry | null;
+ try {
+ container = cfb.read(new Uint8Array(source), { type: 'buffer' });
+ workbookEntry = cfb.find(container, 'Workbook');
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ if (!isObject(workbookEntry)) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ const content = readOwnDataProperty(workbookEntry, 'content');
+ return copyCfbEntryBytes(content, source.byteLength);
+}
+
+function readBiff8HiddenStates(workbookStream: Uint8Array): readonly boolean[] {
+ let offset = 0;
+ let sawWorkbookBof = false;
+ const hiddenStates: boolean[] = [];
+
+ while (offset + 4 <= workbookStream.byteLength) {
+ const recordType = readUint16LittleEndian(workbookStream, offset);
+ const recordLength = readUint16LittleEndian(workbookStream, offset + 2);
+ const payloadOffset = offset + 4;
+ const nextOffset = payloadOffset + recordLength;
+ if (nextOffset > workbookStream.byteLength) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ if (!sawWorkbookBof) {
+ if (
+ recordType !== BIFF8_BOF_RECORD ||
+ recordLength < 4 ||
+ readUint16LittleEndian(workbookStream, payloadOffset) !== BIFF8_VERSION ||
+ readUint16LittleEndian(workbookStream, payloadOffset + 2) !==
+ BIFF8_WORKBOOK_GLOBALS
+ ) {
+ throw unsupportedOrCorruptSource();
+ }
+ sawWorkbookBof = true;
+ } else if (recordType === BIFF8_BOUNDSHEET8_RECORD) {
+ if (recordLength < 8) {
+ throw unsupportedOrCorruptSource();
+ }
+ const visibility = workbookStream[payloadOffset + 4]!;
+ if ((visibility & 0xfc) !== 0 || (visibility & 0x03) === 0x03) {
+ throw unsupportedOrCorruptSource();
+ }
+ hiddenStates.push((visibility & 0x03) !== 0);
+ if (hiddenStates.length > MAX_BIFF8_WORKSHEETS) {
+ throw resourceLimitExceeded();
+ }
+ } else if (recordType === BIFF8_EOF_RECORD) {
+ return hiddenStates;
+ }
+
+ offset = nextOffset;
+ }
+
+ throw unsupportedOrCorruptSource();
+}
+
+function readArrayLength(value: unknown): number {
+ let arrayValue: unknown[];
+ try {
+ if (!Array.isArray(value)) {
+ throw unsupportedOrCorruptSource();
+ }
+ arrayValue = value;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ return readOwnDataProperty(arrayValue, 'length') as number;
+}
+
+function withAuthoritativeBiff8Visibility(
+ parser: SheetJsParserModule,
+ hiddenStates: readonly boolean[],
+): SheetJsParserModule {
+ const workbookMetadata = Object.freeze({
+ Sheets: Object.freeze(
+ hiddenStates.map((hidden) => Object.freeze({ Hidden: hidden ? 1 : 0 })),
+ ),
+ });
+
+ return {
+ read(source, options) {
+ const parsedWorkbook = parser.read(source, options);
+ if (!isObject(parsedWorkbook)) {
+ return parsedWorkbook;
+ }
+
+ const sheetNames = readOwnDataProperty(parsedWorkbook, 'SheetNames');
+ if (readArrayLength(sheetNames) !== hiddenStates.length) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ return new Proxy(parsedWorkbook, {
+ getOwnPropertyDescriptor(target, property) {
+ if (property === 'Workbook') {
+ return {
+ configurable: true,
+ enumerable: true,
+ value: workbookMetadata,
+ writable: false,
+ };
+ }
+ return Reflect.getOwnPropertyDescriptor(target, property);
+ },
+ });
+ },
+ utils: parser.utils,
+ };
+}
+
+/**
+ * Parse supported local XLS/XLSX bytes after preflighting their binary envelope.
+ *
+ * The parser loader is deliberately injected so the preflight ordering is directly
+ * testable without importing the parser package. The loader is not invoked until the
+ * caller-controlled bytes have crossed Inkspan's local signature and resource bounds.
+ * BIFF8 worksheet visibility is additionally recovered from the raw BoundSheet8
+ * records in the CFB Workbook stream so confidentiality does not depend on mutable
+ * parser-emitted visibility metadata observed to vary across repeated reads.
+ */
+export async function parseSheetJsSpreadsheetBytesWithParserLoader(
+ source: Uint8Array,
+ loadParser: () => Promise,
+): Promise {
+ const boundedSource = preflightSpreadsheetBinarySource(source);
+ const parser = await loadParser();
+ if (boundedSource.format !== 'xls') {
+ return sheetJsBytesToWorkbookData(boundedSource.bytes, parser);
+ }
+
+ const workbookStream = readBiff8WorkbookStream(boundedSource.bytes, parser);
+ const hiddenStates = readBiff8HiddenStates(workbookStream);
+ return sheetJsBytesToWorkbookData(
+ boundedSource.bytes,
+ withAuthoritativeBiff8Visibility(parser, hiddenStates),
+ );
+}
+
+/**
+ * Parse supported local XLS/XLSX bytes through Inkspan's bounded SheetJS adapter.
+ *
+ * The public byte-array entry point validates the source envelope and byte ceiling
+ * before the parser module is loaded. The parser package is loaded locally and
+ * receives no network, credential, persistence, model, transport, or editor
+ * mutation authority. Its untrusted materialized output still crosses the same
+ * descriptor-safe resource bounds as an injected parser module before it becomes
+ * parser-neutral workbook data.
+ */
+export async function parseSheetJsSpreadsheetBytes(
+ source: Uint8Array,
+): Promise {
+ return parseSheetJsSpreadsheetBytesWithParserLoader(
+ source,
+ async () => (await import('xlsx')) as unknown as SheetJsParserModule,
+ );
+}
+
+/**
+ * Read one local browser file and convert its visible worksheets to inert TipTap JSON.
+ *
+ * Source size is checked before the file body is read so oversized user-selected
+ * files are rejected before a proportional allocation. Browser `File` values are
+ * read through `arrayBuffer()` when present, otherwise through `FileReader` or
+ * `Response`, matching the image-import fallback for DOMs that omit
+ * `Blob.arrayBuffer`. Read failures and malformed source identities are
+ * normalized to the stable payload-redacted import error contract.
+ */
+export async function spreadsheetFileToDocumentJson(
+ source: SpreadsheetFileSource,
+): Promise {
+ let sourceSize: number;
+ try {
+ sourceSize = source.size;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+
+ if (!Number.isSafeInteger(sourceSize) || sourceSize < 0) {
+ throw unsupportedOrCorruptSource();
+ }
+ if (sourceSize > MAX_SPREADSHEET_SOURCE_BYTES) {
+ throw resourceLimitExceeded();
+ }
+
+ const buffer = await readSourceArrayBuffer(source);
+ let isArrayBuffer: boolean;
+ try {
+ isArrayBuffer = buffer instanceof ArrayBuffer;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ if (!isArrayBuffer) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ let bufferByteLength: number;
+ try {
+ bufferByteLength = buffer.byteLength;
+ } catch {
+ throw unsupportedOrCorruptSource();
+ }
+ if (bufferByteLength !== sourceSize) {
+ throw unsupportedOrCorruptSource();
+ }
+
+ const workbook = await parseSheetJsSpreadsheetBytes(new Uint8Array(buffer));
+ return spreadsheetWorkbookToDocumentJson(workbook);
+}
diff --git a/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts
new file mode 100644
index 00000000..b516633d
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntimeBiff8Validation.test.ts
@@ -0,0 +1,364 @@
+import { describe, expect, it } from 'vitest';
+import type { SheetJsParserModule } from './sheetJsAdapter.js';
+import {
+ parseSheetJsSpreadsheetBytesWithParserLoader,
+} from './sheetJsRuntime.js';
+
+const OLE_SIGNATURE = [
+ 0xd0,
+ 0xcf,
+ 0x11,
+ 0xe0,
+ 0xa1,
+ 0xb1,
+ 0x1a,
+ 0xe1,
+] as const;
+
+function littleEndian16(value: number): readonly number[] {
+ return [value & 0xff, (value >>> 8) & 0xff];
+}
+
+function record(type: number, payload: readonly number[]): readonly number[] {
+ return [
+ ...littleEndian16(type),
+ ...littleEndian16(payload.length),
+ ...payload,
+ ];
+}
+
+const BOF = record(0x0809, [0x00, 0x06, 0x05, 0x00]);
+const EOF = record(0x000a, []);
+
+function boundSheet(visibility: number): readonly number[] {
+ return record(0x0085, [0, 0, 0, 0, visibility, 0, 0, 0]);
+}
+
+function workbookStream(
+ visibilities: readonly number[] = [0],
+ extraRecords: readonly (readonly number[])[] = [],
+): Uint8Array {
+ return Uint8Array.from([
+ ...BOF,
+ ...extraRecords.flat(),
+ ...visibilities.flatMap((visibility) => boundSheet(visibility)),
+ ...EOF,
+ ]);
+}
+
+function sourceFor(stream: Uint8Array): Uint8Array {
+ const source = new Uint8Array(Math.max(64, stream.byteLength + 8));
+ source.set(OLE_SIGNATURE);
+ return source;
+}
+
+function workbookFor(names: readonly string[]): object {
+ return {
+ SheetNames: [...names],
+ Sheets: Object.fromEntries(names.map((name) => [name, {}])),
+ };
+}
+
+function parserWith(
+ cfb: unknown,
+ workbook: unknown = workbookFor(['Visible']),
+): SheetJsParserModule {
+ return {
+ CFB: cfb,
+ read: () => workbook,
+ utils: {
+ decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }),
+ sheet_to_json: () => [],
+ },
+ } as unknown as SheetJsParserModule;
+}
+
+function parserForStream(
+ stream: Uint8Array | readonly number[],
+ workbook: unknown = workbookFor(['Visible']),
+): SheetJsParserModule {
+ return parserWith(
+ {
+ read: () => ({}),
+ find: () => ({ content: stream }),
+ },
+ workbook,
+ );
+}
+
+async function expectUnsupported(promise: Promise): Promise {
+ await expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ });
+}
+
+async function expectResourceLimit(promise: Promise): Promise {
+ await expect(promise).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'RESOURCE_LIMIT_EXCEEDED',
+ message: 'Spreadsheet exceeds the configured resource limits.',
+ });
+}
+
+async function parseWith(
+ stream: Uint8Array,
+ parser: SheetJsParserModule,
+): Promise {
+ return parseSheetJsSpreadsheetBytesWithParserLoader(
+ sourceFor(stream),
+ async () => parser,
+ );
+}
+
+describe('BIFF8 raw visibility validation', () => {
+ it('accepts parser-owned numeric-array CFB bytes and projects visible/hidden state', async () => {
+ const stream = workbookStream([0, 1]);
+ const workbook = await parseWith(
+ stream,
+ parserForStream(Array.from(stream), workbookFor(['Visible', 'Hidden'])),
+ );
+
+ expect(workbook).toEqual({
+ worksheets: [
+ { name: 'Visible', hidden: false, rows: [] },
+ { name: 'Hidden', hidden: true, rows: [] },
+ ],
+ });
+ });
+
+ it.each([
+ ['missing CFB module', undefined],
+ ['non-object CFB module', 'not a cfb module'],
+ ['missing CFB read', { find: () => null }],
+ ['missing CFB find', { read: () => ({}) }],
+ ['non-callable CFB find', { read: () => ({}), find: 1 }],
+ ])('rejects a parser with %s', async (_label, cfb) => {
+ const stream = workbookStream();
+ await expectUnsupported(parseWith(stream, parserWith(cfb)));
+ });
+
+ it('normalizes a throwing CFB reader', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({
+ read() {
+ throw new Error('private parser detail');
+ },
+ find: () => null,
+ }),
+ ),
+ );
+ });
+
+ it('normalizes a throwing CFB finder', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({
+ read: () => ({}),
+ find() {
+ throw new Error('private parser detail');
+ },
+ }),
+ ),
+ );
+ });
+
+ it('rejects a missing Workbook CFB entry', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({ read: () => ({}), find: () => null }),
+ ),
+ );
+ });
+
+ it.each([
+ ['missing content', {}],
+ [
+ 'accessor content',
+ Object.defineProperty({}, 'content', {
+ get() {
+ return workbookStream();
+ },
+ }),
+ ],
+ [
+ 'throwing content descriptor',
+ new Proxy(
+ {},
+ {
+ getOwnPropertyDescriptor() {
+ throw new Error('private parser descriptor');
+ },
+ },
+ ),
+ ],
+ ])('rejects a Workbook entry with %s', async (_label, entry) => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({ read: () => ({}), find: () => entry }),
+ ),
+ );
+ });
+
+ it('rejects a non-byte CFB Workbook entry', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({
+ read: () => ({}),
+ find: () => ({ content: 'not workbook bytes' }),
+ }),
+ ),
+ );
+ });
+
+ it.each([
+ ['view longer than the source', new Uint8Array(65)],
+ ['multi-byte element view', new Uint16Array([1, 2])],
+ ['negative byte value', new Int8Array([-1])],
+ ])('rejects hostile typed CFB content: %s', async (_label, content) => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({ read: () => ({}), find: () => ({ content }) }),
+ ),
+ );
+ });
+
+ it('normalizes a throwing typed-view length accessor', async () => {
+ const stream = workbookStream();
+ const content = new Uint8Array(stream);
+ Object.defineProperty(content, 'length', {
+ get() {
+ throw new Error('private parser detail');
+ },
+ });
+
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({
+ read: () => ({}),
+ find: () => ({ content }),
+ }),
+ ),
+ );
+ });
+
+ it('rejects an array CFB entry longer than the local source envelope', async () => {
+ const stream = workbookStream();
+ const content = new Array(65).fill(0);
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({ read: () => ({}), find: () => ({ content }) }),
+ ),
+ );
+ });
+
+ it.each([
+ ['non-integer', [1.5]],
+ ['negative', [-1]],
+ ['above one byte', [256]],
+ ])('rejects invalid numeric-array CFB content: %s', async (_label, content) => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserWith({ read: () => ({}), find: () => ({ content }) }),
+ ),
+ );
+ });
+
+ it.each([
+ ['record extends beyond stream', Uint8Array.from([0x09, 0x08, 0xff, 0xff])],
+ ['wrong first record type', Uint8Array.from([...record(1, [0, 6, 5, 0]), ...EOF])],
+ ['short workbook BOF', Uint8Array.from([...record(0x0809, [0, 6, 5]), ...EOF])],
+ ['wrong BIFF version', Uint8Array.from([...record(0x0809, [0, 5, 5, 0]), ...EOF])],
+ ['wrong BOF substream', Uint8Array.from([...record(0x0809, [0, 6, 0, 0]), ...EOF])],
+ ['short BoundSheet8', Uint8Array.from([...BOF, ...record(0x0085, [0, 0, 0, 0, 0, 0, 0]), ...EOF])],
+ ['reserved BoundSheet8 visibility bits', workbookStream([4])],
+ ['reserved BoundSheet8 visibility value', workbookStream([3])],
+ ['missing workbook EOF', Uint8Array.from(BOF)],
+ ])('rejects malformed BIFF8 workbook globals: %s', async (_label, stream) => {
+ await expectUnsupported(parseWith(stream, parserForStream(stream)));
+ });
+
+ it('rejects more than the bounded BIFF8 worksheet count', async () => {
+ const stream = workbookStream(new Array(257).fill(0));
+ await expectResourceLimit(parseWith(stream, parserForStream(stream)));
+ });
+
+ it('ignores unrelated workbook-global records before EOF', async () => {
+ const stream = workbookStream([], [record(0x002f, [1, 2])]);
+ const workbook = await parseWith(
+ stream,
+ parserForStream(stream, workbookFor([])),
+ );
+
+ expect(workbook).toEqual({ worksheets: [] });
+ });
+
+ it('rejects a parser workbook whose wrapped result is not an object', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(parseWith(stream, parserForStream(stream, null)));
+ });
+
+ it('rejects non-array parser SheetNames before exposing visibility metadata', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserForStream(stream, { SheetNames: 'Visible', Sheets: {} }),
+ ),
+ );
+ });
+
+ it('normalizes a revoked parser SheetNames array proxy', async () => {
+ const stream = workbookStream();
+ const { proxy, revoke } = Proxy.revocable(['Visible'], {});
+ const workbook = { SheetNames: proxy, Sheets: { Visible: {} } };
+ revoke();
+
+ await expectUnsupported(parseWith(stream, parserForStream(stream, workbook)));
+ });
+
+ it('rejects parser SheetNames that disagree with raw BoundSheet8 count', async () => {
+ const stream = workbookStream();
+ await expectUnsupported(
+ parseWith(
+ stream,
+ parserForStream(stream, workbookFor(['Visible', 'Unexpected'])),
+ ),
+ );
+ });
+
+ it('normalizes a throwing parser SheetNames descriptor', async () => {
+ const stream = workbookStream();
+ const workbook = new Proxy(
+ workbookFor(['Visible']),
+ {
+ getOwnPropertyDescriptor(target, property) {
+ if (property === 'SheetNames') {
+ throw new Error('private parser descriptor');
+ }
+ return Reflect.getOwnPropertyDescriptor(target, property);
+ },
+ },
+ );
+
+ await expectUnsupported(parseWith(stream, parserForStream(stream, workbook)));
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts
new file mode 100644
index 00000000..3e68b8fe
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntimeInputIsolation.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+import * as XLSX from 'xlsx';
+import type { SheetJsParserModule } from './sheetJsAdapter.js';
+import {
+ parseSheetJsSpreadsheetBytes,
+ parseSheetJsSpreadsheetBytesWithParserLoader,
+} from './sheetJsRuntime.js';
+
+function serializeBiff8(workbook: XLSX.WorkBook): Uint8Array {
+ const serialized = XLSX.write(workbook, { type: 'array', bookType: 'biff8' });
+ return serialized instanceof Uint8Array
+ ? serialized
+ : new Uint8Array(serialized as ArrayBuffer);
+}
+
+function realBiff8WithHiddenSheet(): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(
+ workbook,
+ XLSX.utils.aoa_to_sheet([
+ ['Kind', 'Value'],
+ ['Revenue', 42],
+ ]),
+ 'Summary',
+ );
+ XLSX.utils.book_append_sheet(
+ workbook,
+ XLSX.utils.aoa_to_sheet([['private hidden value']]),
+ 'Hidden',
+ );
+ workbook.Workbook = {
+ ...(workbook.Workbook ?? {}),
+ Sheets: [{ Hidden: 0 }, { Hidden: 1 }],
+ };
+ return serializeBiff8(workbook);
+}
+
+function realBiff8WithoutHiddenSheet(): Uint8Array {
+ const workbook = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(
+ workbook,
+ XLSX.utils.aoa_to_sheet([
+ ['Kind', 'Value'],
+ ['Previous', 1],
+ ]),
+ 'Previous',
+ );
+ return serializeBiff8(workbook);
+}
+
+function visibilityProjection(workbook: {
+ readonly worksheets: readonly { readonly name: string; readonly hidden: boolean }[];
+}) {
+ return workbook.worksheets.map(({ name, hidden }) => ({ name, hidden }));
+}
+
+const EXPECTED_VISIBILITY = [
+ { name: 'Summary', hidden: false },
+ { name: 'Hidden', hidden: true },
+] as const;
+
+describe('SheetJS BIFF8 runtime source isolation', () => {
+ it('does not mutate caller bytes and gives the same visibility on repeated imports', async () => {
+ const bytes = realBiff8WithHiddenSheet();
+ const pristineBytes = Array.from(bytes);
+
+ expect(
+ visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)),
+ ).toEqual(EXPECTED_VISIBILITY);
+ expect(Array.from(bytes)).toEqual(pristineBytes);
+
+ expect(
+ visibilityProjection(await parseSheetJsSpreadsheetBytes(bytes)),
+ ).toEqual(EXPECTED_VISIBILITY);
+ expect(Array.from(bytes)).toEqual(pristineBytes);
+ });
+
+ it('does not let an earlier BIFF8 workbook alter a later workbook visibility decision', async () => {
+ expect(
+ visibilityProjection(
+ await parseSheetJsSpreadsheetBytes(realBiff8WithoutHiddenSheet()),
+ ),
+ ).toEqual([{ name: 'Previous', hidden: false }]);
+
+ expect(
+ visibilityProjection(
+ await parseSheetJsSpreadsheetBytes(realBiff8WithHiddenSheet()),
+ ),
+ ).toEqual(EXPECTED_VISIBILITY);
+ });
+
+ it('uses raw BIFF8 BoundSheet8 records when parser-emitted visibility is wrong', async () => {
+ const bytes = realBiff8WithHiddenSheet();
+ const parserThatLosesHiddenMetadata = {
+ CFB: XLSX.CFB,
+ read(
+ source: Uint8Array,
+ options: Parameters[1],
+ ) {
+ const parsed = XLSX.read(source, options as XLSX.ParsingOptions);
+ if (parsed.Workbook?.Sheets?.[1] === undefined) {
+ throw new Error('fixture did not materialize hidden-sheet metadata');
+ }
+ parsed.Workbook.Sheets[1] = {
+ ...parsed.Workbook.Sheets[1],
+ Hidden: 0,
+ };
+ return parsed;
+ },
+ utils: XLSX.utils,
+ } as unknown as SheetJsParserModule;
+
+ const workbook = await parseSheetJsSpreadsheetBytesWithParserLoader(
+ bytes,
+ async () => parserThatLosesHiddenMetadata,
+ );
+
+ expect(visibilityProjection(workbook)).toEqual(EXPECTED_VISIBILITY);
+ });
+});
diff --git a/src/spreadsheet/sheetJsRuntimePreflight.test.ts b/src/spreadsheet/sheetJsRuntimePreflight.test.ts
new file mode 100644
index 00000000..f32134f5
--- /dev/null
+++ b/src/spreadsheet/sheetJsRuntimePreflight.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest';
+import { parseSheetJsSpreadsheetBytesWithParserLoader } from './sheetJsRuntime.js';
+
+describe('SheetJS runtime source preflight ordering', () => {
+ it('rejects an unsupported binary envelope before loading the parser package', async () => {
+ const loadParser = vi.fn(async () => {
+ throw new Error('parser loader must not run before binary preflight');
+ });
+
+ await expect(
+ parseSheetJsSpreadsheetBytesWithParserLoader(
+ new Uint8Array([0x00, 0x01, 0x02, 0x03]),
+ loadParser,
+ ),
+ ).rejects.toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ });
+
+ expect(loadParser).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/spreadsheet/spreadsheetBinaryPreflight.test.ts b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts
new file mode 100644
index 00000000..ee98d74c
--- /dev/null
+++ b/src/spreadsheet/spreadsheetBinaryPreflight.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+import {
+ preflightSpreadsheetBinarySource,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+const UNSUPPORTED_SOURCE_MESSAGE = 'Spreadsheet source is unsupported or corrupt.';
+
+function expectUnsupportedSource(action: () => unknown): void {
+ let thrown: unknown;
+ try {
+ action();
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(SpreadsheetImportError);
+ expect(thrown).toMatchObject({
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: UNSUPPORTED_SOURCE_MESSAGE,
+ });
+}
+
+describe('preflightSpreadsheetBinarySource runtime boundary', () => {
+ it('rejects non-Uint8Array views even when their elements mimic an XLSX signature', () => {
+ const wordView = new Uint16Array([0x50, 0x4b, 0x03, 0x04]);
+
+ expectUnsupportedSource(() =>
+ preflightSpreadsheetBinarySource(wordView as unknown as Uint8Array),
+ );
+ });
+
+ it('rejects hostile non-byte sources before reading caller-controlled members', () => {
+ let byteLengthRead = false;
+ let indexRead = false;
+ const hostileSource = Object.create(null) as Record;
+
+ Object.defineProperty(hostileSource, 'byteLength', {
+ get() {
+ byteLengthRead = true;
+ throw new Error('private-byte-length-sentinel');
+ },
+ });
+ Object.defineProperty(hostileSource, '0', {
+ get() {
+ indexRead = true;
+ throw new Error('private-index-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ preflightSpreadsheetBinarySource(hostileSource as unknown as Uint8Array),
+ );
+ expect(byteLengthRead).toBe(false);
+ expect(indexRead).toBe(false);
+ });
+
+ it('accepts genuine byte-array subclasses without invoking overridden byteLength accessors', () => {
+ let byteLengthRead = false;
+
+ class HostileByteSource extends Uint8Array {
+ override get byteLength(): number {
+ byteLengthRead = true;
+ throw new Error('private-byte-length-sentinel');
+ }
+ }
+
+ const source = new HostileByteSource([0x50, 0x4b, 0x03, 0x04]);
+
+ expect(preflightSpreadsheetBinarySource(source)).toEqual({
+ format: 'xlsx',
+ bytes: source,
+ });
+ expect(byteLengthRead).toBe(false);
+ });
+});
diff --git a/src/spreadsheet/spreadsheetImport.test.ts b/src/spreadsheet/spreadsheetImport.test.ts
new file mode 100644
index 00000000..34d28f47
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImport.test.ts
@@ -0,0 +1,328 @@
+import { describe, expect, it } from 'vitest';
+import {
+ SpreadsheetImportError,
+ spreadsheetWorkbookToDocumentJson,
+} from './spreadsheetImport.js';
+
+describe('spreadsheetWorkbookToDocumentJson', () => {
+ it('converts visible worksheet text into an editable heading and table', () => {
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Summary',
+ hidden: false,
+ rows: [
+ ['Name', 'Value'],
+ ['매출', '42'],
+ ],
+ },
+ {
+ name: 'Private',
+ hidden: true,
+ rows: [['secret']],
+ },
+ ],
+ });
+
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 2,
+ cellCount: 4,
+ });
+ expect(result.content.map((node) => node.type)).toEqual([
+ 'heading',
+ 'table',
+ 'paragraph',
+ ]);
+ expect(result.content[0]).toEqual({
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Summary' }],
+ });
+ expect(result.content[1]).toEqual({
+ type: 'table',
+ content: [
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Name' }],
+ },
+ ],
+ },
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Value' }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: '매출' }],
+ },
+ ],
+ },
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: '42' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('skips visible worksheets that contain no rows', () => {
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ { name: 'Empty', hidden: false, rows: [] },
+ { name: 'Data', hidden: false, rows: [['kept']] },
+ ],
+ });
+
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 1,
+ cellCount: 1,
+ });
+ expect(result.content[0]).toEqual({
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: 'Data' }],
+ });
+ });
+
+ it('pads ragged rows to a rectangular table with valid empty cells', () => {
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Ragged',
+ hidden: false,
+ rows: [
+ ['A', 'B'],
+ ['C'],
+ ],
+ },
+ ],
+ });
+
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 2,
+ cellCount: 4,
+ });
+ expect(result.content[1]).toEqual({
+ type: 'table',
+ content: [
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'A' }],
+ },
+ ],
+ },
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'B' }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'C' }],
+ },
+ ],
+ },
+ {
+ type: 'tableCell',
+ content: [{ type: 'paragraph' }],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('preserves multiline displayed cell text with hard breaks', () => {
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Multiline',
+ hidden: false,
+ rows: [['first line\nsecond line']],
+ },
+ ],
+ });
+
+ expect(result.content[1]).toEqual({
+ type: 'table',
+ content: [
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableCell',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'first line' },
+ { type: 'hardBreak' },
+ { type: 'text', text: 'second line' },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('fails closed before materializing an over-wide worksheet', () => {
+ const workbook = {
+ worksheets: [
+ {
+ name: 'Too wide',
+ hidden: false,
+ rows: [Array.from({ length: 257 }, (_, index) => String(index))],
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('fails closed after 64 visible non-empty worksheets', () => {
+ const workbook = {
+ worksheets: Array.from({ length: 65 }, (_, index) => ({
+ name: `Sheet ${index + 1}`,
+ hidden: false,
+ rows: [['kept']],
+ })),
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('fails closed before materializing more than 10000 rows', () => {
+ const workbook = {
+ worksheets: [
+ {
+ name: 'Too many rows',
+ hidden: false,
+ rows: Array.from({ length: 10_001 }, () => ['kept']),
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('fails closed before materializing more than 262144 rectangular cells', () => {
+ const row = Array.from({ length: 256 }, () => '');
+ const workbook = {
+ worksheets: [
+ {
+ name: 'Too many cells',
+ hidden: false,
+ rows: Array.from({ length: 1_025 }, () => row),
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('fails closed before materializing more than 8388608 text code units', () => {
+ const maximumCellText = 'x'.repeat(32_768);
+ const workbook = {
+ worksheets: [
+ {
+ name: 'Too much text',
+ hidden: false,
+ rows: [
+ Array.from({ length: 256 }, () => maximumCellText),
+ [maximumCellText],
+ ],
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('fails closed before materializing oversized cell text', () => {
+ const workbook = {
+ worksheets: [
+ {
+ name: 'Oversized text',
+ hidden: false,
+ rows: [['x'.repeat(32_769)]],
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('exposes a stable payload-redacted import error identity', () => {
+ const error = new SpreadsheetImportError(
+ 'UNSUPPORTED_OR_CORRUPT',
+ 'Workbook cannot be imported.',
+ );
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error.name).toBe('SpreadsheetImportError');
+ expect(error.code).toBe('UNSUPPORTED_OR_CORRUPT');
+ expect(error.message).toBe('Workbook cannot be imported.');
+ });
+});
diff --git a/src/spreadsheet/spreadsheetImport.ts b/src/spreadsheet/spreadsheetImport.ts
new file mode 100644
index 00000000..b18ffb1a
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImport.ts
@@ -0,0 +1,322 @@
+import type { JSONContent } from '@tiptap/core';
+
+const MAX_SPREADSHEET_SOURCE_BYTES = 64 * 1024 * 1024;
+const MAX_VISIBLE_WORKSHEETS = 64;
+const MAX_WORKBOOK_WORKSHEETS = 256;
+const MAX_WORKSHEET_NAME_CODE_UNITS = 1_024;
+const MAX_WORKBOOK_ROWS = 10_000;
+const MAX_WORKSHEET_COLUMNS = 256;
+const MAX_WORKBOOK_CELLS = 262_144;
+const MAX_CELL_TEXT_CODE_UNITS = 32_768;
+const MAX_WORKBOOK_TEXT_CODE_UNITS = 8_388_608;
+const RESOURCE_LIMIT_MESSAGE =
+ 'Spreadsheet exceeds the configured resource limits.';
+const UNSUPPORTED_SOURCE_MESSAGE =
+ 'Spreadsheet source is unsupported or corrupt.';
+const XLSX_ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04] as const;
+const XLS_COMPOUND_FILE_SIGNATURE = [
+ 0xd0,
+ 0xcf,
+ 0x11,
+ 0xe0,
+ 0xa1,
+ 0xb1,
+ 0x1a,
+ 0xe1,
+] as const;
+const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(
+ Uint8Array.prototype,
+) as object;
+const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor(
+ TYPED_ARRAY_PROTOTYPE,
+ Symbol.toStringTag,
+)!.get!;
+const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(
+ TYPED_ARRAY_PROTOTYPE,
+ 'byteLength',
+)!.get!;
+
+/** Binary spreadsheet container family identified before local parsing. */
+export type SpreadsheetBinaryFormat = 'xls' | 'xlsx';
+
+/** Bounded spreadsheet bytes paired with their detected container family. */
+export interface SpreadsheetBinarySource {
+ /** Container family selected only from the source signature. */
+ readonly format: SpreadsheetBinaryFormat;
+ /** Original local bytes retained without an additional proportional copy. */
+ readonly bytes: Uint8Array;
+}
+
+/** One parser-neutral worksheet supplied to the bounded spreadsheet converter. */
+export interface SpreadsheetWorksheetData {
+ /** Display name used only after the worksheet passes visibility and resource checks. */
+ readonly name: string;
+ /** Whether the source workbook marks this worksheet hidden or very hidden. */
+ readonly hidden: boolean;
+ /** Rectangular or ragged displayed cell text in source reading order. */
+ readonly rows: readonly (readonly string[])[];
+}
+
+/** Parser-neutral workbook projection accepted by the editor conversion boundary. */
+export interface SpreadsheetWorkbookData {
+ /** Source-order worksheet projections. */
+ readonly worksheets: readonly SpreadsheetWorksheetData[];
+}
+
+/** Stable categories for spreadsheet-import failures. */
+export type SpreadsheetImportErrorCode =
+ | 'UNSUPPORTED_OR_CORRUPT'
+ | 'RESOURCE_LIMIT_EXCEEDED';
+
+/** Bounded spreadsheet content ready for one TipTap insertion transaction. */
+export interface SpreadsheetImportResult {
+ /** Block nodes inserted at the active editor selection. */
+ readonly content: readonly JSONContent[];
+ /** Number of visible, non-empty worksheets represented in `content`. */
+ readonly worksheetCount: number;
+ /** Number of represented worksheet rows. */
+ readonly rowCount: number;
+ /** Number of represented rectangular table cells, including blanks. */
+ readonly cellCount: number;
+}
+
+/** Payload-redacted error emitted by the spreadsheet import boundary. */
+export class SpreadsheetImportError extends Error {
+ /** Stable failure category suitable for host telemetry and localized UI. */
+ readonly code: SpreadsheetImportErrorCode;
+
+ /** Create a spreadsheet import error without retaining source content. */
+ constructor(code: SpreadsheetImportErrorCode, message: string) {
+ super(message);
+ this.name = 'SpreadsheetImportError';
+ this.code = code;
+ }
+}
+
+function resourceLimitExceeded(): never {
+ throw new SpreadsheetImportError(
+ 'RESOURCE_LIMIT_EXCEEDED',
+ RESOURCE_LIMIT_MESSAGE,
+ );
+}
+
+function unsupportedOrCorruptSource(): never {
+ throw new SpreadsheetImportError(
+ 'UNSUPPORTED_OR_CORRUPT',
+ UNSUPPORTED_SOURCE_MESSAGE,
+ );
+}
+
+function readOwnDataProperty(source: object, key: string): unknown {
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(source, key);
+ } catch {
+ unsupportedOrCorruptSource();
+ }
+
+ if (descriptor === undefined) unsupportedOrCorruptSource();
+ if (!('value' in descriptor)) unsupportedOrCorruptSource();
+ return descriptor.value;
+}
+
+function readArrayLength(source: readonly unknown[]): number {
+ return readOwnDataProperty(source, 'length') as number;
+}
+
+function isArraySource(source: unknown): source is readonly unknown[] {
+ try {
+ return Array.isArray(source);
+ } catch {
+ return false;
+ }
+}
+
+function isUint8ArraySource(source: unknown): source is Uint8Array {
+ return (
+ ArrayBuffer.isView(source) &&
+ TYPED_ARRAY_TAG_GETTER.call(source) === 'Uint8Array'
+ );
+}
+
+function byteLengthOfUint8Array(source: Uint8Array): number {
+ return TYPED_ARRAY_BYTE_LENGTH_GETTER.call(source) as number;
+}
+
+function startsWithSignature(
+ source: Uint8Array,
+ sourceByteLength: number,
+ signature: readonly number[],
+): boolean {
+ if (sourceByteLength < signature.length) return false;
+ for (let index = 0; index < signature.length; index += 1) {
+ if (source[index] !== signature[index]) return false;
+ }
+ return true;
+}
+
+/**
+ * Bound and classify local XLS/XLSX bytes before any workbook parser is loaded.
+ *
+ * This is deliberately only a source-envelope preflight. A matching ZIP or OLE
+ * signature does not assert that the remainder is a valid workbook; the later
+ * parser boundary must still fail closed on malformed package structure.
+ */
+export function preflightSpreadsheetBinarySource(
+ source: Uint8Array,
+): SpreadsheetBinarySource {
+ if (!isUint8ArraySource(source)) unsupportedOrCorruptSource();
+ const sourceByteLength = byteLengthOfUint8Array(source);
+ if (sourceByteLength > MAX_SPREADSHEET_SOURCE_BYTES) resourceLimitExceeded();
+
+ if (
+ startsWithSignature(
+ source,
+ sourceByteLength,
+ XLS_COMPOUND_FILE_SIGNATURE,
+ )
+ ) {
+ return { format: 'xls', bytes: source };
+ }
+ if (startsWithSignature(source, sourceByteLength, XLSX_ZIP_SIGNATURE)) {
+ return { format: 'xlsx', bytes: source };
+ }
+ return unsupportedOrCorruptSource();
+}
+
+function paragraphWithText(text: string): JSONContent {
+ const content: JSONContent[] = [];
+ const lines = text.split(/\r\n|\r|\n/u);
+
+ for (const [index, line] of lines.entries()) {
+ if (index > 0) content.push({ type: 'hardBreak' });
+ if (line) content.push({ type: 'text', text: line });
+ }
+
+ return content.length > 0
+ ? { type: 'paragraph', content }
+ : { type: 'paragraph' };
+}
+
+interface PreparedWorksheet {
+ readonly name: string;
+ readonly rows: readonly (readonly string[])[];
+ readonly columnCount: number;
+}
+
+/** Convert parser-neutral displayed worksheet text into editable TipTap blocks. */
+export function spreadsheetWorkbookToDocumentJson(
+ workbook: SpreadsheetWorkbookData,
+): SpreadsheetImportResult {
+ if (typeof workbook !== 'object' || workbook === null) {
+ unsupportedOrCorruptSource();
+ }
+
+ const worksheets = readOwnDataProperty(workbook, 'worksheets');
+ if (!isArraySource(worksheets)) unsupportedOrCorruptSource();
+ const worksheetsLength = readArrayLength(worksheets);
+ if (worksheetsLength > MAX_WORKBOOK_WORKSHEETS) resourceLimitExceeded();
+
+ const preparedWorksheets: PreparedWorksheet[] = [];
+ let worksheetCount = 0;
+ let rowCount = 0;
+ let cellCount = 0;
+ let textCodeUnits = 0;
+
+ // Preflight the complete workbook before allocating proportional TipTap nodes.
+ for (let worksheetIndex = 0; worksheetIndex < worksheetsLength; worksheetIndex += 1) {
+ const worksheetSource = readOwnDataProperty(
+ worksheets,
+ String(worksheetIndex),
+ );
+ if (typeof worksheetSource !== 'object' || worksheetSource === null) {
+ unsupportedOrCorruptSource();
+ }
+
+ const hidden = readOwnDataProperty(worksheetSource, 'hidden');
+ const name = readOwnDataProperty(worksheetSource, 'name');
+ const rows = readOwnDataProperty(worksheetSource, 'rows');
+ if (
+ typeof hidden !== 'boolean' ||
+ typeof name !== 'string' ||
+ !isArraySource(rows)
+ ) {
+ unsupportedOrCorruptSource();
+ }
+
+ if (hidden) continue;
+ if (name.length > MAX_WORKSHEET_NAME_CODE_UNITS) {
+ resourceLimitExceeded();
+ }
+
+ const rowsLength = readArrayLength(rows);
+ const nextRowCount = rowCount + rowsLength;
+ if (nextRowCount > MAX_WORKBOOK_ROWS) resourceLimitExceeded();
+
+ const rowSources: (readonly unknown[])[] = [];
+ const rowLengths: number[] = [];
+ let columnCount = 0;
+ for (let rowIndex = 0; rowIndex < rowsLength; rowIndex += 1) {
+ const rowSource = readOwnDataProperty(rows, String(rowIndex));
+ if (!isArraySource(rowSource)) unsupportedOrCorruptSource();
+ const rowLength = readArrayLength(rowSource);
+ columnCount = Math.max(columnCount, rowLength);
+ rowSources.push(rowSource);
+ rowLengths.push(rowLength);
+ }
+ if (columnCount === 0) continue;
+ if (worksheetCount >= MAX_VISIBLE_WORKSHEETS) resourceLimitExceeded();
+ if (columnCount > MAX_WORKSHEET_COLUMNS) resourceLimitExceeded();
+
+ const worksheetCellCount = rowsLength * columnCount;
+ const nextCellCount = cellCount + worksheetCellCount;
+ if (nextCellCount > MAX_WORKBOOK_CELLS) resourceLimitExceeded();
+
+ let worksheetTextCodeUnits = name.length;
+ const preparedRows: string[][] = [];
+ for (let rowIndex = 0; rowIndex < rowSources.length; rowIndex += 1) {
+ const rowSource = rowSources[rowIndex]!;
+ const rowLength = rowLengths[rowIndex]!;
+ const preparedRow: string[] = [];
+ for (let columnIndex = 0; columnIndex < rowLength; columnIndex += 1) {
+ const cellText = readOwnDataProperty(rowSource, String(columnIndex));
+ if (typeof cellText !== 'string') unsupportedOrCorruptSource();
+ if (cellText.length > MAX_CELL_TEXT_CODE_UNITS) resourceLimitExceeded();
+ worksheetTextCodeUnits += cellText.length;
+ if (textCodeUnits + worksheetTextCodeUnits > MAX_WORKBOOK_TEXT_CODE_UNITS) {
+ resourceLimitExceeded();
+ }
+ preparedRow.push(cellText);
+ }
+ preparedRows.push(preparedRow);
+ }
+
+ preparedWorksheets.push({ name, rows: preparedRows, columnCount });
+ worksheetCount += 1;
+ rowCount = nextRowCount;
+ cellCount = nextCellCount;
+ textCodeUnits += worksheetTextCodeUnits;
+ }
+
+ const content: JSONContent[] = [];
+ for (const { name, rows, columnCount } of preparedWorksheets) {
+ content.push({
+ type: 'heading',
+ attrs: { level: 3 },
+ content: [{ type: 'text', text: name }],
+ });
+ content.push({
+ type: 'table',
+ content: rows.map((row) => ({
+ type: 'tableRow',
+ content: Array.from({ length: columnCount }, (_, columnIndex) => ({
+ type: 'tableCell',
+ content: [paragraphWithText(row[columnIndex] ?? '')],
+ })),
+ })),
+ });
+ content.push({ type: 'paragraph' });
+ }
+
+ return { content, worksheetCount, rowCount, cellCount };
+}
diff --git a/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts
new file mode 100644
index 00000000..a416c935
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImportMetadataPreflight.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest';
+import {
+ spreadsheetWorkbookToDocumentJson,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+const UNSUPPORTED_SOURCE_MESSAGE =
+ 'Spreadsheet source is unsupported or corrupt.';
+
+function expectUnsupportedSource(action: () => unknown): void {
+ let thrown: unknown;
+ try {
+ action();
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(SpreadsheetImportError);
+ expect(thrown).toMatchObject({
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: UNSUPPORTED_SOURCE_MESSAGE,
+ });
+}
+
+describe('spreadsheet workbook metadata preflight', () => {
+ it('rejects a missing worksheets data property', () => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson(Object.create(null) as never),
+ );
+ });
+
+ it('rejects an accessor-backed worksheets field without invoking it', () => {
+ let accessed = false;
+ const workbook = Object.create(null) as Record;
+ Object.defineProperty(workbook, 'worksheets', {
+ enumerable: true,
+ get() {
+ accessed = true;
+ throw new Error('private-workbook-worksheets-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson(workbook as never),
+ );
+ expect(accessed).toBe(false);
+ });
+
+ it('redacts a hostile metadata reflection failure', () => {
+ const workbook = new Proxy(Object.create(null) as object, {
+ getOwnPropertyDescriptor() {
+ throw new Error('private-metadata-reflection-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson(workbook as never),
+ );
+ });
+
+ it('does not invoke a hostile worksheets iterator getter', () => {
+ let iteratorRead = false;
+ const worksheet = {
+ name: 'Data',
+ hidden: false,
+ rows: [['kept']],
+ };
+ const worksheets = new Proxy([worksheet], {
+ get(target, property, receiver) {
+ if (property === Symbol.iterator) {
+ iteratorRead = true;
+ throw new Error('private-worksheets-iterator-sentinel');
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ });
+
+ const result = spreadsheetWorkbookToDocumentJson({ worksheets });
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 1,
+ cellCount: 1,
+ });
+ expect(iteratorRead).toBe(false);
+ });
+
+ it('does not invoke hostile worksheet-row length access', () => {
+ let lengthRead = false;
+ const rows = new Proxy([['kept']], {
+ get(target, property, receiver) {
+ if (property === 'length') {
+ lengthRead = true;
+ throw new Error('private-rows-length-sentinel');
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ });
+
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [{ name: 'Data', hidden: false, rows }],
+ });
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 1,
+ cellCount: 1,
+ });
+ expect(lengthRead).toBe(false);
+ });
+
+ it('does not invoke hostile row index access', () => {
+ let indexRead = false;
+ const row = new Proxy(['kept'], {
+ get(target, property, receiver) {
+ if (property === '0') {
+ indexRead = true;
+ throw new Error('private-row-index-sentinel');
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ });
+
+ const result = spreadsheetWorkbookToDocumentJson({
+ worksheets: [{ name: 'Data', hidden: false, rows: [row] }],
+ });
+ expect(result).toMatchObject({
+ worksheetCount: 1,
+ rowCount: 1,
+ cellCount: 1,
+ });
+ expect(indexRead).toBe(false);
+ });
+
+ it.each(['name', 'hidden', 'rows'] as const)(
+ 'rejects an accessor-backed worksheet %s field without invoking it',
+ (field) => {
+ let accessed = false;
+ const worksheet = Object.create(null) as Record;
+ const values = {
+ name: 'Data',
+ hidden: false,
+ rows: [['kept']],
+ } as const;
+
+ for (const key of ['name', 'hidden', 'rows'] as const) {
+ if (key === field) {
+ Object.defineProperty(worksheet, key, {
+ configurable: true,
+ enumerable: true,
+ get() {
+ accessed = true;
+ throw new Error(`private-${key}-sentinel`);
+ },
+ });
+ } else {
+ Object.defineProperty(worksheet, key, {
+ configurable: true,
+ enumerable: true,
+ value: values[key],
+ });
+ }
+ }
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [worksheet as never],
+ }),
+ );
+ expect(accessed).toBe(false);
+ },
+ );
+});
diff --git a/src/spreadsheet/spreadsheetImportRowPreflight.test.ts b/src/spreadsheet/spreadsheetImportRowPreflight.test.ts
new file mode 100644
index 00000000..a1bed440
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImportRowPreflight.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js';
+
+describe('spreadsheet workbook row-count preflight', () => {
+ it('rejects an impossible worksheet row count before reading row entries', () => {
+ let rowRead = false;
+ const rows = new Array(10_001);
+ Object.defineProperty(rows, '0', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ rowRead = true;
+ throw new Error('row payload must not be read');
+ },
+ });
+
+ expect(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [{ name: 'Too many rows', hidden: false, rows }],
+ }),
+ ).toThrowError('Spreadsheet exceeds the configured resource limits.');
+ expect(rowRead).toBe(false);
+ });
+});
diff --git a/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts
new file mode 100644
index 00000000..0790fe6c
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImportRuntimeContainers.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from 'vitest';
+import {
+ spreadsheetWorkbookToDocumentJson,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+const UNSUPPORTED_SOURCE_MESSAGE =
+ 'Spreadsheet source is unsupported or corrupt.';
+
+function expectUnsupportedSource(action: () => unknown): void {
+ let thrown: unknown;
+ try {
+ action();
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(SpreadsheetImportError);
+ expect(thrown).toMatchObject({
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: UNSUPPORTED_SOURCE_MESSAGE,
+ });
+}
+
+function revokedArrayProxy(): readonly unknown[] {
+ const { proxy, revoke } = Proxy.revocable([], {});
+ revoke();
+ return proxy;
+}
+
+describe('spreadsheetWorkbookToDocumentJson runtime containers', () => {
+ it.each([null, undefined, 0, 'workbook']) (
+ 'rejects non-object workbook containers with the stable domain error',
+ (invalidWorkbook) => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson(
+ invalidWorkbook as unknown as Parameters<
+ typeof spreadsheetWorkbookToDocumentJson
+ >[0],
+ ),
+ );
+ },
+ );
+
+ it('rejects a non-array worksheet collection before reading its iterator', () => {
+ let iteratorRead = false;
+ const hostileWorksheets = Object.create(null) as Record;
+ Object.defineProperty(hostileWorksheets, Symbol.iterator, {
+ get() {
+ iteratorRead = true;
+ throw new Error('private-worksheets-iterator-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: hostileWorksheets as unknown as readonly [],
+ }),
+ );
+ expect(iteratorRead).toBe(false);
+ });
+
+ it('normalizes a revoked worksheet-array proxy to the stable domain error', () => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: revokedArrayProxy() as unknown as readonly [],
+ }),
+ );
+ });
+
+ it.each([null, undefined])(
+ 'rejects nullish worksheet entries with the stable domain error',
+ (invalidWorksheet) => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ invalidWorksheet as unknown as Parameters<
+ typeof spreadsheetWorkbookToDocumentJson
+ >[0]['worksheets'][number],
+ ],
+ }),
+ );
+ },
+ );
+
+ it('rejects a non-array row collection before reading its length', () => {
+ let lengthRead = false;
+ const hostileRows = Object.create(null) as Record;
+ Object.defineProperty(hostileRows, 'length', {
+ get() {
+ lengthRead = true;
+ throw new Error('private-rows-length-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: false,
+ rows: hostileRows as unknown as readonly (readonly string[])[],
+ },
+ ],
+ }),
+ );
+ expect(lengthRead).toBe(false);
+ });
+
+ it('normalizes a revoked rows-array proxy to the stable domain error', () => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: false,
+ rows: revokedArrayProxy() as unknown as readonly (readonly string[])[],
+ },
+ ],
+ }),
+ );
+ });
+
+ it('rejects a non-array row before reading its length', () => {
+ let lengthRead = false;
+ const hostileRow = Object.create(null) as Record;
+ Object.defineProperty(hostileRow, 'length', {
+ get() {
+ lengthRead = true;
+ throw new Error('private-row-length-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: false,
+ rows: [hostileRow as unknown as readonly string[]],
+ },
+ ],
+ }),
+ );
+ expect(lengthRead).toBe(false);
+ });
+
+ it('normalizes a revoked row-array proxy to the stable domain error', () => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: false,
+ rows: [revokedArrayProxy() as unknown as readonly string[]],
+ },
+ ],
+ }),
+ );
+ });
+});
diff --git a/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts b/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts
new file mode 100644
index 00000000..3c39f1d6
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImportRuntimeShape.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+import {
+ spreadsheetWorkbookToDocumentJson,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+const UNSUPPORTED_SOURCE_MESSAGE =
+ 'Spreadsheet source is unsupported or corrupt.';
+
+function expectUnsupportedSource(action: () => unknown): void {
+ let thrown: unknown;
+ try {
+ action();
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(SpreadsheetImportError);
+ expect(thrown).toMatchObject({
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: UNSUPPORTED_SOURCE_MESSAGE,
+ });
+}
+
+describe('spreadsheetWorkbookToDocumentJson runtime metadata boundary', () => {
+ it('rejects non-string cells before reading caller-controlled length', () => {
+ let lengthRead = false;
+ const hostileCell = Object.create(null) as Record;
+ Object.defineProperty(hostileCell, 'length', {
+ get() {
+ lengthRead = true;
+ throw new Error('private-cell-length-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: false,
+ rows: [[hostileCell as unknown as string]],
+ },
+ ],
+ }),
+ );
+ expect(lengthRead).toBe(false);
+ });
+
+ it('rejects non-string worksheet names before reading caller-controlled length', () => {
+ let lengthRead = false;
+ const hostileName = Object.create(null) as Record;
+ Object.defineProperty(hostileName, 'length', {
+ get() {
+ lengthRead = true;
+ throw new Error('private-name-length-sentinel');
+ },
+ });
+
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: hostileName as unknown as string,
+ hidden: false,
+ rows: [['kept']],
+ },
+ ],
+ }),
+ );
+ expect(lengthRead).toBe(false);
+ });
+
+ it('rejects non-boolean hidden metadata instead of silently skipping a sheet', () => {
+ expectUnsupportedSource(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: 'Data',
+ hidden: 'false' as unknown as boolean,
+ rows: [['kept']],
+ },
+ ],
+ }),
+ );
+ });
+});
diff --git a/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts b/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts
new file mode 100644
index 00000000..f4682afb
--- /dev/null
+++ b/src/spreadsheet/spreadsheetImportWorksheetNameLimit.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js';
+
+describe('spreadsheet worksheet-name resource preflight', () => {
+ it('rejects worksheet heading text that alone exceeds the workbook text ceiling', () => {
+ const oversizedName = 'x'.repeat(8_388_609);
+
+ expect(() =>
+ spreadsheetWorkbookToDocumentJson({
+ worksheets: [
+ {
+ name: oversizedName,
+ hidden: false,
+ rows: [['kept']],
+ },
+ ],
+ }),
+ ).toThrowError('Spreadsheet exceeds the configured resource limits.');
+ });
+});
diff --git a/src/spreadsheet/spreadsheetPublicSurface.test.ts b/src/spreadsheet/spreadsheetPublicSurface.test.ts
new file mode 100644
index 00000000..3d194fde
--- /dev/null
+++ b/src/spreadsheet/spreadsheetPublicSurface.test.ts
@@ -0,0 +1,50 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import * as spreadsheet from './index.js';
+import {
+ preflightSpreadsheetBinarySource,
+ spreadsheetWorkbookToDocumentJson,
+ SpreadsheetImportError,
+} from './spreadsheetImport.js';
+
+describe('spreadsheet package subpath contract', () => {
+ it('re-exports the framework-neutral spreadsheet runtime through the public source barrel', () => {
+ expect(spreadsheet.preflightSpreadsheetBinarySource).toBe(
+ preflightSpreadsheetBinarySource,
+ );
+ expect(spreadsheet.spreadsheetWorkbookToDocumentJson).toBe(
+ spreadsheetWorkbookToDocumentJson,
+ );
+ expect(spreadsheet.SpreadsheetImportError).toBe(SpreadsheetImportError);
+ });
+
+ it('declares an independently built framework-neutral spreadsheet surface', () => {
+ const repositoryRoot = process.cwd();
+ const packageJson = JSON.parse(
+ readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'),
+ ) as {
+ exports?: Record;
+ scripts?: Record;
+ };
+
+ expect(packageJson.exports?.['./spreadsheet']).toEqual({
+ types: './dist/spreadsheet/index.d.ts',
+ import: './dist/cwl-spreadsheet.js',
+ require: './dist/cwl-spreadsheet.cjs',
+ });
+ expect(packageJson.scripts?.build).toContain('vite.spreadsheet.config.ts');
+ expect(packageJson.scripts?.['verify:package']).toContain(
+ 'verify-spreadsheet-subpath-package.mjs',
+ );
+
+ const requiredFiles = [
+ 'src/spreadsheet/index.ts',
+ 'vite.spreadsheet.config.ts',
+ 'scripts/verify-spreadsheet-subpath-package.mjs',
+ ];
+ for (const relativePath of requiredFiles) {
+ expect(existsSync(resolve(repositoryRoot, relativePath))).toBe(true);
+ }
+ });
+});
diff --git a/src/spreadsheet/spreadsheetSourcePreflight.test.ts b/src/spreadsheet/spreadsheetSourcePreflight.test.ts
new file mode 100644
index 00000000..a823e12f
--- /dev/null
+++ b/src/spreadsheet/spreadsheetSourcePreflight.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import * as spreadsheetImport from './spreadsheetImport.js';
+
+type SpreadsheetBinaryFormat = 'xls' | 'xlsx';
+type SpreadsheetBinarySource = Readonly<{
+ format: SpreadsheetBinaryFormat;
+ bytes: Uint8Array;
+}>;
+type PreflightSpreadsheetBinarySource = (
+ source: Uint8Array,
+) => SpreadsheetBinarySource;
+
+function preflightSpreadsheetBinarySource(): PreflightSpreadsheetBinarySource {
+ const candidate = (
+ spreadsheetImport as unknown as {
+ preflightSpreadsheetBinarySource?: PreflightSpreadsheetBinarySource;
+ }
+ ).preflightSpreadsheetBinarySource;
+ expect(candidate).toBeTypeOf('function');
+ return candidate!;
+}
+
+function xlsxEnvelope(): Uint8Array {
+ return Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]);
+}
+
+function xlsEnvelope(): Uint8Array {
+ return Uint8Array.from([
+ 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0x00, 0x00,
+ ]);
+}
+
+describe('spreadsheet binary source preflight', () => {
+ it('detects an XLSX ZIP envelope without copying source bytes', () => {
+ const source = xlsxEnvelope();
+ const result = preflightSpreadsheetBinarySource()(source);
+
+ expect(result).toEqual({ format: 'xlsx', bytes: source });
+ expect(result.bytes).toBe(source);
+ });
+
+ it('detects a legacy XLS compound-file envelope without copying source bytes', () => {
+ const source = xlsEnvelope();
+ const result = preflightSpreadsheetBinarySource()(source);
+
+ expect(result).toEqual({ format: 'xls', bytes: source });
+ expect(result.bytes).toBe(source);
+ });
+
+ it('rejects a real source larger than the 64 MiB local ceiling', () => {
+ const source = new Uint8Array(64 * 1024 * 1024 + 1);
+
+ expect(() => preflightSpreadsheetBinarySource()(source)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+
+ it('rejects empty and unknown binary envelopes with a payload-redacted category', () => {
+ const preflight = preflightSpreadsheetBinarySource();
+
+ for (const source of [
+ new Uint8Array(0),
+ Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]),
+ ]) {
+ try {
+ preflight(source);
+ throw new Error('expected spreadsheet preflight to reject unsupported input');
+ } catch (error) {
+ expect(error).toMatchObject({
+ name: 'SpreadsheetImportError',
+ code: 'UNSUPPORTED_OR_CORRUPT',
+ message: 'Spreadsheet source is unsupported or corrupt.',
+ });
+ }
+ }
+ });
+});
diff --git a/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts b/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts
new file mode 100644
index 00000000..27592ab6
--- /dev/null
+++ b/src/spreadsheet/spreadsheetWorksheetCountLimit.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ SpreadsheetImportError,
+ spreadsheetWorkbookToDocumentJson,
+} from './spreadsheetImport';
+
+const MAX_BOUNDED_WORKSHEET_DESCRIPTORS = 256;
+
+function hiddenWorksheet(name: string) {
+ return { hidden: true, name, rows: [] as string[][] };
+}
+
+describe('spreadsheet workbook worksheet-count resource boundary', () => {
+ it('accepts the maximum bounded hidden-sheet descriptor count', () => {
+ const worksheets = Array.from(
+ { length: MAX_BOUNDED_WORKSHEET_DESCRIPTORS },
+ (_, index) => hiddenWorksheet(`hidden-${index}`),
+ );
+
+ expect(spreadsheetWorkbookToDocumentJson({ worksheets })).toEqual({
+ content: [],
+ worksheetCount: 0,
+ rowCount: 0,
+ cellCount: 0,
+ });
+ });
+
+ it('rejects an oversized worksheet descriptor set before inspecting worksheet members', () => {
+ const worksheets = Array.from(
+ { length: MAX_BOUNDED_WORKSHEET_DESCRIPTORS + 1 },
+ (_, index) => hiddenWorksheet(`hidden-${index}`),
+ );
+ Object.defineProperty(worksheets[0]!, 'hidden', {
+ configurable: true,
+ get() {
+ throw new Error('private worksheet getter should not execute');
+ },
+ });
+
+ expect(() => spreadsheetWorkbookToDocumentJson({ worksheets })).toThrowError(
+ expect.objectContaining({
+ name: 'SpreadsheetImportError',
+ code: 'RESOURCE_LIMIT_EXCEEDED',
+ message: 'Spreadsheet exceeds the configured resource limits.',
+ }) as SpreadsheetImportError,
+ );
+ });
+});
diff --git a/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts b/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts
new file mode 100644
index 00000000..53fba4ac
--- /dev/null
+++ b/src/spreadsheet/spreadsheetWorksheetNameLimit.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { spreadsheetWorkbookToDocumentJson } from './spreadsheetImport.js';
+
+describe('spreadsheet worksheet-name resource boundary', () => {
+ it('fails closed before materializing an oversized worksheet heading', () => {
+ const workbook = {
+ worksheets: [
+ {
+ name: 'x'.repeat(1_025),
+ hidden: false,
+ rows: [['kept']],
+ },
+ ],
+ };
+
+ expect(() => spreadsheetWorkbookToDocumentJson(workbook)).toThrowError(
+ 'Spreadsheet exceeds the configured resource limits.',
+ );
+ });
+});
diff --git a/src/spreadsheetBodyImportDocumentation.test.ts b/src/spreadsheetBodyImportDocumentation.test.ts
new file mode 100644
index 00000000..76b61b5f
--- /dev/null
+++ b/src/spreadsheetBodyImportDocumentation.test.ts
@@ -0,0 +1,48 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+const repositoryFile = (path: string): string =>
+ readFileSync(resolve(process.cwd(), path), 'utf8');
+
+describe('active-PR spreadsheet body-import documentation', () => {
+ it('keeps ADR 0032 Proposed and discoverable without promoting it to protected main', () => {
+ const adrPath = 'docs/adr/0032-bounded-local-spreadsheet-body-import.md';
+ const index = repositoryFile('docs/adr/README.md');
+ const changelog = repositoryFile('CHANGELOG.md');
+ const distribution = repositoryFile('docs/package-distribution.md');
+ const contracts = repositoryFile('docs/CONTRACTS.md');
+ const fitness = repositoryFile('docs/DOCUMENTATION_FITNESS.md');
+ const unreleased = changelog.slice(
+ changelog.indexOf('## [Unreleased]'),
+ changelog.indexOf('## [0.6.0]'),
+ );
+
+ expect(existsSync(resolve(process.cwd(), adrPath))).toBe(true);
+ const adr = repositoryFile(adrPath);
+ expect(index).toContain(
+ '[0032](0032-bounded-local-spreadsheet-body-import.md) | Proposed',
+ );
+ expect(adr).toMatch(/^Status: Proposed$/mu);
+ expect(adr).not.toMatch(/^Status: Accepted$/mu);
+ expect(adr).toContain('not protected-main implementation authority');
+ expect(unreleased).toContain('Active PR / Proposed');
+ expect(unreleased).toContain('local XLS/XLSX worksheet insertion');
+ expect(unreleased).toContain('not protected-main behavior');
+ expect(distribution).toMatch(
+ /`@contextualwisdomlab\/cwl-editor\/spreadsheet`\s*\|\s*`implemented_on_active_pr`/u,
+ );
+ expect(distribution).not.toMatch(
+ /`@contextualwisdomlab\/cwl-editor\/spreadsheet`[^\n]*implemented_on_protected_main/u,
+ );
+ expect(contracts).toContain('Local spreadsheet body-import contract');
+ expect(contracts).toContain('not protected-main authority');
+ expect(fitness).toMatch(
+ /Bounded local spreadsheet body import[^\n]*implemented_on_active_pr/u,
+ );
+ expect(fitness).not.toMatch(
+ /Bounded local spreadsheet body import[^\n]*implemented_on_protected_main/u,
+ );
+ });
+});
diff --git a/src/types.ts b/src/types.ts
index 0292d4a2..ce5f8818 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -298,6 +298,12 @@ export interface CwlEditorProps {
* silently swallowing failures on the commercial path.
*/
onImageError?: (error: unknown) => void;
+ /**
+ * Fired when a local XLS/XLSX toolbar import cannot be parsed or inserted.
+ * The toolbar renders only stable redacted status text; this callback carries
+ * the underlying error so the host can log or present its own bounded UX.
+ */
+ onSpreadsheetError?: (error: unknown) => void;
/**
* Bounded rich-HTML paste policy. Word, Google Docs, email, and web markup is
* rebuilt through Inkspan's strict semantic allowlist before insertion. The
diff --git a/vite.spreadsheet.config.ts b/vite.spreadsheet.config.ts
new file mode 100644
index 00000000..10ac7259
--- /dev/null
+++ b/vite.spreadsheet.config.ts
@@ -0,0 +1,28 @@
+import { resolve } from 'node:path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+// Framework-neutral spreadsheet conversion build. The implementation imports
+// TipTap only as a TypeScript type, so the emitted runtime bundle carries no
+// React, TipTap, network, credential, persistence, or model authority.
+export default defineConfig({
+ plugins: [
+ dts({
+ include: ['src/spreadsheet'],
+ exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'],
+ rollupTypes: false,
+ entryRoot: 'src',
+ }),
+ ],
+ build: {
+ emptyOutDir: false,
+ lib: {
+ entry: resolve(__dirname, 'src/spreadsheet/index.ts'),
+ name: 'InkspanSpreadsheet',
+ fileName: (format) =>
+ format === 'es' ? 'cwl-spreadsheet.js' : 'cwl-spreadsheet.cjs',
+ formats: ['es', 'cjs'],
+ },
+ sourcemap: true,
+ },
+});