From 20311d42074298b8b846099275eafa9a2061ced3 Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:34:27 -0700 Subject: [PATCH 1/3] chore(deps): bump pdf-inspector to 1.14.2 Pick up the unified crate release with parser resource bounds and Form XObject / small-caps extraction fixes. Co-authored-by: Cursor --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1b1f7e7..e1a53b47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,9 +1071,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pdf-inspector" -version = "0.1.8" +version = "1.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd2f755e49ad38eafbc82ba2bec1c59d57b5bf829b13a8f35e4263bc8913df3a" +checksum = "1e024ae242c514e2adf6aee186678e0eabdc2e5ecfbb2159186881b4498593cb" dependencies = [ "env_logger", "include_dir", diff --git a/Cargo.toml b/Cargo.toml index 97d57d07..733f14ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ csv = "1.4.0" flate2 = "1" encoding_rs = "0.8.35" log = "0.4" -pdf-inspector = "0.1.8" +pdf-inspector = "1.14.2" quick-xml = "0.41.0" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } From e754e1d33a1a540ebc9226e36f11d3f401852c9e Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:39:50 -0700 Subject: [PATCH 2/3] chore: release v0.1.9 Ship pdf-inspector 1.14.2 to the PDF frontend. Co-authored-by: Cursor --- Cargo.lock | 6 +-- Cargo.toml | 2 +- node/index.js | 108 ++++++++++++++++++++--------------------- node/package-lock.json | 4 +- node/package.json | 2 +- python/Cargo.toml | 2 +- wasm/Cargo.toml | 2 +- 7 files changed, 63 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1a53b47..a01c9140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "anydoc" -version = "0.1.8" +version = "0.1.9" dependencies = [ "calamine", "cfb", @@ -116,7 +116,7 @@ dependencies = [ [[package]] name = "anydoc-python" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anydoc", "pyo3", @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "anydoc-wasm" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anydoc", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 733f14ba..04ed82f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["fuzz"] [package] name = "anydoc" -version = "0.1.8" +version = "0.1.9" edition = "2024" # Edition 2024 needs 1.85; zip and calamine both raise it to 1.88. rust-version = "1.88" diff --git a/node/index.js b/node/index.js index ca22b8b7..4f592d69 100644 --- a/node/index.js +++ b/node/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-android-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-android-arm64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-android-arm-eabi') const bindingPackageVersion = require('@firecrawl/anydoc-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-x64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-x64-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-ia32-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-arm64-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-universal') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-x64') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-freebsd-x64') const bindingPackageVersion = require('@firecrawl/anydoc-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-freebsd-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-x64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-x64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm-musleabihf') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm-gnueabihf') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-loong64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-loong64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-riscv64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-riscv64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-ppc64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-s390x-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-x64') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-arm') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.1.8' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -648,8 +648,8 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@firecrawl/anydoc-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '0.1.8') { - throw new Error(`WASI binding package version mismatch, expected 0.1.8 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.9') { + throw new Error(`WASI binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } wasiBinding = require('@firecrawl/anydoc-wasm32-wasi') diff --git a/node/package-lock.json b/node/package-lock.json index 0cf0f80a..aa680a0f 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firecrawl/anydoc", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firecrawl/anydoc", - "version": "0.1.8", + "version": "0.1.9", "license": "MIT", "bin": { "anydoc": "cli.js" diff --git a/node/package.json b/node/package.json index 0d2d02c2..6424f043 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@firecrawl/anydoc", - "version": "0.1.8", + "version": "0.1.9", "description": "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown", "license": "MIT", "homepage": "https://github.com/firecrawl/anydoc#readme", diff --git a/python/Cargo.toml b/python/Cargo.toml index 446925e4..b600464b 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -2,7 +2,7 @@ # dynamic version), so bump it together with the workspace release version. [package] name = "anydoc-python" -version = "0.1.8" +version = "0.1.9" edition = "2024" description = "Python bindings for anydoc" license = "MIT" diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 9ea88417..4adc8a70 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -4,7 +4,7 @@ # only keeps it off crates.io. [package] name = "anydoc-wasm" -version = "0.1.8" +version = "0.1.9" edition = "2024" description = "WebAssembly bindings for anydoc: convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown in the browser" license = "MIT" From a725a8819dfeb2c73c63ad395ff1a596477784ea Mon Sep 17 00:00:00 2001 From: naaiyy Date: Fri, 14 Aug 2026 18:34:04 +0200 Subject: [PATCH 3/3] Refactor spreadsheet chart support --- .changeset/bright-charts-render.md | 6 + .../src/chart-references.ts | 132 ++++++++ packages/spreadsheet-engine/src/charts.ts | 303 +++++++++++------ .../src/document-adapter.ts | 154 +++++++-- packages/spreadsheet-engine/src/engine.ts | 49 +-- packages/spreadsheet-engine/src/index.ts | 6 + packages/spreadsheet-engine/src/model.ts | 52 ++- packages/spreadsheet-engine/src/objects.ts | 48 ++- .../spreadsheet-engine/test/engine.test.ts | 142 +++++++- packages/spreadsheet-viewer/package.json | 3 +- .../spreadsheet-viewer/src/sheet-grid.tsx | 306 +++++++++++------- .../spreadsheet-viewer/test/viewer.test.tsx | 14 +- pnpm-lock.yaml | 3 + 13 files changed, 933 insertions(+), 285 deletions(-) create mode 100644 .changeset/bright-charts-render.md create mode 100644 packages/spreadsheet-engine/src/chart-references.ts diff --git a/.changeset/bright-charts-render.md b/.changeset/bright-charts-render.md new file mode 100644 index 00000000..48fd3231 --- /dev/null +++ b/.changeset/bright-charts-render.md @@ -0,0 +1,6 @@ +--- +"@baseblocks/anydoc-spreadsheet-engine": patch +"@baseblocks/anydoc-spreadsheet-viewer": patch +--- + +Parse ordered multi-area chart references, isolate malformed chart parts, use authored caches as a fallback, and render normalized spreadsheet charts with ECharts. diff --git a/packages/spreadsheet-engine/src/chart-references.ts b/packages/spreadsheet-engine/src/chart-references.ts new file mode 100644 index 00000000..a4fefd91 --- /dev/null +++ b/packages/spreadsheet-engine/src/chart-references.ts @@ -0,0 +1,132 @@ +import { cellKey, parseRangeAddress } from "./coordinates.ts"; +import type { + SpreadsheetChartDataSource, + SpreadsheetChartReference, + SpreadsheetScalar, + SpreadsheetSheet, +} from "./model.ts"; + +const MAX_FORMULA_CHARACTERS = 32_768; +const MAX_REFERENCE_AREAS = 4_096; +const MAX_RESOLVED_POINTS = 1_000_000; + +function stripOuterParentheses(source: string): string { + const trimmed = source.trim(); + if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return trimmed; + let depth = 0; + let quoted = false; + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index]; + if (character === "'") { + if (quoted && trimmed[index + 1] === "'") index += 1; + else quoted = !quoted; + continue; + } + if (quoted) continue; + if (character === "(") depth += 1; + else if (character === ")") depth -= 1; + if (depth === 0 && index < trimmed.length - 1) return trimmed; + if (depth < 0) return trimmed; + } + return depth === 0 && !quoted ? trimmed.slice(1, -1).trim() : trimmed; +} + +function splitUnion(source: string): readonly string[] | undefined { + const parts: string[] = []; + let start = 0; + let depth = 0; + let quoted = false; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character === "'") { + if (quoted && source[index + 1] === "'") index += 1; + else quoted = !quoted; + continue; + } + if (quoted) continue; + if (character === "(") depth += 1; + else if (character === ")") depth -= 1; + else if (character === "," && depth === 0) { + parts.push(source.slice(start, index).trim()); + start = index + 1; + } + if (depth < 0) return undefined; + } + if (quoted || depth !== 0) return undefined; + parts.push(source.slice(start).trim()); + return parts.every(Boolean) ? parts : undefined; +} + +function referenceSeparator(source: string): number { + let quoted = false; + let separator = -1; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === "'") { + if (quoted && source[index + 1] === "'") index += 1; + else quoted = !quoted; + } else if (!quoted && source[index] === "!") separator = index; + } + return quoted ? -1 : separator; +} + +function sheetName(source: string): string | undefined { + const trimmed = source.trim(); + if (!trimmed || trimmed.includes("[") || trimmed.includes("]")) return undefined; + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1).replaceAll("''", "'"); + } + return /^[^'!]+$/u.test(trimmed) ? trimmed : undefined; +} + +export function parseChartReferenceFormula(formula: string): SpreadsheetChartReference { + const raw = formula.trim(); + if (!raw || raw.length > MAX_FORMULA_CHARACTERS) return { formula: raw, kind: "opaque" }; + const operands = splitUnion(stripOuterParentheses(raw)); + if (!operands || operands.length > MAX_REFERENCE_AREAS) return { formula: raw, kind: "opaque" }; + try { + const areas = operands.map((operand) => { + const separator = referenceSeparator(operand); + const qualifier = separator < 0 ? undefined : sheetName(operand.slice(0, separator)); + if (separator >= 0 && qualifier === undefined) + throw new Error("Unsupported chart qualifier."); + const address = (separator < 0 ? operand : operand.slice(separator + 1)).replaceAll("$", ""); + return { + range: parseRangeAddress(address), + ...(qualifier ? { sheetName: qualifier } : {}), + }; + }); + return { areas, formula: raw, kind: "areas" }; + } catch { + return { formula: raw, kind: "opaque" }; + } +} + +function cellValue(sheet: SpreadsheetSheet, row: number, column: number): SpreadsheetScalar { + const cell = sheet.cells.get(cellKey(row, column)); + return cell?.formula ? (cell.formulaResult ?? null) : (cell?.value ?? null); +} + +export function resolveChartDataSource( + source: SpreadsheetChartDataSource, + currentSheet: SpreadsheetSheet, + resolveSheet: (name: string) => SpreadsheetSheet | undefined, +): readonly SpreadsheetScalar[] { + const reference = source.reference; + if (reference?.kind === "areas") { + const values: SpreadsheetScalar[] = []; + for (const area of reference.areas) { + const sheet = area.sheetName ? resolveSheet(area.sheetName) : currentSheet; + if (!sheet) return source.cache; + const count = + (area.range.bottom - area.range.top + 1) * (area.range.right - area.range.left + 1); + if (values.length + count > MAX_RESOLVED_POINTS) return source.cache; + for (let row = area.range.top; row <= area.range.bottom; row += 1) { + for (let column = area.range.left; column <= area.range.right; column += 1) { + values.push(cellValue(sheet, row, column)); + } + } + } + return values; + } + return source.cache; +} diff --git a/packages/spreadsheet-engine/src/charts.ts b/packages/spreadsheet-engine/src/charts.ts index 8501b3c7..1e200314 100644 --- a/packages/spreadsheet-engine/src/charts.ts +++ b/packages/spreadsheet-engine/src/charts.ts @@ -1,76 +1,200 @@ -import { cellAddress, cellKey, parseRangeAddress } from "./coordinates.ts"; +import { SaxesParser, type SaxesTagNS } from "saxes"; + +import { parseChartReferenceFormula, resolveChartDataSource } from "./chart-references.ts"; +import { rangeAddress } from "./coordinates.ts"; import type { SpreadsheetChart, + SpreadsheetChartDataSource, + SpreadsheetChartGroup, + SpreadsheetChartInput, SpreadsheetChartSeries, SpreadsheetObjectAnchor, SpreadsheetScalar, SpreadsheetSheet, } from "./model.ts"; -import { attributes, decodeXml, escapeXml } from "./xml.ts"; +import { escapeXml } from "./xml.ts"; + +const MAX_CHART_XML_NODES = 100_000; +const MAX_CHART_CACHE_POINTS = 1_000_000; -function formula(source: string, tag: "cat" | "val"): string | undefined { - const block = new RegExp(`]*>([\\s\\S]*?)<\\/c:${tag}>`, "iu").exec(source)?.[1]; - const value = /]*>([\s\S]*?)<\/c:f>/iu.exec(block ?? "")?.[1]; - return value === undefined ? undefined : decodeXml(value); +type ChartXmlNode = { + attributes: Readonly>; + children: ChartXmlNode[]; + name: string; + text: string; +}; + +function parseChartXml(xml: string): ChartXmlNode | undefined { + const roots: ChartXmlNode[] = []; + const stack: ChartXmlNode[] = []; + let nodeCount = 0; + let failure: Error | undefined; + const parser = new SaxesParser({ xmlns: true }); + parser.on("opentag", (tag: SaxesTagNS) => { + nodeCount += 1; + if (nodeCount > MAX_CHART_XML_NODES) { + failure = new Error("Chart XML exceeds the node limit."); + return; + } + const attributes: Record = {}; + for (const attribute of Object.values(tag.attributes)) { + attributes[attribute.local] = attribute.value; + attributes[attribute.name] = attribute.value; + } + const node: ChartXmlNode = { + attributes, + children: [], + name: tag.local, + text: "", + }; + const parent = stack.at(-1); + if (parent) parent.children.push(node); + else roots.push(node); + stack.push(node); + }); + parser.on("text", (text: string) => { + const node = stack.at(-1); + if (node) node.text += text; + }); + parser.on("cdata", (text: string) => { + const node = stack.at(-1); + if (node) node.text += text; + }); + parser.on("closetag", () => { + stack.pop(); + }); + parser.on("error", (error: Error) => { + failure = error; + }); + parser.write(xml).close(); + return failure ? undefined : roots[0]; } -function stripSheet(range: string): string { - return range.slice(range.lastIndexOf("!") + 1).replaceAll("$", ""); +function child(node: ChartXmlNode | undefined, name: string): ChartXmlNode | undefined { + return node?.children.find((candidate) => candidate.name === name); } -function formulaSheetName(range: string): string | undefined { - const separator = range.lastIndexOf("!"); - if (separator < 0) return undefined; - const value = range.slice(0, separator); - return value.startsWith("'") && value.endsWith("'") - ? value.slice(1, -1).replaceAll("''", "'") - : value; +function children(node: ChartXmlNode | undefined, name: string): readonly ChartXmlNode[] { + return node?.children.filter((candidate) => candidate.name === name) ?? []; } -export function parseChart(part: string, xml: string): SpreadsheetChart | undefined { - const typeMatch = /]*)\/>/iu.exec(xml)?.[1] ?? "").val; - const type = - typeMatch === "lineChart" - ? "line" - : typeMatch === "pieChart" - ? "pie" - : barDirection === "bar" - ? "bar" - : "column"; - const title = decodeXml( - /]*>([\s\S]*?)<\/a:t>[\s\S]*?<\/c:title>/iu.exec(xml)?.[1] ?? "", +function descendants(node: ChartXmlNode | undefined, name: string): readonly ChartXmlNode[] { + if (!node) return []; + const matches: ChartXmlNode[] = []; + const visit = (candidate: ChartXmlNode) => { + if (candidate.name === name) matches.push(candidate); + for (const nested of candidate.children) visit(nested); + }; + visit(node); + return matches; +} + +function firstText(node: ChartXmlNode | undefined, names: readonly string[]): string | undefined { + for (const name of names) { + const value = descendants(node, name)[0]?.text.trim(); + if (value) return value; + } + return undefined; +} + +function cachedValues( + container: ChartXmlNode, + valueType: SpreadsheetChartDataSource["valueType"], +): readonly SpreadsheetScalar[] { + const cache = + descendants(container, valueType === "number" ? "numCache" : "strCache")[0] ?? + descendants(container, "multiLvlStrCache")[0] ?? + descendants(container, valueType === "number" ? "numLit" : "strLit")[0]; + if (!cache) return []; + const pointCount = Number(child(cache, "ptCount")?.attributes.val ?? 0); + const points = descendants(cache, "pt") + .map((point) => ({ + index: Number(point.attributes.idx), + value: child(point, "v")?.text ?? "", + })) + .filter(({ index }) => Number.isInteger(index) && index >= 0 && index < MAX_CHART_CACHE_POINTS); + const length = Math.min( + MAX_CHART_CACHE_POINTS, + Math.max( + Number.isInteger(pointCount) ? pointCount : 0, + ...points.map(({ index }) => index + 1), + 0, + ), ); - const legendPosition = attributes(/]*)\/>/iu.exec(xml)?.[1] ?? "").val; - const legend = { - b: "bottom", - l: "left", - r: "right", - t: "top", - }[legendPosition ?? ""] as SpreadsheetChart["legend"] | undefined; - const series: SpreadsheetChartSeries[] = []; - for (const match of xml.matchAll(/]*>([\s\S]*?)<\/c:ser>/giu)) { - const categoryRange = formula(match[1], "cat"); - const valueRange = formula(match[1], "val"); - if (!categoryRange || !valueRange) continue; - const name = decodeXml( - /]*>([\s\S]*?)<\/c:v>[\s\S]*?<\/c:tx>/iu.exec(match[1])?.[1] ?? "", - ); - const sourceSheetName = formulaSheetName(valueRange); - series.push({ - categoryRange: stripSheet(categoryRange), - ...(name ? { name } : {}), - ...(sourceSheetName ? { sourceSheetName } : {}), - valueRange: stripSheet(valueRange), - }); + const values: SpreadsheetScalar[] = Array.from({ length }, () => null); + for (const point of points) { + if (valueType === "string") values[point.index] = point.value; + else { + const numeric = Number(point.value); + values[point.index] = Number.isFinite(numeric) ? numeric : null; + } } + return values; +} + +function dataSource( + series: ChartXmlNode, + role: "cat" | "val", +): SpreadsheetChartDataSource | undefined { + const container = child(series, role); + if (!container) return undefined; + const sourceKind = ["numRef", "numLit", "strRef", "strLit", "multiLvlStrRef"].find( + (name) => descendants(container, name).length > 0, + ); + if (!sourceKind) return undefined; + const valueType = sourceKind.startsWith("num") ? "number" : "string"; + const formula = descendants(container, "f")[0]?.text.trim(); return { + cache: cachedValues(container, valueType), + ...(formula ? { reference: parseChartReferenceFormula(formula) } : {}), + valueType, + }; +} + +function chartType(group: ChartXmlNode): SpreadsheetChartGroup["type"] | undefined { + if (group.name === "lineChart") return "line"; + if (group.name === "pieChart") return "pie"; + if (group.name === "barChart") { + return child(group, "barDir")?.attributes.val === "bar" ? "bar" : "column"; + } + return undefined; +} + +export function parseChart(part: string, xml: string): SpreadsheetChart | undefined { + const root = parseChartXml(xml); + const plotArea = descendants(root, "plotArea")[0]; + const groups: SpreadsheetChartGroup[] = []; + for (const groupNode of plotArea?.children ?? []) { + const type = chartType(groupNode); + if (!type) continue; + const series: SpreadsheetChartSeries[] = []; + for (const seriesNode of children(groupNode, "ser")) { + const values = dataSource(seriesNode, "val"); + if (!values) continue; + const categories = dataSource(seriesNode, "cat"); + const name = firstText(child(seriesNode, "tx"), ["v"]); + series.push({ + ...(categories ? { categories } : {}), + ...(name ? { name } : {}), + values, + }); + } + if (series.length > 0) groups.push({ series, type }); + } + if (groups.length === 0) return undefined; + const legendNode = descendants(root, "legend")[0]; + const legendPosition = descendants(legendNode, "legendPos")[0]?.attributes.val; + const legend = legendNode + ? (({ b: "bottom", l: "left", r: "right", t: "top" } as const)[legendPosition as "b"] ?? + "right") + : "none"; + const titleNode = descendants(root, "title")[0]; + const title = firstText(titleNode, ["t", "v"]); + return { + groups, id: part, - legend: / string = () => sheetName, + chart: SpreadsheetChartInput, + sourceSheetName: (source: SpreadsheetChartInput["series"][number]["values"]) => string, ): string { const tag = chart.type === "line" ? "lineChart" : chart.type === "pie" ? "pieChart" : "barChart"; const series = chart.series - .map( - (item, index) => - `${item.name ? `${escapeXml(item.name)}` : ""}${escapeXml(`${quotedSheet(sourceSheetName(item))}!${item.categoryRange}`)}${escapeXml(`${quotedSheet(sourceSheetName(item))}!${item.valueRange}`)}`, - ) + .map((item, index) => { + const categoryFormula = `${quotedSheet(sourceSheetName(item.categories))}!${rangeAddress(item.categories.range)}`; + const valueFormula = `${quotedSheet(sourceSheetName(item.values))}!${rangeAddress(item.values.range)}`; + return `${item.name ? `${escapeXml(item.name)}` : ""}${escapeXml(categoryFormula)}${escapeXml(valueFormula)}`; + }) .join(""); const chartBody = tag === "barChart" @@ -127,49 +251,48 @@ export function chartAnchorXml( return `${frame}`; } -function scalar(sheet: SpreadsheetSheet, address: string): SpreadsheetScalar { - const range = parseRangeAddress(address); - const cell = sheet.cells.get(cellKey(range.top, range.left)); - return cell?.formula ? (cell.formulaResult ?? null) : (cell?.value ?? null); -} - -function values(sheet: SpreadsheetSheet, address: string): readonly SpreadsheetScalar[] { - const range = parseRangeAddress(address); - const result: SpreadsheetScalar[] = []; - for (let row = range.top; row <= range.bottom; row += 1) - for (let column = range.left; column <= range.right; column += 1) - result.push(scalar(sheet, cellAddress(row, column))); - return result; -} - export type SpreadsheetRenderedChart = Readonly<{ chartId: string; categories: readonly string[]; - series: ReadonlyArray>; + legend: SpreadsheetChart["legend"]; + series: ReadonlyArray< + Readonly<{ + name: string; + type: SpreadsheetChartGroup["type"]; + values: readonly number[]; + }> + >; title?: string; - type: SpreadsheetChart["type"]; + type: SpreadsheetChartGroup["type"]; }>; export function renderChartModel( chart: SpreadsheetChart, sheet: SpreadsheetSheet, - resolveSourceSheet: (series: SpreadsheetChartSeries) => SpreadsheetSheet = () => sheet, + resolveSheet: (name: string) => SpreadsheetSheet | undefined = () => undefined, ): SpreadsheetRenderedChart { - const first = chart.series[0]; - const firstSheet = first ? resolveSourceSheet(first) : sheet; - const categories = first - ? values(firstSheet, first.categoryRange).map((value) => (value === null ? "" : String(value))) + const firstGroup = chart.groups[0]; + if (!firstGroup) throw new Error("A rendered chart requires at least one chart group."); + const first = firstGroup.series[0]; + const categories = first?.categories + ? resolveChartDataSource(first.categories, sheet, resolveSheet).map((value) => + value === null ? "" : String(value), + ) : []; return { chartId: chart.id, categories, - series: chart.series.map((item, index) => ({ - name: item.name ?? `Series ${index + 1}`, - values: values(resolveSourceSheet(item), item.valueRange).map((value) => - typeof value === "number" && Number.isFinite(value) ? value : 0, - ), - })), + legend: chart.legend, + series: chart.groups.flatMap((group) => + group.series.map((item, index) => ({ + name: item.name ?? `Series ${index + 1}`, + type: group.type, + values: resolveChartDataSource(item.values, sheet, resolveSheet).map((value) => + typeof value === "number" && Number.isFinite(value) ? value : 0, + ), + })), + ), ...(chart.title ? { title: chart.title } : {}), - type: chart.type, + type: firstGroup.type, }; } diff --git a/packages/spreadsheet-engine/src/document-adapter.ts b/packages/spreadsheet-engine/src/document-adapter.ts index b505c172..0de61761 100644 --- a/packages/spreadsheet-engine/src/document-adapter.ts +++ b/packages/spreadsheet-engine/src/document-adapter.ts @@ -62,7 +62,10 @@ const objectSchema = (properties: Record, required: readonly st type: "object", }); -const rangeSchema = { pattern: "^[A-Za-z]+[1-9][0-9]*(?::[A-Za-z]+[1-9][0-9]*)?$", type: "string" }; +const rangeSchema = { + pattern: "^[A-Za-z]+[1-9][0-9]*(?::[A-Za-z]+[1-9][0-9]*)?$", + type: "string", +}; const sheetSchema = { minLength: 1, type: "string" }; const colorSchema = { pattern: "^#[0-9A-Fa-f]{6}$", type: "string" }; const scalarSchema = { @@ -126,7 +129,12 @@ const metricSchema = objectSchema( ); const partitionFilterSchema = objectSchema( { - predicates: { items: predicateSchema, maxItems: 8, minItems: 1, type: "array" }, + predicates: { + items: predicateSchema, + maxItems: 8, + minItems: 1, + type: "array", + }, quantifier: { enum: ["exists", "not-exists"] }, }, ["quantifier", "predicates"], @@ -206,7 +214,12 @@ const commandSchemas = [ maxItems: 3, type: "array", }, - metrics: { items: metricSchema, maxItems: 8, minItems: 1, type: "array" }, + metrics: { + items: metricSchema, + maxItems: 8, + minItems: 1, + type: "array", + }, partitionBy: { minLength: 1, type: "string" }, partitionFilters: { items: partitionFilterSchema, @@ -410,7 +423,12 @@ const commandSchemas = [ formula: { minLength: 1, type: "string" }, range: rangeSchema, sheetId: sheetSchema, - values: { items: { type: "string" }, maxItems: 100, minItems: 1, type: "array" }, + values: { + items: { type: "string" }, + maxItems: 100, + minItems: 1, + type: "array", + }, }, ["sheetId", "range"], ), @@ -421,7 +439,12 @@ const commandSchemas = [ { description: "Promote a rectangular range with headers to a native structured Excel table.", inputSchema: objectSchema( - { name: sheetSchema, range: rangeSchema, sheetId: sheetSchema, style: { type: "string" } }, + { + name: sheetSchema, + range: rangeSchema, + sheetId: sheetSchema, + style: { type: "string" }, + }, ["sheetId", "range", "name"], ), mutates: true, @@ -434,7 +457,12 @@ const commandSchemas = [ inputSchema: objectSchema( { name: sheetSchema, - rowFields: { items: sheetSchema, maxItems: 3, minItems: 1, type: "array" }, + rowFields: { + items: sheetSchema, + maxItems: 3, + minItems: 1, + type: "array", + }, sourceRange: rangeSchema, sourceSheetId: sheetSchema, target: rangeSchema, @@ -444,7 +472,9 @@ const commandSchemas = [ { field: sheetSchema, name: sheetSchema, - summarizeBy: { enum: ["average", "count", "maximum", "minimum", "sum"] }, + summarizeBy: { + enum: ["average", "count", "maximum", "minimum", "sum"], + }, }, ["field", "summarizeBy"], ), @@ -637,8 +667,11 @@ function cellInput(value: unknown): SpreadsheetCellInput | SpreadsheetScalar { if ("formulaResult" in input && !("formula" in input)) { throw new TypeError("formulaResult requires formula."); } - const result: { formula?: string; formulaResult?: SpreadsheetScalar; value?: SpreadsheetScalar } = - {}; + const result: { + formula?: string; + formulaResult?: SpreadsheetScalar; + value?: SpreadsheetScalar; + } = {}; if ("formula" in input) result.formula = string(input.formula, "formula"); if ("formulaResult" in input) result.formulaResult = scalar(input.formulaResult); if ("value" in input) result.value = scalar(input.value); @@ -780,8 +813,18 @@ export class SpreadsheetDocumentEngine implements DocumentFormatEngine = []; + const renderArtifacts: Array<{ + bytes: Uint8Array; + region: string; + surfaceId: string; + }> = []; try { for (const sheet of reopened.model.sheets) { - const target = sheet.usedRange ?? { bottom: 1, left: 1, right: 1, top: 1 }; + const target = sheet.usedRange ?? { + bottom: 1, + left: 1, + right: 1, + top: 1, + }; renderArtifacts.push({ bytes: new TextEncoder().encode(reopened.renderRange(sheet.id, target)), region: `${target.top}:${target.left}:${target.bottom}:${target.right}`, diff --git a/packages/spreadsheet-engine/src/engine.ts b/packages/spreadsheet-engine/src/engine.ts index e7c0726c..126c86f5 100644 --- a/packages/spreadsheet-engine/src/engine.ts +++ b/packages/spreadsheet-engine/src/engine.ts @@ -20,6 +20,7 @@ import { translateSpreadsheetFormula } from "./formula-fill.ts"; import { chartAnchorXml, chartXml, + parseChart, renderChartModel, type SpreadsheetRenderedChart, } from "./charts.ts"; @@ -162,7 +163,11 @@ function parseRelationships(xml: string): ReadonlyMap { for (const match of xml.matchAll(/]*)\/?\s*>/giu)) { const attrs = attributes(match[1]); if (attrs.Id && attrs.Target && attrs.Type) { - result.set(attrs.Id, { id: attrs.Id, target: attrs.Target, type: attrs.Type }); + result.set(attrs.Id, { + id: attrs.Id, + target: attrs.Target, + type: attrs.Type, + }); } } return result; @@ -495,7 +500,11 @@ function featureDiagnostics( } function publicAxis(axis: MutableAxis): SpreadsheetAxis { - return { defaultSize: axis.defaultSize, hidden: axis.hidden, sizes: axis.sizes }; + return { + defaultSize: axis.defaultSize, + hidden: axis.hidden, + sizes: axis.sizes, + }; } function publicSheet(sheet: SheetState): SpreadsheetSheet { @@ -685,7 +694,8 @@ export class SpreadsheetEngine { static async open(bytes: Uint8Array, limits?: SpreadsheetOpenLimits): Promise { const maxCells = limits?.maxCells ?? defaultDocumentLimits.maxSpreadsheetCells; - if (!Number.isInteger(maxCells) || maxCells < 1) throw new Error("Spreadsheet cell limit is invalid."); + if (!Number.isInteger(maxCells) || maxCells < 1) + throw new Error("Spreadsheet cell limit is invalid."); const cellBudget = { remaining: maxCells }; const archive = await OoxmlArchive.open(bytes, limits); for (const required of ["[Content_Types].xml", "_rels/.rels", MAIN_WORKBOOK, WORKBOOK_RELS]) { @@ -1123,14 +1133,11 @@ export class SpreadsheetEngine { return sheet.objects.flatMap((object) => object.chart ? [ - renderChartModel(object.chart, publicModel, (series) => { - const source = - series.sourceSheetId !== undefined - ? this.#sheet(series.sourceSheetId) - : series.sourceSheetName !== undefined - ? this.#sheet(series.sourceSheetName) - : sheet; - return publicSheet(source); + renderChartModel(object.chart, publicModel, (name) => { + const source = this.#sheets.find( + (candidate) => candidate.id === name || candidate.name === name, + ); + return source ? publicSheet(source) : undefined; }), ] : [], @@ -1346,9 +1353,10 @@ export class SpreadsheetEngine { this.#ensureRelationshipNamespace(sheet); if (chartInput.series.length === 0) throw new Error("A chart requires at least one series."); for (const series of chartInput.series) { - this.#sheet(series.sourceSheetId ?? sheet.id); - const categories = parseRangeAddress(series.categoryRange); - const values = parseRangeAddress(series.valueRange); + this.#sheet(series.categories.sheetId ?? sheet.id); + this.#sheet(series.values.sheetId ?? sheet.id); + const categories = normalizeRange(series.categories.range); + const values = normalizeRange(series.values.range); if (rangeCellCount(categories) !== rangeCellCount(values)) { throw new Error("Chart category and value ranges must contain the same number of cells."); } @@ -1357,14 +1365,13 @@ export class SpreadsheetEngine { } } const chartPart = this.#journal.allocatePart("xl/charts", "chart"); - const chart = { - ...chartInput, - id: chartInput.id ?? chartPart, - }; - this.#journal.write( - chartPart, - chartXml(chart, sheet.name, (series) => this.#sheet(series.sourceSheetId ?? sheet.id).name), + const serializedChart = chartXml( + chartInput, + (source) => this.#sheet(source.sheetId ?? sheet.id).name, ); + const chart = parseChart(chartPart, serializedChart); + if (!chart) throw new Error("The created chart could not be projected."); + this.#journal.write(chartPart, serializedChart); this.#journal.addContentType(chartPart, CHART_CONTENT_TYPE); const existingDrawing = this.#journal .relationships(sheet.partName) diff --git a/packages/spreadsheet-engine/src/index.ts b/packages/spreadsheet-engine/src/index.ts index ff1b7e08..507ebcb6 100644 --- a/packages/spreadsheet-engine/src/index.ts +++ b/packages/spreadsheet-engine/src/index.ts @@ -36,8 +36,14 @@ export type { SpreadsheetCell, SpreadsheetCellInput, SpreadsheetCellStyle, + SpreadsheetChartAreaReference, SpreadsheetChart, + SpreadsheetChartDataSource, + SpreadsheetChartGroup, + SpreadsheetChartInput, + SpreadsheetChartReference, SpreadsheetChartSeries, + SpreadsheetChartSeriesInput, SpreadsheetChartType, SpreadsheetConditionalFormat, SpreadsheetDataValidation, diff --git a/packages/spreadsheet-engine/src/model.ts b/packages/spreadsheet-engine/src/model.ts index 801c60db..4a5064e9 100644 --- a/packages/spreadsheet-engine/src/model.ts +++ b/packages/spreadsheet-engine/src/model.ts @@ -1,8 +1,7 @@ export type SpreadsheetScalar = string | number | boolean | null; export type SpreadsheetProjectedValue = - | SpreadsheetScalar - | Readonly<{ displayValue: string; value: Exclude }>; + SpreadsheetScalar | Readonly<{ displayValue: string; value: Exclude }>; export type SpreadsheetColor = `#${string}`; @@ -78,18 +77,55 @@ export type SpreadsheetObject = Readonly<{ export type SpreadsheetChartType = "bar" | "column" | "line" | "pie"; +export type SpreadsheetChartAreaReference = Readonly<{ + range: SpreadsheetRange; + sheetName?: string; +}>; + +export type SpreadsheetChartReference = + | Readonly<{ + areas: ReadonlyArray; + formula: string; + kind: "areas"; + }> + | Readonly<{ + formula: string; + kind: "opaque"; + }>; + +export type SpreadsheetChartDataSource = Readonly<{ + cache: ReadonlyArray; + reference?: SpreadsheetChartReference; + valueType: "number" | "string"; +}>; + export type SpreadsheetChartSeries = Readonly<{ - categoryRange: string; + categories?: SpreadsheetChartDataSource; name?: string; - sourceSheetId?: string; - sourceSheetName?: string; - valueRange: string; + values: SpreadsheetChartDataSource; +}>; + +export type SpreadsheetChartGroup = Readonly<{ + series: ReadonlyArray; + type: SpreadsheetChartType; }>; export type SpreadsheetChart = Readonly<{ + groups: ReadonlyArray; id: string; legend: "bottom" | "left" | "none" | "right" | "top"; - series: ReadonlyArray; + title?: string; +}>; + +export type SpreadsheetChartSeriesInput = Readonly<{ + categories: Readonly<{ range: SpreadsheetRange; sheetId?: string }>; + name?: string; + values: Readonly<{ range: SpreadsheetRange; sheetId?: string }>; +}>; + +export type SpreadsheetChartInput = Readonly<{ + legend: SpreadsheetChart["legend"]; + series: ReadonlyArray; title?: string; type: SpreadsheetChartType; }>; @@ -308,7 +344,7 @@ export type SpreadsheetOperation = }> | Readonly<{ anchor: SpreadsheetObjectAnchor; - chart: Omit & { id?: string }; + chart: SpreadsheetChartInput; kind: "create-chart"; sheetId: string; }> diff --git a/packages/spreadsheet-engine/src/objects.ts b/packages/spreadsheet-engine/src/objects.ts index 6b3a9e8b..db7d195b 100644 --- a/packages/spreadsheet-engine/src/objects.ts +++ b/packages/spreadsheet-engine/src/objects.ts @@ -3,6 +3,7 @@ import { parseChart } from "./charts.ts"; import { cellKey, parseRangeAddress } from "./coordinates.ts"; import type { SpreadsheetAnchorPoint, + SpreadsheetChart, SpreadsheetDiagnostic, SpreadsheetHyperlink, SpreadsheetObject, @@ -20,6 +21,32 @@ type Relationship = Readonly<{ type: string; }>; +function chartDiagnostics( + chart: SpreadsheetChart, + part: string, + sheetId: string, +): readonly SpreadsheetDiagnostic[] { + const diagnostics: SpreadsheetDiagnostic[] = []; + const formulas = new Set(); + for (const group of chart.groups) { + for (const series of group.series) { + for (const source of [series.categories, series.values]) { + if (source?.reference?.kind !== "opaque" || formulas.has(source.reference.formula)) + continue; + formulas.add(source.reference.formula); + diagnostics.push({ + code: "xlsx.chart.reference.unsupported", + message: `Chart data uses an unsupported reference expression and will use its authored cache: ${source.reference.formula}`, + part, + severity: "warning", + sheetId, + }); + } + } + } + return diagnostics; +} + export type WorksheetProjection = Readonly<{ diagnostics: ReadonlyArray; hyperlinkCount: number; @@ -276,6 +303,19 @@ function drawingObjects(input: { chartPart && input.archive.has(chartPart) ? parseChart(chartPart, input.archive.text(chartPart)) : undefined; + if (chart && chartPart) { + input.diagnostics.push(...chartDiagnostics(chart, chartPart, input.sheetId)); + } else if (kind === "chart") { + input.diagnostics.push({ + code: "xlsx.chart.unavailable", + message: chartPart + ? "The chart part could not be projected and was preserved without blocking the worksheet." + : "The chart relationship target is missing and was preserved without blocking the worksheet.", + part: chartPart ?? input.drawingPart, + severity: "warning", + sheetId: input.sheetId, + }); + } objects.push({ ...(anchor ? { anchor } : {}), ...(chart ? { chart } : {}), @@ -394,5 +434,11 @@ export function projectWorksheetObjects(input: { }), ); } - return { diagnostics, hyperlinkCount, hyperlinks, objects, surfacedHyperlinkCount }; + return { + diagnostics, + hyperlinkCount, + hyperlinks, + objects, + surfacedHyperlinkCount, + }; } diff --git a/packages/spreadsheet-engine/test/engine.test.ts b/packages/spreadsheet-engine/test/engine.test.ts index 9c431770..b223213d 100644 --- a/packages/spreadsheet-engine/test/engine.test.ts +++ b/packages/spreadsheet-engine/test/engine.test.ts @@ -21,7 +21,7 @@ function prefixSpreadsheetElements(xml: string): string { } async function fixture( - options: { prefixedSpreadsheetElements?: boolean } = {}, + options: { chartXml?: string; prefixedSpreadsheetElements?: boolean } = {}, ): Promise { const writer = new ZipWriter( new BlobWriter("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), @@ -73,7 +73,7 @@ async function fixture( '', ); await writer.add("xl/custom/opaque.bin", new Uint8ArrayReader(OPAQUE)); - await addText("xl/charts/chart1.xml", ""); + await addText("xl/charts/chart1.xml", options.chartXml ?? ""); await addText("xl/charts/chartStyle1.xml", ""); await addText("xl/charts/_rels/chart1.xml.rels", ""); await addText( @@ -91,8 +91,87 @@ async function fixture( } describe("SpreadsheetEngine", () => { + it("opens charts whose category formula is an ordered union of worksheet areas", async () => { + const session = await SpreadsheetReadSession.open( + await fixture({ + chartXml: + 'Amount(\'Summary\'!$A$2:$A$2,\'Summary\'!$A$3:$A$3)stale-astale-b\'Summary\'!$B$2:$B$312', + }), + ); + + expect(session.readCharts("1")).toEqual([ + expect.objectContaining({ + categories: ["Tickets", "Fees"], + series: [expect.objectContaining({ name: "Amount", values: [1200, 120] })], + }), + ]); + }); + + it("preserves each chart group and series type in a combination chart", async () => { + const session = await SpreadsheetReadSession.open( + await fixture({ + chartXml: + 'ColumnsSummary!$A$2:$A$3Summary!$B$2:$B$3LineSummary!$A$2:$A$3Summary!$B$2:$B$3', + }), + ); + + expect(session.readCharts("1")[0]?.series).toEqual([ + expect.objectContaining({ + name: "Columns", + type: "column", + values: [1200, 120], + }), + expect.objectContaining({ + name: "Line", + type: "line", + values: [1200, 120], + }), + ]); + }); + + it("uses authored chart caches and reports unsupported reference expressions", async () => { + const session = await SpreadsheetReadSession.open( + await fixture({ + chartXml: + 'NamedCategoriesCached ACached BNamedValues1020', + }), + ); + + expect(session.readCharts("1")[0]).toMatchObject({ + categories: ["Cached A", "Cached B"], + series: [{ values: [10, 20] }], + }); + expect(session.metadata.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xlsx.chart.reference.unsupported", + part: "xl/charts/chart1.xml", + severity: "warning", + }), + ); + }); + + it("keeps the worksheet readable when one chart cannot be projected", async () => { + const session = await SpreadsheetReadSession.open( + await fixture({ + chartXml: '', + }), + ); + + expect(session.readRange("1", { bottom: 3, left: 1, right: 2, top: 1 }).cells).toHaveLength(6); + expect(session.readCharts("1")).toEqual([]); + expect(session.metadata.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xlsx.chart.unavailable", + part: "xl/charts/chart1.xml", + severity: "warning", + }), + ); + }); + it("rejects a workbook before projecting more than the configured cell budget", async () => { - await expect(SpreadsheetEngine.open(await fixture(), { maxCells: 3 })).rejects.toThrow(/cell limit/iu); + await expect(SpreadsheetEngine.open(await fixture(), { maxCells: 3 })).rejects.toThrow( + /cell limit/iu, + ); }); it("opens and mutates namespace-prefixed spreadsheet parts", async () => { @@ -121,7 +200,10 @@ describe("SpreadsheetEngine", () => { expect(sheet.rows.sizes.get(1)).toBe(20); expect(sheet.merges).toEqual([{ bottom: 1, left: 1, right: 2, top: 1 }]); expect(sheet.cells.get(cellKey(1, 1))?.value).toBe("Revenue"); - expect(sheet.cells.get(cellKey(3, 2))).toMatchObject({ formula: "B2*0.1", formulaResult: 120 }); + expect(sheet.cells.get(cellKey(3, 2))).toMatchObject({ + formula: "B2*0.1", + formulaResult: 120, + }); expect(workbook.model.features).toContainEqual({ count: 1, editableCount: 0, @@ -210,8 +292,19 @@ describe("SpreadsheetEngine", () => { sheetId: "1", style: { background: "#DCE6F1", bold: true, borderBottom: "#1F4E78" }, }, - { axis: "columns", end: 2, kind: "resize", sheetId: "1", size: 18, start: 2 }, - { kind: "merge", range: { bottom: 5, left: 1, right: 2, top: 5 }, sheetId: "1" }, + { + axis: "columns", + end: 2, + kind: "resize", + sheetId: "1", + size: 18, + start: 2, + }, + { + kind: "merge", + range: { bottom: 5, left: 1, right: 2, top: 5 }, + sheetId: "1", + }, ]); const bytes = await workbook.export(); const archive = await OoxmlArchive.open(bytes); @@ -270,10 +363,15 @@ describe("SpreadsheetEngine", () => { legend: "bottom", series: [ { - categoryRange: "A2:A3", + categories: { + range: { bottom: 3, left: 1, right: 1, top: 2 }, + sheetId: "1", + }, name: "Amount", - sourceSheetId: "1", - valueRange: "B2:B3", + values: { + range: { bottom: 3, left: 2, right: 2, top: 2 }, + sheetId: "1", + }, }, ], title: "Executive summary", @@ -402,7 +500,10 @@ describe("SpreadsheetEngine", () => { session.readRange("1", { bottom: 3, left: 3, right: 3, top: 2 }).cells[0]?.style, ).toMatchObject({ background: "#FFC7CE", color: "#9C0006" }); expect(session.metadata.sheets[1].dataValidations).toHaveLength(1); - expect(await workbook.verify()).toMatchObject({ sheetCount: 2, valid: true }); + expect(await workbook.verify()).toMatchObject({ + sheetCount: 2, + valid: true, + }); }); it("deletes worksheet package parts instead of leaving orphaned ZIP entries", async () => { @@ -428,7 +529,12 @@ describe("SpreadsheetEngine", () => { }, chart: { legend: "none", - series: [{ categoryRange: "A2:A2", valueRange: "B2:B2" }], + series: [ + { + categories: { range: { bottom: 2, left: 1, right: 1, top: 2 } }, + values: { range: { bottom: 2, left: 2, right: 2, top: 2 } }, + }, + ], type: "column", }, kind: "create-chart", @@ -452,7 +558,12 @@ describe("SpreadsheetEngine", () => { it("renders deterministic SVG from the same model used for export", async () => { const workbook = await SpreadsheetEngine.open(await fixture()); - const svg = workbook.renderRange("Summary", { bottom: 3, left: 1, right: 2, top: 1 }); + const svg = workbook.renderRange("Summary", { + bottom: 3, + left: 1, + right: 2, + top: 1, + }); expect(svg).toMatch(/^ { it("inspects bounded ranges without materializing empty cells", async () => { const workbook = await SpreadsheetEngine.open(await fixture()); - const inspection = workbook.inspect("1", { bottom: 3, left: 2, right: 2, top: 2 }); + const inspection = workbook.inspect("1", { + bottom: 3, + left: 2, + right: 2, + top: 2, + }); expect(inspection.cells.map((cell) => cell.address)).toEqual(["B2", "B3"]); expect(() => workbook.inspect("1", { bottom: 1000, left: 1, right: 1000, top: 1 })).toThrow( "too large", diff --git a/packages/spreadsheet-viewer/package.json b/packages/spreadsheet-viewer/package.json index 3d918525..fb456911 100644 --- a/packages/spreadsheet-viewer/package.json +++ b/packages/spreadsheet-viewer/package.json @@ -31,7 +31,8 @@ "dependencies": { "@baseblocks/anydoc-contracts": "workspace:0.1.0-alpha.14", "@baseblocks/anydoc-viewer-ui": "workspace:0.1.0-alpha.14", - "@baseblocks/anydoc-spreadsheet-engine": "workspace:0.1.0-alpha.14" + "@baseblocks/anydoc-spreadsheet-engine": "workspace:0.1.0-alpha.14", + "echarts": "6.1.0" }, "peerDependencies": { "react": ">=18", diff --git a/packages/spreadsheet-viewer/src/sheet-grid.tsx b/packages/spreadsheet-viewer/src/sheet-grid.tsx index c0007c37..290e2b78 100644 --- a/packages/spreadsheet-viewer/src/sheet-grid.tsx +++ b/packages/spreadsheet-viewer/src/sheet-grid.tsx @@ -7,6 +7,24 @@ import { type KeyboardEvent, type PointerEvent, } from "react"; +import { + BarChart, + LineChart, + PieChart, + type BarSeriesOption, + type LineSeriesOption, + type PieSeriesOption, +} from "echarts/charts"; +import type { EChartsOption } from "echarts"; +import { + AriaComponent, + GridComponent, + LegendComponent, + TitleComponent, + TooltipComponent, +} from "echarts/components"; +import { init, use } from "echarts/core"; +import { SVGRenderer } from "echarts/renderers"; import { cellAddress, @@ -58,130 +76,139 @@ const ROW_HEADER_WIDTH = 52; const CHART_COLORS = ["#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5"]; +use([ + AriaComponent, + BarChart, + GridComponent, + LegendComponent, + LineChart, + PieChart, + SVGRenderer, + TitleComponent, + TooltipComponent, +]); + +function chartOption(chart: SpreadsheetRenderedChart): EChartsOption { + const horizontal = chart.series.length > 0 && chart.series.every(({ type }) => type === "bar"); + const cartesian = chart.series.some(({ type }) => type !== "pie"); + const legendPosition = chart.legend === "none" ? undefined : chart.legend; + const series: Array = chart.series.map( + (item) => { + if (item.type === "pie") { + return { + data: chart.categories.map((name, index) => ({ + name, + value: item.values[index] ?? 0, + })), + label: { show: false }, + name: item.name, + radius: "55%", + type: "pie", + }; + } + if (item.type === "line") { + return { data: [...item.values], name: item.name, type: "line" }; + } + return { data: [...item.values], name: item.name, type: "bar" }; + }, + ); + return { + animation: false, + aria: { enabled: true }, + backgroundColor: "#FFFFFF", + color: CHART_COLORS, + ...(cartesian + ? { + grid: { + bottom: legendPosition === "bottom" ? 54 : 34, + containLabel: true, + left: legendPosition === "left" ? 88 : 36, + right: legendPosition === "right" ? 88 : 24, + top: chart.title ? 54 : 24, + }, + } + : {}), + legend: + legendPosition === undefined + ? { show: false } + : { + ...(legendPosition === "bottom" ? { bottom: 4 } : {}), + ...(legendPosition === "left" + ? { left: 4 } + : legendPosition === "right" + ? {} + : { left: "center" }), + orient: + legendPosition === "left" || legendPosition === "right" ? "vertical" : "horizontal", + type: "scroll", + ...(legendPosition === "right" ? { right: 4 } : {}), + ...(legendPosition === "left" || legendPosition === "right" + ? { top: "middle" } + : legendPosition === "top" + ? { top: chart.title ? 30 : 4 } + : {}), + }, + series, + textStyle: { color: "#222222", fontFamily: "Arial, sans-serif" }, + ...(chart.title + ? { + title: { + left: "center", + text: chart.title, + textStyle: { fontSize: 14, fontWeight: 600 }, + }, + } + : {}), + tooltip: { trigger: cartesian ? "axis" : "item" }, + ...(cartesian + ? { + xAxis: horizontal + ? { type: "value" as const } + : { + axisLabel: { interval: 0, overflow: "break", width: 96 }, + data: [...chart.categories], + type: "category" as const, + }, + yAxis: horizontal + ? { + axisLabel: { interval: 0, overflow: "truncate", width: 120 }, + data: [...chart.categories], + type: "category" as const, + } + : { type: "value" as const }, + } + : {}), + }; +} + function NativeChart({ chart, height, width, -}: Readonly<{ chart: SpreadsheetRenderedChart; height: number; width: number }>) { - const top = chart.title ? 30 : 12; - const bottom = 30; - const left = 40; - const plotWidth = Math.max(1, width - left - 12); - const plotHeight = Math.max(1, height - top - bottom); - const maximum = Math.max(1, ...chart.series.flatMap(({ values }) => values.map(Math.abs))); - const categoryPoints = chart.categories.map((label, position) => ({ - id: cellAddress(1, position + 1), - label, - position, - })); - const chartSeries = chart.series.map((series, position) => ({ - color: CHART_COLORS[position % CHART_COLORS.length], - id: cellAddress(position + 1, 1), - series, - })); - if (chart.type === "pie") { - const values = chart.series[0]?.values ?? []; - const total = values.reduce((sum, value) => sum + Math.max(0, value), 0) || 1; - let offset = 0; - const gradient = values - .map((value, index) => { - const start = (offset / total) * 360; - offset += Math.max(0, value); - return `${CHART_COLORS[index % CHART_COLORS.length]} ${start}deg ${(offset / total) * 360}deg`; - }) - .join(","); - return ( -
- {chart.title ? ( -
{chart.title}
- ) : null} -
-
- ); - } - const categoryCount = Math.max(1, chart.categories.length); - const groupWidth = plotWidth / categoryCount; - const barWidth = Math.max(2, (groupWidth * 0.72) / Math.max(1, chart.series.length)); +}: Readonly<{ + chart: SpreadsheetRenderedChart; + height: number; + width: number; +}>) { + const containerRef = useRef(null); + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const instance = init(container, undefined, { + height: Math.max(1, height), + renderer: "svg", + width: Math.max(1, width), + }); + instance.setOption(chartOption(chart), { notMerge: true }); + return () => instance.dispose(); + }, [chart, height, width]); return ( - - {chart.title ? ( - - {chart.title} - - ) : null} - - {chart.type === "line" - ? chartSeries.map(({ color, id, series }) => { - const points = series.values - .map( - (value, index) => - `${left + groupWidth * (index + 0.5)},${top + plotHeight - (value / maximum) * plotHeight}`, - ) - .join(" "); - return ; - }) - : chartSeries.flatMap(({ color, id, series }, seriesIndex) => - categoryPoints.map((category) => { - const value = series.values[category.position] ?? 0; - return ( - - ); - }), - )} - {categoryPoints.map((category) => ( - - {category.label.slice(0, 16)} - - ))} - + style={{ height, overflow: "hidden", width }} + /> ); } @@ -259,7 +286,12 @@ function visibleRanges( for (const row of contiguousIntervals(rows)) { for (const column of contiguousIntervals(columns)) { const range = tileSpreadsheetViewerRange( - { bottom: row.end, left: column.start, right: column.end, top: row.start }, + { + bottom: row.end, + left: column.start, + right: column.end, + top: row.start, + }, { columns: columnCount, rows: rowCount }, ); ranges.set(`${range.top}:${range.left}:${range.bottom}:${range.right}`, range); @@ -465,7 +497,12 @@ function nextSelection( column = Math.max(1, Math.min(columnCount, column)); const range = event.shiftKey && event.key !== "Tab" - ? { ...active, focusColumn: column, focusRow: row, kind: "cells" as const } + ? { + ...active, + focusColumn: column, + focusRow: row, + kind: "cells" as const, + } : createSpreadsheetSelectionRange(row, column); return event.shiftKey && event.key !== "Tab" ? replaceActiveSelectionRange(selection, range) @@ -834,7 +871,16 @@ export function SheetGrid({ width: columnProjection.physicalSize + ROW_HEADER_WIDTH, }} > -
+
-
+
{visible.columns.map((column) => { const columnNumber = column.index + 1; const selected = selectionIntersectsColumn(selection, columnNumber); @@ -905,7 +959,15 @@ export function SheetGrid({ ); })}
-
+
{visible.rows.map((row) => { const rowNumber = row.index + 1; const selected = selectionIntersectsRow(selection, rowNumber); diff --git a/packages/spreadsheet-viewer/test/viewer.test.tsx b/packages/spreadsheet-viewer/test/viewer.test.tsx index 627dc1d3..96cde853 100644 --- a/packages/spreadsheet-viewer/test/viewer.test.tsx +++ b/packages/spreadsheet-viewer/test/viewer.test.tsx @@ -27,7 +27,12 @@ describe("spreadsheet viewer read-session integration", () => { }); await expect( session.selectionStatistics("1", [{ bottom: 3, left: 2, right: 2, top: 2 }]), - ).resolves.toMatchObject({ average: 660, count: 2, numericCount: 2, sum: 1320 }); + ).resolves.toMatchObject({ + average: 660, + count: 2, + numericCount: 2, + sum: 1320, + }); await expect( session.copy("1", [{ bottom: 3, left: 1, right: 2, top: 1 }]), ).resolves.toMatchObject({ @@ -56,7 +61,12 @@ describe("spreadsheet viewer read-session integration", () => { }, chart: { legend: "bottom", - series: [{ categoryRange: "A2:A3", valueRange: "B2:B3" }], + series: [ + { + categories: { range: { bottom: 3, left: 1, right: 1, top: 2 } }, + values: { range: { bottom: 3, left: 2, right: 2, top: 2 } }, + }, + ], title: "Revenue summary", type: "column", }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ceff3ec1..cc0f4ce1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: '@baseblocks/anydoc-viewer-ui': specifier: workspace:0.1.0-alpha.14 version: link:../viewer-ui + echarts: + specifier: 6.1.0 + version: 6.1.0 devDependencies: '@types/react': specifier: 19.2.15