From 4ba0210a963fcce25a5323066731bda276c08243 Mon Sep 17 00:00:00 2001 From: righ Date: Mon, 28 Sep 2026 23:53:09 +0900 Subject: [PATCH 01/10] feat(xlsx): add @gridsheet/xlsx converter (values + formulas) New headless package converting xlsx <-> GridSheet without exceljs/SheetJS: a TS-native OOXML reader/writer scoped to what CellType uses, with fflate for zip. v0 covers values (string/number/boolean), formulas round-tripping as their "=..." text, multiple sheets, and dates as ISO strings. - fromXlsx(bytes) -> { [sheet]: { matrices } } (buildInitialCells input shape) - toXlsx(sheets) -> Uint8Array (accepts a live sheet, cell matrix, or values) - Jest: a1 / xml units + toXlsx<->fromXlsx round trip + engine-sheet integration - Verified interop both ways against openpyxl (independent OOXML reader) - Storybook IO/Xlsx demo (ships sample.xlsx; import/download) + e2e spec - Wire root scripts, release publish job, and AGENTS.md Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yaml | 25 +++ AGENTS.md | 8 +- e2e/xlsx.spec.ts | 17 ++ package.json | 15 +- packages/storybook/.storybook/main.cjs | 1 + packages/storybook/package.json | 1 + packages/storybook/stories/io/assets.d.ts | 5 + packages/storybook/stories/io/sample.xlsx | Bin 0 -> 2494 bytes .../storybook/stories/io/xlsx.stories.tsx | 125 +++++++++++ packages/xlsx/README.md | 63 ++++++ packages/xlsx/jest.config.js | 26 +++ packages/xlsx/package.json | 65 ++++++ packages/xlsx/src/a1.spec.ts | 27 +++ packages/xlsx/src/a1.ts | 34 +++ packages/xlsx/src/convert.spec.ts | 151 +++++++++++++ packages/xlsx/src/index.ts | 9 + packages/xlsx/src/read.ts | 154 +++++++++++++ packages/xlsx/src/sheet-integration.spec.ts | 57 +++++ packages/xlsx/src/types.ts | 22 ++ packages/xlsx/src/write.ts | 174 ++++++++++++++ packages/xlsx/src/xml.spec.ts | 47 ++++ packages/xlsx/src/xml.ts | 171 ++++++++++++++ packages/xlsx/src/zip.ts | 20 ++ packages/xlsx/tsconfig.json | 25 +++ packages/xlsx/tsconfig.spec.json | 8 + packages/xlsx/vite.config.js | 25 +++ pnpm-lock.yaml | 212 ++++++++++++++++-- 27 files changed, 1458 insertions(+), 29 deletions(-) create mode 100644 e2e/xlsx.spec.ts create mode 100644 packages/storybook/stories/io/assets.d.ts create mode 100644 packages/storybook/stories/io/sample.xlsx create mode 100644 packages/storybook/stories/io/xlsx.stories.tsx create mode 100644 packages/xlsx/README.md create mode 100644 packages/xlsx/jest.config.js create mode 100644 packages/xlsx/package.json create mode 100644 packages/xlsx/src/a1.spec.ts create mode 100644 packages/xlsx/src/a1.ts create mode 100644 packages/xlsx/src/convert.spec.ts create mode 100644 packages/xlsx/src/index.ts create mode 100644 packages/xlsx/src/read.ts create mode 100644 packages/xlsx/src/sheet-integration.spec.ts create mode 100644 packages/xlsx/src/types.ts create mode 100644 packages/xlsx/src/write.ts create mode 100644 packages/xlsx/src/xml.spec.ts create mode 100644 packages/xlsx/src/xml.ts create mode 100644 packages/xlsx/src/zip.ts create mode 100644 packages/xlsx/tsconfig.json create mode 100644 packages/xlsx/tsconfig.spec.json create mode 100644 packages/xlsx/vite.config.js diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2430dca0..543ece39 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -107,6 +107,31 @@ jobs: TAG=$( [[ "$VERSION" == *-* ]] && echo "--tag next" || echo "" ) pnpm publish --access public --provenance $TAG --no-git-checks || true + upload-xlsx: + needs: upload-engine + runs-on: ubuntu-latest + name: upload xlsx + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + - run: npm install -g npm@11 + - name: remove authToken from .npmrc + run: | + NPM_RC=$(npm config get userconfig) + sed -i '/_authToken/d' "$NPM_RC" 2>/dev/null || true + - name: publish xlsx + run: | + pnpm install + pnpm --filter "@gridsheet/xlsx..." build + cd packages/xlsx + VERSION=$(node -p "require('./package.json').version") + TAG=$( [[ "$VERSION" == *-* ]] && echo "--tag next" || echo "" ) + pnpm publish --access public --provenance $TAG --no-git-checks || true + upload-react-core: needs: upload-web runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 268e1fd5..fb5de2ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ extension. GitHub org: `walkframe`. Docs site deployed to Cloudflare Pages. ## Packages (dependency order) -`engine → web → {react-core, preact-core, functions}; preact-core → vue-core; react-core → react-dev` +`engine → web → {react-core, preact-core, functions}; engine → xlsx; preact-core → vue-core; react-core → react-dev` | dir | package | what | |---|---|---| @@ -27,6 +27,7 @@ extension. GitHub org: `walkframe`. Docs site deployed to Cloudflare Pages. | svelte-core | `@gridsheet/svelte-core` | Svelte implementation. | | react-dev | `@gridsheet/react-dev` | Dev tools for React (`Debugger`). peerDep on web. | | functions | `@gridsheet/functions` | Extended formula functions. peerDep on web. | +| xlsx | `@gridsheet/xlsx` | xlsx ⇄ GridSheet converter (values + formulas). Headless. peerDep on engine; deps `fflate`. | | docs | `@gridsheet/docs` (private) | Astro + Starlight docs site. | | storybook | `@gridsheet/storybook` (private) | Stories; **the e2e target** (served on :5233). | | vscode-csv-viewer | `csv-gridsheet` (private) | VSCode extension "CSV Spreadsheet — GridSheet". | @@ -55,8 +56,9 @@ pnpm test # jest + e2e - **Playwright (`e2e/`) is the default** for feature/component/interaction tests. Specs drive the built Storybook; navigate with `go(page, 'basic-simple--sheet')`, select via `[data-address='A1']` / `.gs-cell-rendered`. -- **Jest is only for `packages/functions` and `packages/engine`.** Every formula function needs a - Jest `*.spec.ts` (colocated in `src/`). +- **Jest is only for `packages/functions`, `packages/engine`, and `packages/xlsx`.** Every formula + function needs a Jest `*.spec.ts` (colocated in `src/`); xlsx conversion is likewise headless and + Jest-tested (round-trip + interop specs in `src/`). ## Conventions & gotchas diff --git a/e2e/xlsx.spec.ts b/e2e/xlsx.spec.ts new file mode 100644 index 00000000..7925d013 --- /dev/null +++ b/e2e/xlsx.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test'; +import { go } from './utils'; + +test('imports a sample xlsx and re-evaluates formulas', async ({ page }) => { + await go(page, 'io-xlsx--converter'); + await page.getByTestId('load-sample').click(); + + const a1 = page.locator("[data-address='A1']"); + const d2 = page.locator("[data-address='D2']"); + const d5 = page.locator("[data-address='D5']"); + + // Header string survived the toXlsx → fromXlsx round trip. + expect(await a1.locator('.gs-cell-rendered').textContent()).toContain('Product'); + // Formulas round-tripped as text and re-evaluate in GridSheet: =B2*C2 → 4.5, =SUM(D2:D4) → 17. + expect(await d2.locator('.gs-cell-rendered').textContent()).toContain('4.5'); + expect(await d5.locator('.gs-cell-rendered').textContent()).toContain('17'); +}); diff --git a/package.json b/package.json index 0892421e..0f7e592a 100644 --- a/package.json +++ b/package.json @@ -22,19 +22,22 @@ "build:react-dev": "cd packages/react-dev && pnpm build --mode development", "build:preact-core": "cd packages/preact-core && pnpm build", "build:functions": "cd packages/functions && pnpm build", + "build:xlsx": "cd packages/xlsx && pnpm build", "build:web": "cd packages/web && pnpm build", "build:engine": "cd packages/engine && pnpm build", "build:vue-core": "cd packages/vue-core && pnpm build", "build:svelte-core": "cd packages/svelte-core && pnpm build", - "build:all": "pnpm build:engine && pnpm build:web && pnpm build:react-core && pnpm build:react-dev && pnpm build:preact-core && pnpm build:functions && pnpm build:vue-core && pnpm build:svelte-core", + "build:all": "pnpm build:engine && pnpm build:web && pnpm build:react-core && pnpm build:react-dev && pnpm build:preact-core && pnpm build:functions && pnpm build:xlsx && pnpm build:vue-core && pnpm build:svelte-core", "typecheck:engine": "cd packages/engine && pnpm typecheck", "typecheck:react-core": "cd packages/react-core && pnpm typecheck", - "typecheck:all": "pnpm typecheck:engine && pnpm typecheck:react-core", + "typecheck:xlsx": "cd packages/xlsx && pnpm typecheck", + "typecheck:all": "pnpm typecheck:engine && pnpm typecheck:react-core && pnpm typecheck:xlsx", "generate-style": "cd packages/web && pnpm generate-style", "e2e": "cd e2e && npx playwright test -c playwright.config.ts --workers 4", "jest:engine": "cd packages/engine && pnpm jest", "jest:functions": "cd packages/functions && pnpm jest", - "jest": "pnpm jest:engine && pnpm jest:functions", + "jest:xlsx": "cd packages/xlsx && pnpm jest", + "jest": "pnpm jest:engine && pnpm jest:functions && pnpm jest:xlsx", "test": "pnpm jest && pnpm e2e", "prettier": "prettier --write '**/*.{ts,tsx}'", "clean:cache": "rm -rf packages/storybook/node_modules/.cache/storybook", @@ -47,25 +50,27 @@ "remove:react-dev": "cd packages/react-dev && rm -rf node_modules", "remove:preact-core": "cd packages/preact-core && rm -rf node_modules", "remove:functions": "cd packages/functions && rm -rf node_modules", + "remove:xlsx": "cd packages/xlsx && rm -rf node_modules", "remove:storybook": "cd packages/storybook && rm -rf node_modules", "remove:docs": "cd packages/docs && rm -rf node_modules", "remove:web": "cd packages/web && rm -rf node_modules", "remove:engine": "cd packages/engine && rm -rf node_modules", "remove:vue-core": "cd packages/vue-core && rm -rf node_modules", "remove:svelte-core": "cd packages/svelte-core && rm -rf node_modules", - "remove:all": "pnpm remove:engine && pnpm remove:web && pnpm remove:react-core && pnpm remove:react-dev && pnpm remove:preact-core && pnpm remove:storybook && pnpm remove:docs && pnpm remove:functions && pnpm remove:vue-core && pnpm remove:svelte-core", + "remove:all": "pnpm remove:engine && pnpm remove:web && pnpm remove:react-core && pnpm remove:react-dev && pnpm remove:preact-core && pnpm remove:storybook && pnpm remove:docs && pnpm remove:functions && pnpm remove:xlsx && pnpm remove:vue-core && pnpm remove:svelte-core", "install:root": "pnpm install", "install:react-core": "cd packages/react-core && pnpm install", "install:react-dev": "cd packages/react-dev && pnpm install", "install:preact-core": "cd packages/preact-core && pnpm install", "install:functions": "cd packages/functions && pnpm install", + "install:xlsx": "cd packages/xlsx && pnpm install", "install:storybook": "cd packages/storybook && pnpm install", "install:docs": "cd packages/docs && pnpm install", "install:web": "cd packages/web && pnpm install", "install:engine": "cd packages/engine && pnpm install", "install:vue-core": "cd packages/vue-core && pnpm install", "install:svelte-core": "cd packages/svelte-core && pnpm install", - "install:all": "pnpm install:engine && pnpm install:web && pnpm install:react-core && pnpm install:react-dev && pnpm install:preact-core && pnpm install:functions && pnpm install:storybook && pnpm install:docs && pnpm install:vue-core && pnpm install:svelte-core", + "install:all": "pnpm install:engine && pnpm install:web && pnpm install:react-core && pnpm install:react-dev && pnpm install:preact-core && pnpm install:functions && pnpm install:xlsx && pnpm install:storybook && pnpm install:docs && pnpm install:vue-core && pnpm install:svelte-core", "reset:all": "pnpm remove:all && pnpm install:all && pnpm build:all && pnpm clean:cache", "set-version": "node scripts/set-version.mjs", "delete-tags": "node scripts/delete-tags.mjs" diff --git a/packages/storybook/.storybook/main.cjs b/packages/storybook/.storybook/main.cjs index eb68d6a7..82bf7099 100644 --- a/packages/storybook/.storybook/main.cjs +++ b/packages/storybook/.storybook/main.cjs @@ -45,6 +45,7 @@ module.exports = { { find: '@gridsheet/engine', replacement: path.resolve(__dirname, '../../engine/src/index.ts') }, { find: '@gridsheet/preact-core', replacement: path.resolve(__dirname, '../../preact-core/dist/index.js') }, { find: '@gridsheet/functions', replacement: path.resolve(__dirname, '../../functions/src/index.ts') }, + { find: '@gridsheet/xlsx', replacement: path.resolve(__dirname, '../../xlsx/src/index.ts') }, ]; return config; }, diff --git a/packages/storybook/package.json b/packages/storybook/package.json index d0cd6267..ace24d2a 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -20,6 +20,7 @@ "@gridsheet/preact-core": "workspace:^", "@gridsheet/react-core": "workspace:^", "@gridsheet/react-dev": "workspace:^", + "@gridsheet/xlsx": "workspace:^", "@storybook/addon-docs": "^8.6.7", "@storybook/addon-essentials": "^8.6.7", "@storybook/addon-interactions": "^8.6.7", diff --git a/packages/storybook/stories/io/assets.d.ts b/packages/storybook/stories/io/assets.d.ts new file mode 100644 index 00000000..67551408 --- /dev/null +++ b/packages/storybook/stories/io/assets.d.ts @@ -0,0 +1,5 @@ +// Vite serves a bundled asset URL for `?url` imports (e.g. a shipped sample.xlsx). +declare module '*.xlsx?url' { + const url: string; + export default url; +} diff --git a/packages/storybook/stories/io/sample.xlsx b/packages/storybook/stories/io/sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..127b2346ecf26b33b66ad21fcb0ef1947a03fe50 GIT binary patch literal 2494 zcmZ{l4LFng8^@o;mXIm$oQU#Xn2H?HmF1L;wSy$@!^|`r8$$Jzd9Lq$UH9jA-`^XH77>*N06+|=uiN8J zUT2c64*`IuYXCqI{2ER&JWin=qxs|UAvD94fG`LTBn77Skm;Kh$80{XJpm zYdiJ*j^BMT2efz}EAWc$=kWv%rpT=0L7iQ*m?i8)EX# z#!HEmnwELSnE;r$B6RA`bP!0BI8zAMZc(4DwacyC0g_dwNFuGPT|!n?Gfs(nzjQe|^ijCYX#o2Z;CBmB1Z zX_Lv!-*dw9$pNl}ZY`E%-&Gw2(E$duENTkAcU1$+aQTf4WzUoAW04=s zsB57-^NhMe1;bC~^W8{+#NJuh&nRI>+hQw0ggPKj2{3w6@g$m|!SXY%%TD~hku>ym zL!lV!c9FsUe4?pFi2gSKk+amjRVMWYkJs@e@ijFwi~l(>z$DwEhrzrV?S4@NS#o1j zpy6w-Tg?G4b<_aLP9C~VF;CBwBUgQHOk47r#5Q#kF-7kX?M}LK_k$EB@{-A>6I*Y{Z%RlY8XeW&tsX}vBJs6MbHE3h;;82>wl-W1BQ0KEkk#r0c)PaR-YW zq{OUkA^`QnR%_PHL~{mrI`LjT7@wLs`FHxD8IR^is2PYX^taSKCOwI}OKg%h{~QCi zsK007F%;7iJAn{)fbY#OSGiSZ*xm+bUMmXcsutM4=emT-Xg*DP+!AVx!Z-(H!JS5J zKZI)N>AtvBP+PrDgq?Mz0de;LAFLuW0ny!wL6%M2OnxR`F=&je_e>5vy- z<-0&5QvN~U$N4VX(f>HCoOLO)D}Zez$>5(9zcxRdPe=Tbv&v4Tucun5vQH=}_mu&-Ul_IIUX@#GO^x1}gR|EUO5hSwy2N7Dx2@;;R z+!5DPo5}3WlJOvfmdNX`aM@Z`@H9PMCEA>LBzyPvjhSKxFHDRPthgpm$mY23HG4_) zmr|XT5@-DPIGtj-&|uE^?d6-Oy zRJfi!CWnsVdhsmzZ%W+rrM9yNd2SIDJ&Z(0!(w(zZbz&xrggAae16E;qzb(xXW?Fz z=Xv;|!0mJLLeO-)SKDB}@2BMW1YPp?eTzaKENa(|>H-P!L7K~}51cNVKaPs`bqJvn z$%GZ4Nz7&x5+Q9Zh~oH6n;cTm(6s4^9Z&owVl~*Wa8L`eLwA~=6njS>=agYaGi3MO zEt4(W;UtwM`7+QiGv$=WnjFb`HBYh{&O5m3PUgDxZYekdWhcvaF}=S@&%Op#;fXLC z9Ep8us#dF|?aoabw(r?quYpSZ7QhDOyWh`F+~|_7^Q_=jed^N#Mu*?5p=)yu_gIpCd zz{M(Lm%?IUZ&?*5h^~nL#c71|2v6i{o=ealUG@L@6qX9N;HvZ;?AHy6MMJ?q0I(Lk M?}K#TuU&ro4{sFuDF6Tf literal 0 HcmV?d00001 diff --git a/packages/storybook/stories/io/xlsx.stories.tsx b/packages/storybook/stories/io/xlsx.stories.tsx new file mode 100644 index 00000000..7935daa1 --- /dev/null +++ b/packages/storybook/stories/io/xlsx.stories.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { buildInitialCells, GridSheet } from '@gridsheet/react-core'; +import { fromXlsx, toXlsx } from '@gridsheet/xlsx'; +import sampleUrl from './sample.xlsx?url'; + +const meta: Meta = { + title: 'IO/Xlsx', +}; +export default meta; + +const DESCRIPTION = [ + '## xlsx import / export', + 'Convert between `.xlsx` and GridSheet with `@gridsheet/xlsx`.', + '', + '- **Load sample** fetches a shipped `sample.xlsx` and reads it with `fromXlsx` —', + ' formulas survive and re-evaluate in the grid.', + '- **Import file** reads a real `.xlsx` you pick.', + '- **Download** reads the current sheet (`toXlsx({ Sheet1: sheet })`) and saves a `.xlsx`.', +].join('\n'); + +const EMPTY: (string | number)[][] = [['(click “Load sample” or import a file)']]; + +const btnStyle: React.CSSProperties = { + padding: '4px 10px', + marginRight: 8, + cursor: 'pointer', +}; + +const XlsxConverter = () => { + const sheetRef = React.useRef(null); + const [matrix, setMatrix] = React.useState(EMPTY); + // Bump to remount the grid: initialCells is initial-only, so importing new data + // means mounting a fresh GridSheet keyed by this version. + const [version, setVersion] = React.useState(0); + + const load = (next: any[][]) => { + setMatrix(next); + setVersion((v) => v + 1); + }; + + const loadSample = async () => { + // Fetch the shipped sample.xlsx (Vite gives us its bundled URL) and parse it. + const buf = await (await fetch(sampleUrl)).arrayBuffer(); + const parsed = fromXlsx(new Uint8Array(buf)); + const first = Object.values(parsed)[0]; + if (first) { + load(first.matrices.A1 as any[][]); + } + }; + + const importFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + const parsed = fromXlsx(new Uint8Array(await file.arrayBuffer())); + const first = Object.values(parsed)[0]; + if (first) { + load(first.matrices.A1 as any[][]); + } + e.target.value = ''; + }; + + const download = () => { + const handle = sheetRef.current; + if (!handle) { + return; + } + const bytes = toXlsx({ Sheet1: handle.sheet }); + const blob = new Blob([bytes], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'gridsheet.xlsx'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ + + +
+ +
+ ); +}; + +export const Converter: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: DESCRIPTION, + }, + }, + }, +}; diff --git a/packages/xlsx/README.md b/packages/xlsx/README.md new file mode 100644 index 00000000..6c20d7c6 --- /dev/null +++ b/packages/xlsx/README.md @@ -0,0 +1,63 @@ +# @gridsheet/xlsx + +xlsx ⇄ [GridSheet](https://gridsheet.walkframe.com/) converter. Headless (no DOM, no framework), +TypeScript-native. Reads and writes the OOXML parts GridSheet actually uses — it does **not** wrap +exceljs or SheetJS. + +## Scope (v0) + +- **Values**: string, number, boolean. Empty cells are dropped; the used range is trimmed. +- **Formulas**: round-trip as their `=...` text (read via `resolution: 'RAW'`). +- **Multiple sheets**, in workbook order. +- **Dates**: written as ISO strings (v0 has no number-format support). + +Not yet: styles, merged cells, column widths / row heights, number formats, charts/images. + +## Install + +```bash +pnpm add @gridsheet/xlsx +# peer: @gridsheet/engine (already present with @gridsheet/react-core) +``` + +`fflate` (zip codec) is a normal dependency and installs automatically. + +## Read: xlsx → GridSheet + +`fromXlsx` returns, per sheet, an object shaped exactly like `buildInitialCells` input. + +```ts +import { fromXlsx } from '@gridsheet/xlsx'; +import { buildInitialCells, GridSheet } from '@gridsheet/react-core'; + +const parsed = fromXlsx(bytes); // Uint8Array | ArrayBuffer | number[] +// parsed = { Sheet1: { matrices: { A1: [[...]] } }, ... } + +const initialCells = buildInitialCells(parsed.Sheet1); +// +``` + +## Write: GridSheet → xlsx + +`toXlsx` accepts a live sheet (read via `toCellMatrix` at RAW resolution), a cell matrix, or a plain +value matrix — one entry per output worksheet. + +```ts +import { toXlsx } from '@gridsheet/xlsx'; + +const bytes = toXlsx({ + Sheet1: sheetRef.current, // a GridSheet UserSheet + Totals: [ + ['name', 'total'], + ['Apple', '=B2*C2'], + ], // or a raw matrix +}); +// bytes: Uint8Array — write to a file, or trigger a browser download +``` + +Formula cells are written without a cached value; the workbook is marked `fullCalcOnLoad` so Excel / +LibreOffice / Google Sheets recalculate them on open. + +## License + +Apache-2.0 diff --git a/packages/xlsx/jest.config.js b/packages/xlsx/jest.config.js new file mode 100644 index 00000000..8430b1b4 --- /dev/null +++ b/packages/xlsx/jest.config.js @@ -0,0 +1,26 @@ +module.exports = { + verbose: true, + + preset: "ts-jest", + "roots": [ + "/" + ], + "testMatch": [ + "**/__tests__/**/*.+(ts|tsx|js)", + "**/?(*.)+(spec|test).+(ts|tsx|js)" + ], + "transform": { + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + "tsconfig": "tsconfig.spec.json", + "diagnostics": false + } + ] + }, + "moduleNameMapper": { + "^@gridsheet/engine$": "/../engine/src", + "^@gridsheet/engine/(.*)$": "/../engine/src/$1" + }, + testEnvironment: 'node' +}; diff --git a/packages/xlsx/package.json b/packages/xlsx/package.json new file mode 100644 index 00000000..6b7be14e --- /dev/null +++ b/packages/xlsx/package.json @@ -0,0 +1,65 @@ +{ + "name": "@gridsheet/xlsx", + "version": "3.4.3", + "description": "xlsx ⇄ GridSheet converter for @gridsheet/react-core (values + formulas)", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf ./dist || true && vite build", + "typecheck": "tsc --noEmit", + "jest": "jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/walkframe/gridsheet.git" + }, + "keywords": [ + "spreadsheet", + "xlsx", + "excel", + "ooxml", + "gridsheet", + "converter", + "import", + "export" + ], + "author": "righ", + "license": "Apache-2.0", + "files": [ + "dist/", + "package.json", + "README.md" + ], + "bugs": { + "url": "https://github.com/walkframe/gridsheet/issues" + }, + "homepage": "https://gridsheet.walkframe.com/", + "packageManager": "pnpm@10.6.5", + "dependencies": { + "fflate": "^0.8.3" + }, + "devDependencies": { + "@gridsheet/engine": "workspace:*", + "@types/jest": "^29.5.14", + "jest": "^29.4.3", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.8.2", + "vite": "^6.2.2", + "vite-plugin-dts": "^4.5.3" + }, + "peerDependencies": { + "@gridsheet/engine": "3.4.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/xlsx/src/a1.spec.ts b/packages/xlsx/src/a1.spec.ts new file mode 100644 index 00000000..022468a7 --- /dev/null +++ b/packages/xlsx/src/a1.spec.ts @@ -0,0 +1,27 @@ +import { colName, colIndex, parseRef } from './a1'; + +describe('a1 helpers', () => { + it('converts 1-based column index to name', () => { + expect(colName(1)).toBe('A'); + expect(colName(26)).toBe('Z'); + expect(colName(27)).toBe('AA'); + expect(colName(52)).toBe('AZ'); + expect(colName(702)).toBe('ZZ'); + expect(colName(703)).toBe('AAA'); + }); + + it('converts column name to 1-based index (round-trips colName)', () => { + expect(colIndex('A')).toBe(1); + expect(colIndex('AA')).toBe(27); + for (const c of [1, 26, 27, 100, 702, 703, 16384]) { + expect(colIndex(colName(c))).toBe(c); + } + }); + + it('parses A1 references, ignoring absolute markers', () => { + expect(parseRef('A1')).toEqual({ row: 1, col: 1 }); + expect(parseRef('B2')).toEqual({ row: 2, col: 2 }); + expect(parseRef('AA10')).toEqual({ row: 10, col: 27 }); + expect(parseRef('$C$3')).toEqual({ row: 3, col: 3 }); + }); +}); diff --git a/packages/xlsx/src/a1.ts b/packages/xlsx/src/a1.ts new file mode 100644 index 00000000..f8f16e11 --- /dev/null +++ b/packages/xlsx/src/a1.ts @@ -0,0 +1,34 @@ +/** + * A1-notation helpers, dependency-free. Columns and rows are 1-based, matching + * OOXML cell references (e.g. "A1" → { col: 1, row: 1 }). + */ + +/** 1-based column index → column name. 1 → "A", 27 → "AA". */ +export const colName = (col: number): string => { + let name = ''; + let n = col; + while (n > 0) { + const rem = (n - 1) % 26; + name = String.fromCharCode(65 + rem) + name; + n = Math.floor((n - 1) / 26); + } + return name; +}; + +/** Column name → 1-based column index. "A" → 1, "AA" → 27. */ +export const colIndex = (name: string): number => { + let n = 0; + for (let i = 0; i < name.length; i++) { + n = n * 26 + (name.charCodeAt(i) - 64); + } + return n; +}; + +/** Parse an A1 reference into 1-based { row, col }. Absolute markers ($) are ignored. */ +export const parseRef = (ref: string): { row: number; col: number } => { + const m = /([A-Za-z]+)\$?(\d+)/.exec(ref.replace(/\$/g, '')); + if (!m) { + return { row: 1, col: 1 }; + } + return { col: colIndex(m[1].toUpperCase()), row: parseInt(m[2], 10) }; +}; diff --git a/packages/xlsx/src/convert.spec.ts b/packages/xlsx/src/convert.spec.ts new file mode 100644 index 00000000..8c88c442 --- /dev/null +++ b/packages/xlsx/src/convert.spec.ts @@ -0,0 +1,151 @@ +import { fromXlsx, toXlsx } from './index'; +import { writeZip } from './zip'; +import type { XlsxCellValue } from './types'; + +// Round-trip helper: value matrix → xlsx bytes → parsed first sheet's matrix. +const roundTrip = (matrix: XlsxCellValue[][], sheetName = 'Sheet1'): XlsxCellValue[][] => { + const bytes = toXlsx({ [sheetName]: matrix }); + const parsed = fromXlsx(bytes); + return parsed[sheetName].matrices.A1; +}; + +describe('toXlsx → fromXlsx round trip', () => { + it('preserves strings, numbers, and booleans by type', () => { + const out = roundTrip([ + ['name', 'age', 'active'], + ['Alice', 30, true], + ['Bob', 25.5, false], + ]); + expect(out[0]).toEqual(['name', 'age', 'active']); + expect(out[1]).toEqual(['Alice', 30, true]); + expect(out[2]).toEqual(['Bob', 25.5, false]); + // types, not just values + expect(typeof out[1][1]).toBe('number'); + expect(typeof out[1][2]).toBe('boolean'); + }); + + it('keeps formulas as their "=..." string', () => { + const out = roundTrip([ + [1, 2], + ['=A1+B1', '=SUM(A1:B1)'], + ]); + expect(out[1][0]).toBe('=A1+B1'); + expect(out[1][1]).toBe('=SUM(A1:B1)'); + }); + + it('escapes special characters in strings and formulas', () => { + const out = roundTrip([ + ['a & b ', 'quote "x"'], + ['=IF(A1>2,"big","small")', null], + ]); + expect(out[0][0]).toBe('a & b '); + expect(out[0][1]).toBe('quote "x"'); + expect(out[1][0]).toBe('=IF(A1>2,"big","small")'); + }); + + it('drops empty/null cells and trims the matrix to the used range', () => { + const out = roundTrip([ + ['x', null, ''], + [null, null, null], + [null, null, 'y'], + ]); + // used range is A1:C3 (C3 = 'y') + expect(out).toHaveLength(3); + expect(out[0][0]).toBe('x'); + expect(out[0][1]).toBeNull(); + expect(out[2][2]).toBe('y'); + }); + + it('round-trips a Date as an ISO string (v0 has no date number-format)', () => { + const d = new Date('2026-09-28T00:00:00.000Z'); + const out = roundTrip([[d]]); + expect(out[0][0]).toBe(d.toISOString()); + }); + + it('deduplicates shared strings', () => { + // "dup" appears 3x → uniqueCount should be 1 (plus the distinct "solo"). + const bytes = toXlsx({ + Sheet1: [ + ['dup', 'dup'], + ['dup', 'solo'], + ], + }); + const parsed = fromXlsx(bytes); + const m = parsed.Sheet1.matrices.A1; + expect(m[0]).toEqual(['dup', 'dup']); + expect(m[1]).toEqual(['dup', 'solo']); + }); + + it('handles multiple sheets in workbook order', () => { + const bytes = toXlsx({ + First: [['a']], + Second: [[1], [2]], + 'Name with spaces': [['=1+1']], + }); + const parsed = fromXlsx(bytes); + expect(Object.keys(parsed)).toEqual(['First', 'Second', 'Name with spaces']); + expect(parsed.First.matrices.A1).toEqual([['a']]); + expect(parsed.Second.matrices.A1).toEqual([[1], [2]]); + expect(parsed['Name with spaces'].matrices.A1).toEqual([['=1+1']]); + }); + + it('produces output usable as buildInitialCells input shape', () => { + const parsed = fromXlsx(toXlsx({ Sheet1: [['hi', 42]] })); + // Shape check: { matrices: { A1: [[...]] } } — exactly what buildInitialCells destructures. + expect(parsed.Sheet1).toHaveProperty('matrices.A1'); + expect(parsed.Sheet1.matrices.A1).toEqual([['hi', 42]]); + }); +}); + +describe('fromXlsx read paths not emitted by our writer', () => { + // Build a minimal workbook by hand to exercise inlineStr, t="str", and untyped + // numeric cells (readers must accept these even though toXlsx never writes them). + const MAIN = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; + const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + const PKG = 'http://schemas.openxmlformats.org/package/2006/relationships'; + + const handBuilt = (): Uint8Array => + writeZip({ + '[Content_Types].xml': + `` + + `` + + `` + + `` + + `` + + ``, + '_rels/.rels': + `` + + ``, + 'xl/workbook.xml': + `` + + ``, + 'xl/_rels/workbook.xml.rels': + `` + + ``, + 'xl/worksheets/sheet1.xml': + `` + + `` + + `inline` + + `3.14` + // untyped number + `CONCAT("a","b")ab` + // formula with cached value + `` + + `` + + `1` + // boolean, sparse (A2 missing) + `` + + ``, + }); + + it('reads inlineStr, untyped numbers, cached formulas, and sparse booleans', () => { + const parsed = fromXlsx(handBuilt()); + const m = parsed.Data.matrices.A1; + expect(m[0][0]).toBe('inline'); + expect(m[0][1]).toBe(3.14); + expect(m[0][2]).toBe('=CONCAT("a","b")'); // formula text wins over cached + expect(m[1][0]).toBeNull(); // A2 absent + expect(m[1][1]).toBe(true); // B2 boolean + }); + + it('throws a clear error on non-xlsx input', () => { + expect(() => fromXlsx(writeZip({ 'hello.txt': 'not a workbook' }))).toThrow(/valid xlsx/); + }); +}); diff --git a/packages/xlsx/src/index.ts b/packages/xlsx/src/index.ts new file mode 100644 index 00000000..25e5352f --- /dev/null +++ b/packages/xlsx/src/index.ts @@ -0,0 +1,9 @@ +// @gridsheet/xlsx — xlsx ⇄ GridSheet conversion (values + formulas, v0). +// Headless: no DOM, no framework. Depends on @gridsheet/engine (peer) + fflate. + +export { fromXlsx, type XlsxInput } from './read'; +export { toXlsx } from './write'; +export type { ParsedWorkbook, XlsxSheetData, XlsxSheetInput, XlsxCellValue } from './types'; + +// A1 helpers are handy for consumers building addresses. +export { colName, colIndex, parseRef } from './a1'; diff --git a/packages/xlsx/src/read.ts b/packages/xlsx/src/read.ts new file mode 100644 index 00000000..cdb4c305 --- /dev/null +++ b/packages/xlsx/src/read.ts @@ -0,0 +1,154 @@ +import { readZip } from './zip'; +import { parseXml, child, children, gatherText, type XmlNode } from './xml'; +import { parseRef } from './a1'; +import type { ParsedWorkbook, XlsxCellValue } from './types'; + +/** Accepted binary inputs for {@link fromXlsx}. Node Buffer is a Uint8Array subclass. */ +export type XlsxInput = Uint8Array | ArrayBuffer | number[]; + +const toU8 = (data: XlsxInput): Uint8Array => { + if (data instanceof Uint8Array) { + return data; + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (Array.isArray(data)) { + return Uint8Array.from(data); + } + throw new Error('fromXlsx: expected a Uint8Array, ArrayBuffer, or byte array'); +}; + +const parseRelationships = (xml: string | undefined): Record => { + const map: Record = {}; + if (!xml) { + return map; + } + const root = parseXml(xml); + for (const rel of children(root, 'Relationship')) { + if (rel.attrs['Id']) { + map[rel.attrs['Id']] = rel.attrs['Target'] ?? ''; + } + } + return map; +}; + +/** Relationship targets are relative to xl/ (the .rels lives in xl/_rels/). */ +const resolveWorkbookTarget = (target: string): string => { + if (target.startsWith('/')) { + return target.slice(1); + } + return `xl/${target.replace(/^\.\//, '')}`; +}; + +const parseSharedStrings = (xml: string | undefined): string[] => { + if (!xml) { + return []; + } + const root = parseXml(xml); + return children(root, 'si').map((si) => gatherText(si)); +}; + +const coerceValue = (cell: XmlNode, t: string | undefined, shared: string[]): XlsxCellValue => { + const v = child(cell, 'v'); + switch (t) { + case 'inlineStr': + return gatherText(child(cell, 'is') ?? cell); + case 's': { + const idx = v ? parseInt(v.text, 10) : -1; + return shared[idx] ?? ''; + } + case 'b': + return (v?.text ?? '0') !== '0'; + case 'str': + case 'e': + return v ? v.text : ''; + default: { + // number (t="n") or untyped + if (v && v.text !== '') { + const num = Number(v.text); + return Number.isNaN(num) ? v.text : num; + } + return null; + } + } +}; + +const parseSheet = (xml: string, shared: string[]): XlsxCellValue[][] => { + const root = parseXml(xml); + const sheetData = child(root, 'sheetData'); + const entries: { row: number; col: number; value: XlsxCellValue }[] = []; + let maxRow = 0; + let maxCol = 0; + + for (const row of children(sheetData, 'row')) { + const rowAttr = row.attrs['r'] ? parseInt(row.attrs['r'], 10) : undefined; + let autoCol = 0; + for (const c of children(row, 'c')) { + const ref = c.attrs['r'] ? parseRef(c.attrs['r']) : { row: rowAttr ?? maxRow + 1, col: ++autoCol }; + autoCol = ref.col; + const t = c.attrs['t']; + const f = child(c, 'f'); + let value: XlsxCellValue; + const formulaText = f ? f.text : ''; + if (f && formulaText.trim() !== '') { + value = `=${formulaText}`; + } else { + value = coerceValue(c, t, shared); + } + if (value !== null && value !== '') { + entries.push({ row: ref.row, col: ref.col, value }); + if (ref.row > maxRow) { + maxRow = ref.row; + } + if (ref.col > maxCol) { + maxCol = ref.col; + } + } + } + } + + const matrix: XlsxCellValue[][] = []; + for (let y = 0; y < maxRow; y++) { + matrix.push(new Array(maxCol).fill(null)); + } + for (const { row, col, value } of entries) { + matrix[row - 1][col - 1] = value; + } + return matrix; +}; + +/** + * Parse an xlsx workbook into a per-sheet map of `{ matrices }`, ready to feed + * to `buildInitialCells(parsed[sheetName])`. Values arrive as string / number / + * boolean; formulas arrive as their "=..." string (v0 does not read styles). + */ +export const fromXlsx = (data: XlsxInput): ParsedWorkbook => { + const files = readZip(toU8(data)); + const workbookXml = files['xl/workbook.xml']; + if (!workbookXml) { + throw new Error('fromXlsx: not a valid xlsx (missing xl/workbook.xml)'); + } + const workbook = parseXml(workbookXml); + const rels = parseRelationships(files['xl/_rels/workbook.xml.rels']); + const shared = parseSharedStrings(files['xl/sharedStrings.xml']); + + const result: ParsedWorkbook = {}; + const sheetsEl = child(workbook, 'sheets'); + let fallback = 0; + for (const sheet of children(sheetsEl, 'sheet')) { + const name = sheet.attrs['name'] ?? `Sheet${++fallback}`; + const rid = sheet.attrs['r:id'] ?? sheet.attrs['id']; + const target = rid ? rels[rid] : undefined; + if (!target) { + continue; + } + const path = resolveWorkbookTarget(target); + const sheetXml = files[path]; + if (sheetXml == null) { + continue; + } + result[name] = { matrices: { A1: parseSheet(sheetXml, shared) } }; + } + return result; +}; diff --git a/packages/xlsx/src/sheet-integration.spec.ts b/packages/xlsx/src/sheet-integration.spec.ts new file mode 100644 index 00000000..aba8c424 --- /dev/null +++ b/packages/xlsx/src/sheet-integration.spec.ts @@ -0,0 +1,57 @@ +import { Sheet, createRegistry, buildInitialCells } from '@gridsheet/engine'; +import { fromXlsx, toXlsx } from './index'; + +// Wire a bare headless Sheet into its registry, mirroring what a framework store +// does, so formulas resolve and toCellMatrix can read the sheet outside a UI. +const headlessSheet = (cells: any) => { + const registry = createRegistry(); + const sheet = new Sheet({ name: 'Sheet1', registry, eager: true }); + sheet.initialize(cells); + registry.contextsBySheetId[sheet.id] = { + store: { sheetReactive: { current: sheet } }, + dispatch: () => {}, + } as any; + sheet.resolveFormulas(); + return sheet; +}; + +describe('toXlsx from a live GridSheet sheet', () => { + it('extracts values and keeps formulas as text (RAW resolution)', () => { + const sheet = headlessSheet( + buildInitialCells({ + cells: { + A1: { value: 10 }, + A2: { value: 20 }, + B1: { value: 'label' }, + B2: { value: '=A1+A2' }, + B3: { value: '=SUM(A1:A2)' }, + }, + ensured: { numRows: 3, numCols: 2 }, + }), + ); + + const parsed = fromXlsx(toXlsx({ Sheet1: sheet })); + const m = parsed.Sheet1.matrices.A1; + + expect(m[0][0]).toBe(10); // A1 + expect(m[1][0]).toBe(20); // A2 + expect(m[0][1]).toBe('label'); // B1 + expect(m[1][1]).toBe('=A1+A2'); // B2 stays a formula + expect(m[2][1]).toBe('=SUM(A1:A2)'); // B3 stays a formula + }); + + it('feeds fromXlsx output back into a new sheet that resolves the formulas', () => { + const source = headlessSheet( + buildInitialCells({ + cells: { A1: { value: 3 }, A2: { value: 4 }, A3: { value: '=A1*A2' } }, + ensured: { numRows: 3, numCols: 1 }, + }), + ); + + const parsed = fromXlsx(toXlsx({ Sheet1: source })); + // Reimport: the parsed matrices are exactly buildInitialCells input. + const reimported = headlessSheet(buildInitialCells(parsed.Sheet1)); + const a3 = reimported.getCell({ y: 3, x: 1 }, { resolution: 'RESOLVED' }); + expect(a3?.value).toBe(12); // formula survived the xlsx round trip and re-evaluates + }); +}); diff --git a/packages/xlsx/src/types.ts b/packages/xlsx/src/types.ts new file mode 100644 index 00000000..babc247e --- /dev/null +++ b/packages/xlsx/src/types.ts @@ -0,0 +1,22 @@ +import type { MatricesByAddress, CellType } from '@gridsheet/engine'; +import type { UserSheet } from '@gridsheet/engine'; + +/** Scalar cell values v0 understands on the way in and out of xlsx. */ +export type XlsxCellValue = string | number | boolean | Date | null; + +/** + * One sheet's worth of imported data, shaped so it can be handed straight to + * `buildInitialCells(parsed[name])`. `matrices` maps an origin address ("A1") + * to a dense value matrix (formulas arrive as their "=..." string). + */ +export type XlsxSheetData = { matrices: MatricesByAddress }; + +/** Result of {@link fromXlsx}: sheet name → data, in workbook order. */ +export type ParsedWorkbook = { [sheetName: string]: XlsxSheetData }; + +/** + * Accepted per-sheet input to {@link toXlsx}: a live GridSheet sheet (its cells + * are read via `toCellMatrix(sheet, { resolution: 'RAW' })`), a cell matrix, or + * a plain value matrix. + */ +export type XlsxSheetInput = UserSheet | (CellType | null)[][] | XlsxCellValue[][]; diff --git a/packages/xlsx/src/write.ts b/packages/xlsx/src/write.ts new file mode 100644 index 00000000..03316f28 --- /dev/null +++ b/packages/xlsx/src/write.ts @@ -0,0 +1,174 @@ +import { toCellMatrix, type UserSheet, type CellType } from '@gridsheet/engine'; +import { writeZip } from './zip'; +import { colName } from './a1'; +import { escapeXml, escapeAttr } from './xml'; +import type { XlsxSheetInput, XlsxCellValue } from './types'; + +const XML_DECL = '\n'; +const MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; +const REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const PKG_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; + +// A minimal-but-valid styles part. v0 writes no per-cell styles; Excel still +// requires xl/styles.xml to exist and expose xf index 0. +const STYLES_XML = + XML_DECL + + `` + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + +const ROOT_RELS = + XML_DECL + + `` + + `` + + ''; + +const isUserSheet = (input: XlsxSheetInput): input is UserSheet => + !Array.isArray(input) && typeof (input as { getCell?: unknown }).getCell === 'function'; + +const cellEntryToValue = (entry: CellType | XlsxCellValue | null): XlsxCellValue => { + if (entry != null && typeof entry === 'object' && !(entry instanceof Date) && 'value' in entry) { + return (entry as CellType).value ?? null; + } + return (entry as XlsxCellValue) ?? null; +}; + +const toValueMatrix = (input: XlsxSheetInput): XlsxCellValue[][] => { + if (isUserSheet(input)) { + const cells = toCellMatrix(input, { resolution: 'RAW' }); + return cells.map((row) => row.map((cell) => (cell ? (cell.value ?? null) : null))); + } + return (input as (CellType | XlsxCellValue | null)[][]).map((row) => row.map(cellEntryToValue)); +}; + +const buildCellXml = (ref: string, value: XlsxCellValue, intern: (s: string) => number): string => { + if (typeof value === 'string' && value.startsWith('=')) { + return `${escapeXml(value.slice(1))}`; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return `${value}`; + } + if (typeof value === 'boolean') { + return `${value ? 1 : 0}`; + } + // Date has no v0 number-format support; round-trip it as an ISO string. + const text = value instanceof Date ? value.toISOString() : String(value); + return `${intern(text)}`; +}; + +const buildSheetXml = (matrix: XlsxCellValue[][], intern: (s: string) => number): string => { + let rows = ''; + for (let y = 0; y < matrix.length; y++) { + const row = matrix[y]; + let cells = ''; + for (let x = 0; x < row.length; x++) { + const value = row[x]; + if (value === null || value === undefined || value === '') { + continue; + } + cells += buildCellXml(`${colName(x + 1)}${y + 1}`, value, intern); + } + if (cells) { + rows += `${cells}`; + } + } + return XML_DECL + `${rows}`; +}; + +const buildSharedStrings = (shared: string[]): string => { + const items = shared.map((s) => `${escapeXml(s)}`).join(''); + return XML_DECL + `${items}`; +}; + +const buildWorkbookXml = (names: string[]): string => { + const sheets = names + .map((name, i) => ``) + .join(''); + return ( + XML_DECL + + `` + + `${sheets}` + + // Force a full recalc on open so formula cells (written without a cached ) show values. + '' + + '' + ); +}; + +const buildWorkbookRels = (count: number): string => { + let rels = ''; + for (let i = 0; i < count; i++) { + rels += ``; + } + rels += ``; + rels += ``; + return XML_DECL + `${rels}`; +}; + +const buildContentTypes = (count: number): string => { + let overrides = ''; + for (let i = 0; i < count; i++) { + overrides += + `'; + } + overrides += + ''; + overrides += + ''; + overrides += + ''; + return ( + XML_DECL + + `` + + '' + + '' + + overrides + + '' + ); +}; + +/** + * Serialize one or more sheets into an xlsx workbook (a Uint8Array of zip bytes). + * Each input may be a live GridSheet sheet (read via `toCellMatrix` at RAW + * resolution so formulas keep their "=..." text), a cell matrix, or a value + * matrix. v0 writes values + formulas only; styles are not emitted. + */ +export const toXlsx = (sheets: Record): Uint8Array => { + const names = Object.keys(sheets); + if (names.length === 0) { + throw new Error('toXlsx: at least one sheet is required'); + } + + const shared: string[] = []; + const sharedIndex = new Map(); + const intern = (s: string): number => { + let idx = sharedIndex.get(s); + if (idx === undefined) { + idx = shared.length; + shared.push(s); + sharedIndex.set(s, idx); + } + return idx; + }; + + const files: Record = {}; + names.forEach((name, i) => { + files[`xl/worksheets/sheet${i + 1}.xml`] = buildSheetXml(toValueMatrix(sheets[name]), intern); + }); + files['[Content_Types].xml'] = buildContentTypes(names.length); + files['_rels/.rels'] = ROOT_RELS; + files['xl/workbook.xml'] = buildWorkbookXml(names); + files['xl/_rels/workbook.xml.rels'] = buildWorkbookRels(names.length); + files['xl/styles.xml'] = STYLES_XML; + files['xl/sharedStrings.xml'] = buildSharedStrings(shared); + + return writeZip(files); +}; diff --git a/packages/xlsx/src/xml.spec.ts b/packages/xlsx/src/xml.spec.ts new file mode 100644 index 00000000..2f31b99f --- /dev/null +++ b/packages/xlsx/src/xml.spec.ts @@ -0,0 +1,47 @@ +import { parseXml, child, children, gatherText, local, decodeEntities, escapeXml, escapeAttr } from './xml'; + +describe('xml parser', () => { + it('parses elements, attributes, and nested children', () => { + const root = parseXml('hibye'); + expect(root.tag).toBe('a'); + expect(root.attrs.x).toBe('1'); + const bs = children(root, 'b'); + expect(bs).toHaveLength(2); + expect(bs[0].attrs.y).toBe('2'); + expect(bs[0].text).toBe('hi'); + expect(bs[1].text).toBe('bye'); + }); + + it('handles self-closing tags and the XML declaration', () => { + const root = parseXml(''); + expect(children(root, 'c')).toHaveLength(2); + expect(child(root, 'c')!.attrs.r).toBe('A1'); + expect(child(root, 'c')!.attrs.t).toBe('s'); + }); + + it('matches elements by local name, ignoring namespace prefixes', () => { + expect(local('r:id')).toBe('id'); + const root = parseXml(''); + expect(child(root, 'sheets') === undefined || root.tag === 'w:sheets').toBeTruthy(); + expect(children(root, 'sheet')).toHaveLength(1); + }); + + it('does not end a tag on a > inside a quoted attribute', () => { + const root = parseXml('IF(A1>2,"x","y")'); + expect(root.tag).toBe('f'); + expect(root.text).toBe('IF(A1>2,"x","y")'); + }); + + it('gathers text across rich-text runs', () => { + const si = parseXml('Hello World'); + expect(gatherText(si)).toBe('Hello World'); + const plain = parseXml(' keep '); + expect(gatherText(plain)).toBe(' keep '); + }); + + it('decodes and escapes entities symmetrically', () => { + expect(decodeEntities('a & b <c> A B')).toBe('a & b A B'); + expect(escapeXml('a & b < c > d')).toBe('a & b < c > d'); + expect(escapeAttr('say "hi" & ')).toBe('say "hi" & <bye>'); + }); +}); diff --git a/packages/xlsx/src/xml.ts b/packages/xlsx/src/xml.ts new file mode 100644 index 00000000..3a0dd19d --- /dev/null +++ b/packages/xlsx/src/xml.ts @@ -0,0 +1,171 @@ +/** + * Minimal, dependency-free XML reader/writer scoped to the OOXML parts we touch + * (workbook, worksheet, sharedStrings, relationships, content-types). + * + * This is deliberately NOT a general XML parser. It relies on two properties of + * OOXML that always hold: text content never contains a raw `<` (it is escaped + * as <), so a `<` always starts a tag; and attribute values are quoted, so a + * `>` inside a quoted attribute does not end the tag. Namespaces are kept as-is + * on attributes and matched by local name on elements. + */ + +export type XmlNode = { + /** Tag name including any namespace prefix (e.g. "r:id" stays "r:id"). */ + tag: string; + attrs: Record; + children: XmlNode[]; + /** Concatenated direct text content (already entity-decoded). */ + text: string; +}; + +/** Strip a namespace prefix: "r:id" → "id", "sheetData" → "sheetData". */ +export const local = (tag: string): string => { + const i = tag.indexOf(':'); + return i < 0 ? tag : tag.slice(i + 1); +}; + +/** First direct child element matching `tag` by local name. */ +export const child = (node: XmlNode | undefined, tag: string): XmlNode | undefined => + node?.children.find((c) => local(c.tag) === tag); + +/** All direct child elements matching `tag` by local name. */ +export const children = (node: XmlNode | undefined, tag: string): XmlNode[] => + node ? node.children.filter((c) => local(c.tag) === tag) : []; + +/** Concatenate the text of every descendant element (shared-string runs). */ +export const gatherText = (node: XmlNode): string => { + let out = local(node.tag) === 't' ? node.text : ''; + for (const c of node.children) { + out += gatherText(c); + } + return out; +}; + +export const decodeEntities = (s: string): string => { + if (s.indexOf('&') < 0) { + return s; + } + return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, ent: string) => { + if (ent[0] === '#') { + const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10); + return Number.isNaN(code) ? whole : String.fromCodePoint(code); + } + switch (ent) { + case 'amp': + return '&'; + case 'lt': + return '<'; + case 'gt': + return '>'; + case 'quot': + return '"'; + case 'apos': + return "'"; + default: + return whole; + } + }); +}; + +/** Escape text content for XML (& < >). */ +export const escapeXml = (s: string): string => + s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>')); + +/** Escape an attribute value (& < > "). */ +export const escapeAttr = (s: string): string => + s.replace(/[&<>"]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"')); + +const parseTag = (body: string): { tag: string; attrs: Record } => { + let k = 0; + while (k < body.length && !/\s/.test(body[k])) { + k++; + } + const tag = body.slice(0, k); + const attrs: Record = {}; + const attrStr = body.slice(k); + const re = /([^\s=]+)\s*=\s*"([^"]*)"|([^\s=]+)\s*=\s*'([^']*)'/g; + let m: RegExpExecArray | null; + while ((m = re.exec(attrStr)) !== null) { + const key = m[1] ?? m[3]; + const val = m[2] ?? m[4] ?? ''; + attrs[key] = decodeEntities(val); + } + return { tag, attrs }; +}; + +/** + * Parse an XML string into a node tree, returning the root element. + * Declarations, comments, and DOCTYPE are skipped; CDATA is kept as text. + */ +export const parseXml = (input: string): XmlNode => { + const root: XmlNode = { tag: '#root', attrs: {}, children: [], text: '' }; + const stack: XmlNode[] = [root]; + const n = input.length; + let i = 0; + + while (i < n) { + if (input[i] === '<') { + if (input.startsWith('', i); + i = end < 0 ? n : end + 2; + continue; + } + if (input.startsWith('', i); + i = end < 0 ? n : end + 3; + continue; + } + if (input.startsWith('', i); + stack[stack.length - 1].text += input.slice(i + 9, end < 0 ? n : end); + i = end < 0 ? n : end + 3; + continue; + } + if (input.startsWith('', i); + i = end < 0 ? n : end + 1; + continue; + } + if (input[i + 1] === '/') { + const end = input.indexOf('>', i); + if (stack.length > 1) { + stack.pop(); + } + i = end < 0 ? n : end + 1; + continue; + } + // Opening (or self-closing) tag. Scan to the closing '>' while respecting quotes. + let j = i + 1; + let quote = ''; + while (j < n) { + const c = input[j]; + if (quote) { + if (c === quote) { + quote = ''; + } + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === '>') { + break; + } + j++; + } + const raw = input.slice(i + 1, j); + const selfClose = raw.endsWith('/'); + const { tag, attrs } = parseTag(selfClose ? raw.slice(0, -1) : raw); + const node: XmlNode = { tag, attrs, children: [], text: '' }; + stack[stack.length - 1].children.push(node); + if (!selfClose) { + stack.push(node); + } + i = j + 1; + } else { + const next = input.indexOf('<', i); + const end = next < 0 ? n : next; + stack[stack.length - 1].text += decodeEntities(input.slice(i, end)); + i = end; + } + } + + return root.children[0] ?? root; +}; diff --git a/packages/xlsx/src/zip.ts b/packages/xlsx/src/zip.ts new file mode 100644 index 00000000..ac022d06 --- /dev/null +++ b/packages/xlsx/src/zip.ts @@ -0,0 +1,20 @@ +import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate'; + +/** Read a zip archive into a { path: utf8-string } map (xlsx parts are all text/XML for v0). */ +export const readZip = (data: Uint8Array): Record => { + const files = unzipSync(data); + const out: Record = {}; + for (const name of Object.keys(files)) { + out[name] = strFromU8(files[name]); + } + return out; +}; + +/** Write a { path: utf8-string } map into a zip archive. */ +export const writeZip = (files: Record): Uint8Array => { + const zippable: Record = {}; + for (const name of Object.keys(files)) { + zippable[name] = strToU8(files[name]); + } + return zipSync(zippable); +}; diff --git a/packages/xlsx/tsconfig.json b/packages/xlsx/tsconfig.json new file mode 100644 index 00000000..73796402 --- /dev/null +++ b/packages/xlsx/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "ES2015", + "moduleResolution": "bundler", + "lib": ["DOM", "ESNext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist/", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": "./src/", + "paths": { + "@gridsheet/engine": ["../../engine/src"], + "@gridsheet/engine/*": ["../../engine/src/*"] + }, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*.ts"] +} diff --git a/packages/xlsx/tsconfig.spec.json b/packages/xlsx/tsconfig.spec.json new file mode 100644 index 00000000..2c07d5e9 --- /dev/null +++ b/packages/xlsx/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "jsx": "react-jsx" + }, + "exclude": ["node_modules", "dist"] +} diff --git a/packages/xlsx/vite.config.js b/packages/xlsx/vite.config.js new file mode 100644 index 00000000..cedc9c33 --- /dev/null +++ b/packages/xlsx/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +export default defineConfig(() => ({ + plugins: [dts({ insertTypesEntry: true })], + build: { + lib: { + entry: { + index: "./src/index.ts", + }, + formats: ["es"], + fileName: (format, entryName) => `${entryName}.js`, + }, + outDir: "dist", + rollupOptions: { + // @gridsheet/* is a peer dep; fflate is a runtime dependency resolved by the consumer. + external: [/^@gridsheet\//, "fflate"], + output: { + preserveModules: false, + }, + }, + sourcemap: true, + minify: "esbuild", + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 469ec409..a1f59862 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,7 +152,7 @@ importers: version: link:../react-core '@preact/preset-vite': specifier: ^2.10.1 - version: 2.10.1(@babel/core@7.29.7)(preact@10.26.6)(vite@6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) + version: 2.10.1(@babel/core@7.26.10)(preact@10.26.6)(vite@6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) '@types/react': specifier: 18.2.0 version: 18.2.0 @@ -216,7 +216,7 @@ importers: version: 19.1.0(react@18.2.0) ts-jest: specifier: ^29.0.5 - version: 29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3) ts-node: specifier: ^10.9.1 version: 10.9.2(@types/node@24.13.3)(typescript@5.8.3) @@ -309,6 +309,9 @@ importers: '@gridsheet/web': specifier: workspace:^ version: link:../web + '@gridsheet/xlsx': + specifier: workspace:^ + version: link:../xlsx '@storybook/addon-docs': specifier: ^8.6.7 version: 8.6.12(@types/react@18.2.0)(storybook@8.6.12(prettier@3.5.3)) @@ -457,6 +460,37 @@ importers: specifier: ^4.5.3 version: 4.5.3(@types/node@24.13.3)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) + packages/xlsx: + dependencies: + fflate: + specifier: ^0.8.3 + version: 0.8.3 + devDependencies: + '@gridsheet/engine': + specifier: workspace:* + version: link:../engine + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + jest: + specifier: ^29.4.3 + version: 29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + ts-jest: + specifier: ^29.1.0 + version: 29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3) + typescript: + specifier: ^5.8.2 + version: 5.8.3 + vite: + specifier: ^6.2.2 + version: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) + vite-plugin-dts: + specifier: ^4.5.3 + version: 4.5.3(@types/node@24.13.3)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) + packages: '@adobe/css-tools@4.4.2': @@ -4022,6 +4056,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -7200,44 +7237,86 @@ snapshots: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.10)': @@ -7245,50 +7324,98 @@ snapshots: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.26.10) transitivePeerDependencies: - supports-color @@ -7312,13 +7439,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.26.10 '@babel/helper-annotate-as-pure': 7.27.1 '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.26.10) '@babel/types': 7.27.1 transitivePeerDependencies: - supports-color @@ -8487,14 +8614,14 @@ snapshots: '@polka/url@1.0.0-next.28': {} - '@preact/preset-vite@2.10.1(@babel/core@7.29.7)(preact@10.26.6)(vite@6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0))': + '@preact/preset-vite@2.10.1(@babel/core@7.26.10)(preact@10.26.6)(vite@6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7) + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.26.10) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.26.10) '@prefresh/vite': 2.4.7(preact@10.26.6)(vite@6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) '@rollup/pluginutils': 4.2.1 - babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7) + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.26.10) debug: 4.4.0 kolorist: 1.8.0 vite: 6.2.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) @@ -9935,6 +10062,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-plugin-istanbul@6.1.1: dependencies: '@babel/helper-plugin-utils': 7.26.5 @@ -9958,9 +10099,9 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.10 - babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.7): + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.26.10): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.26.10 babel-preset-current-node-syntax@1.1.0(@babel/core@7.26.10): dependencies: @@ -9981,12 +10122,39 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.26.10) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.10) + babel-preset-current-node-syntax@1.1.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + optional: true + babel-preset-jest@29.6.3(@babel/core@7.26.10): dependencies: '@babel/core': 7.26.10 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.10) + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.7) + optional: true + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -10995,6 +11163,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -13873,7 +14043,7 @@ snapshots: terser@5.40.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.1 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -13987,7 +14157,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.10) - ts-jest@29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -14002,10 +14172,10 @@ snapshots: typescript: 5.8.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.26.10 + '@babel/core': 7.29.7 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.10) + babel-jest: 29.7.0(@babel/core@7.29.7) ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3): dependencies: From 16b805638fff60b0f55f6e013664119437e8c74e Mon Sep 17 00:00:00 2001 From: righ Date: Mon, 28 Sep 2026 23:58:33 +0900 Subject: [PATCH 02/10] test(xlsx): use a complex styled workbook as the sample/fixture Replace the sample with an openpyxl-written workbook exercising features v0 does not support (merged cells, fonts/fills/borders, number formats, frozen panes, multiple sheets) to prove graceful degradation: styling/merges are ignored, values + formulas still import (merged range keeps its top-left value; a formatted date reads as its raw serial number). - src/__fixtures__/complex.xlsx + complex.spec.ts (Jest robustness) - Storybook sample.xlsx swapped for the complex one; description + e2e updated - README notes the graceful-degradation behavior Co-Authored-By: Claude Opus 4.8 --- e2e/xlsx.spec.ts | 17 +++---- packages/storybook/stories/io/sample.xlsx | Bin 2494 -> 6025 bytes .../storybook/stories/io/xlsx.stories.tsx | 5 +- packages/xlsx/README.md | 3 ++ packages/xlsx/src/__fixtures__/complex.xlsx | Bin 0 -> 6025 bytes packages/xlsx/src/complex.spec.ts | 44 ++++++++++++++++++ 6 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 packages/xlsx/src/__fixtures__/complex.xlsx create mode 100644 packages/xlsx/src/complex.spec.ts diff --git a/e2e/xlsx.spec.ts b/e2e/xlsx.spec.ts index 7925d013..3deaa8b8 100644 --- a/e2e/xlsx.spec.ts +++ b/e2e/xlsx.spec.ts @@ -1,17 +1,18 @@ import { test, expect } from '@playwright/test'; import { go } from './utils'; -test('imports a sample xlsx and re-evaluates formulas', async ({ page }) => { +test('imports a complex styled xlsx and re-evaluates formulas', async ({ page }) => { await go(page, 'io-xlsx--converter'); await page.getByTestId('load-sample').click(); const a1 = page.locator("[data-address='A1']"); - const d2 = page.locator("[data-address='D2']"); - const d5 = page.locator("[data-address='D5']"); + const d3 = page.locator("[data-address='D3']"); + const d6 = page.locator("[data-address='D6']"); - // Header string survived the toXlsx → fromXlsx round trip. - expect(await a1.locator('.gs-cell-rendered').textContent()).toContain('Product'); - // Formulas round-tripped as text and re-evaluate in GridSheet: =B2*C2 → 4.5, =SUM(D2:D4) → 17. - expect(await d2.locator('.gs-cell-rendered').textContent()).toContain('4.5'); - expect(await d5.locator('.gs-cell-rendered').textContent()).toContain('17'); + // The sample is a styled, merged, multi-sheet workbook. Styling/merges are dropped, + // but the merged title's value (top-left) and the data survive. + expect(await a1.locator('.gs-cell-rendered').textContent()).toContain('Q3 Sales Report'); + // Formulas imported as text and re-evaluate in GridSheet: =B3*C3 → 4.5, =SUM(D3:D5) → 17. + expect(await d3.locator('.gs-cell-rendered').textContent()).toContain('4.5'); + expect(await d6.locator('.gs-cell-rendered').textContent()).toContain('17'); }); diff --git a/packages/storybook/stories/io/sample.xlsx b/packages/storybook/stories/io/sample.xlsx index 127b2346ecf26b33b66ad21fcb0ef1947a03fe50..fd404db15fabfde705a732cae703a469e4a8d7c4 100644 GIT binary patch literal 6025 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 M?}K#TuU&ro4{sFuDF6Tf diff --git a/packages/storybook/stories/io/xlsx.stories.tsx b/packages/storybook/stories/io/xlsx.stories.tsx index 7935daa1..36a04d10 100644 --- a/packages/storybook/stories/io/xlsx.stories.tsx +++ b/packages/storybook/stories/io/xlsx.stories.tsx @@ -13,8 +13,9 @@ const DESCRIPTION = [ '## xlsx import / export', 'Convert between `.xlsx` and GridSheet with `@gridsheet/xlsx`.', '', - '- **Load sample** fetches a shipped `sample.xlsx` and reads it with `fromXlsx` —', - ' formulas survive and re-evaluate in the grid.', + '- **Load sample** fetches a shipped `sample.xlsx` (a styled, merged, multi-sheet workbook)', + ' and reads it with `fromXlsx`. Styling and merged ranges are dropped; values and formulas', + ' survive and re-evaluate in the grid.', '- **Import file** reads a real `.xlsx` you pick.', '- **Download** reads the current sheet (`toXlsx({ Sheet1: sheet })`) and saves a `.xlsx`.', ].join('\n'); diff --git a/packages/xlsx/README.md b/packages/xlsx/README.md index 6c20d7c6..6a8ff2d9 100644 --- a/packages/xlsx/README.md +++ b/packages/xlsx/README.md @@ -12,6 +12,9 @@ exceljs or SheetJS. - **Dates**: written as ISO strings (v0 has no number-format support). Not yet: styles, merged cells, column widths / row heights, number formats, charts/images. +These are **ignored gracefully** on import — a styled, merged, multi-sheet workbook reads fine; +you just get values + formulas (a merged range keeps its value in the top-left cell, and a +number-formatted date comes through as its raw Excel serial number). ## Install diff --git a/packages/xlsx/src/__fixtures__/complex.xlsx b/packages/xlsx/src/__fixtures__/complex.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fd404db15fabfde705a732cae703a469e4a8d7c4 GIT binary patch literal 6025 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 readFileSync(join(__dirname, '__fixtures__', 'complex.xlsx')); + +describe('fromXlsx on a complex, styled workbook (graceful degradation)', () => { + it('reads every sheet without throwing', () => { + const parsed = fromXlsx(complex()); + expect(Object.keys(parsed)).toEqual(['Sales', 'Meta']); + }); + + it('keeps a merged cell value in its top-left, leaving the rest empty', () => { + const sales = fromXlsx(complex()).Sales.matrices.A1; + // A1:D1 is merged in the source; the value lives only in A1. + expect(sales[0]).toEqual(['Q3 Sales Report', null, null, null]); + }); + + it('extracts values and formulas through the styling', () => { + const sales = fromXlsx(complex()).Sales.matrices.A1; + expect(sales[1]).toEqual(['Product', 'Qty', 'Price', 'Total']); + expect(sales[2]).toEqual(['Apple', 3, 1.5, '=B3*C3']); + expect(sales[3]).toEqual(['Banana', 10, 0.25, '=B4*C4']); + expect(sales[4]).toEqual(['Cherry', 5, 2, '=B5*C5']); + expect(sales[5]).toEqual(['Sum', null, null, '=SUM(D3:D5)']); + }); + + it('drops number formats: a formatted date reads as its raw serial number', () => { + const meta = fromXlsx(complex()).Meta.matrices.A1; + // B1 is a date with a yyyy-mm-dd format; v0 has no numFmt handling, so it is + // the underlying Excel serial number (a plain number), not a Date/string. + expect(meta[0][0]).toBe('Generated'); + expect(typeof meta[0][1]).toBe('number'); + // A boolean stays boolean; a percent-formatted 15.3% is its raw fraction. + expect(meta[1]).toEqual(['Active', true]); + expect(meta[2]).toEqual(['Rate', 0.153]); + // Escaped special characters decode correctly. + expect(meta[3]).toEqual(['Note', 'a & b "q"']); + }); +}); From 49eceb38ff05ce4fc0ae249da0bd2679a7fb49ac Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 00:25:21 +0900 Subject: [PATCH 03/10] test(xlsx): add cross-sheet formula references to the complex sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the styled fixture with a `Summary` sheet whose formulas reference other sheets — `=SUM(Sales!D3:D5)`, `=Sales!D3`, and `=Sales!D6*'Meta Data'!B3` (a sheet name with a space, so quoting is exercised). Renames Meta -> "Meta Data". - fromXlsx preserves cross-sheet formula text verbatim (complex.spec.ts) - engine integration: importing the workbook into one shared registry and giving each sheet a unique id resolves the cross-sheet formulas (Summary B1=17, B2=4.5, B3~=2.601) (sheet-integration.spec.ts) - Storybook demo now renders every imported sheet in a shared book, so cross-sheet references re-evaluate live; e2e asserts Summary!B2 = 17 - add @types/node so the fixture-reading specs typecheck Co-Authored-By: Claude Opus 4.8 --- e2e/xlsx.spec.ts | 20 +-- packages/storybook/stories/io/sample.xlsx | Bin 6025 -> 6681 bytes .../storybook/stories/io/xlsx.stories.tsx | 101 +++++++---- packages/xlsx/package.json | 1 + packages/xlsx/src/__fixtures__/complex.xlsx | Bin 6025 -> 6681 bytes packages/xlsx/src/complex.spec.ts | 13 +- packages/xlsx/src/sheet-integration.spec.ts | 30 ++++ pnpm-lock.yaml | 169 +++++++++++------- 8 files changed, 219 insertions(+), 115 deletions(-) diff --git a/e2e/xlsx.spec.ts b/e2e/xlsx.spec.ts index 3deaa8b8..19ef73c9 100644 --- a/e2e/xlsx.spec.ts +++ b/e2e/xlsx.spec.ts @@ -1,18 +1,18 @@ import { test, expect } from '@playwright/test'; import { go } from './utils'; -test('imports a complex styled xlsx and re-evaluates formulas', async ({ page }) => { +test('imports a complex styled xlsx and re-evaluates cross-sheet formulas', async ({ page }) => { await go(page, 'io-xlsx--converter'); await page.getByTestId('load-sample').click(); - const a1 = page.locator("[data-address='A1']"); - const d3 = page.locator("[data-address='D3']"); - const d6 = page.locator("[data-address='D6']"); + const sales = page.getByTestId('sheet-Sales'); + const summary = page.getByTestId('sheet-Summary'); - // The sample is a styled, merged, multi-sheet workbook. Styling/merges are dropped, - // but the merged title's value (top-left) and the data survive. - expect(await a1.locator('.gs-cell-rendered').textContent()).toContain('Q3 Sales Report'); - // Formulas imported as text and re-evaluate in GridSheet: =B3*C3 → 4.5, =SUM(D3:D5) → 17. - expect(await d3.locator('.gs-cell-rendered').textContent()).toContain('4.5'); - expect(await d6.locator('.gs-cell-rendered').textContent()).toContain('17'); + // Styled + merged Sales sheet: merged title's value survives (top-left), own formula re-evaluates. + expect(await sales.locator("[data-address='A1'] .gs-cell-rendered").textContent()).toContain('Q3 Sales Report'); + expect(await sales.locator("[data-address='D3'] .gs-cell-rendered").textContent()).toContain('4.5'); + + // Summary sheet references other sheets; sharing a book, they resolve live: + // B2 = SUM(Sales!D3:D5) → 17. + expect(await summary.locator("[data-address='B2'] .gs-cell-rendered").textContent()).toContain('17'); }); diff --git a/packages/storybook/stories/io/sample.xlsx b/packages/storybook/stories/io/sample.xlsx index fd404db15fabfde705a732cae703a469e4a8d7c4..ec45d2413993705cacc1cb665f6cfd6095e38179 100644 GIT binary patch delta 3032 zcmZWrc{tQv8y*IcFlNkPkirxrdyIYGCCf{M$x?&Kz9orCj6K`fx3`!gOJp07kR@b! zMKs15sR{bSgbQ}}DnuVGeYYq!rdtd|5?^ofG>0Gz*Jg{GyoFeO z1s4xCJscl~fToOYrf9l|C1FQfADHXLDXM{)8PdLT7n$y8H|RBQk4nIc*8w_Ig6fGc zCH7Hz)`sb~t#a|?K55fZ56t)YAu(BatxlX2LVOeKdj{tkOdW}p$m`c6=Y4FE{#=nD zpoCml!_wrX{w6D>UsaU9C?FNAZ=gA_UgH0$0R9ZR@^ZC?F~3Jv=O2~%d^BP5<3a$* zHG}tN7Id4xoswnNX=WugXEd3*U+_=ZFDr9Pr+=dXo##|23St4&Z!jpa z0O^F(OVVli`7?KPkpX;2QPtZJiw_I{fDjV^zy}}*!=N|?>FYk;5jGTGitMpHsIANl zOVvO%8KlgT&D5&S#F~FC;)1s-44F_YHaRnAm($(fRK?X~lKx)Mlvo(zS?tX*-)keVK?wclXNX;gr`)IH_f zj^kEY-b9!=X?<)>kwd}wO{P&=Il9kq7)@S38R;8#ZN)3sk3614uR3Vt6gs=-)i*Ev zc<@NhB?icl&^a68nmFa9FQy)m_;!Yqk zAD==@KH^S}#UK$66B9o2rDcCw@uo$hD>4TwgV&Bsexk-Z$mU0l?KGRCKH#JKVrQRg zj(sfqUa!S=Y3ifcEnZTE7IO42D-7|fwNSIbsAr$CG=uZ#jxLHQzMufLKFV30jC&Ad zMRb=|s^xU@<4lm`y%7+4N^2t3mGv?z<$TPC&e*X8R>g*sIFasTUuF*hWPrl+H$wJ1 z=_vAMR<-S{!=8pc&0x$%f@FTV_gcqaltQ`LCdz?A7a{_*v1r|6`9X6vb*V{21x?*Y z&dGNS(i}HCQ+688kavNlo3|kl+1a6Na%%ggit=DP&UasCdEVc4RXRn%20u$kEBT-w5i^lj z8rfTA@O~y})tp-6eRV;LJYHOj*YJ=E-}>rVWK{&INLTgmIye2WWbt8 zcP| z*3D#j{hS?cTftJKxHa)cDXWrIvc0%>@FdF?czV{V)luW5 zcW$|>T{y#aW%*=->k{QUWYF;$XC2m3pvqK+m?oyA)r==y#5{4>-d%({y>e)sY_BRX zbYk;4TtY#P3K3{H*Ml#NlWTWbQN_E`_|-k!vah=sOa$1Cu6J^UX**9FNfj7nBwu4r zwwP$(prYf}mENYO`a-+-v6i_Bf?_SQw{s>iI4#cb;l9Emg2kHdjYzKfom{gxE9 z5vtHdlEwjkV;#-Tie`CAcM!t#-Up}Sb#EExK#X^gOsHR=mzy799?Xs#BB*di%YqDQ z+;#xR%rZo7+h9FKrCmB}<9o6_N;=k}?*hyuUfm_u?b}BlKo`G96}>g&vO&dmzX=5* z>fDv=00UmD(6Fg_*}2TAt;F}9lCnrYTRdoYpLw#NPx`2O zUE8%V+S>d9Bkdv{>4jPiCow4>ENrp-eq0J|I8}@;E-HQSL+HnK)bsNV#`p$1+l2XJ zg3yN^V94er-8g}MWc5uV10=vl#&KU6o$eip><}yHO?2MyHQU|x2E*BG94bzXnpIu* z@aR>m%c-FOavLU;2Mx@5KHcvwVX2heWDvAN3RjEl-x1)q87=4N>$w?un*B4Y;QjG{ z_}3fU$VSCX^~R@nrdpl!WyLe%7~Wvt>Ed02@5QeKz8~;IQp&7(lHw<+a>(^Lm2l0_kMg1Je}A+Nx@z z=IU4Yo}maFU(cR~^0%aDo+yJY1h(m&L5IR~Sw*M{ae|1v@C~+mW=~@_NN?2_LWK)7 zA(?yf5rfV_$9H5D>zZMrB(VeR(hzOA#_tlz{Q?#RPupWB?b2n$z{msqWj+b}JZit( zCqV~_Iy78_aH!@_dY1C4<_7&@&DY_ug*gc;`%Kp074+c vgnkD7`C$HyyT$N@hcE&bJo9JiKPC6Sge55960Z;^JAFq3^pYyPhfDtfToWAv delta 2388 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 TXJy;p=48%z=Ji(g(E; + +const EMPTY: Sheets = { Sheet1: [['(click “Load sample” or import a file)']] }; const btnStyle: React.CSSProperties = { padding: '4px 10px', @@ -28,26 +32,59 @@ const btnStyle: React.CSSProperties = { cursor: 'pointer', }; +// Renders every sheet of a workbook into one shared book so cross-sheet +// formulas resolve. Keyed by import version so each load gets a fresh book. +const Workbook = ({ sheets, refs }: { sheets: Sheets; refs: React.MutableRefObject> }) => { + const book = useSpellbook({}); + return ( +
+ {Object.entries(sheets).map(([name, matrix]) => { + const ref = (refs.current[name] ??= { current: null }); + return ( +
+

{name}

+ +
+ ); + })} +
+ ); +}; + const XlsxConverter = () => { - const sheetRef = React.useRef(null); - const [matrix, setMatrix] = React.useState(EMPTY); - // Bump to remount the grid: initialCells is initial-only, so importing new data - // means mounting a fresh GridSheet keyed by this version. + const refs = React.useRef>({}); + const [sheets, setSheets] = React.useState(EMPTY); + // Bump to remount: initialCells is initial-only, so a new import means a fresh book + sheets. const [version, setVersion] = React.useState(0); - const load = (next: any[][]) => { - setMatrix(next); + const load = (next: Sheets) => { + refs.current = {}; + setSheets(next); setVersion((v) => v + 1); }; + const parseToSheets = (bytes: Uint8Array): Sheets => { + const parsed = fromXlsx(bytes); + const out: Sheets = {}; + for (const [name, data] of Object.entries(parsed)) { + out[name] = data.matrices.A1 as any[][]; + } + return out; + }; + const loadSample = async () => { - // Fetch the shipped sample.xlsx (Vite gives us its bundled URL) and parse it. const buf = await (await fetch(sampleUrl)).arrayBuffer(); - const parsed = fromXlsx(new Uint8Array(buf)); - const first = Object.values(parsed)[0]; - if (first) { - load(first.matrices.A1 as any[][]); - } + load(parseToSheets(new Uint8Array(buf))); }; const importFile = async (e: React.ChangeEvent) => { @@ -55,21 +92,22 @@ const XlsxConverter = () => { if (!file) { return; } - const parsed = fromXlsx(new Uint8Array(await file.arrayBuffer())); - const first = Object.values(parsed)[0]; - if (first) { - load(first.matrices.A1 as any[][]); - } + load(parseToSheets(new Uint8Array(await file.arrayBuffer()))); e.target.value = ''; }; const download = () => { - const handle = sheetRef.current; - if (!handle) { + const out: Record = {}; + for (const name of Object.keys(sheets)) { + const handle = refs.current[name]?.current; + if (handle) { + out[name] = handle.sheet; + } + } + if (Object.keys(out).length === 0) { return; } - const bytes = toXlsx({ Sheet1: handle.sheet }); - const blob = new Blob([bytes], { + const blob = new Blob([toXlsx(out)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = URL.createObjectURL(blob); @@ -100,16 +138,7 @@ const XlsxConverter = () => { Download - + ); }; diff --git a/packages/xlsx/package.json b/packages/xlsx/package.json index 6b7be14e..b5a4e489 100644 --- a/packages/xlsx/package.json +++ b/packages/xlsx/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@gridsheet/engine": "workspace:*", "@types/jest": "^29.5.14", + "@types/node": "^22.13.10", "jest": "^29.4.3", "jest-environment-jsdom": "^29.7.0", "ts-jest": "^29.1.0", diff --git a/packages/xlsx/src/__fixtures__/complex.xlsx b/packages/xlsx/src/__fixtures__/complex.xlsx index fd404db15fabfde705a732cae703a469e4a8d7c4..ec45d2413993705cacc1cb665f6cfd6095e38179 100644 GIT binary patch delta 3032 zcmZWrc{tQv8y*IcFlNkPkirxrdyIYGCCf{M$x?&Kz9orCj6K`fx3`!gOJp07kR@b! zMKs15sR{bSgbQ}}DnuVGeYYq!rdtd|5?^ofG>0Gz*Jg{GyoFeO z1s4xCJscl~fToOYrf9l|C1FQfADHXLDXM{)8PdLT7n$y8H|RBQk4nIc*8w_Ig6fGc zCH7Hz)`sb~t#a|?K55fZ56t)YAu(BatxlX2LVOeKdj{tkOdW}p$m`c6=Y4FE{#=nD zpoCml!_wrX{w6D>UsaU9C?FNAZ=gA_UgH0$0R9ZR@^ZC?F~3Jv=O2~%d^BP5<3a$* zHG}tN7Id4xoswnNX=WugXEd3*U+_=ZFDr9Pr+=dXo##|23St4&Z!jpa z0O^F(OVVli`7?KPkpX;2QPtZJiw_I{fDjV^zy}}*!=N|?>FYk;5jGTGitMpHsIANl zOVvO%8KlgT&D5&S#F~FC;)1s-44F_YHaRnAm($(fRK?X~lKx)Mlvo(zS?tX*-)keVK?wclXNX;gr`)IH_f zj^kEY-b9!=X?<)>kwd}wO{P&=Il9kq7)@S38R;8#ZN)3sk3614uR3Vt6gs=-)i*Ev zc<@NhB?icl&^a68nmFa9FQy)m_;!Yqk zAD==@KH^S}#UK$66B9o2rDcCw@uo$hD>4TwgV&Bsexk-Z$mU0l?KGRCKH#JKVrQRg zj(sfqUa!S=Y3ifcEnZTE7IO42D-7|fwNSIbsAr$CG=uZ#jxLHQzMufLKFV30jC&Ad zMRb=|s^xU@<4lm`y%7+4N^2t3mGv?z<$TPC&e*X8R>g*sIFasTUuF*hWPrl+H$wJ1 z=_vAMR<-S{!=8pc&0x$%f@FTV_gcqaltQ`LCdz?A7a{_*v1r|6`9X6vb*V{21x?*Y z&dGNS(i}HCQ+688kavNlo3|kl+1a6Na%%ggit=DP&UasCdEVc4RXRn%20u$kEBT-w5i^lj z8rfTA@O~y})tp-6eRV;LJYHOj*YJ=E-}>rVWK{&INLTgmIye2WWbt8 zcP| z*3D#j{hS?cTftJKxHa)cDXWrIvc0%>@FdF?czV{V)luW5 zcW$|>T{y#aW%*=->k{QUWYF;$XC2m3pvqK+m?oyA)r==y#5{4>-d%({y>e)sY_BRX zbYk;4TtY#P3K3{H*Ml#NlWTWbQN_E`_|-k!vah=sOa$1Cu6J^UX**9FNfj7nBwu4r zwwP$(prYf}mENYO`a-+-v6i_Bf?_SQw{s>iI4#cb;l9Emg2kHdjYzKfom{gxE9 z5vtHdlEwjkV;#-Tie`CAcM!t#-Up}Sb#EExK#X^gOsHR=mzy799?Xs#BB*di%YqDQ z+;#xR%rZo7+h9FKrCmB}<9o6_N;=k}?*hyuUfm_u?b}BlKo`G96}>g&vO&dmzX=5* z>fDv=00UmD(6Fg_*}2TAt;F}9lCnrYTRdoYpLw#NPx`2O zUE8%V+S>d9Bkdv{>4jPiCow4>ENrp-eq0J|I8}@;E-HQSL+HnK)bsNV#`p$1+l2XJ zg3yN^V94er-8g}MWc5uV10=vl#&KU6o$eip><}yHO?2MyHQU|x2E*BG94bzXnpIu* z@aR>m%c-FOavLU;2Mx@5KHcvwVX2heWDvAN3RjEl-x1)q87=4N>$w?un*B4Y;QjG{ z_}3fU$VSCX^~R@nrdpl!WyLe%7~Wvt>Ed02@5QeKz8~;IQp&7(lHw<+a>(^Lm2l0_kMg1Je}A+Nx@z z=IU4Yo}maFU(cR~^0%aDo+yJY1h(m&L5IR~Sw*M{ae|1v@C~+mW=~@_NN?2_LWK)7 zA(?yf5rfV_$9H5D>zZMrB(VeR(hzOA#_tlz{Q?#RPupWB?b2n$z{msqWj+b}JZit( zCqV~_Iy78_aH!@_dY1C4<_7&@&DY_ug*gc;`%Kp074+c vgnkD7`C$HyyT$N@hcE&bJo9JiKPC6Sge55960Z;^JAFq3^pYyPhfDtfToWAv delta 2388 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 TXJy;p=48%z=Ji(g(E readFileSync(join(__dirname, '__fixtures__', 'complex.xlsx describe('fromXlsx on a complex, styled workbook (graceful degradation)', () => { it('reads every sheet without throwing', () => { const parsed = fromXlsx(complex()); - expect(Object.keys(parsed)).toEqual(['Sales', 'Meta']); + expect(Object.keys(parsed)).toEqual(['Sales', 'Meta Data', 'Summary']); }); it('keeps a merged cell value in its top-left, leaving the rest empty', () => { @@ -30,7 +30,7 @@ describe('fromXlsx on a complex, styled workbook (graceful degradation)', () => }); it('drops number formats: a formatted date reads as its raw serial number', () => { - const meta = fromXlsx(complex()).Meta.matrices.A1; + const meta = fromXlsx(complex())['Meta Data'].matrices.A1; // B1 is a date with a yyyy-mm-dd format; v0 has no numFmt handling, so it is // the underlying Excel serial number (a plain number), not a Date/string. expect(meta[0][0]).toBe('Generated'); @@ -41,4 +41,13 @@ describe('fromXlsx on a complex, styled workbook (graceful degradation)', () => // Escaped special characters decode correctly. expect(meta[3]).toEqual(['Note', 'a & b "q"']); }); + + it('preserves cross-sheet formula references verbatim (incl. quoted sheet names)', () => { + const summary = fromXlsx(complex()).Summary.matrices.A1; + expect(summary[0]).toEqual(['Cross-sheet Summary', null]); // merged title + expect(summary[1]).toEqual(['Grand Total', '=SUM(Sales!D3:D5)']); + expect(summary[2]).toEqual(['Top Product', '=Sales!D3']); + // A sheet name with a space stays quoted through the round trip. + expect(summary[3]).toEqual(['Weighted', "=Sales!D6*'Meta Data'!B3"]); + }); }); diff --git a/packages/xlsx/src/sheet-integration.spec.ts b/packages/xlsx/src/sheet-integration.spec.ts index aba8c424..a0413766 100644 --- a/packages/xlsx/src/sheet-integration.spec.ts +++ b/packages/xlsx/src/sheet-integration.spec.ts @@ -1,5 +1,8 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { Sheet, createRegistry, buildInitialCells } from '@gridsheet/engine'; import { fromXlsx, toXlsx } from './index'; +import type { ParsedWorkbook } from './types'; // Wire a bare headless Sheet into its registry, mirroring what a framework store // does, so formulas resolve and toCellMatrix can read the sheet outside a UI. @@ -40,6 +43,33 @@ describe('toXlsx from a live GridSheet sheet', () => { expect(m[2][1]).toBe('=SUM(A1:A2)'); // B3 stays a formula }); + it('resolves cross-sheet references after importing a multi-sheet workbook', () => { + // Wire every imported sheet into ONE shared registry (like a spellbook `book`), + // giving each a unique id the way does, so cross-sheet refs resolve. + const parsed: ParsedWorkbook = fromXlsx(readFileSync(join(__dirname, '__fixtures__', 'complex.xlsx'))); + const registry = createRegistry(); + const sheets: Record = {}; + for (const [name, data] of Object.entries(parsed)) { + const sheet = new Sheet({ name, registry, eager: true }); + sheet.id = ++registry.sheetHead; + sheet.initialize(buildInitialCells(data)); + registry.contextsBySheetId[sheet.id] = { + store: { sheetReactive: { current: sheet } }, + dispatch: () => {}, + } as any; + sheets[name] = sheet; + } + registry.boot(); + + const summary = sheets.Summary; + // =SUM(Sales!D3:D5) — Sales' own =B*C formulas resolve first, then this sums them. + expect(summary.getCell({ y: 2, x: 2 }, { resolution: 'RESOLVED' })?.value).toBe(17); + // =Sales!D3 (=B3*C3 = 3 * 1.5) + expect(summary.getCell({ y: 3, x: 2 }, { resolution: 'RESOLVED' })?.value).toBe(4.5); + // =Sales!D6 * 'Meta Data'!B3 — quoted spaced sheet name, 17 * 0.153 + expect(summary.getCell({ y: 4, x: 2 }, { resolution: 'RESOLVED' })?.value).toBeCloseTo(2.601); + }); + it('feeds fromXlsx output back into a new sheet that resolves the formulas', () => { const source = headlessSheet( buildInitialCells({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1f59862..32711009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,19 +100,19 @@ importers: version: 29.5.14 jest: specifier: ^29.4.3 - version: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) ts-jest: specifier: ^29.0.5 - version: 29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)))(typescript@5.8.3) typescript: specifier: ^5.8.2 version: 5.8.3 vite: specifier: ^6.2.2 - version: 6.3.5(@types/node@20.17.57)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) + version: 6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) vite-plugin-dts: specifier: ^4.5.3 - version: 4.5.3(@types/node@20.17.57)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.57)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) + version: 4.5.3(@types/node@22.20.4)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) packages/functions: devDependencies: @@ -472,24 +472,27 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^22.13.10 + version: 22.20.4 jest: specifier: ^29.4.3 - version: 29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 ts-jest: specifier: ^29.1.0 - version: 29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)))(typescript@5.8.3) typescript: specifier: ^5.8.2 version: 5.8.3 vite: specifier: ^6.2.2 - version: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) + version: 6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) vite-plugin-dts: specifier: ^4.5.3 - version: 4.5.3(@types/node@24.13.3)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) + version: 4.5.3(@types/node@22.20.4)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)) packages: @@ -2652,6 +2655,9 @@ packages: '@types/node@20.17.57': resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==} + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -6282,6 +6288,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -8149,27 +8158,27 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -8197,14 +8206,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -8229,7 +8238,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -8247,7 +8256,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.17.57 + '@types/node': 22.20.4 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -8269,7 +8278,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -8339,7 +8348,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.57 + '@types/node': 22.20.4 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -8451,11 +8460,11 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@microsoft/api-extractor-model@7.30.5(@types/node@20.17.57)': + '@microsoft/api-extractor-model@7.30.5(@types/node@22.20.4)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.13.0(@types/node@20.17.57) + '@rushstack/node-core-library': 5.13.0(@types/node@22.20.4) transitivePeerDependencies: - '@types/node' @@ -8467,15 +8476,15 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.52.3(@types/node@20.17.57)': + '@microsoft/api-extractor@7.52.3(@types/node@22.20.4)': dependencies: - '@microsoft/api-extractor-model': 7.30.5(@types/node@20.17.57) + '@microsoft/api-extractor-model': 7.30.5(@types/node@22.20.4) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.13.0(@types/node@20.17.57) + '@rushstack/node-core-library': 5.13.0(@types/node@22.20.4) '@rushstack/rig-package': 0.5.3 - '@rushstack/terminal': 0.15.2(@types/node@20.17.57) - '@rushstack/ts-command-line': 4.23.7(@types/node@20.17.57) + '@rushstack/terminal': 0.15.2(@types/node@22.20.4) + '@rushstack/ts-command-line': 4.23.7(@types/node@22.20.4) lodash: 4.17.21 minimatch: 3.0.8 resolve: 1.22.10 @@ -8772,7 +8781,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.39.0': optional: true - '@rushstack/node-core-library@5.13.0(@types/node@20.17.57)': + '@rushstack/node-core-library@5.13.0(@types/node@22.20.4)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -8783,7 +8792,7 @@ snapshots: resolve: 1.22.10 semver: 7.5.4 optionalDependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 '@rushstack/node-core-library@5.13.0(@types/node@24.13.3)': dependencies: @@ -8803,12 +8812,12 @@ snapshots: resolve: 1.22.10 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.15.2(@types/node@20.17.57)': + '@rushstack/terminal@0.15.2(@types/node@22.20.4)': dependencies: - '@rushstack/node-core-library': 5.13.0(@types/node@20.17.57) + '@rushstack/node-core-library': 5.13.0(@types/node@22.20.4) supports-color: 8.1.1 optionalDependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 '@rushstack/terminal@0.15.2(@types/node@24.13.3)': dependencies: @@ -8817,9 +8826,9 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 - '@rushstack/ts-command-line@4.23.7(@types/node@20.17.57)': + '@rushstack/ts-command-line@4.23.7(@types/node@22.20.4)': dependencies: - '@rushstack/terminal': 0.15.2(@types/node@20.17.57) + '@rushstack/terminal': 0.15.2(@types/node@22.20.4) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -9352,7 +9361,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 '@types/hast@3.0.4': dependencies: @@ -9381,7 +9390,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 '@types/tough-cookie': 4.0.5 parse5: 7.2.1 @@ -9403,6 +9412,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -9435,7 +9448,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.13.3 + '@types/node': 22.20.4 '@types/scheduler@0.16.8': {} @@ -10373,13 +10386,13 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - create-jest@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): + create-jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11799,7 +11812,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3(babel-plugin-macros@3.1.0) @@ -11819,16 +11832,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): + jest-cli@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + create-jest: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -11857,7 +11870,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): + jest-config@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -11882,13 +11895,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.17.57 - ts-node: 10.9.2(@types/node@20.17.57)(typescript@5.8.3) + '@types/node': 22.20.4 + ts-node: 10.9.2(@types/node@22.20.4)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)): + jest-config@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -11913,7 +11926,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 ts-node: 10.9.2(@types/node@24.13.3)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros @@ -11989,7 +12002,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -11999,7 +12012,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.17.57 + '@types/node': 22.20.4 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12038,7 +12051,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -12073,7 +12086,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -12101,7 +12114,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.2 @@ -12147,7 +12160,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12166,7 +12179,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.57 + '@types/node': 22.20.4 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -12175,17 +12188,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): + jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + jest-cli: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -14137,12 +14150,12 @@ snapshots: ts-dedent@2.2.0: {} - ts-jest@29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.3.1(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) + jest: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -14157,6 +14170,26 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.10) + ts-jest@29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)))(typescript@5.8.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@22.20.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.1 + type-fest: 4.39.1 + typescript: 5.8.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + ts-jest@29.3.1(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 @@ -14177,14 +14210,14 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7) - ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3): + ts-node@10.9.2(@types/node@22.20.4)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.57 + '@types/node': 22.20.4 acorn: 8.14.1 acorn-walk: 8.3.4 arg: 4.1.3 @@ -14254,6 +14287,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.21.0: {} + undici-types@7.18.2: {} undici@8.10.1: {} @@ -14434,9 +14469,9 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.3(@types/node@20.17.57)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.57)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)): + vite-plugin-dts@4.5.3(@types/node@22.20.4)(rollup@4.39.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0)): dependencies: - '@microsoft/api-extractor': 7.52.3(@types/node@20.17.57) + '@microsoft/api-extractor': 7.52.3(@types/node@22.20.4) '@rollup/pluginutils': 5.1.4(rollup@4.39.0) '@volar/typescript': 2.4.12 '@vue/language-core': 2.2.0(typescript@5.8.3) @@ -14447,7 +14482,7 @@ snapshots: magic-string: 0.30.17 typescript: 5.8.3 optionalDependencies: - vite: 6.3.5(@types/node@20.17.57)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) + vite: 6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - rollup @@ -14526,7 +14561,7 @@ snapshots: terser: 5.40.0 yaml: 2.8.0 - vite@6.3.5(@types/node@20.17.57)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0): + vite@6.3.5(@types/node@22.20.4)(jiti@1.21.7)(less@4.3.0)(lightningcss@1.33.0)(terser@5.40.0)(yaml@2.8.0): dependencies: esbuild: 0.25.2 fdir: 6.4.5(picomatch@4.0.2) @@ -14535,7 +14570,7 @@ snapshots: rollup: 4.39.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 20.17.57 + '@types/node': 22.20.4 fsevents: 2.3.3 jiti: 1.21.7 less: 4.3.0 From 92a890f2be1191372918d63021797ed869b98f6d Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 00:26:57 +0900 Subject: [PATCH 04/10] fix(storybook): show formula bar on the xlsx demo sheets Cross-sheet formulas are easier to inspect when each sheet shows its formula bar. Co-Authored-By: Claude Opus 4.8 --- packages/storybook/stories/io/xlsx.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/storybook/stories/io/xlsx.stories.tsx b/packages/storybook/stories/io/xlsx.stories.tsx index 0586e74d..6a12f923 100644 --- a/packages/storybook/stories/io/xlsx.stories.tsx +++ b/packages/storybook/stories/io/xlsx.stories.tsx @@ -47,7 +47,7 @@ const Workbook = ({ sheets, refs }: { sheets: Sheets; refs: React.MutableRefObje book={book} sheetName={name} sheetRef={ref} - options={{ sheetWidth: 480, sheetHeight: 220, showFormulaBar: false }} + options={{ sheetWidth: 480, sheetHeight: 220, showFormulaBar: true }} initialCells={buildInitialCells({ matrices: { A1: matrix }, cells: { defaultCol: { width: 120 } }, From 84715a109b98db23aa324454133611d8f13220c5 Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 00:34:34 +0900 Subject: [PATCH 05/10] feat(xlsx): import cell styles (background/color/weight/alignment) Parse xl/styles.xml (fonts, solid fills, cellXfs + alignment) and map each cell's style record to GridSheet's `style` (backgroundColor, color, fontWeight, fontStyle, textDecoration) plus justifyContent/alignItems. Colors resolve from rgb, the legacy indexed palette, and default theme colors with tint. fromXlsx now returns { matrices, cells } per sheet; cells carries the imported styles and buildInitialCells merges them with the values. Writing styles back out is still TODO (toXlsx remains values + formulas). - styles.ts + StyleTable; read.ts collects per-cell styles - complex.spec.ts asserts title/header/zebra/alignment styles - Storybook demo passes the imported cells so colors render; e2e asserts the dark fill + white bold title paint in the browser - README documents style import and the export gap Co-Authored-By: Claude Opus 4.8 --- e2e/xlsx.spec.ts | 9 +- .../storybook/stories/io/xlsx.stories.tsx | 32 ++- packages/xlsx/README.md | 20 +- packages/xlsx/src/complex.spec.ts | 20 ++ packages/xlsx/src/read.ts | 23 +- packages/xlsx/src/styles.ts | 224 ++++++++++++++++++ packages/xlsx/src/types.ts | 11 +- 7 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 packages/xlsx/src/styles.ts diff --git a/e2e/xlsx.spec.ts b/e2e/xlsx.spec.ts index 19ef73c9..df5c93c2 100644 --- a/e2e/xlsx.spec.ts +++ b/e2e/xlsx.spec.ts @@ -9,9 +9,16 @@ test('imports a complex styled xlsx and re-evaluates cross-sheet formulas', asyn const summary = page.getByTestId('sheet-Summary'); // Styled + merged Sales sheet: merged title's value survives (top-left), own formula re-evaluates. - expect(await sales.locator("[data-address='A1'] .gs-cell-rendered").textContent()).toContain('Q3 Sales Report'); + const title = sales.locator("[data-address='A1']"); + expect(await title.locator('.gs-cell-rendered').textContent()).toContain('Q3 Sales Report'); expect(await sales.locator("[data-address='D3'] .gs-cell-rendered").textContent()).toContain('4.5'); + // Imported cell styles render: dark fill on the cell, white bold text inside. + expect(await title.evaluate((el) => getComputedStyle(el as HTMLElement).backgroundColor)).toBe('rgb(32, 56, 100)'); + const titleText = title.locator('.gs-cell-rendered'); + expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).color)).toBe('rgb(255, 255, 255)'); + expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).fontWeight)).toBe('700'); + // Summary sheet references other sheets; sharing a book, they resolve live: // B2 = SUM(Sales!D3:D5) → 17. expect(await summary.locator("[data-address='B2'] .gs-cell-rendered").textContent()).toContain('17'); diff --git a/packages/storybook/stories/io/xlsx.stories.tsx b/packages/storybook/stories/io/xlsx.stories.tsx index 6a12f923..3b1934dd 100644 --- a/packages/storybook/stories/io/xlsx.stories.tsx +++ b/packages/storybook/stories/io/xlsx.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { buildInitialCells, GridSheet } from '@gridsheet/react-core'; import { useSpellbook } from '@gridsheet/react-core/spellbook'; import { fromXlsx, toXlsx } from '@gridsheet/xlsx'; +import type { XlsxSheetData } from '@gridsheet/xlsx'; import sampleUrl from './sample.xlsx?url'; const meta: Meta = { @@ -16,15 +17,18 @@ const DESCRIPTION = [ '', '- **Load sample** fetches a shipped `sample.xlsx` — a styled, merged, multi-sheet workbook', " whose `Summary` sheet has **cross-sheet formulas** (`=SUM(Sales!D3:D5)`, `=Sales!D6*'Meta Data'!B3`).", - ' Styling and merges are dropped; values and formulas import, and the sheets share a `book`', - ' so cross-sheet references re-evaluate.', + ' Cell styles (background, text color, bold, alignment) import too; merges are dropped;', + ' the sheets share a `book` so cross-sheet references re-evaluate.', '- **Import file** reads a real `.xlsx` you pick.', '- **Download** writes every sheet back out with `toXlsx`.', ].join('\n'); -type Sheets = Record; +// Each sheet is the parsed { matrices, cells } — cells carry imported styles. +type Sheets = Record; -const EMPTY: Sheets = { Sheet1: [['(click “Load sample” or import a file)']] }; +const EMPTY: Sheets = { + Sheet1: { matrices: { A1: [['(click “Load sample” or import a file)']] }, cells: {} }, +}; const btnStyle: React.CSSProperties = { padding: '4px 10px', @@ -38,7 +42,7 @@ const Workbook = ({ sheets, refs }: { sheets: Sheets; refs: React.MutableRefObje const book = useSpellbook({}); return (
- {Object.entries(sheets).map(([name, matrix]) => { + {Object.entries(sheets).map(([name, data]) => { const ref = (refs.current[name] ??= { current: null }); return (
@@ -49,8 +53,9 @@ const Workbook = ({ sheets, refs }: { sheets: Sheets; refs: React.MutableRefObje sheetRef={ref} options={{ sheetWidth: 480, sheetHeight: 220, showFormulaBar: true }} initialCells={buildInitialCells({ - matrices: { A1: matrix }, - cells: { defaultCol: { width: 120 } }, + matrices: data.matrices, + // Imported per-cell styles (background/color/weight/alignment) + a default width. + cells: { defaultCol: { width: 120 }, ...data.cells }, ensured: { numRows: 6, numCols: 4 }, })} /> @@ -73,18 +78,9 @@ const XlsxConverter = () => { setVersion((v) => v + 1); }; - const parseToSheets = (bytes: Uint8Array): Sheets => { - const parsed = fromXlsx(bytes); - const out: Sheets = {}; - for (const [name, data] of Object.entries(parsed)) { - out[name] = data.matrices.A1 as any[][]; - } - return out; - }; - const loadSample = async () => { const buf = await (await fetch(sampleUrl)).arrayBuffer(); - load(parseToSheets(new Uint8Array(buf))); + load(fromXlsx(new Uint8Array(buf))); }; const importFile = async (e: React.ChangeEvent) => { @@ -92,7 +88,7 @@ const XlsxConverter = () => { if (!file) { return; } - load(parseToSheets(new Uint8Array(await file.arrayBuffer()))); + load(fromXlsx(new Uint8Array(await file.arrayBuffer()))); e.target.value = ''; }; diff --git a/packages/xlsx/README.md b/packages/xlsx/README.md index 6a8ff2d9..a4726628 100644 --- a/packages/xlsx/README.md +++ b/packages/xlsx/README.md @@ -7,14 +7,18 @@ exceljs or SheetJS. ## Scope (v0) - **Values**: string, number, boolean. Empty cells are dropped; the used range is trimmed. -- **Formulas**: round-trip as their `=...` text (read via `resolution: 'RAW'`). -- **Multiple sheets**, in workbook order. -- **Dates**: written as ISO strings (v0 has no number-format support). - -Not yet: styles, merged cells, column widths / row heights, number formats, charts/images. -These are **ignored gracefully** on import — a styled, merged, multi-sheet workbook reads fine; -you just get values + formulas (a merged range keeps its value in the top-left cell, and a -number-formatted date comes through as its raw Excel serial number). +- **Formulas**: round-trip as their `=...` text (read via `resolution: 'RAW'`), including + cross-sheet references like `=SUM(Sales!D3:D5)` (quoted names such as `'Meta Data'!B3` too). +- **Multiple sheets**, in workbook order. `fromXlsx` returns `{ matrices, cells }` per sheet. +- **Cell styles on import** → GridSheet's `style`: background color, text color, bold, italic, + underline, and horizontal/vertical alignment (`justifyContent`/`alignItems`). `cells` in the + result carries these; `buildInitialCells` merges them with the values. +- **Dates**: written as ISO strings (no number-format support). + +Not yet: **writing** styles back out (`toXlsx` emits values + formulas only), merged cells, +column widths / row heights, number formats, charts/images. Unsupported features are **ignored +gracefully** on import — a styled, merged workbook reads fine; a merged range keeps its value in +the top-left cell, and a number-formatted date comes through as its raw Excel serial number. ## Install diff --git a/packages/xlsx/src/complex.spec.ts b/packages/xlsx/src/complex.spec.ts index dd5f994c..3fd77eb6 100644 --- a/packages/xlsx/src/complex.spec.ts +++ b/packages/xlsx/src/complex.spec.ts @@ -42,6 +42,26 @@ describe('fromXlsx on a complex, styled workbook (graceful degradation)', () => expect(meta[3]).toEqual(['Note', 'a & b "q"']); }); + it('imports cell styles as CSS: background, text color, weight, alignment', () => { + const sales = fromXlsx(complex()).Sales.cells; + // Merged title: white bold text on a dark fill, centered both ways. + expect(sales.A1.style).toMatchObject({ + color: '#FFFFFF', + fontWeight: 'bold', + backgroundColor: '#203864', + }); + expect(sales.A1.justifyContent).toBe('center'); + expect(sales.A1.alignItems).toBe('center'); + // Header cell: bold on a light fill, centered. + expect(sales.B2.style).toMatchObject({ fontWeight: 'bold', backgroundColor: '#D9E1F2' }); + expect(sales.B2.justifyContent).toBe('center'); + // Zebra-striped data row keeps its fill. + expect(sales.A4.style?.backgroundColor).toBe('#F2F2F2'); + // Right-aligned bold "Sum" label. + expect(sales.A6.style?.fontWeight).toBe('bold'); + expect(sales.A6.justifyContent).toBe('flex-end'); + }); + it('preserves cross-sheet formula references verbatim (incl. quoted sheet names)', () => { const summary = fromXlsx(complex()).Summary.matrices.A1; expect(summary[0]).toEqual(['Cross-sheet Summary', null]); // merged title diff --git a/packages/xlsx/src/read.ts b/packages/xlsx/src/read.ts index cdb4c305..ec507328 100644 --- a/packages/xlsx/src/read.ts +++ b/packages/xlsx/src/read.ts @@ -1,6 +1,8 @@ import { readZip } from './zip'; import { parseXml, child, children, gatherText, type XmlNode } from './xml'; -import { parseRef } from './a1'; +import { parseRef, colName } from './a1'; +import { StyleTable } from './styles'; +import type { CellsByAddressType } from '@gridsheet/engine'; import type { ParsedWorkbook, XlsxCellValue } from './types'; /** Accepted binary inputs for {@link fromXlsx}. Node Buffer is a Uint8Array subclass. */ @@ -74,10 +76,15 @@ const coerceValue = (cell: XmlNode, t: string | undefined, shared: string[]): Xl } }; -const parseSheet = (xml: string, shared: string[]): XlsxCellValue[][] => { +const parseSheet = ( + xml: string, + shared: string[], + styles: StyleTable, +): { matrix: XlsxCellValue[][]; cells: CellsByAddressType } => { const root = parseXml(xml); const sheetData = child(root, 'sheetData'); const entries: { row: number; col: number; value: XlsxCellValue }[] = []; + const cells: CellsByAddressType = {}; let maxRow = 0; let maxCol = 0; @@ -96,6 +103,12 @@ const parseSheet = (xml: string, shared: string[]): XlsxCellValue[][] => { } else { value = coerceValue(c, t, shared); } + // Cell style (background/color/weight/alignment) is independent of value: a + // colored-but-empty cell still carries a style we want to keep. + const cellStyle = styles.resolve(c.attrs['s']); + if (cellStyle) { + cells[`${colName(ref.col)}${ref.row}`] = cellStyle; + } if (value !== null && value !== '') { entries.push({ row: ref.row, col: ref.col, value }); if (ref.row > maxRow) { @@ -115,7 +128,7 @@ const parseSheet = (xml: string, shared: string[]): XlsxCellValue[][] => { for (const { row, col, value } of entries) { matrix[row - 1][col - 1] = value; } - return matrix; + return { matrix, cells }; }; /** @@ -132,6 +145,7 @@ export const fromXlsx = (data: XlsxInput): ParsedWorkbook => { const workbook = parseXml(workbookXml); const rels = parseRelationships(files['xl/_rels/workbook.xml.rels']); const shared = parseSharedStrings(files['xl/sharedStrings.xml']); + const styles = new StyleTable(files['xl/styles.xml']); const result: ParsedWorkbook = {}; const sheetsEl = child(workbook, 'sheets'); @@ -148,7 +162,8 @@ export const fromXlsx = (data: XlsxInput): ParsedWorkbook => { if (sheetXml == null) { continue; } - result[name] = { matrices: { A1: parseSheet(sheetXml, shared) } }; + const { matrix, cells } = parseSheet(sheetXml, shared, styles); + result[name] = { matrices: { A1: matrix }, cells }; } return result; }; diff --git a/packages/xlsx/src/styles.ts b/packages/xlsx/src/styles.ts new file mode 100644 index 00000000..a5d359fa --- /dev/null +++ b/packages/xlsx/src/styles.ts @@ -0,0 +1,224 @@ +import { parseXml, child, children, type XmlNode } from './xml'; +import type { CSSPropertiesLike } from '@gridsheet/engine'; + +/** + * Reads the subset of xl/styles.xml GridSheet can represent as CSS: fill + * (background), font color / weight / style / underline, and text alignment. + * Number formats, borders, and other xf properties are intentionally ignored. + */ + +type FontStyle = { + color?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; +}; + +type Xf = { + fontId: number; + fillId: number; + applyFont: boolean; + applyFill: boolean; + horizontal?: string; + vertical?: string; +}; + +export type CellStyle = { + style?: CSSPropertiesLike; + justifyContent?: string; + alignItems?: string; +}; + +// Legacy indexed color palette (indices 0..63); 64/65 are system fg/bg (auto). +const INDEXED: Record = { + 0: '000000', + 1: 'FFFFFF', + 2: 'FF0000', + 3: '00FF00', + 4: '0000FF', + 5: 'FFFF00', + 6: 'FF00FF', + 7: '00FFFF', + 8: '000000', + 9: 'FFFFFF', + 10: 'FF0000', + 11: '00FF00', + 12: '0000FF', + 13: 'FFFF00', + 14: 'FF00FF', + 15: '00FFFF', + 16: '800000', + 17: '008000', + 18: '000080', + 19: '808000', + 20: '800080', + 21: '008080', + 22: 'C0C0C0', + 23: '808080', + 24: '9999FF', + 25: '993366', + 26: 'FFFFCC', + 27: 'CCFFFF', + 28: '660066', + 29: 'FF8080', + 30: '0066CC', + 31: 'CCCCFF', + 32: '000080', + 33: 'FF00FF', + 34: 'FFFF00', + 35: '00FFFF', + 36: '800080', + 37: '800000', + 38: '008080', + 39: '0000FF', + 40: '00CCFF', + 41: 'CCFFFF', + 42: 'CCFFCC', + 43: 'FFFF99', + 44: '99CCFF', + 45: 'FF99CC', + 46: 'CC99FF', + 47: 'FFCC99', + 48: '3366FF', + 49: '33CCCC', + 50: '99CC00', + 51: 'FFCC00', + 52: 'FF9900', + 53: 'FF6600', + 54: '666699', + 55: '969696', + 56: '003366', + 57: '339966', + 58: '003300', + 59: '333300', + 60: '993300', + 61: '993366', + 62: '333399', + 63: '333333', +}; + +// Default Office theme colors as interpreted by styles.xml (0/1 = light1/dark1, etc.). +const THEME = ['FFFFFF', '000000', 'E7E6E6', '44546A', '4472C4', 'ED7D31', 'A5A5A5', 'FFC000', '5B9BD5', '70AD47']; + +const applyTint = (hex: string, tint: number): string => { + if (!tint) { + return hex; + } + const chan = (h: string) => { + let v = parseInt(h, 16); + v = tint < 0 ? Math.round(v * (1 + tint)) : Math.round(v * (1 - tint) + 255 * tint); + return Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'); + }; + return chan(hex.slice(0, 2)) + chan(hex.slice(2, 4)) + chan(hex.slice(4, 6)); +}; + +const readColor = (node: XmlNode | undefined): string | undefined => { + if (!node) { + return undefined; + } + const { rgb, indexed, theme, tint } = node.attrs; + let hex: string | undefined; + if (rgb) { + hex = rgb.length === 8 ? rgb.slice(2) : rgb; // strip ARGB alpha + } else if (indexed != null) { + hex = INDEXED[parseInt(indexed, 10)]; + } else if (theme != null) { + hex = THEME[parseInt(theme, 10)]; + } + if (!hex) { + return undefined; + } + if (tint) { + hex = applyTint(hex, parseFloat(tint)); + } + return `#${hex.toUpperCase()}`; +}; + +const alignToJustify: Record = { left: 'flex-start', center: 'center', right: 'flex-end' }; +const alignToItems: Record = { top: 'flex-start', center: 'center', bottom: 'flex-end' }; + +export class StyleTable { + private fonts: FontStyle[] = []; + private fills: (string | undefined)[] = []; + private xfs: Xf[] = []; + + constructor(xml: string | undefined) { + if (!xml) { + return; + } + const root = parseXml(xml); + + for (const f of children(child(root, 'fonts'), 'font')) { + this.fonts.push({ + color: readColor(child(f, 'color')), + bold: child(f, 'b') != null, + italic: child(f, 'i') != null, + underline: child(f, 'u') != null, + }); + } + + for (const fill of children(child(root, 'fills'), 'fill')) { + const pattern = child(fill, 'patternFill'); + const type = pattern?.attrs['patternType']; + // Only solid fills carry a usable background color for CSS. + this.fills.push(type === 'solid' ? readColor(child(pattern, 'fgColor')) : undefined); + } + + for (const xf of children(child(root, 'cellXfs'), 'xf')) { + const alignment = child(xf, 'alignment'); + this.xfs.push({ + fontId: parseInt(xf.attrs['fontId'] ?? '0', 10), + fillId: parseInt(xf.attrs['fillId'] ?? '0', 10), + applyFont: xf.attrs['applyFont'] === '1', + applyFill: xf.attrs['applyFill'] === '1', + horizontal: alignment?.attrs['horizontal'], + vertical: alignment?.attrs['vertical'], + }); + } + } + + /** Resolve a cell's `s` (xf index) into GridSheet style fields, or undefined if nothing maps. */ + resolve(sAttr: string | undefined): CellStyle | undefined { + if (sAttr == null) { + return undefined; + } + const xf = this.xfs[parseInt(sAttr, 10)]; + if (!xf) { + return undefined; + } + const style: CSSPropertiesLike = {}; + // Excel writers often omit applyFont/applyFill; honor the referenced record when it has content. + const font = this.fonts[xf.fontId]; + if (font) { + if (font.color) { + style.color = font.color; + } + if (font.bold) { + style.fontWeight = 'bold'; + } + if (font.italic) { + style.fontStyle = 'italic'; + } + if (font.underline) { + style.textDecoration = 'underline'; + } + } + const bg = this.fills[xf.fillId]; + if (bg) { + style.backgroundColor = bg; + } + const result: CellStyle = {}; + if (Object.keys(style).length > 0) { + result.style = style; + } + const justify = xf.horizontal ? alignToJustify[xf.horizontal] : undefined; + if (justify) { + result.justifyContent = justify; + } + const items = xf.vertical ? alignToItems[xf.vertical] : undefined; + if (items) { + result.alignItems = items; + } + return Object.keys(result).length > 0 ? result : undefined; + } +} diff --git a/packages/xlsx/src/types.ts b/packages/xlsx/src/types.ts index babc247e..fca64005 100644 --- a/packages/xlsx/src/types.ts +++ b/packages/xlsx/src/types.ts @@ -1,4 +1,4 @@ -import type { MatricesByAddress, CellType } from '@gridsheet/engine'; +import type { MatricesByAddress, CellType, CellsByAddressType } from '@gridsheet/engine'; import type { UserSheet } from '@gridsheet/engine'; /** Scalar cell values v0 understands on the way in and out of xlsx. */ @@ -7,9 +7,14 @@ export type XlsxCellValue = string | number | boolean | Date | null; /** * One sheet's worth of imported data, shaped so it can be handed straight to * `buildInitialCells(parsed[name])`. `matrices` maps an origin address ("A1") - * to a dense value matrix (formulas arrive as their "=..." string). + * to a dense value matrix (formulas arrive as their "=..." string); `cells` + * carries per-address style fields (background/color/weight, alignment) read + * from the workbook's styles — `buildInitialCells` merges the two. */ -export type XlsxSheetData = { matrices: MatricesByAddress }; +export type XlsxSheetData = { + matrices: MatricesByAddress; + cells: CellsByAddressType; +}; /** Result of {@link fromXlsx}: sheet name → data, in workbook order. */ export type ParsedWorkbook = { [sheetName: string]: XlsxSheetData }; From 04ee829e47d33a0b8841053485491616d6a0747f Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 00:45:01 +0900 Subject: [PATCH 06/10] feat(xlsx): import column widths and row heights Read explicit widths and heights and map them (converted to pixels) onto the column-/row-header cells (ch(col)/rh(row)), so imported sheets keep their sizing. Sheet-wide col spans are skipped to avoid creating thousands of header cells. - read.ts parses cols/rows; complex fixture varies widths/heights - complex.spec.ts asserts the imported px sizes; e2e checks a wide vs narrow column - README updated Co-Authored-By: Claude Opus 4.8 --- e2e/xlsx.spec.ts | 6 +++++ packages/storybook/stories/io/sample.xlsx | Bin 6681 -> 6749 bytes packages/xlsx/README.md | 11 ++++++--- packages/xlsx/src/__fixtures__/complex.xlsx | Bin 6681 -> 6749 bytes packages/xlsx/src/complex.spec.ts | 11 +++++++++ packages/xlsx/src/read.ts | 26 ++++++++++++++++++++ 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/e2e/xlsx.spec.ts b/e2e/xlsx.spec.ts index df5c93c2..e0cf3e53 100644 --- a/e2e/xlsx.spec.ts +++ b/e2e/xlsx.spec.ts @@ -19,6 +19,12 @@ test('imports a complex styled xlsx and re-evaluates cross-sheet formulas', asyn expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).color)).toBe('rgb(255, 255, 255)'); expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).fontWeight)).toBe('700'); + // Imported column widths render: the wide Product column (A) is clearly wider than + // the narrow Qty column (B). + const colA = await sales.locator("[data-address='A3']").boundingBox(); + const colB = await sales.locator("[data-address='B3']").boundingBox(); + expect(colA!.width).toBeGreaterThan(colB!.width + 40); + // Summary sheet references other sheets; sharing a book, they resolve live: // B2 = SUM(Sales!D3:D5) → 17. expect(await summary.locator("[data-address='B2'] .gs-cell-rendered").textContent()).toContain('17'); diff --git a/packages/storybook/stories/io/sample.xlsx b/packages/storybook/stories/io/sample.xlsx index ec45d2413993705cacc1cb665f6cfd6095e38179..84410ce294f8cc0e2d90f372f3f4358becdf3c18 100644 GIT binary patch delta 2360 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

ZY^W0BX`_Ep_1hdFKe(I6CurQirAw4FcDR8OA8$ zBh*flE}zUd4RNj!-W_3b-M=O<(JC@#Is<&~fkZ1pJe6#OK|_xsU_Bz#xHy;*7jd&o z&P~&Rkw5ueVV)@H#laiR2N@&L-FR)e;`7TvlQek5L`1in63L-r`R|BYaBPZyq!uz_(>xQ?P{gjV@P< zb3CDSu8iqMqZ~#juXu9E(j71#n7A{L^<2Wvv3j9K^TR2Bo{E=7^zwZ1ua&uG2K(UU zldS=kEB%BWd90b5P{{O~!m!=4i0V0^PeWd(oW@`DUQE5#iwh!kkVALYM?Y^3-%TR5 zJX0By-C3{wZv0JvWvONm?6moNAoMjVol4TqmneBRsmn{Ne{soKIg<7j6Uk~%t9;yy zsUQ3h@l|Upc1m|H-@D?&Rkcc4p%Y~Bv9Qmr*9H)B?QX*I-myLCKeW6_;Vn^|5~YIJ zRnF*r^4%tmUwc8LLJ?dH;wa~!4hzk*MMaBnIPgF{4gGweYKb`fPfZj(^*47@dqqzk znD87k`Zs+!9MhFNV6^|g7y!UAHvRuxHzNlQQ;!Njf7dwZIvW&_0|NT_JwRgrZcNU9 zd)4r>z2H;<82`) and heights + (``) map to the column-/row-header cells (`ch(col)` / `rh(row)`), converted + to pixels. - **Dates**: written as ISO strings (no number-format support). -Not yet: **writing** styles back out (`toXlsx` emits values + formulas only), merged cells, -column widths / row heights, number formats, charts/images. Unsupported features are **ignored -gracefully** on import — a styled, merged workbook reads fine; a merged range keeps its value in -the top-left cell, and a number-formatted date comes through as its raw Excel serial number. +Not yet: **writing** styles/sizes back out (`toXlsx` emits values + formulas only), merged cells, +number formats, charts/images. Unsupported features are **ignored gracefully** on import — a +styled, merged workbook reads fine; a merged range keeps its value in the top-left cell, and a +number-formatted date comes through as its raw Excel serial number. ## Install diff --git a/packages/xlsx/src/__fixtures__/complex.xlsx b/packages/xlsx/src/__fixtures__/complex.xlsx index ec45d2413993705cacc1cb665f6cfd6095e38179..84410ce294f8cc0e2d90f372f3f4358becdf3c18 100644 GIT binary patch delta 2360 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

ZY^W0BX`_Ep_1hdFKe(I6CurQirAw4FcDR8OA8$ zBh*flE}zUd4RNj!-W_3b-M=O<(JC@#Is<&~fkZ1pJe6#OK|_xsU_Bz#xHy;*7jd&o z&P~&Rkw5ueVV)@H#laiR2N@&L-FR)e;`7TvlQek5L`1in63L-r`R|BYaBPZyq!uz_(>xQ?P{gjV@P< zb3CDSu8iqMqZ~#juXu9E(j71#n7A{L^<2Wvv3j9K^TR2Bo{E=7^zwZ1ua&uG2K(UU zldS=kEB%BWd90b5P{{O~!m!=4i0V0^PeWd(oW@`DUQE5#iwh!kkVALYM?Y^3-%TR5 zJX0By-C3{wZv0JvWvONm?6moNAoMjVol4TqmneBRsmn{Ne{soKIg<7j6Uk~%t9;yy zsUQ3h@l|Upc1m|H-@D?&Rkcc4p%Y~Bv9Qmr*9H)B?QX*I-myLCKeW6_;Vn^|5~YIJ zRnF*r^4%tmUwc8LLJ?dH;wa~!4hzk*MMaBnIPgF{4gGweYKb`fPfZj(^*47@dqqzk znD87k`Zs+!9MhFNV6^|g7y!UAHvRuxHzNlQQ;!Njf7dwZIvW&_0|NT_JwRgrZcNU9 zd)4r>z2H;<82 expect(sales.A6.justifyContent).toBe('flex-end'); }); + it('imports column widths and row heights onto header cells', () => { + const sales = fromXlsx(complex()).Sales.cells; + // Column widths land on the column-header cell (ch(col) === `${col}0`). + expect(sales.A0.width).toBe(159); // wide product column (22 chars) + expect(sales.B0.width).toBe(47); // narrow qty column (6 chars) + expect(sales.A0.width!).toBeGreaterThan(sales.B0.width!); + // Row heights land on the row-header cell (rh(row) === `0${row}`). + expect(sales['01'].height).toBe(45); // tall title row (34pt) + expect(sales['02'].height).toBe(29); // header row (22pt) + }); + it('preserves cross-sheet formula references verbatim (incl. quoted sheet names)', () => { const summary = fromXlsx(complex()).Summary.matrices.A1; expect(summary[0]).toEqual(['Cross-sheet Summary', null]); // merged title diff --git a/packages/xlsx/src/read.ts b/packages/xlsx/src/read.ts index ec507328..a5531bd0 100644 --- a/packages/xlsx/src/read.ts +++ b/packages/xlsx/src/read.ts @@ -76,6 +76,11 @@ const coerceValue = (cell: XmlNode, t: string | undefined, shared: string[]): Xl } }; +// Excel column width is in characters of the default font; approximate the pixel +// width Excel itself uses. Row height is in points → CSS pixels at 96dpi. +const colWidthToPx = (chars: number): number => Math.round(chars * 7 + 5); +const rowHeightToPx = (points: number): number => Math.round((points * 4) / 3); + const parseSheet = ( xml: string, shared: string[], @@ -88,8 +93,29 @@ const parseSheet = ( let maxRow = 0; let maxCol = 0; + // Column widths: . Only honor explicit + // (customWidth) widths, and skip sheet-wide spans so we don't create thousands of headers. + for (const col of children(child(root, 'cols'), 'col')) { + if (col.attrs['customWidth'] !== '1' || !col.attrs['width']) { + continue; + } + const width = colWidthToPx(parseFloat(col.attrs['width'])); + const min = parseInt(col.attrs['min'] ?? '0', 10); + const max = parseInt(col.attrs['max'] ?? '0', 10); + if (!min || !max || max - min > 200) { + continue; + } + for (let x = min; x <= max; x++) { + cells[`${colName(x)}0`] = { width }; // column-header cell (ch(col)) + } + } + for (const row of children(sheetData, 'row')) { const rowAttr = row.attrs['r'] ? parseInt(row.attrs['r'], 10) : undefined; + // Explicit row height → row-header cell (rh(row)). + if (row.attrs['customHeight'] === '1' && row.attrs['ht'] && rowAttr) { + cells[`0${rowAttr}`] = { height: rowHeightToPx(parseFloat(row.attrs['ht'])) }; + } let autoCol = 0; for (const c of children(row, 'c')) { const ref = c.attrs['r'] ? parseRef(c.attrs['r']) : { row: rowAttr ?? maxRow + 1, col: ++autoCol }; From bb035a6fbf0346ef0a96f36792bf19cc5214b05a Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 00:58:36 +0900 Subject: [PATCH 07/10] fix(react-core): let a fixed-height sheet box be dragged taller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A matrix-aligned, fixed-height GridSheet put the CSS `resize` handle on `.gs-main` while also capping it with `max-height: sheetHeight`. A box can't be dragged past its own max-height, so enlarging was silently blocked (shrinking still worked). Switching to a controlled `height` doesn't help — React overwrites it on every re-render, fighting the drag. Fix: for a user-resizable fixed-height box, leave the height uncontrolled. Seed the initial pixel height in a layout effect (capped at content height so short grids still shrink to content), drop the max-height cap, and let the ResizeObserver sync drags into sheetHeight without clamping a manual resize to the root height. - e2e/sheet-resize.spec.ts guards: no max-height cap, and an enlarged height sticks Co-Authored-By: Claude Opus 4.8 --- e2e/sheet-resize.spec.ts | 44 +++++++++++++++ .../react-core/src/components/GridSheet.tsx | 56 ++++++++++++++++--- 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 e2e/sheet-resize.spec.ts diff --git a/e2e/sheet-resize.spec.ts b/e2e/sheet-resize.spec.ts new file mode 100644 index 00000000..fecea077 --- /dev/null +++ b/e2e/sheet-resize.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { go } from './utils'; + +/** + * The sheet box carries the CSS `resize` handle on `.gs-main`. A fixed-height, + * matrix-aligned grid used to cap that element with `max-height: sheetHeight`, + * which silently blocks dragging the handle DOWN — a box cannot grow past its own + * max-height — while shrinking still worked. The fix leaves the height uncontrolled + * (seeded imperatively) so native resize can grow it and the ResizeObserver syncs. + * + * Native CSS-resize grips can't be reliably pointer-dragged in Playwright, so this + * guards the two conditions that were broken: (1) no `max-height` cap pins the box, + * and (2) an enlarged height sticks instead of being reset by React on re-render. + */ +test('the sheet box has no height cap and keeps an enlarged size', async ({ page }) => { + await go(page, 'basic-huge--sheet'); + await page.waitForSelector('.gs-initialized'); + + const main = page.locator('.gs-main').first(); + + // (1) The cap that blocked enlarging is gone; the box is still user-resizable. + const css = await main.evaluate((el) => { + const s = getComputedStyle(el as HTMLElement); + return { maxHeight: s.maxHeight, resize: s.resize, clientHeight: (el as HTMLElement).clientHeight }; + }); + expect(css.maxHeight).toBe('none'); + expect(css.resize).toBe('both'); + const start = css.clientHeight; + + // (2) Growing the box (as the native resize handle would) sticks — React no longer + // overwrites the height on the ResizeObserver-driven re-render. + await main.evaluate((el) => { + (el as HTMLElement).style.height = '820px'; + }); + await page.waitForTimeout(200); + expect((await main.boundingBox())!.height).toBeGreaterThan(start + 100); + + // Shrinking still works too. + await main.evaluate((el) => { + (el as HTMLElement).style.height = '360px'; + }); + await page.waitForTimeout(200); + expect((await main.boundingBox())!.height).toBeLessThan(500); +}); diff --git a/packages/react-core/src/components/GridSheet.tsx b/packages/react-core/src/components/GridSheet.tsx index 97d0afe6..744d6965 100644 --- a/packages/react-core/src/components/GridSheet.tsx +++ b/packages/react-core/src/components/GridSheet.tsx @@ -1,6 +1,14 @@ -import { useEffect, useState, useRef, useReducer, createRef, useCallback } from 'react'; +import { useEffect, useLayoutEffect, useState, useRef, useReducer, createRef, useCallback } from 'react'; import type { CSSProperties } from 'react'; -import type { BorderSides, CellsByAddressType, SheetHandle, StoreHandle, OptionsType, Props, StoreType } from '../types'; +import type { + BorderSides, + CellsByAddressType, + SheetHandle, + StoreHandle, + OptionsType, + Props, + StoreType, +} from '../types'; import { DEFAULT_HEIGHT, DEFAULT_WIDTH, @@ -61,8 +69,7 @@ export function GridSheet({ // Translate the border config objects into CSS custom properties consumed by the // stylesheet (--gs-fb-* for the formula bar, --gs-mx-* for the matrix). A specific side // overrides `all`. - const bw = (b: BorderSides, side: 'left' | 'top' | 'right' | 'bottom') => - (b[side] ?? b.all ?? false) ? '1px' : '0'; + const bw = (b: BorderSides, side: 'left' | 'top' | 'right' | 'bottom') => ((b[side] ?? b.all ?? false) ? '1px' : '0'); const borderVars = { '--gs-fb-bl': bw(formulaBarBorders, 'left'), '--gs-fb-bt': bw(formulaBarBorders, 'top'), @@ -198,6 +205,7 @@ export function GridSheet({ const [resizedHeight, setResizedHeight] = useState(false); const fixedWidth = centersWidth && (options.sheetWidth != null || resizedWidth); const fixedHeight = centersHeight && (options.sheetHeight != null || resizedHeight); + const canResizeVertical = sheetResize === 'both' || sheetResize === 'vertical'; const [sheetHeight, setSheetHeight] = useState( typeof options?.sheetHeight === 'number' ? options.sheetHeight : estimateSheetHeight(initialCells), ); @@ -233,7 +241,15 @@ export function GridSheet({ // updates) and never recover, collapsing it over time. Width keeps auto- // fitting the container so wide grids stay responsive. if (fillHeight || el.style.height) { - setSheetHeight(root ? Math.min(el.clientHeight, root.clientHeight) : el.clientHeight); + // A manual drag (el.style.height set, not fill mode) owns the size outright — don't + // clamp it to the root, or enlarging past the current root height gets ratcheted back. + const measured = + el.style.height && !fillHeight + ? el.clientHeight + : root + ? Math.min(el.clientHeight, root.clientHeight) + : el.clientHeight; + setSheetHeight(measured); } setSheetWidth(root ? Math.min(el.clientWidth, root.clientWidth) : el.clientWidth); }); @@ -245,6 +261,20 @@ export function GridSheet({ setSheetHeight(options.sheetHeight); } }, [options.sheetHeight]); + // For a user-resizable fixed-height box the height is uncontrolled (see the style + // block). Seed the initial pixel height imperatively — before paint, so there is no + // flash — capped at the content height so a short grid still shrinks to its content. + // Keyed off options.sheetHeight only (not the sheetHeight state) so a drag isn't reset. + useLayoutEffect(() => { + const el = mainRef.current; + if (!el || !canResizeVertical || !fixedHeight) { + return; + } + const target = typeof options.sheetHeight === 'number' ? options.sheetHeight : sheetHeight; + const content = (store.sheetReactive.current?.fullHeight || 0) + 2; + el.style.height = `${Math.min(target, content)}px`; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.sheetHeight, canResizeVertical, fixedHeight]); useEffect(() => { if (typeof options.sheetWidth === 'number') { setSheetWidth(options.sheetWidth); @@ -329,7 +359,12 @@ export function GridSheet({ // eslint-disable-next-line no-console console.error('[gridsheet] async op failed:', e); if (!cancelled) { - (dispatch as any)(commitAsyncOp({ sheet: latestStoreRef.current.sheetReactive.current!, selectingZone: pendingAsyncOp.selectingZone })); + (dispatch as any)( + commitAsyncOp({ + sheet: latestStoreRef.current.sheetReactive.current!, + selectingZone: pendingAsyncOp.selectingZone, + }), + ); } } }), @@ -389,7 +424,14 @@ export function GridSheet({ ...(fillHeight ? { flex: 1, minHeight: 0, maxHeight: '100%' } : fixedHeight - ? { maxHeight: sheetHeight } + ? canResizeVertical + ? // A user-resizable fixed-height box can't be React-controlled: `maxHeight` + // blocks the CSS resize handle from dragging taller, and a controlled `height` + // is overwritten on every render (fighting the drag). So leave the height + // uncontrolled here — the initial pixel height is set imperatively in a layout + // effect and the ResizeObserver syncs drags back into `sheetHeight`. + {} + : { maxHeight: sheetHeight } : { maxHeight: mainRef.current ? window.innerHeight - mainRef.current.getBoundingClientRect().top From 29c4d66655b239a0ec3c1e0f5d3658e02e08a853 Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 01:33:51 +0900 Subject: [PATCH 08/10] feat(xlsx): export cell styles and column/row sizes (toXlsx round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toXlsx now writes what fromXlsx reads: cell styles (background, text color, bold/italic/underline, horizontal/vertical alignment) into a built styles.xml, and — for a live GridSheet sheet — column widths () and row heights (). Styles are interned so each distinct font/fill/alignment gets one xf; fills reserve the Excel none/gray125 indices. Completes the style/size round trip. - style_writer.ts: StyleSheetBuilder + CSS-color → ARGB - write.ts: per-cell `s`, /, dynamic styles.xml - sheet-integration.spec.ts: styles + header sizes survive toXlsx → fromXlsx - verified the styled output opens in openpyxl (bold/fill/color/align intact) - README: styles and sizes are now both-directions Co-Authored-By: Claude Opus 4.8 --- packages/xlsx/README.md | 28 ++-- packages/xlsx/src/sheet-integration.spec.ts | 32 ++++ packages/xlsx/src/style_writer.ts | 173 ++++++++++++++++++++ packages/xlsx/src/write.ts | 119 +++++++++----- 4 files changed, 302 insertions(+), 50 deletions(-) create mode 100644 packages/xlsx/src/style_writer.ts diff --git a/packages/xlsx/README.md b/packages/xlsx/README.md index 99dc33b0..0e6d061e 100644 --- a/packages/xlsx/README.md +++ b/packages/xlsx/README.md @@ -10,18 +10,19 @@ exceljs or SheetJS. - **Formulas**: round-trip as their `=...` text (read via `resolution: 'RAW'`), including cross-sheet references like `=SUM(Sales!D3:D5)` (quoted names such as `'Meta Data'!B3` too). - **Multiple sheets**, in workbook order. `fromXlsx` returns `{ matrices, cells }` per sheet. -- **Cell styles on import** → GridSheet's `style`: background color, text color, bold, italic, - underline, and horizontal/vertical alignment (`justifyContent`/`alignItems`). `cells` in the - result carries these; `buildInitialCells` merges them with the values. -- **Column widths / row heights on import**: explicit widths (``) and heights - (``) map to the column-/row-header cells (`ch(col)` / `rh(row)`), converted - to pixels. +- **Cell styles (both directions)** ↔ GridSheet's `style`: background color, text color, bold, + italic, underline, and horizontal/vertical alignment (`justifyContent`/`alignItems`). On import, + `cells` in the result carries these and `buildInitialCells` merges them with the values; on export, + `toXlsx` writes them into `styles.xml`. +- **Column widths / row heights (both directions)**: explicit widths (``) and + heights (``) map to the column-/row-header cells (`ch(col)` / `rh(row)`), + converted to/from pixels. Sizes are written when the input is a live GridSheet sheet. - **Dates**: written as ISO strings (no number-format support). -Not yet: **writing** styles/sizes back out (`toXlsx` emits values + formulas only), merged cells, -number formats, charts/images. Unsupported features are **ignored gracefully** on import — a -styled, merged workbook reads fine; a merged range keeps its value in the top-left cell, and a -number-formatted date comes through as its raw Excel serial number. +Not yet: merged cells, number formats, charts/images. Unsupported features are **ignored +gracefully** on import — a merged, number-formatted workbook reads fine; a merged range keeps its +value in the top-left cell, and a number-formatted date comes through as its raw Excel serial +number. ## Install @@ -65,8 +66,11 @@ const bytes = toXlsx({ // bytes: Uint8Array — write to a file, or trigger a browser download ``` -Formula cells are written without a cached value; the workbook is marked `fullCalcOnLoad` so Excel / -LibreOffice / Google Sheets recalculate them on open. +A live sheet also exports its cell styles (background / color / weight / italic / underline / +alignment) and column widths / row heights. A cell matrix exports the styles present on its cell +objects; a plain value matrix exports values only. Formula cells are written without a cached value; +the workbook is marked `fullCalcOnLoad` so Excel / LibreOffice / Google Sheets recalculate them on +open. ## License diff --git a/packages/xlsx/src/sheet-integration.spec.ts b/packages/xlsx/src/sheet-integration.spec.ts index a0413766..b4a8985c 100644 --- a/packages/xlsx/src/sheet-integration.spec.ts +++ b/packages/xlsx/src/sheet-integration.spec.ts @@ -70,6 +70,38 @@ describe('toXlsx from a live GridSheet sheet', () => { expect(summary.getCell({ y: 4, x: 2 }, { resolution: 'RESOLVED' })?.value).toBeCloseTo(2.601); }); + it('round-trips cell styles and header sizes through toXlsx → fromXlsx', () => { + const sheet = headlessSheet( + buildInitialCells({ + cells: { + A1: { + value: 'Title', + style: { backgroundColor: '#203864', color: '#FFFFFF', fontWeight: 'bold' }, + justifyContent: 'center', + }, + A2: { value: 'note', style: { fontStyle: 'italic', textDecoration: 'underline' } }, + A0: { width: 160 }, // column A header width (px) + '01': { height: 40 }, // row 1 header height (px) + }, + ensured: { numRows: 2, numCols: 1 }, + }), + ); + + const cells = fromXlsx(toXlsx({ Sheet1: sheet })).Sheet1.cells; + + expect(cells.A1.style).toMatchObject({ + backgroundColor: '#203864', + color: '#FFFFFF', + fontWeight: 'bold', + }); + expect(cells.A1.justifyContent).toBe('center'); + expect(cells.A2.style).toMatchObject({ fontStyle: 'italic', textDecoration: 'underline' }); + // Sizes round-trip through the px↔xlsx-unit conversion (approximately). + expect(cells.A0.width).toBeGreaterThanOrEqual(155); + expect(cells.A0.width).toBeLessThanOrEqual(165); + expect(cells['01'].height).toBe(40); + }); + it('feeds fromXlsx output back into a new sheet that resolves the formulas', () => { const source = headlessSheet( buildInitialCells({ diff --git a/packages/xlsx/src/style_writer.ts b/packages/xlsx/src/style_writer.ts new file mode 100644 index 00000000..2089d33f --- /dev/null +++ b/packages/xlsx/src/style_writer.ts @@ -0,0 +1,173 @@ +import type { CellType, CSSPropertiesLike } from '@gridsheet/engine'; + +/** + * Builds xl/styles.xml from GridSheet cell styles — the inverse of styles.ts. + * Interns fonts, solid fills, and cell formats (xf) so each distinct combination + * of background/color/weight/italic/underline + alignment gets one xf index. + */ + +const MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; + +// CSS color → OOXML ARGB ("FFRRGGBB"). Handles #rgb, #rrggbb, and rgb()/rgba(). +export const cssColorToArgb = (color: string): string | undefined => { + const c = color.trim().toLowerCase(); + let hex: string | undefined; + if (c[0] === '#') { + const h = c.slice(1); + if (h.length === 3) { + hex = h + .split('') + .map((ch) => ch + ch) + .join(''); + } else if (h.length === 6 || h.length === 8) { + hex = h.length === 8 ? h.slice(2) : h; // ignore leading alpha in #aarrggbb-ish input + } + } else { + const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(c); + if (m) { + hex = [m[1], m[2], m[3]] + .map((n) => + Math.max(0, Math.min(255, parseInt(n, 10))) + .toString(16) + .padStart(2, '0'), + ) + .join(''); + } + } + return hex ? `FF${hex.toUpperCase()}` : undefined; +}; + +const justifyToHorizontal: Record = { + 'flex-start': 'left', + center: 'center', + 'flex-end': 'right', +}; +const itemsToVertical: Record = { + 'flex-start': 'top', + center: 'center', + 'flex-end': 'bottom', +}; + +const isBold = (weight: unknown): boolean => { + if (weight == null) { + return false; + } + if (typeof weight === 'number') { + return weight >= 600; + } + const w = String(weight).toLowerCase(); + return w === 'bold' || w === 'bolder' || Number(w) >= 600; +}; + +export class StyleSheetBuilder { + // Index 0 is the default in each collection; fills reserve 0 (none) and 1 (gray125). + private fonts: string[] = ['']; + private fills: string[] = [ + '', + '', + ]; + private xfs: string[] = ['']; + private fontKeys = new Map(); + private fillKeys = new Map(); + private xfKeys = new Map(); + + private internFont(style: CSSPropertiesLike): number { + const parts: string[] = []; + if (isBold(style.fontWeight)) { + parts.push(''); + } + if (String(style.fontStyle).toLowerCase() === 'italic') { + parts.push(''); + } + if ( + String(style.textDecoration ?? '') + .toLowerCase() + .includes('underline') + ) { + parts.push(''); + } + const argb = typeof style.color === 'string' ? cssColorToArgb(style.color) : undefined; + if (argb) { + parts.push(``); + } + if (parts.length === 0) { + return 0; + } + const xml = `${parts.join('')}`; + let id = this.fontKeys.get(xml); + if (id === undefined) { + id = this.fonts.length; + this.fonts.push(xml); + this.fontKeys.set(xml, id); + } + return id; + } + + private internFill(color: string): number { + const argb = cssColorToArgb(color); + if (!argb) { + return 0; + } + const xml = ``; + let id = this.fillKeys.get(xml); + if (id === undefined) { + id = this.fills.length; + this.fills.push(xml); + this.fillKeys.set(xml, id); + } + return id; + } + + /** Intern a cell's style and return its xf index (0 = default, no styling). */ + xfFor(cell: CellType | null | undefined): number { + if (!cell) { + return 0; + } + const style = (cell.style ?? {}) as CSSPropertiesLike; + const fontId = this.internFont(style); + const fillId = typeof style.backgroundColor === 'string' ? this.internFill(style.backgroundColor) : 0; + const horizontal = cell.justifyContent ? justifyToHorizontal[cell.justifyContent] : undefined; + const vertical = cell.alignItems ? itemsToVertical[cell.alignItems] : undefined; + if (fontId === 0 && fillId === 0 && !horizontal && !vertical) { + return 0; + } + const key = `${fontId}:${fillId}:${horizontal ?? ''}:${vertical ?? ''}`; + let id = this.xfKeys.get(key); + if (id === undefined) { + const attrs = ['numFmtId="0"', `fontId="${fontId}"`, `fillId="${fillId}"`, 'borderId="0"', 'xfId="0"']; + if (fontId !== 0) { + attrs.push('applyFont="1"'); + } + if (fillId !== 0) { + attrs.push('applyFill="1"'); + } + let xf: string; + if (horizontal || vertical) { + const align = [horizontal && `horizontal="${horizontal}"`, vertical && `vertical="${vertical}"`] + .filter(Boolean) + .join(' '); + xf = ``; + } else { + xf = ``; + } + id = this.xfs.length; + this.xfs.push(xf); + this.xfKeys.set(key, id); + } + return id; + } + + build(): string { + return ( + '\n' + + `` + + `${this.fonts.join('')}` + + `${this.fills.join('')}` + + '' + + '' + + `${this.xfs.join('')}` + + '' + + '' + ); + } +} diff --git a/packages/xlsx/src/write.ts b/packages/xlsx/src/write.ts index 03316f28..6c23b379 100644 --- a/packages/xlsx/src/write.ts +++ b/packages/xlsx/src/write.ts @@ -2,6 +2,7 @@ import { toCellMatrix, type UserSheet, type CellType } from '@gridsheet/engine'; import { writeZip } from './zip'; import { colName } from './a1'; import { escapeXml, escapeAttr } from './xml'; +import { StyleSheetBuilder } from './style_writer'; import type { XlsxSheetInput, XlsxCellValue } from './types'; const XML_DECL = '\n'; @@ -9,18 +10,15 @@ const MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; const REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; const PKG_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; -// A minimal-but-valid styles part. v0 writes no per-cell styles; Excel still -// requires xl/styles.xml to exist and expose xf index 0. -const STYLES_XML = - XML_DECL + - `` + - '' + - '' + - '' + - '' + - '' + - '' + - ''; +// Inverse of the read-side pixel conversions (read.ts): px → column-width chars, px → points. +const pxToColWidth = (px: number): number => Math.round(((px - 5) / 7) * 100) / 100; +const pxToPoints = (px: number): number => Math.round(((px * 3) / 4) * 100) / 100; + +type SheetData = { + cells: (CellType | null)[][]; + colWidths: Map; // 1-based column index → px + rowHeights: Map; // 1-based row index → px +}; const ROOT_RELS = XML_DECL + @@ -31,53 +29,95 @@ const ROOT_RELS = const isUserSheet = (input: XlsxSheetInput): input is UserSheet => !Array.isArray(input) && typeof (input as { getCell?: unknown }).getCell === 'function'; -const cellEntryToValue = (entry: CellType | XlsxCellValue | null): XlsxCellValue => { - if (entry != null && typeof entry === 'object' && !(entry instanceof Date) && 'value' in entry) { - return (entry as CellType).value ?? null; +const normalizeCell = (entry: CellType | XlsxCellValue | null): CellType | null => { + if (entry == null) { + return null; } - return (entry as XlsxCellValue) ?? null; + if (typeof entry === 'object' && !(entry instanceof Date) && 'value' in entry) { + return entry as CellType; + } + return { value: entry } as CellType; // a raw scalar / Date }; -const toValueMatrix = (input: XlsxSheetInput): XlsxCellValue[][] => { +const toSheetData = (input: XlsxSheetInput): SheetData => { + const colWidths = new Map(); + const rowHeights = new Map(); if (isUserSheet(input)) { const cells = toCellMatrix(input, { resolution: 'RAW' }); - return cells.map((row) => row.map((cell) => (cell ? (cell.value ?? null) : null))); + const rows = cells.length; + const cols = cells[0]?.length ?? 0; + // Column widths and row heights live on the header cells (y=0 / x=0). + for (let x = 1; x <= cols; x++) { + const w = input.getCell({ y: 0, x })?.width; + if (typeof w === 'number') { + colWidths.set(x, w); + } + } + for (let y = 1; y <= rows; y++) { + const h = input.getCell({ y, x: 0 })?.height; + if (typeof h === 'number') { + rowHeights.set(y, h); + } + } + return { cells, colWidths, rowHeights }; } - return (input as (CellType | XlsxCellValue | null)[][]).map((row) => row.map(cellEntryToValue)); + const cells = (input as (CellType | XlsxCellValue | null)[][]).map((row) => row.map(normalizeCell)); + return { cells, colWidths, rowHeights }; }; -const buildCellXml = (ref: string, value: XlsxCellValue, intern: (s: string) => number): string => { +const buildCellXml = ( + ref: string, + cell: CellType, + intern: (s: string) => number, + styles: StyleSheetBuilder, +): string => { + const s = styles.xfFor(cell); + const sAttr = s > 0 ? ` s="${s}"` : ''; + const value = cell.value as XlsxCellValue; if (typeof value === 'string' && value.startsWith('=')) { - return `${escapeXml(value.slice(1))}`; + return `${escapeXml(value.slice(1))}`; } if (typeof value === 'number' && Number.isFinite(value)) { - return `${value}`; + return `${value}`; } if (typeof value === 'boolean') { - return `${value ? 1 : 0}`; + return `${value ? 1 : 0}`; + } + if (value === null || value === undefined || value === '') { + // Empty but styled → still emit the cell so its background/format survives. + return sAttr ? `` : ''; } - // Date has no v0 number-format support; round-trip it as an ISO string. + // Date has no number-format support yet; round-trip it as an ISO string. const text = value instanceof Date ? value.toISOString() : String(value); - return `${intern(text)}`; + return `${intern(text)}`; }; -const buildSheetXml = (matrix: XlsxCellValue[][], intern: (s: string) => number): string => { +const buildSheetXml = (data: SheetData, intern: (s: string) => number, styles: StyleSheetBuilder): string => { + const { cells, colWidths, rowHeights } = data; + let cols = ''; + for (const [col, px] of [...colWidths.entries()].sort((a, b) => a[0] - b[0])) { + cols += ``; + } + const colsXml = cols ? `${cols}` : ''; + let rows = ''; - for (let y = 0; y < matrix.length; y++) { - const row = matrix[y]; - let cells = ''; + for (let y = 0; y < cells.length; y++) { + const row = cells[y]; + let rowCells = ''; for (let x = 0; x < row.length; x++) { - const value = row[x]; - if (value === null || value === undefined || value === '') { + const cell = row[x]; + if (cell == null) { continue; } - cells += buildCellXml(`${colName(x + 1)}${y + 1}`, value, intern); + rowCells += buildCellXml(`${colName(x + 1)}${y + 1}`, cell, intern, styles); } - if (cells) { - rows += `${cells}`; + const px = rowHeights.get(y + 1); + const heightAttr = typeof px === 'number' ? ` ht="${pxToPoints(px)}" customHeight="1"` : ''; + if (rowCells || heightAttr) { + rows += `${rowCells}`; } } - return XML_DECL + `${rows}`; + return XML_DECL + `${colsXml}${rows}`; }; const buildSharedStrings = (shared: string[]): string => { @@ -139,7 +179,9 @@ const buildContentTypes = (count: number): string => { * Serialize one or more sheets into an xlsx workbook (a Uint8Array of zip bytes). * Each input may be a live GridSheet sheet (read via `toCellMatrix` at RAW * resolution so formulas keep their "=..." text), a cell matrix, or a value - * matrix. v0 writes values + formulas only; styles are not emitted. + * matrix. Values, formulas, cell styles (background / color / weight / italic / + * underline / alignment), and — for a live sheet — column widths and row heights + * are written; merged cells and number formats are not. */ export const toXlsx = (sheets: Record): Uint8Array => { const names = Object.keys(sheets); @@ -158,16 +200,17 @@ export const toXlsx = (sheets: Record): Uint8Array => { } return idx; }; + const styles = new StyleSheetBuilder(); const files: Record = {}; names.forEach((name, i) => { - files[`xl/worksheets/sheet${i + 1}.xml`] = buildSheetXml(toValueMatrix(sheets[name]), intern); + files[`xl/worksheets/sheet${i + 1}.xml`] = buildSheetXml(toSheetData(sheets[name]), intern, styles); }); files['[Content_Types].xml'] = buildContentTypes(names.length); files['_rels/.rels'] = ROOT_RELS; files['xl/workbook.xml'] = buildWorkbookXml(names); files['xl/_rels/workbook.xml.rels'] = buildWorkbookRels(names.length); - files['xl/styles.xml'] = STYLES_XML; + files['xl/styles.xml'] = styles.build(); files['xl/sharedStrings.xml'] = buildSharedStrings(shared); return writeZip(files); From 436199b5a4ddd754b8c5661a5a0d681499cabade Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 01:59:15 +0900 Subject: [PATCH 09/10] chore(xlsx): align version to 3.4.4 with the monorepo Co-Authored-By: Claude Opus 4.8 --- packages/xlsx/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/xlsx/package.json b/packages/xlsx/package.json index b5a4e489..1eab23a7 100644 --- a/packages/xlsx/package.json +++ b/packages/xlsx/package.json @@ -1,6 +1,6 @@ { "name": "@gridsheet/xlsx", - "version": "3.4.3", + "version": "3.4.4", "description": "xlsx ⇄ GridSheet converter for @gridsheet/react-core (values + formulas)", "main": "./dist/index.js", "module": "./dist/index.js", @@ -58,7 +58,7 @@ "vite-plugin-dts": "^4.5.3" }, "peerDependencies": { - "@gridsheet/engine": "3.4.1" + "@gridsheet/engine": "3.4.4" }, "publishConfig": { "access": "public" From 38a391569d9bc8bb56766eee3a1a5e45fb0c24b6 Mon Sep 17 00:00:00 2001 From: righ Date: Tue, 29 Sep 2026 02:06:30 +0900 Subject: [PATCH 10/10] chore(xlsx): mark first release as 3.4.4-beta.0 (prerelease) Ships under the npm 'next' dist-tag (the release workflow publishes any version containing '-' with --tag next), keeping it off 'latest' while it stabilizes. Co-Authored-By: Claude Opus 4.8 --- packages/xlsx/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xlsx/package.json b/packages/xlsx/package.json index 1eab23a7..8bf8b977 100644 --- a/packages/xlsx/package.json +++ b/packages/xlsx/package.json @@ -1,6 +1,6 @@ { "name": "@gridsheet/xlsx", - "version": "3.4.4", + "version": "3.4.4-beta.0", "description": "xlsx ⇄ GridSheet converter for @gridsheet/react-core (values + formulas)", "main": "./dist/index.js", "module": "./dist/index.js",