diff --git a/CHANGELOG.md b/CHANGELOG.md index d058a89f2..52f198c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,179 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-10-06 + +### BREAKING + +- C ABI: `fm_cell_xf` grows from 88 to 128 bytes (apply flags, `quotePrefix` + and protection) and `fm_row_layout_t` from 32 to 40 bytes (`has_height`, + `custom_height`). Callers built against the old layouts must rebuild. +- C ABI: `fm_styles_set_cell_style` takes one `fm_cell_style_record_t` (name, + `xf_id`, `builtin_id`, `i_level`, `hidden`, `custom_builtin`). The JS + `setCellStyle` and Python `set_cell_style` take the matching record. +- The native Node addon and WASM validate arguments strictly before entering + native code: a wrongly typed or out-of-range positional or nested argument + throws `TypeError` / `RangeError` instead of being coerced. String fields + take a string (or, on Node, a one-byte buffer), and 64-bit cursors are + bounded by `Number.MAX_SAFE_INTEGER` on both surfaces. + +### Added + +Everything below is available through the C ABI, WASM, native Node and Python. + +- Sheet geometry: default column width and row height can be read and set, + and a row height cleared back to auto. Cell rectangles, column widths and + row heights are reported in points in display or print mode, with the + width model and chars/points conversion. +- Pagination results carry paper, margins, printable area, scale, page order, + print titles, per-page layout and manual-break flags. +- Cells and formulas in a range are enumerated through a handle with a + resumable cursor, and merges in a range are listed. +- A cell's formula in A1 and R1C1 notation, and its display text (or any + value under a format code) with an `ok` / overflow / invalid status. +- The workbook theme: the 12 colours and the major and minor fonts can be + read and set, including on a workbook with no theme part. Any colour spec + (theme and tint, indexed, auto, RGB) resolves to ARGB for a font, fill or + border context, and a cell's effective style reports its resolved fonts, + fills and borders. +- Named cell styles can be removed, and built-in style ids 48..53 are + accepted. `setCellStyle` is now available on the native Node addon. +- Typed AutoFilters for sheets and tables: filter columns (values, date + groups, custom, top 10, dynamic, colour, icon) and sort state can be read, + set, removed, cleared, evaluated to visible rows and applied. Applying a + filter hides and unhides rows and recalculates `SUBTOTAL` and `AGGREGATE`. + A typed set keeps `xr:uid`, `calendarType`, `extLst` and unknown + attributes. +- Data-validation evaluation: whether a proposed value satisfies the rule + that applies to a cell, which rule applied and its error style, plus a + resumable listing of invalid cells. +- Threaded comments with replies, mentions, resolved state and the + workbook's person list can be read, added, edited and removed. Each thread + keeps Excel's legacy note stub in sync, and row and column edits move or + remove threads with their anchors. +- Images: PNG, JPEG, GIF and BMP bytes are probed for format and pixel size, + a sheet's drawing objects (pictures, charts, shapes, groups) are listed + with anchors, and pictures can be read, inserted with a one-cell, two-cell + or absolute anchor, and removed. Row and column edits move anchors the way + Excel does. +- Enum constants mirroring the C enums on every surface: `GeometryMode`, + `DisplayStatus`, `ColorContext`, `ColorResolution`, `ThemeSource`, + `EffectiveStyleSource`, `FilterKind`, `FilterOperator`, + `DynamicFilterType`, `SortBy`, `SortMethod`, `DateTimeGrouping`, + `ValidationErrorStyle`, `ImageFormat`, `DrawingObjectKind`, `AnchorKind` + and `AnchorEditAs`. + +### Changed + +- `SUBTOTAL` and `AGGREGATE` (options 0..3 and the array form 14..19) skip a + referenced cell whose own formula calls `SUBTOTAL` or `AGGREGATE`, + including inside `LET` / `LAMBDA` bodies and unevaluated `IF` branches, + and every cell of a spilled or CSE array whose anchor qualifies. A name, + `INDIRECT` or a plain reference to such a cell does not count. A + `SUBTOTAL` whose cells are all excluded returns its empty-set value + instead of `#VALUE!`. +- `SUBTOTAL` 1..11 treat hidden rows as filtered only when a sheet or table + AutoFilter on that sheet has a criterion, and then skip every hidden row + on the sheet. 101..111 keep skipping every hidden row. +- Rows with an auto height are no longer written with `customHeight`; only + explicit heights are. +- `TEXT` renderings match Excel for fraction padding on whole numbers, + `[DBNum1]` with General, serial 0 as 1900/1/0, negative 1904-system dates + and times, and the 1904 upper serial bound. +- The WASM size report's ceilings moved to 3.50 MiB / 896 KiB Brotli soft + and 3.75 MiB / 928 KiB Brotli hard, keeping the 0.25 MiB and 32 KiB gap + between each soft and hard ceiling. +- Number formats are validated the way Excel validates them: more than four + sections, a condition in the third section or later, `@` mixed with + numeric or date tokens, and an unterminated quote, bracket, escape, `_` + or `*` are rejected. Conditional sections take Excel's implicit sign + arms, and a value no arm accepts is `#VALUE!` in `TEXT` and `#####` in + display text. A text-only section shows numbers as General. +- Fractions are rendered by a continued-fraction search with fixed and + zero-prefixed denominators and `%` applied first; scientific formats + support engineering grouping, and commas before the decimal point scale. + Dates render from one rounded second with midnight carry. +- A cell with an explicit style 0 is distinct from an unstyled cell and + overrides its row or column style; the distinction round-trips through + `.xlsx` and `.xlsb`. `CELL()` and display text read the effective + cell, row or column format, and spilled cells are saved with their + effective style. +- Formulas that call a sheet- or workbook-qualified built-in are rejected at + every entry point (cells, conditional formats, defined names, validation), + not only `set_cell_formula`. +- Pivot fields named ambiguously (a name shared by distinct fields) are + rejected by filters and the C API without mutation, and `GETPIVOTDATA` + returns `#REF!` for them. Value filters on an unknown field are rejected + at authoring time. +- A row or column insert removes a pivot table pushed wholly off the grid; + a `GETPIVOTDATA` reading it becomes `#REF!`. +- `TIME` caps each component at 32767 as Excel does, and the date and time + functions return `#NUM!` for non-finite or out-of-range inputs. + +### Performance + +- The WASM module no longer links libc `printf`: numbers are formatted by a + byte-identical in-tree formatter built on double-conversion. +- Loading a sheet with many dynamic-array anchors is no longer quadratic: + committed spills are indexed by row band. +- Adding formulas under whole-column watchers no longer costs quadratic + time during dirty marking. +- Duplicated function preludes and out-of-line paths are shared, shrinking + the WASM module (`.wasm` 3,292,036 bytes raw, 901,702 bytes Brotli). + +### Fixed + +- Stored number formats with English colour names (`[Red]`, `[ColorN]`) no + longer render as `########` in display text and `CELL`. +- `FIXED` and `DOLLAR` accept the full Excel decimals range. +- `RANDBETWEEN` and `RANDARRAY` stay finite and in range when the bounds are + huge or span beyond the 64-bit integer range. +- Dates past month 12 were computed two days off in the civil-date + conversion. +- ISO `t="d"` date cells are rebased onto the 1904 date system when the + workbook uses it, and date-only ISO strings with a `Z` or `+hh:mm` suffix + are accepted. +- Toggling the 1904 date system keeps the saved `` in step with + the model; package-root parts such as `customXml` get correct relationship + targets in `.xlsx` and `.xlsb`; unknown relationship types are + attribute-escaped. +- Formulas referencing a missing sheet recalculate when that sheet is added + or renamed into existence. Changing iteration, the profile, the 1904 date + system or the pinned clock dirties every formula (and clears pivot + results where they depend on it). A partial recalc marks dependents + outside its closure dirty, so a later full recalc refreshes them. +- Row and column edits rewrite defined names and unowned table and pivot + cache ranges, dirty committed spill anchors, drop data validations and + ranges pushed wholly off the grid, and no longer overflow drawing anchors + near the grid edge. Removing or moving a sheet remaps the active and + first visible tab. +- Pivot authoring edits dirty dependent formulas so `GETPIVOTDATA` + refreshes; custom subtotals re-aggregate with their own function after a + value filter; invalid date serials stay out of date groups. +- `INDIRECT` and `OFFSET` fill a clamped expansion shorter than its + rectangle with blanks instead of uninitialised cells. +- Conditional-format theme, indexed and auto colours no longer render black: + they resolve against the workbook theme and palette in the engine and in + the C API payloads, and survive an XLSB save and load. +- Table AutoFilters follow row and column inserts and deletes, and + AutoFilter `colId`s shift when columns are inserted or deleted inside the + filter. + +### Known limitations + +- Saving as XLSB does not write threaded comments, inserted images or typed + AutoFilter edits; each is reported as a diagnostic. AutoFilters loaded + from XLSB are not visible to the typed API. +- Custom data-validation formulas and rule bounds evaluate against the + stored workbook, not the proposed value. +- AutoFilter evaluation approximates Excel's text ordering for custom `<` + and `>` text comparisons, and computes top N and average filters over all + body rows. +- Display text does not depend on column width: narrow columns never show + `####` and repeat fills contribute nothing. +- The legacy note text of a threaded comment and the regenerated comment VML + geometry approximate Excel's. + ## [0.12.0] - 2026-09-30 ### Added @@ -1832,7 +2005,8 @@ See the [GitHub release page](https://github.com/libraz/formulon/releases/tag/v0.9.0) for the full auto-generated change list. -[Unreleased]: https://github.com/libraz/formulon/compare/v0.12.0...HEAD +[Unreleased]: https://github.com/libraz/formulon/compare/v0.13.0...HEAD +[0.13.0]: https://github.com/libraz/formulon/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/libraz/formulon/compare/v0.11.1...v0.12.0 [0.11.1]: https://github.com/libraz/formulon/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/libraz/formulon/compare/v0.10.0...v0.11.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index 98778ce4c..c45c6cc40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(formulon VERSION 0.12.0 LANGUAGES CXX) +project(formulon VERSION 0.13.0 LANGUAGES CXX) # --------------------------------------------------------------------------- # Language standard and global settings @@ -112,12 +112,18 @@ set(FORMULON_CORE_SOURCES src/utils/double_parse.cpp src/utils/index_sort.cpp src/utils/japanese_era.cpp + src/utils/number_text.cpp src/utils/structured_log.cpp src/utils/text_ops.cpp src/utils/thread_launch.cpp src/utils/utf8_length.cpp src/external_book.cpp + src/auto_filter.cpp src/styles.cpp + src/threaded_comment.cpp + src/style_resolve.cpp + src/theme.cpp + src/color_resolve.cpp src/value.cpp src/sheet.cpp src/sheet_name.cpp @@ -125,9 +131,12 @@ set(FORMULON_CORE_SOURCES src/sheet_name_casefold_data.cpp src/workbook.cpp src/workbook_ref_rewrite.cpp + src/io/auto_filter_xml.cpp src/io/cell_parser.cpp src/io/cf_overlay.cpp src/io/cf_reader.cpp + src/io/color_spec_xml.cpp + src/io/threaded_comments_io.cpp src/io/cf_writer.cpp src/io/comments_reader.cpp src/io/comments_writer.cpp @@ -140,6 +149,7 @@ set(FORMULON_CORE_SOURCES src/io/ooxml/external_link_reader.cpp src/io/ooxml/package_validator.cpp src/io/ooxml/pivot_target_reader.cpp + src/io/ooxml/part_dom.cpp src/io/ooxml/print_settings_parse.cpp src/io/ooxml/sheet_aux_rels_reader.cpp src/io/ooxml/workbook_rels_reader.cpp @@ -158,11 +168,14 @@ set(FORMULON_CORE_SOURCES src/io/pivot_table_writer.cpp src/io/format_detect.cpp src/io/sax_xml_reader.cpp + src/io/sheet_layout_reader.cpp + src/io/sheet_overlay_reader.cpp src/io/sheet_reader.cpp src/io/sst_reader.cpp src/io/styles_reader.cpp src/io/styles_writer.cpp src/io/tables_reader.cpp + src/io/theme_part.cpp src/io/xml_escape.cpp src/io/xml_utils.cpp src/io/xsd_double.cpp @@ -175,6 +188,7 @@ set(FORMULON_CORE_SOURCES src/io/xlsb/func_id_table.cpp src/io/xlsb/metadata_bin.cpp src/io/xlsb/ptg.cpp + src/io/xlsb/ptg_declarations.cpp src/io/xlsb/ptg_reader.cpp src/io/xlsb/ptg_writer.cpp src/io/xlsb/pivot_reader.cpp @@ -190,19 +204,28 @@ set(FORMULON_CORE_SOURCES src/io/xlsb/styles_reader.cpp src/io/xlsb/styles_writer.cpp src/io/xlsb/tail_refs.cpp + src/io/xlsb/workbook_bin_reader.cpp + src/io/xlsb/workbook_bin_writer.cpp src/io/xlsb/writer.cpp + src/cf/auto_filter_eval.cpp src/cf/cf_evaluator.cpp src/cf/cf_helpers.cpp src/cf/rule_match.cpp src/cf/scale_evaluator.cpp + src/cf/validation_eval.cpp src/parser/tokenizer.cpp src/parser/ast.cpp + src/parser/reference.cpp src/parser/ast_dump.cpp src/parser/ast_format.cpp + src/parser/ast_format_r1c1.cpp + src/parser/formula_prefix.cpp src/parser/ast_shift.cpp + src/parser/parse_error.cpp src/parser/parser.cpp src/parser/parser_atoms.cpp src/parser/parser_reference.cpp + src/parser/parser_let_lambda.cpp src/parser/ref_transforms.cpp src/pivot/aggregator.cpp src/pivot/filter_engine.cpp @@ -215,6 +238,10 @@ set(FORMULON_CORE_SOURCES src/print/page_setup.cpp src/print/print_area.cpp src/print/pagination.cpp + src/print/sheet_geometry.cpp + src/drawing/drawing_xml.cpp + src/drawing/image_header.cpp + src/drawing/drawing_edit.cpp src/eval/array_alloc.cpp src/eval/coerce.cpp src/eval/rng.cpp @@ -285,6 +312,7 @@ set(FORMULON_CORE_SOURCES src/eval/builtins/text_format.cpp src/eval/builtins/text_width.cpp src/eval/builtins/web.cpp + src/eval/text_format/display_text.cpp src/eval/text_format/number_format.cpp src/eval/text_format/number_format_scanner.cpp src/eval/text_format/number_format_section.cpp @@ -294,14 +322,17 @@ set(FORMULON_CORE_SOURCES src/eval/text_format/render_date.cpp src/eval/text_format/render_fraction.cpp src/eval/stats/special_functions.cpp + src/eval/tree_walker/binding_source.cpp src/eval/tree_walker/broadcast.cpp src/eval/tree_walker/dispatch.cpp + src/eval/tree_walker/lambda_invoke.cpp src/eval/tree_walker/walker.cpp src/eval/tree_walker_lazy_table.cpp src/eval/eval_context.cpp src/eval/external_ref.cpp src/eval/eval_state.cpp src/eval/criteria.cpp + src/eval/wildcard.cpp src/eval/jp_fold.cpp src/eval/jp_kana_table.cpp src/eval/jis0208_table.cpp @@ -315,7 +346,7 @@ set(FORMULON_CORE_SOURCES src/eval/spill_committer.cpp src/eval/special_forms_lazy.cpp src/eval/structured_ref.cpp - src/eval/aggregate_kernels.cpp + src/numeric_aggregate_kernels.cpp src/eval/aggregate_lazy.cpp src/eval/areas_lazy.cpp src/eval/cell_lazy.cpp @@ -337,11 +368,14 @@ src/eval/aggregate_lazy.cpp src/eval/workdays_lazy.cpp src/eval/financial_lazy.cpp src/eval/forecast_ets_lazy.cpp + src/eval/ets_series.cpp + src/eval/holt_winters.cpp src/eval/getpivotdata_lazy.cpp src/eval/linest_lazy.cpp src/eval/textjoin_lazy.cpp src/eval/textsplit_lazy.cpp src/eval/trimrange_lazy.cpp + src/eval/a1_parse.cpp src/eval/reference/common.cpp src/eval/reference/indirect.cpp src/eval/reference/intersection.cpp @@ -357,12 +391,15 @@ src/eval/aggregate_lazy.cpp src/eval/lookups/common.cpp src/eval/lookups/index.cpp src/eval/lookups/xlookup.cpp + src/c_api/parts/auto_filter.cpp src/c_api/parts/cells.cpp src/c_api/parts/cf_eval.cpp src/c_api/parts/eval_adhoc.cpp src/c_api/parts/cf_mutate.cpp src/c_api/parts/common.cpp src/c_api/parts/diagnostics.cpp + src/c_api/parts/drawing.cpp + src/c_api/parts/display.cpp src/c_api/parts/external_links.cpp src/c_api/parts/function_catalog.cpp src/c_api/parts/pivot_cache.cpp @@ -370,16 +407,21 @@ src/eval/aggregate_lazy.cpp src/c_api/parts/pivot_layout.cpp src/c_api/parts/pivot_table.cpp src/c_api/parts/pagination.cpp + src/c_api/parts/print_area_titles.cpp src/c_api/parts/print_settings.cpp src/c_api/parts/xml_fragment.cpp src/c_api/parts/sheet_layout.cpp src/c_api/parts/sheets_ui.cpp src/c_api/parts/styles.cpp + src/c_api/parts/styles_mutate.cpp src/c_api/parts/trace.cpp src/c_api/parts/version.cpp src/c_api/parts/workbook.cpp src/c_api/parts/recalc.cpp src/c_api/parts/tables.cpp + src/c_api/parts/theme.cpp + src/c_api/parts/threaded_comments.cpp + src/c_api/parts/validation.cpp src/c_api/generated/sheet_counts.cpp src/c_api/generated/styles_counts.cpp src/c_api/generated/workbook_counts.cpp diff --git a/Makefile b/Makefile index 437273cf0..1648985af 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,10 @@ BIOME_PATHS := packages/npm packages/npm-native tests/wasm src/wasm # walk through the ~10k oracle parametric tests. CTEST_JOBS ?= 0 +# Build parallelism for every `cmake --build` recipe. Empty lets the +# generator pick; `make wasm JOBS=2` caps a build on a shared host. +JOBS ?= + SRC_DIRS := src tests CPP_GLOB := $(shell find $(SRC_DIRS) -type f \( -name '*.cpp' -o -name '*.h' \) 2>/dev/null) @@ -66,11 +70,11 @@ all: build build: $(CMAKE) -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Debug - $(CMAKE) --build $(BUILD_DIR) --parallel + $(CMAKE) --build $(BUILD_DIR) --parallel $(JOBS) release: $(CMAKE) -B build-release -DCMAKE_BUILD_TYPE=Release - $(CMAKE) --build build-release --parallel + $(CMAKE) --build build-release --parallel $(JOBS) # A gate result is only a claim about the tree it ran in, and that tree's # configuration can change without this file being touched: any @@ -271,7 +275,7 @@ wasm: fi $(EM_CMAKE) -B $(WASM_BUILD_DIR) -DCMAKE_BUILD_TYPE=Release \ -DFM_BUILD_WASM=ON -DFM_BUILD_TESTING=OFF -DFM_BUILD_CLI=OFF - $(CMAKE) --build $(WASM_BUILD_DIR) --parallel \ + $(CMAKE) --build $(WASM_BUILD_DIR) --parallel $(JOBS) \ --target formulon_wasm formulon_wasm_stack_probe @echo "" @echo "wasm artifacts:" @@ -286,7 +290,7 @@ wasm-threads: $(EM_CMAKE) -B $(WASM_THREADS_BUILD_DIR) -DCMAKE_BUILD_TYPE=Release \ -DFM_BUILD_WASM=ON -DFM_WASM_THREADS=ON \ -DFM_BUILD_TESTING=OFF -DFM_BUILD_CLI=OFF - $(CMAKE) --build $(WASM_THREADS_BUILD_DIR) --parallel \ + $(CMAKE) --build $(WASM_THREADS_BUILD_DIR) --parallel $(JOBS) \ --target formulon_wasm formulon_wasm_stack_probe @echo "" @echo "wasm-threads artifacts:" @@ -300,7 +304,7 @@ wasm-debug: fi $(EM_CMAKE) -B $(WASM_DEBUG_BUILD_DIR) -DCMAKE_BUILD_TYPE=Debug \ -DFM_BUILD_WASM=ON -DFM_BUILD_TESTING=OFF -DFM_BUILD_CLI=OFF - $(CMAKE) --build $(WASM_DEBUG_BUILD_DIR) --parallel --target formulon_wasm + $(CMAKE) --build $(WASM_DEBUG_BUILD_DIR) --parallel $(JOBS) --target formulon_wasm # capi WASM: standalone reactor build consumed by wasmtime-py. No JS # glue, no pthread, exports the curated fm_* list from @@ -313,7 +317,7 @@ wasm-capi: $(EM_CMAKE) -B $(WASM_CAPI_BUILD_DIR) -DCMAKE_BUILD_TYPE=Release \ -DFM_BUILD_WASM=ON -DFM_WASM_VARIANT=capi \ -DFM_BUILD_TESTING=OFF -DFM_BUILD_CLI=OFF - $(CMAKE) --build $(WASM_CAPI_BUILD_DIR) --parallel --target formulon_wasm + $(CMAKE) --build $(WASM_CAPI_BUILD_DIR) --parallel $(JOBS) --target formulon_wasm @echo "" @echo "wasm-capi artifact:" @ls -la $(WASM_CAPI_BUILD_DIR)/formulon_capi.wasm 2>/dev/null || \ @@ -455,7 +459,7 @@ NODE_NATIVE_BUILD_DIR ?= build node-native: $(CMAKE) -B $(NODE_NATIVE_BUILD_DIR) -DCMAKE_BUILD_TYPE=Release -DFM_BUILD_NODE_ADDON=ON - $(CMAKE) --build $(NODE_NATIVE_BUILD_DIR) --target formulon_node --parallel + $(CMAKE) --build $(NODE_NATIVE_BUILD_DIR) --target formulon_node --parallel $(JOBS) node-package: node-native @if ! command -v $(NODE) >/dev/null 2>&1; then \ @@ -618,8 +622,8 @@ oracle-verify: @if [ ! -f $(BUILD_DIR)/CMakeCache.txt ]; then \ echo "oracle-verify: run 'make build' first"; exit 1; \ fi - @$(CMAKE) --build $(BUILD_DIR) --target formulon_oracle_tests --parallel - @$(CMAKE) --build $(BUILD_DIR) --target formulon_workbook_oracle_tests --parallel + @$(CMAKE) --build $(BUILD_DIR) --target formulon_oracle_tests --parallel $(JOBS) + @$(CMAKE) --build $(BUILD_DIR) --target formulon_workbook_oracle_tests --parallel $(JOBS) @# gtest_discover_tests caches the parameter list keyed by binary @# timestamp, but our goldens aren't a build input. Force @# rediscovery so newly regenerated golden*/*.golden.json files land @@ -667,7 +671,7 @@ ironcalc-verify: @if [ ! -f $(BUILD_DIR)/CMakeCache.txt ]; then \ echo "ironcalc-verify: run 'make build' first"; exit 1; \ fi - @$(CMAKE) --build $(BUILD_DIR) --target formulon_ironcalc_oracle_tests --parallel + @$(CMAKE) --build $(BUILD_DIR) --target formulon_ironcalc_oracle_tests --parallel $(JOBS) @# Force ctest to rediscover parameter list; the goldens aren't a @# build input so the cache would otherwise stick. @rm -f $(BUILD_DIR)/tests/oracle/formulon_ironcalc_oracle_tests*_tests.cmake @@ -699,7 +703,7 @@ fuzz: $(CMAKE) -B $(FUZZ_BUILD_DIR) -DCMAKE_BUILD_TYPE=Debug -DFM_BUILD_FUZZ=ON \ -DFM_FUZZ_SANITIZERS="$(FUZZ_SANITIZERS)" \ -DCMAKE_C_COMPILER="$(FUZZ_CC)" -DCMAKE_CXX_COMPILER="$(FUZZ_CXX)" - $(CMAKE) --build $(FUZZ_BUILD_DIR) --parallel + $(CMAKE) --build $(FUZZ_BUILD_DIR) --parallel $(JOBS) @(cd $(FUZZ_BUILD_DIR) && $(CTEST) -R Fuzz --output-on-failure --timeout 300) # The same harnesses under a wall-clock budget instead of an iteration cap. @@ -724,7 +728,7 @@ fuzz-long: fuzz # threshold is tunable, so it is run on demand rather than gated. bench: $(CMAKE) -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release - $(CMAKE) --build $(BUILD_DIR) --parallel + $(CMAKE) --build $(BUILD_DIR) --parallel $(JOBS) @(cd $(BUILD_DIR) && $(CTEST) -L BENCH --output-on-failure --timeout 600) # Local coverage diagnostic. Builds with the gcov-instrumented preset, diff --git a/README.md b/README.md index 5b167e8cc..7a066f632 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ CLI binaries are available from [GitHub Releases](https://github.com/libraz/form - **Checked against real Excel.** The runtime default is `win-365-ja_JP`, and profile-specific oracle suites pin observed Excel behavior. Formula results are pinned against Mac Excel 365 (ja-JP); pivot tables and print layout are pinned against Windows Excel 365 (ja-JP), because creating PivotTables by automation requires Windows COM. Both come from a verified Microsoft 365 install. Outputs are checked for bit-level parity against golden data regenerated from the real product; every accepted divergence (transcendental ulp drift, volatile-function snapshots, Excel quirks where Formulon deliberately keeps a saner answer) is recorded case-by-case in [`tests/divergence.yaml`](tests/divergence.yaml) with a reason and the last verified Excel build. - **One C++ core, identical results everywhere.** The browser, Python, and CLI builds all ship the same engine rather than separate calculation logic, so there is no second implementation for results to drift against. -- **WASM size budget.** CI enforces a **3.25 MiB** uncompressed and **864 KiB** Brotli hard ceiling, and reports **3.00 MiB** / **832 KiB** soft ceilings. Brotli is the wire size that actually binds, so it gates on equal footing. Run `make size-check` to measure the current artifact. +- **WASM size budget.** CI enforces a **3.75 MiB** uncompressed and **928 KiB** Brotli hard ceiling, and reports **3.50 MiB** / **896 KiB** soft ceilings. Brotli is the wire size that actually binds, so it gates on equal footing. Run `make size-check` to measure the current artifact. - **Small dependency set.** Engine deps: `miniz` (zip/deflate), `pugixml` (XML + XPath 1.0), `PCRE2` (Excel-compatible regex for `REGEX*`), `double-conversion` (Grisu3 shortest-roundtrip `dtoa`). Linear algebra, UTF-8 handling, and most number coercion are in-tree. - **C++ written for auditability.** `Expected` error handling, RAII, `-fno-exceptions -fno-rtti`, Google C++ style. @@ -52,7 +52,7 @@ Formulon deliberately does **not** cover: | Chart / drawing rendering | Belongs to a rendering layer, not the engine. | | PowerQuery (M) / DAX | Separate engine, separate problem domain. | | Pivot cache regeneration | The stored `pivotCacheRecords` snapshot is preserved as-is, never rebuilt from the source range. PivotTable *results* are evaluated on demand through the API. | -| Spreadsheet UI | Rendering belongs to the caller. The engine exposes what a UI needs to drive it — viewport recalc (`partialRecalc`), conditional-format evaluation over a range, spill info. | +| Spreadsheet UI | Rendering belongs to the caller. The engine exposes what a UI needs to drive it — viewport recalc (`partialRecalc`), conditional-format evaluation over a range, spill info, cell geometry and display text, effective styles with resolved colours, and AutoFilter and data-validation evaluation. | These are **permanent** non-goals, not "not yet." The scope is finite on purpose. @@ -125,20 +125,20 @@ additional category (508 + 15 = 523, not 525). |-------|--------| | `ctest -LE "SLOW\|BENCH\|TSAN"` — `make test`, the PR gate | all passing | | `ctest -LE "BENCH\|TSAN"` — `make test-slow`, adds the `SLOW` tier | all passing | -| Primary formula oracle | `4546/4546` passing, `125` documented skips | +| Primary formula oracle | `4768/4768` passing, `133` documented skips | | Conditional-formatting oracle | `23/23` | -| Workbook oracle (pivot + print) | `73/73` passing, `9` documented skips | +| Workbook oracle (pivot + print) | `83/83` passing, `10` documented skips | | Imported third-party engine corpus (cross-check) | `12510/12510` passing, `168` documented divergences | Three labels partition the CTest suite: `SLOW` (minutes-scale integration and concurrency cases), `TSAN` (thread-sanitizer runs), and `BENCH` (microbenchmark regression checks, whose threshold is tunable, so they run on demand). Everything else is the unlabeled fast tier that CI gates on; there is no separate load-test tier. The libFuzzer harnesses also carry the `SLOW` label, but they exist only in a build configured with `-DFM_BUILD_FUZZ=ON` (`make fuzz`), not in a default build or in CI. They need a Clang that ships the libFuzzer runtime, which the Apple toolchain does not, so macOS needs a separate LLVM. AddressSanitizer is off by default there as well — it deadlocks in its own shadow-memory setup against recent macOS dynamic linkers — so a macOS fuzz run detects crashes, timeouts and undefined behaviour but not heap corruption. Every skip is an explicit divergence, host-service dependency, volatile/environment-bound case, or driver limitation, not a silent stub. Of the 523 catalogued functions, `519` satisfy all six closure conditions (`behaviors_declared` / `cases_cover_behaviors` / `golden_present` / `divergence_documented` / `not_in_pilot` / `behavior_drift`); the remaining `4` (`ARRAYTOTEXT`, `FILTERXML`, `GETPIVOTDATA`, `PHONETIC`) fail only `behaviors_declared` — their behavior taxonomy is under-specified. `JIS` closes as a declared alias of `DBCS`: Excel rewrites that ja-JP formula-bar spelling before it stores or evaluates a formula, so no oracle case can name it, and the closure harness resolves the alias to the function it defers to rather than taking the declaration on trust. -Beyond formula results, **pivot tables and print areas / pagination** have a dedicated **workbook oracle track**, captured through a WSL2 → Windows COM bridge. Its nine remaining skips are all the same Excel quirk: at a print scale or zoom of 50% or less, Excel's page-break preview emits column auto-breaks that do not follow geometric pagination, so the observed break set stops shrinking with the scale and grows again at 25%. Each skipped case records the Microsoft 365 observation it was measured against. +Beyond formula results, **pivot tables and print areas / pagination** have a dedicated **workbook oracle track**, captured through a WSL2 → Windows COM bridge. Nine of its ten skips are the same Excel quirk: at a print scale or zoom of 50% or less, Excel's page-break preview emits column auto-breaks that do not follow geometric pagination, so the observed break set stops shrinking with the scale and grows again at 25%. Each of those nine records the Microsoft 365 observation it was measured against. The tenth is a smoke case that only checks the case-file shape and has nothing to compare. New workbooks use the `win-365-ja_JP` formula profile by default; callers can switch with the profile-id API (`mac-365-ja_JP`, `win-365-ja_JP`). English-locale profiles are intentionally not exposed until matching EN oracle data and verified locale-specific behavior are available. -The OOXML reader/writer round-trips sheets, styles, conditional formatting, comments, hyperlinks, merges, data validations, defined names, tables, and pivot tables; an MS-XLSB reader/writer covers cell values, styles, cross-sheet 3-D references, and common tokenized formulas, with array-constant literals and post-2007 "future function" IDs still limited compared to the OOXML path. Workbook operations are available through the C ABI and language bindings; the CLI deliberately exposes only `eval`, `recalc`, `dump`, and `paginate`. +The OOXML reader/writer round-trips sheets, styles, conditional formatting, comments (including threaded comments), hyperlinks, merges, data validations, defined names, tables, pivot tables, images and drawings, and the theme; an MS-XLSB reader/writer covers cell values, styles, cross-sheet 3-D references, and common tokenized formulas, with array-constant literals and post-2007 "future function" IDs still limited compared to the OOXML path. Threaded comments, inserted images and typed AutoFilter edits are not written to XLSB. Workbook operations are available through the C ABI and language bindings; the CLI deliberately exposes only `eval`, `recalc`, `dump`, and `paginate`. Feedback, issue reports, and oracle divergence reports are very welcome. diff --git a/README_ja.md b/README_ja.md index 880ac69ef..279a74770 100644 --- a/README_ja.md +++ b/README_ja.md @@ -26,7 +26,7 @@ CLI バイナリは [GitHub Releases](https://github.com/libraz/formulon/release - **互換性は実際の Excel と照合して確かめています。** 既定の profile は `win-365-ja_JP` です。数式の結果は Mac Excel 365 (ja-JP)、ピボットテーブルと印刷レイアウトは Windows Excel 365 (ja-JP) を基準に固定しています。ピボットテーブルの作成を自動化するには Windows COM が必要なためです。いずれも検証済みの Microsoft 365 環境から採取しています。出力は実 Excel から再生成した golden と照合します。許容している差分、たとえば超越関数の ulp 差、揮発関数、Excel 側の不整合を Formulon が意図的に採らないケースは、[`tests/divergence.yaml`](tests/divergence.yaml) に理由と確認済み Excel ビルドを記録します。 - **どの環境でも同じ C++ コアが計算します。** ブラウザ、Python、CLI で別々の計算ロジックを持たず、同じエンジンを配布しています。実装が分かれないので、環境ごとに結果がずれることもありません。 -- **WASM のサイズに上限を設けています。** CI は非圧縮 **3.25 MiB**、Brotli **864 KiB** を超えると失敗し、**3.00 MiB** / **832 KiB** を超えると警告を出します。実際に効いてくるのは配信時の Brotli サイズなので、非圧縮と対等に検査します。現在値は `make size-check` で確認できます。 +- **WASM のサイズに上限を設けています。** CI は非圧縮 **3.75 MiB**、Brotli **928 KiB** を超えると失敗し、**3.50 MiB** / **896 KiB** を超えると警告を出します。実際に効いてくるのは配信時の Brotli サイズなので、非圧縮と対等に検査します。現在値は `make size-check` で確認できます。 - **依存は小さく保っています。** ランタイム依存は `miniz` (zip/deflate)、`pugixml` (XML + XPath 1.0)、`PCRE2` (`REGEX*`)、`double-conversion` (Grisu3 `dtoa`) の 4 つです。線形代数、UTF-8 処理、数値変換の多くはリポジトリ内で実装しています。 - **C++ は監査しやすさを優先して書いています。** `Expected` ベースのエラー処理、RAII、`-fno-exceptions -fno-rtti`、Google C++ Style を採用しています。 @@ -52,7 +52,7 @@ Formulon は以下を **意図的にサポートしません**。 | グラフ / 図形のレンダリング | 描画レイヤの責務。計算エンジンの仕事ではありません。 | | PowerQuery (M) / DAX | 別のエンジンが扱う別の問題領域です。 | | Pivot キャッシュの再生成 | 保存済み `pivotCacheRecords` はそのまま保持し、ソース範囲から作り直すことはしません。PivotTable の計算結果自体は API から都度評価できます。 | -| スプレッドシート UI 本体 | 描画は呼び出し側の責任です。UI から使うための API(ビューポート単位の再計算 `partialRecalc`、範囲単位の条件付き書式評価、spill 情報など)はエンジン側で提供しています。 | +| スプレッドシート UI 本体 | 描画は呼び出し側の責任です。UI から使うための API(ビューポート単位の再計算 `partialRecalc`、範囲単位の条件付き書式評価、spill 情報、セルの位置・サイズと表示テキスト、色を解決済みの実効スタイル、AutoFilter と入力規則の評価など)はエンジン側で提供しています。 | これらは「まだやっていない」機能ではなく、スコープ外として固定しています。 @@ -102,20 +102,20 @@ oracle は **104 カテゴリ** あります。数式 track と条件付き書 |------|------| | `ctest -LE "SLOW\|BENCH\|TSAN"` — `make test`、PR ゲート | すべて passed | | `ctest -LE "BENCH\|TSAN"` — `make test-slow`、`SLOW` 層を追加 | すべて passed | -| primary formula oracle | `4546/4546` passed / `125` documented skips | +| primary formula oracle | `4768/4768` passed / `133` documented skips | | 条件付き書式 oracle | `23/23` | -| workbook oracle (pivot + print) | `73/73` passed / `9` documented skips | +| workbook oracle (pivot + print) | `83/83` passed / `10` documented skips | | 取り込み済み外部エンジンコーパス (クロスチェック) | `12510/12510` passed / `168` documented divergences | CTest スイートを分けているラベルは 3 つです。`SLOW`(数分かかる結合・並行性テスト)、`TSAN`(ThreadSanitizer での実行)、`BENCH`(しきい値を調整できるマイクロベンチの回帰チェックで、必要なときだけ実行)です。ラベルのないテストはすべて高速層で、CI はこれを合否判定に使います。負荷試験専用の層はありません。libFuzzer ハーネスも `SLOW` ラベルを持ちますが、`-DFM_BUILD_FUZZ=ON` を指定したビルド (`make fuzz`) にしか存在せず、既定ビルドにも CI にも含まれません。libFuzzer ランタイムを同梱する Clang が必要で、Apple の toolchain はこれを持たないため、macOS では別途 LLVM が要ります。macOS では AddressSanitizer も既定で無効です。最近の macOS の動的リンカと組み合わせると、shadow memory の初期化中にデッドロックするためです。そのため macOS での fuzz 実行で検出できるのはクラッシュ・タイムアウト・未定義動作までで、ヒープ破壊は検出できません。 残っている skip は、明示済みの divergence、ホストサービス依存、揮発・環境依存ケース、またはドライバ制約です。黙って未実装 stub に落としているものではありません。523 関数のうち `519` は closure 6 条件 (`behaviors_declared` / `cases_cover_behaviors` / `golden_present` / `divergence_documented` / `not_in_pilot` / `behavior_drift`) を全て満たします。残る 4 件 (`ARRAYTOTEXT`, `FILTERXML`, `GETPIVOTDATA`, `PHONETIC`) が満たさないのは `behaviors_declared` だけで、挙動の分類がまだ書き切れていないためです。`JIS` は `DBCS` の別名として宣言し、closure を満たしています。Excel は ja-JP の数式バーで入力された `JIS` を保存・評価の前に `DBCS` へ書き換えるため、`JIS` を直接呼ぶ oracle case は作れません。closure harness は宣言をそのまま信用せず、別名の参照先の関数を実際に評価して判定します。 -数式の結果に加えて、**ピボットテーブルと印刷範囲・改ページ**には専用の **workbook oracle track** があり、WSL2 から Windows COM へ渡すブリッジ経由で採取します。残る 9 件の skip はいずれも同じ Excel の癖です。印刷倍率またはズームが 50% 以下のとき、Excel の改ページプレビューは幾何的なページ分割に従わない列の自動改ページを出すため、観測される改ページ位置は倍率を下げても縮まらず、25% では逆に増えます。skip した各ケースには、照合した Microsoft 365 の観測値を記録しています。 +数式の結果に加えて、**ピボットテーブルと印刷範囲・改ページ**には専用の **workbook oracle track** があり、WSL2 から Windows COM へ渡すブリッジ経由で採取します。skip は 10 件あり、そのうち 9 件は同じ Excel の癖によるものです。印刷倍率またはズームが 50% 以下のとき、Excel の改ページプレビューは幾何的なページ分割に従わない列の自動改ページを出すため、観測される改ページ位置は倍率を下げても縮まらず、25% では逆に増えます。この 9 件には、照合した Microsoft 365 の観測値を記録しています。残る 1 件はケースファイルの形だけを確かめるスモーク用のケースで、照合する値を持ちません。 新規ワークブックはデフォルトで `win-365-ja_JP` profile を使います。必要に応じて profile-id API (`mac-365-ja_JP` / `win-365-ja_JP`) で切り替えられます。英語ロケール profile は、対応する EN oracle データとロケール固有挙動の検証が揃うまで公開しません。 -OOXML reader / writer はシート、スタイル、条件付き書式、コメント、ハイパーリンク、結合セル、入力規則、定義済み名前、テーブル、ピボットテーブルを round-trip します。MS-XLSB reader / writer はセル値、スタイル、シート間 3-D 参照、および一般的なトークン化数式をカバーします。配列定数リテラルと 2007 年以降の future function ID は、OOXML 経路に比べてまだ限定的です。ワークブック操作は C ABI と各言語バインディングから利用できます。CLI は意図的に `eval` / `recalc` / `dump` / `paginate` のみを公開します。 +OOXML reader / writer はシート、スタイル、条件付き書式、コメント (スレッド形式を含む)、ハイパーリンク、結合セル、入力規則、定義済み名前、テーブル、ピボットテーブル、画像と図形、テーマを round-trip します。MS-XLSB reader / writer はセル値、スタイル、シート間 3-D 参照、および一般的なトークン化数式をカバーします。配列定数リテラルと 2007 年以降の future function ID は、OOXML 経路に比べてまだ限定的です。スレッド形式のコメント、挿入した画像、型付き AutoFilter の編集は XLSB へ書き出しません。ワークブック操作は C ABI と各言語バインディングから利用できます。CLI は意図的に `eval` / `recalc` / `dump` / `paginate` のみを公開します。 不具合報告・oracle 差分レポート・ご意見はいつでも歓迎しています。 diff --git a/cmake/FormulonNodeAddon.cmake b/cmake/FormulonNodeAddon.cmake index 0d785215c..bff240f07 100644 --- a/cmake/FormulonNodeAddon.cmake +++ b/cmake/FormulonNodeAddon.cmake @@ -68,12 +68,15 @@ endif() add_library(formulon_node MODULE src/node_addon/addon.cc src/node_addon/parts/addon_common.cc + src/node_addon/parts/cells.cc + src/node_addon/parts/conditional_format.cc src/node_addon/parts/free_funcs.cc src/node_addon/parts/lifecycle.cc src/node_addon/parts/pivot_cache.cc src/node_addon/parts/pivot_table.cc src/node_addon/parts/print.cc src/node_addon/parts/sheet.cc + src/node_addon/parts/sheet_ui.cc src/node_addon/parts/sheet_view.cc src/node_addon/parts/styles.cc src/node_addon/parts/trace_catalog.cc diff --git a/cmake/FormulonWasm.cmake b/cmake/FormulonWasm.cmake index 6ade913be..bbe562cfe 100644 --- a/cmake/FormulonWasm.cmake +++ b/cmake/FormulonWasm.cmake @@ -36,9 +36,9 @@ # --------------------------------------- # # * Engine is built `-fno-exceptions -fno-rtti`. We keep that here -# (`-sDISABLE_EXCEPTION_CATCHING=1`); the embind binding never throws -# because `JsWorkbook` and friends route every failure through a -# `{ ok, status, message }` envelope. +# (`-sDISABLE_EXCEPTION_CATCHING=1`). Native methods return failures +# through result envelopes; JS argument guards can throw before entry. +# Caller-owned getters are caught in JS before returning to C++. # # * `MODULARIZE=1 + EXPORT_NAME=createFormulon + EXPORT_ES6=1` produces # an ES module factory: `import createFormulon from './formulon.js'`. @@ -135,6 +135,37 @@ else() target_link_libraries(formulon_wasm PRIVATE formulon_static) endif() +# The N-API registration table is the canonical declaration of primitive +# positional argument types. Generate the embind pre-js from that table so +# both JavaScript surfaces reject coercing values before embind enters a +# native method. The generated file is glue only and is therefore kept out +# of the wasm link itself. +if(FM_WASM_VARIANT STREQUAL "embind") + find_package(Python3 COMPONENTS Interpreter REQUIRED) + set(_FM_WASM_POSITIONAL_GUARD_SCRIPT + "${CMAKE_CURRENT_SOURCE_DIR}/tools/codegen/gen_wasm_positional_guards.py") + set(_FM_WASM_POSITIONAL_GUARD_PRE_JS + "${CMAKE_CURRENT_BINARY_DIR}/generated/checked_arguments.pre.js") + add_custom_command( + OUTPUT "${_FM_WASM_POSITIONAL_GUARD_PRE_JS}" + COMMAND "${Python3_EXECUTABLE}" "${_FM_WASM_POSITIONAL_GUARD_SCRIPT}" + --node-source "${CMAKE_CURRENT_SOURCE_DIR}/src/node_addon/parts/workbook_class.cc" + --wasm-source "${CMAKE_CURRENT_SOURCE_DIR}/src/wasm/parts/bindings_register.cpp" + --output "${_FM_WASM_POSITIONAL_GUARD_PRE_JS}" + DEPENDS + "${_FM_WASM_POSITIONAL_GUARD_SCRIPT}" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node_addon/parts/workbook_class.cc" + "${CMAKE_CURRENT_SOURCE_DIR}/src/wasm/parts/bindings_register.cpp" + COMMENT "Generating WASM positional argument guards" + VERBATIM + ) + add_custom_target(formulon_wasm_positional_guards + DEPENDS "${_FM_WASM_POSITIONAL_GUARD_PRE_JS}") + add_dependencies(formulon_wasm formulon_wasm_positional_guards) + set_property(TARGET formulon_wasm APPEND PROPERTY LINK_DEPENDS + "${_FM_WASM_POSITIONAL_GUARD_PRE_JS}") +endif() + # Shared optimisation flags. if(CMAKE_BUILD_TYPE STREQUAL "Debug") set(_FM_WASM_OPT_FLAGS "-O0;-g") @@ -205,6 +236,8 @@ if(FM_WASM_VARIANT STREQUAL "embind") "-sSTACK_SIZE=${_FM_WASM_STACK_SIZE}" "--closure=0" ) + list(APPEND _FM_WASM_COMMON_LINK_FLAGS + "--pre-js=${_FM_WASM_POSITIONAL_GUARD_PRE_JS}") # Compile flags for the embind TU only. formulon_core is built # -fno-exceptions -fno-rtti (the project-wide stance). embind, however, @@ -369,15 +402,18 @@ set_target_properties(formulon_wasm_stack_probe PROPERTIES # always-active SAX). Native builds keep the threshold at 256 KiB. target_compile_definitions(formulon_core PRIVATE FORMULON_WASM=1) -# pugixml parses numbers with strtod. Wrapping the symbol routes those calls -# to the engine's decimal parser (`__wrap_strtod` in src/utils/double_parse.cpp), -# which keeps libc's scanner and the 128-bit float routines it needs out of -# the module: ~10 KB uncompressed, ~5 KB Brotli. +# pugixml parses numbers with strtod and prints them with snprintf. Wrapping +# the symbols routes those calls to the engine's decimal parser +# (`__wrap_strtod` in src/utils/double_parse.cpp) and formatter +# (`__wrap_snprintf` in src/utils/number_text.cpp), which keeps libc's scanner, +# its printf machinery and the 128-bit float routines they need out of the +# module. target_link_options(formulon_wasm PRIVATE ${_FM_WASM_OPT_FLAGS} ${_FM_WASM_LINK_OPT_FLAGS} ${_FM_WASM_COMMON_LINK_FLAGS} "-Wl,--wrap=strtod" + "-Wl,--wrap=snprintf" ) # Land the artifact at /.{js,wasm}. Emscripten infers the diff --git a/docs/releases/README.md b/docs/releases/README.md index 40a23cf24..0928d7749 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -6,7 +6,8 @@ The high-level summary lives in [`CHANGELOG.md`](../../CHANGELOG.md). ## Available Versions -- [v0.12.0](https://github.com/libraz/formulon/releases/tag/v0.12.0) — Latest release (2026-09-30) — a single-threaded default npm build with pthreads moved to `@libraz/formulon/threads`, conditional formatting, data validation and protection carried through `.xlsb`, formulas encoded and marked the way Excel saves them, cycles judged by the reads evaluation makes, lifted `*IF`/`*IFS` criteria, `USDOLLAR`, Excel's day-interval pivot grouping in place of the week bucket, and broad evaluation / I/O correctness work. +- [v0.13.0](https://github.com/libraz/formulon/releases/tag/v0.13.0) — Latest release (2026-10-06) — workbook-level APIs across every binding (sheet geometry and pagination detail, cell display text and R1C1 formulas, the workbook theme and effective styles, typed AutoFilters, data-validation evaluation, threaded comments and images), Excel-exact number-format validation and rendering, strict argument validation on the Node addon and WASM, a printf-free WASM module, and broad recalc / structural-edit / pivot correctness work. +- [v0.12.0](https://github.com/libraz/formulon/releases/tag/v0.12.0) — 2026-09-30 — a single-threaded default npm build with pthreads moved to `@libraz/formulon/threads`, conditional formatting, data validation and protection carried through `.xlsb`, formulas encoded and marked the way Excel saves them, cycles judged by the reads evaluation makes, lifted `*IF`/`*IFS` criteria, `USDOLLAR`, Excel's day-interval pivot grouping in place of the week bucket, and broad evaluation / I/O correctness work. - [v0.11.1](https://github.com/libraz/formulon/releases/tag/v0.11.1) — 2026-08-22 — a phonetic-guide authoring surface across every binding (run spans with the text they annotate, and the font, kana form and distribution that render them), a declarable workbook default font, a font's `` theme link and a guide's `` block round-tripping through both containers, and furigana carried through the MS-XLSB shared-string table. - [v0.11.0](https://github.com/libraz/formulon/releases/tag/v0.11.0) — 2026-08-22 — a print-settings authoring API across the C ABI, WASM, the native Node addon and Python, the Excel minimum style table seeded on workbook creation, cross-workbook reference resolution and XLSB pivot decoding, the range operator composed to any depth, spill anchors that are computed rather than written out, and phonetic run spans preserved end to end. - [v0.10.0](https://github.com/libraz/formulon/releases/tag/v0.10.0) — 2026-08-18 — C ABI entry-point consolidation (the `_ex` variants folded into their base names, a binary break against v0.9.7), pivot report-filter rendering, a verified Windows Excel 365 primary oracle for the workbook track, and broad evaluation / I/O correctness work. diff --git a/packages/npm-native/README.md b/packages/npm-native/README.md index cbd4e4f18..49a5dc312 100644 --- a/packages/npm-native/README.md +++ b/packages/npm-native/README.md @@ -38,19 +38,19 @@ Why prefer the native build: This package exposes the shared `Workbook` surface of the WASM-backed `@libraz/formulon` package, all marshalling to the identical C-ABI functions. Its TypeScript declarations and its native class table -register 225 instance methods plus the three static factories. Of those -instance methods, 223 are shared with WASM; seven remain WASM-only, while +register 274 instance methods plus the three static factories. Of those +instance methods, 272 are shared with WASM; six remain WASM-only, while `dispose()` and `memoryUsage()` are native-only lifecycle helpers. The shared `Workbook` methods use the same status-bearing result envelopes and field shapes; switching packages still requires updating the module import and validating the target platform's native prebuild. The additional -native-only methods are operational helpers; the seven +native-only methods are operational helpers; the six WASM-only methods remain available through the WASM package. The WASM-only methods are, in full: ``` -addCellStyleXf, setCellStyle +addCellStyleXf createTable, updateTable, removeTable getSheetAutoFilterXml, setSheetAutoFilterXml ``` @@ -113,7 +113,8 @@ Styles getFont, getFill, getBorder, getNumFmt addFont, addFill, addBorder, addNumFmt, addXf fontCount, fillCount, borderCount, xfCount - cellStyleCount, cellStyleXfCount, getCellStyle, getCellStyleXf + cellStyleCount, cellStyleXfCount, getCellStyle, getCellStyleXf, setCellStyle, removeCellStyle + getTheme, setThemeColors, setThemeFonts, resolveColor, getEffectiveStyle Merges / comments / hyperlinks / validations addMerge, removeMerge, removeMergeAt, clearMerges, getMerges diff --git a/packages/npm-native/index.d.ts b/packages/npm-native/index.d.ts index 7f93c8e1b..f2e45047d 100644 --- a/packages/npm-native/index.d.ts +++ b/packages/npm-native/index.d.ts @@ -31,7 +31,8 @@ export const ValueKind: Readonly<{ Lambda: 7; }>; -/** Result envelope returned by every fallible binding call. */ +/** Status carried by binding result envelopes. Argument validation may + * throw `TypeError` or `RangeError` before returning a result. */ export interface Status { /** True when the underlying C ABI returned `kOk`. */ ok: boolean; @@ -51,7 +52,7 @@ export interface Status { /** Flattened mirror of `fm_value_t`. Only the field selected by `kind` * is meaningful; the others carry default-zero values. */ export interface Value { - kind: number; + kind: FilterKind; /** Active when `kind === ValueKind.Number`. */ number: number; /** Active when `kind === ValueKind.Bool` (0 or 1). */ @@ -221,7 +222,7 @@ export interface PivotCell { /** Absolute 0-based sheet column. */ col: number; value: Value; - kind: number; + kind: FilterKind; /** Header nesting depth; 0 for data cells. */ depth: number; /** Source field name when known. */ @@ -543,7 +544,7 @@ export interface CfValueObjectInput { /** Resolved CF match. Active fields depend on `kind`; the others carry * default-zero values. */ export interface CfMatch { - kind: number; + kind: FilterKind; priority: number; /** `1` when `dxfId` is meaningful; `0` otherwise. */ dxfIdEngaged: number; @@ -591,6 +592,78 @@ export interface PaginationResult { horizontalBreaks: number[]; verticalBreaks: number[]; pageCount: number; + /** Paper size after the orientation swap. */ + paper: PaginationPaper; + margins: PaginationMargins; + /** Cell-body rectangle before print-title reservation, from the page's top-left corner. */ + printable: RectPt; + /** Effective scale as a factor (1.0 is 100%). */ + scale: number; + /** 0 down-then-over (OOXML default), 1 over-then-down. */ + pageOrder: number; + printTitles: PaginationPrintTitles; + /** One entry per physical page, in print order. */ + pages: PaginationPage[]; + /** Per horizontal break (same index as `horizontalBreaks`): true when the break is manual. */ + horizontalBreakManual: boolean[]; + /** Per vertical break (same index as `verticalBreaks`): true when the break is manual. */ + verticalBreakManual: boolean[]; +} + +/** A rectangle in points. Sheet geometry measures from the top-left of cell + * A1; pagination measures from the page's top-left corner. */ +export interface RectPt { + x: number; + y: number; + width: number; + height: number; +} + +/** `PaginationResult.paper`. `known` is false when `paperSize` was + * unrecognised and A4 was substituted. */ +export interface PaginationPaper { + widthPt: number; + heightPt: number; + landscape: boolean; + known: boolean; +} + +/** `PaginationResult.margins`, in points; `header` / `footer` are measured + * from the page edge to the header / footer band. */ +export interface PaginationMargins { + left: number; + right: number; + top: number; + bottom: number; + header: number; + footer: number; +} + +/** `PaginationResult.printTitles`: repeat rows / columns (0-based, + * inclusive). A span is meaningful only when its `has*` flag is true. */ +export interface PaginationPrintTitles { + hasRows: boolean; + firstRow: number; + lastRow: number; + hasCols: boolean; + firstCol: number; + lastCol: number; +} + +/** One physical page. `areaIndex` indexes `printArea` (0 for the used-range + * fallback); `widthPt` / `heightPt` are the scaled extent of the page's own + * cell block (repeated titles excluded) and `originXPt` / `originYPt` its + * top-left corner on the paper. */ +export interface PaginationPage { + areaIndex: number; + firstRow: number; + lastRow: number; + firstCol: number; + lastCol: number; + originXPt: number; + originYPt: number; + widthPt: number; + heightPt: number; } /** Result of the five raw print-settings getters. The empty `xml` string @@ -749,6 +822,237 @@ export const SheetVisibility: Readonly<{ }>; export type SheetVisibility = 0 | 1 | 2; +/** Which Windows column-width figure a geometry call uses. Mirrors + * `fm_geometry_mode_t`; row heights are identical in both modes. */ +export const GeometryMode: Readonly<{ + Display: 0; + Print: 1; +}>; +export type GeometryMode = 0 | 1; + +/** Outcome of a display-text rendering. Mirrors `fm_display_status_t`: + * `Overflow` text is `########`, `InvalidFormat` text is the General + * rendering. */ +export const DisplayStatus: Readonly<{ + Ok: 0; + Overflow: 1; + InvalidFormat: 2; +}>; +export type DisplayStatus = 0 | 1 | 2; + +/** Criterion of an AutoFilter column. Mirrors `fm_filter_kind_t`. */ +export const FilterKind: Readonly<{ + None: 0; + Values: 1; + Custom: 2; + Top10: 3; + Dynamic: 4; + Color: 5; + Icon: 6; +}>; +export type FilterKind = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +/** Comparison operator of a custom AutoFilter condition. Mirrors `fm_filter_operator_t`. */ +export const FilterOperator: Readonly<{ + Equal: 0; + LessThan: 1; + LessThanOrEqual: 2; + NotEqual: 3; + GreaterThanOrEqual: 4; + GreaterThan: 5; +}>; +export type FilterOperator = 0 | 1 | 2 | 3 | 4 | 5; + +/** Dynamic AutoFilter type, in `ST_DynamicFilterType` schema order. Mirrors `fm_dynamic_filter_type_t`. */ +export const DynamicFilterType: Readonly<{ + Null: 0; + AboveAverage: 1; + BelowAverage: 2; + Tomorrow: 3; + Today: 4; + Yesterday: 5; + NextWeek: 6; + ThisWeek: 7; + LastWeek: 8; + NextMonth: 9; + ThisMonth: 10; + LastMonth: 11; + NextQuarter: 12; + ThisQuarter: 13; + LastQuarter: 14; + NextYear: 15; + ThisYear: 16; + LastYear: 17; + YearToDate: 18; + Q1: 19; + Q2: 20; + Q3: 21; + Q4: 22; + M1: 23; + M2: 24; + M3: 25; + M4: 26; + M5: 27; + M6: 28; + M7: 29; + M8: 30; + M9: 31; + M10: 32; + M11: 33; + M12: 34; +}>; +export type DynamicFilterType = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30 + | 31 + | 32 + | 33 + | 34; + +/** What a sort condition sorts by. Mirrors `fm_sort_by_t`. */ +export const SortBy: Readonly<{ + Value: 0; + CellColor: 1; + FontColor: 2; + Icon: 3; +}>; +export type SortBy = 0 | 1 | 2 | 3; + +/** Sort method of an AutoFilter sort state. Mirrors `fm_sort_method_t`. */ +export const SortMethod: Readonly<{ + None: 0; + PinYin: 1; + Stroke: 2; +}>; +export type SortMethod = 0 | 1 | 2; + +/** Granularity of an AutoFilter date-group item. Mirrors `fm_date_time_grouping_t`. */ +export const DateTimeGrouping: Readonly<{ + Year: 0; + Month: 1; + Day: 2; + Hour: 3; + Minute: 4; + Second: 5; +}>; +export type DateTimeGrouping = 0 | 1 | 2 | 3 | 4 | 5; + +/** Data-validation error style. Mirrors `fm_validation_error_style_t`. */ +export const ValidationErrorStyle: Readonly<{ + Stop: 0; + Warning: 1; + Information: 2; +}>; +export type ValidationErrorStyle = 0 | 1 | 2; + +/** Raster format of an image. Mirrors `fm_image_format_t`; `Unknown` marks "no image bytes found". */ +export const ImageFormat: Readonly<{ + Unknown: 0; + Png: 1; + Jpeg: 2; + Gif: 3; + Bmp: 4; +}>; +export type ImageFormat = 0 | 1 | 2 | 3 | 4; + +/** What a drawing anchor holds. Mirrors `fm_drawing_object_kind_t`. */ +export const DrawingObjectKind: Readonly<{ + Picture: 0; + Shape: 1; + Chart: 2; + Group: 3; + Connector: 4; + GraphicFrame: 5; + Other: 6; +}>; +export type DrawingObjectKind = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +/** The anchor element type of a drawing object. Mirrors `fm_anchor_kind_t`. */ +export const AnchorKind: Readonly<{ + OneCell: 0; + TwoCell: 1; + Absolute: 2; +}>; +export type AnchorKind = 0 | 1 | 2; + +/** How a drawing object follows row and column edits. Mirrors `fm_anchor_edit_as_t`. */ +export const AnchorEditAs: Readonly<{ + TwoCell: 0; + OneCell: 1; + Absolute: 2; +}>; +export type AnchorEditAs = 0 | 1 | 2; + +/** Where a colour is used; decides what an automatic colour means. Mirrors + * `fm_color_context_t`. */ +export const ColorContext: Readonly<{ + Font: 0; + FillForeground: 1; + FillBackground: 2; + Border: 3; +}>; +export type ColorContext = 0 | 1 | 2 | 3; + +/** How a colour was resolved. Mirrors `fm_color_resolution_t`: `Exact` is a + * literal or a theme / palette colour from the workbook's own data; + * `DefaultTheme` and `ThemeUnparseable` resolved against the default theme; + * `IndexOutOfRange` is black; `AutoContext` is an automatic colour chosen by + * context (black for font and border, white for fills). */ +export const ColorResolution: Readonly<{ + Exact: 0; + DefaultTheme: 1; + IndexOutOfRange: 2; + ThemeUnparseable: 3; + AutoContext: 4; +}>; +export type ColorResolution = 0 | 1 | 2 | 3 | 4; + +/** Where `getTheme` read the theme from. Mirrors the C `out_source` values. */ +export const ThemeSource: Readonly<{ + Part: 0; + Default: 1; + Unparseable: 2; +}>; +export type ThemeSource = 0 | 1 | 2; + +/** Where `getEffectiveStyle` took its xf from. */ +export const EffectiveStyleSource: Readonly<{ + Cell: 0; + Row: 1; + Column: 2; + Default: 3; +}>; +export type EffectiveStyleSource = 0 | 1 | 2 | 3; + /** Per-sheet view: zoom (10..400, default 100), frozen-pane row/col * counts, tab visibility, and the display / orientation flags mirrored * from OOXML ``. Booleans are encoded as `0`/`1` to match @@ -852,6 +1156,10 @@ export interface RowLayout { /** Whether OOXML customFormat=1 makes the row style effective. */ hasStyle: number; styleXf: number; + /** Whether a height is stored (0/1); `height` is meaningful only then. */ + hasHeight: number; + /** Whether the height is an explicit override (`customHeight="1"`, 0/1) rather than a cached auto height. */ + customHeight: number; } /** Return type of `Workbook.getSheetRowOverrides(sheet)`. */ @@ -868,6 +1176,297 @@ export interface MergeRange { lastCol: number; } +/** Return type of `Workbook.getSheetFormatDefaults(sheet)`; also the + * argument shape of `setSheetFormatDefaults`. `defaultColWidth` is in + * character units and `defaultRowHeight` in points, each meaningful only + * when its `has*` flag is set. `baseColWidth` is in characters (OOXML + * default 8). */ +export interface SheetFormatDefaults { + defaultColWidth: number; + defaultRowHeight: number; + baseColWidth: number; + hasDefaultColWidth: boolean; + hasDefaultRowHeight: boolean; +} + +/** Return type of `Workbook.getSheetFormatDefaults(sheet)`. */ +export interface SheetFormatDefaultsResult extends SheetFormatDefaults { + status: Status; +} + +/** Return type of `Workbook.getCellRectPt(sheet, range, mode)`: the + * rectangle in points from the top-left of cell A1. Hidden rows and + * columns contribute zero extent. */ +export interface CellRectResult extends RectPt { + status: Status; +} + +/** Return type of `Workbook.getWidthModel(sheet, mode)`. `calibrated` is + * false when the Normal font is not a measured calibration point and the + * Calibri 11 figure was extrapolated. */ +export interface WidthModelResult { + status: Status; + pointsPerChar: number; + paddingPt: number; + normalFontSize: number; + calibrated: boolean; + normalFontName: string; + platform: string; +} + +/** Return type of `Workbook.getFormula` / `getFormulaR1C1`. `formula` is + * `null` when the cell holds no formula (and on failure). */ +export interface FormulaResult { + status: Status; + formula: string | null; +} + +/** One `` of a values filter. `grouping` is the granularity + * the item matches at (a `DateTimeGrouping`); fields finer than `grouping` are zero. */ +export interface AutoFilterDateGroup { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + grouping: DateTimeGrouping; +} + +/** One ``. `colId` is the 0-based offset from the first column + * of the AutoFilter range. `kind` selects the criterion and only that + * criterion's fields are read on input (a `FilterKind`); on output every other criterion's + * fields are zero. Custom operators `op1` / `op2` are + * `FilterOperator`s; `dynamicType` is a `DynamicFilterType`. + * `dxfId` of `4294967295` means absent. `iconSet` is the conditional-format + * icon-set ordinal. Omitted input fields read as zero / false / empty, + * except `showButton` (true) and `dxfId` (absent). */ +export interface AutoFilterColumn { + colId: number; + hiddenButton: boolean; + showButton: boolean; + kind: FilterKind; + filterBlank: boolean; + values: string[]; + dateGroups: AutoFilterDateGroup[]; + customAnd: boolean; + customCount: number; + op1: FilterOperator; + val1: string; + op2: FilterOperator; + val2: string; + top: boolean; + percent: boolean; + hasFilterVal: boolean; + topVal: number; + filterVal: number; + dynamicType: DynamicFilterType; + hasDynVal: boolean; + hasDynMaxVal: boolean; + dynVal: number; + dynMaxVal: number; + valIso: string; + maxValIso: string; + dxfId: number; + cellColor: boolean; + iconSet: number; + iconId: number; + hasIconId: boolean; +} + +/** One ``. `sortBy` is a `SortBy`. `dxfId` is read only when `hasDxfId`, `iconId` only when + * `hasIconId`. */ +export interface AutoFilterSortCondition { + ref: MergeRange; + descending: boolean; + sortBy: SortBy; + customList: string; + dxfId: number; + hasDxfId: boolean; + iconSet: number; + iconId: number; + hasIconId: boolean; +} + +/** The `` of an AutoFilter. `sortMethod` is a + * `SortMethod`. */ +export interface AutoFilterSortState { + ref: MergeRange; + columnSort: boolean; + caseSensitive: boolean; + sortMethod: SortMethod; + conditions: AutoFilterSortCondition[]; +} + +/** A sheet or table AutoFilter. `range` includes the header row; `columns` + * must be in strictly ascending `colId` order inside it. */ +export interface AutoFilter { + range: MergeRange; + columns: AutoFilterColumn[]; + sort: AutoFilterSortState | null; +} + +/** Return type of `Workbook.getAutoFilter` / `getTableAutoFilter`. `autoFilter` is + * `null` when there is none. */ +export interface AutoFilterResult { + status: Status; + autoFilter: AutoFilter | null; +} + +/** Return type of `Workbook.evaluateAutoFilter` / `evaluateTableAutoFilter`. + * `match[i]` tells whether body row `firstRow + i` satisfies the criteria. */ +export interface AutoFilterEvalResult { + status: Status; + firstRow: number; + match: boolean[]; +} + +/** Return type of `Workbook.validateValue`. `hasRule` is false when no rule + * covers the cell, and `valid` is then true. `ruleIndex` is the + * `getValidations` index of the applied rule and `errorStyle` its style + * (a `ValidationErrorStyle`). */ +export interface ValidationOutcomeResult { + status: Status; + hasRule: boolean; + valid: boolean; + ruleIndex: number; + errorStyle: ValidationErrorStyle; +} + +/** One `@person` mention in a threaded comment's text; `start` and `length` + * are in UTF-16 code units. */ +export interface Mention { + personId: string; + mentionId: string; + start: number; + length: number; +} + +/** One threaded comment: a thread's opening comment, or a reply when + * `parentId` is non-empty. Ids are brace-wrapped uppercase GUIDs and + * `created` is `YYYY-MM-DDTHH:MM:SS.ss`; the caller supplies both. `done` + * is the thread's resolved state, meaningful on the opening comment only. */ +export interface ThreadedComment { + id: string; + row: number; + col: number; + personId: string; + created: string; + text: string; + parentId: string; + done: boolean; + mentions: Mention[]; +} + +/** One entry of the workbook's person list. */ +export interface Person { + id: string; + displayName: string; + userId: string; + providerId: string; +} + +/** Result of `Workbook.getThreadedComments`, in file order: each thread's + * opening comment followed by its replies. */ +export interface ThreadedCommentsResult { + status: Status; + comments: ThreadedComment[]; +} + +/** Result of `Workbook.getPersons`. */ +export interface PersonsResult { + status: Status; + persons: Person[]; +} + +/** Format and pixel size of an image; zeroed when `status` is not ok. */ +export interface ImageInfoResult { + status: Status; + format: ImageFormat; + pxWidth: number; + pxHeight: number; +} + +/** Result of `Workbook.getImage`. `bytes` is a copy, empty on failure. */ +export interface ImageResult extends ImageInfoResult { + bytes: Uint8Array; +} + +/** Result of `Workbook.insertImage`; `objectId` is zero on failure. */ +export interface InsertImageResult { + status: Status; + objectId: number; +} + +/** One anchored object of a sheet's drawing. An absolute anchor reports its position from A1 as + * `fromColOff` (x) and `fromRowOff` (y); `toRow`/`toCol` and the `to*Off` fields are zero unless the anchor is + * two-cell. Offsets and `cx`/`cy` are in EMU. `imageFormat` is non-zero for a picture whose media part is present; + * `mediaPath` is empty for a non-picture. */ +export interface DrawingObject { + objectId: number; + kind: DrawingObjectKind; + anchorKind: AnchorKind; + editAs: AnchorEditAs; + fromRow: number; + fromCol: number; + fromRowOff: number; + fromColOff: number; + toRow: number; + toCol: number; + toRowOff: number; + toColOff: number; + cx: number; + cy: number; + imageFormat: ImageFormat; + name: string; + descr: string; + mediaPath: string; +} + +/** Placement of an inserted image. `anchorKind` is `OneCell` (default) or + * `TwoCell`; `editAs` applies to a two-cell anchor only. `row` / `col` is + * the 0-based top-left cell and `rowOffEmu` / `colOffEmu` the offset inside + * it. A zero `widthEmu` / `heightEmu` means the pixel size times 9525. An + * empty `name` becomes `Picture `. */ +export interface ImageInsertOptions { + name?: string; + descr?: string; + anchorKind?: AnchorKind; + editAs?: AnchorEditAs; + row?: number; + col?: number; + rowOffEmu?: number; + colOffEmu?: number; + widthEmu?: number; + heightEmu?: number; +} + +/** One populated cell in `getCellsInRange`. `formula` is the stored A1 + * formula, or `null` when the cell holds none. */ +export interface RangeCell { + row: number; + col: number; + formula: string | null; + value: Value; +} + +/** Return type of `Workbook.getCellsInRange`. `nextCursor` resumes the + * enumeration and is `null` when the rectangle holds no further populated + * cell. */ +export interface CellsInRangeResult { + status: Status; + cells: ReadonlyArray; + nextCursor: number | null; +} + +/** Return type of `Workbook.getDisplayText` / `formatValue`. */ +export interface DisplayTextResult { + status: Status; + text: string; + /** 0 ok; 1 overflow (the value cannot be shown in the format; the text is `"########"`); 2 invalid format (the text is the General rendering). */ + displayStatus: DisplayStatus; +} + /** Sheet hyperlink entry as returned by `getHyperlinks(sheet)`. */ export interface HyperlinkEntry { row: number; @@ -924,13 +1523,13 @@ export interface DataValidationRange { * * `op` — 0 between, 1 notBetween, 2 equal, 3 notEqual, * 4 greaterThan, 5 lessThan, * 6 greaterThanOrEqual, 7 lessThanOrEqual. - * * `errorStyle` — 0 stop, 1 warning, 2 information. + * * `errorStyle` — a `ValidationErrorStyle`. */ export interface DataValidationEntry { readonly ranges: ReadonlyArray; readonly type: number; readonly op: number; - readonly errorStyle: number; + readonly errorStyle: ValidationErrorStyle; readonly allowBlank: boolean; readonly showInputMessage: boolean; readonly showErrorMessage: boolean; @@ -953,7 +1552,7 @@ export interface DataValidationInput { ranges?: ReadonlyArray; type: number; op?: number; - errorStyle?: number; + errorStyle?: ValidationErrorStyle; allowBlank?: boolean; showInputMessage?: boolean; showErrorMessage?: boolean; @@ -1011,6 +1610,20 @@ export interface CellXfResult { readingOrder?: number; /** Index into `` for this cell format. */ xfId?: number; + /** ``: the number format overrides the parent named style. */ + applyNumberFormat: boolean; + applyFont: boolean; + applyFill: boolean; + applyBorder: boolean; + applyAlignment: boolean; + applyProtection: boolean; + /** OOXML `quotePrefix`: the cell text is shown as typed text. */ + quotePrefix: boolean; + /** Whether the `` child is present; otherwise `locked` / `hidden` take the defaults. */ + hasProtection: boolean; + /** Cell is locked; true when no `` child is present. */ + locked: boolean; + hidden: boolean; } /** How an OOXML `` element expressed its value. @@ -1024,7 +1637,7 @@ export interface CellXfResult { * the writer emits the sibling `*Argb` as `rgb`. */ export interface ColorSpec { /** 0=none, 1=rgb, 2=theme, 3=indexed, 4=auto. */ - kind: number; + kind: FilterKind; /** AARRGGBB; meaningful when `kind` is 1. */ rgb: number; /** Theme index; meaningful when `kind` is 2. */ @@ -1140,6 +1753,20 @@ export interface CellXf { readingOrder?: number; /** Index of the named style in `` this format inherits from. */ xfId?: number; + /** ``: the number format overrides the parent named style. */ + applyNumberFormat?: boolean; + applyFont?: boolean; + applyFill?: boolean; + applyBorder?: boolean; + applyAlignment?: boolean; + applyProtection?: boolean; + /** OOXML `quotePrefix`: the cell text is shown as typed text. */ + quotePrefix?: boolean; + /** Whether the `` child is present; otherwise `locked` / `hidden` take the defaults. */ + hasProtection?: boolean; + /** Cell is locked (default true). Ignored on insertion unless `hasProtection` is set or inferred from `locked` / `hidden`. */ + locked?: boolean; + hidden?: boolean; } /** One `` block: `text` reads the half-open span `[sb, eb)` of the @@ -1248,6 +1875,75 @@ export interface CellStyleResult { customBuiltin: boolean; } +/** Argument of `Workbook.setCellStyle`. Mirrors `CellStyleResult` without the status. */ +export interface CellStyleRecord { + /** Display name of the style. */ + name: string; + /** Index into the `` table; defaults to 0. */ + xfId?: number; + /** OOXML built-in ordinal (`0..53`); defaults to `0xFFFFFFFF` (custom). */ + builtinId?: number; + /** Outline level of the `RowLevel_n` / `ColLevel_n` styles; defaults to 0. */ + iLevel?: number; + hidden?: boolean; + customBuiltin?: boolean; +} + +/** Theme typefaces. `*EastAsian` is the `a:ea` face, else the Jpan script face. */ +export interface ThemeFonts { + majorLatin: string; + majorEastAsian: string; + minorLatin: string; + minorEastAsian: string; +} + +/** Return type of `Workbook.getTheme()`. */ +export interface ThemeResult { + status: Status; + /** One of `ThemeSource.*`. */ + source: ThemeSource; + /** The 12 scheme colours as 0xFFRRGGBB in `a:clrScheme` order: dk1, lt1, + * dk2, lt2, accent1..6, hlink, folHlink. */ + colors: number[]; + fonts: ThemeFonts; +} + +/** A resolved colour: 0xAARRGGBB as Excel renders it, plus how it was obtained. */ +export interface ResolvedColor { + argb: number; + resolution: ColorResolution; +} + +/** Return type of `Workbook.resolveColor(spec, context)`. */ +export interface ResolvedColorResult extends ResolvedColor { + status: Status; +} + +/** Return type of `Workbook.getEffectiveStyle(sheet, row, col)`. Named-style + * inheritance is not recomputed and conditional formatting is not applied. */ +export interface EffectiveStyleResult { + status: Status; + xfIndex: number; + /** One of `EffectiveStyleSource.*`. */ + source: EffectiveStyleSource; + fontIndex: number; + fillIndex: number; + borderIndex: number; + font: ResolvedColor; + fillForeground: ResolvedColor; + fillBackground: ResolvedColor; + borders: { + left: ResolvedColor; + right: ResolvedColor; + top: ResolvedColor; + bottom: ResolvedColor; + diagonal: ResolvedColor; + }; + locked: boolean; + hidden: boolean; + numFmtCode: string; +} + /** Element type returned by `Workbook.getExternalLinks()`. Mirrors * `formulon::io::ExternalLinkRecord`. The body part itself is not * exposed (it round-trips through the OOXML passthrough mechanism); @@ -1269,7 +1965,7 @@ export interface ExternalLinkRecord { * `TargetMode="External"` (the common case). */ targetExternal: boolean; /** External-link kind: 0 unknown, 1 externalBook, 2 ole, 3 dde. */ - kind: number; + kind: FilterKind; } /** One inclusive cell-range entry inside a CF rule's `sqref` union. */ @@ -1794,6 +2490,16 @@ export interface Workbook { // Iteration / metadata. cellCount(sheet: number): NumberResult; cellAt(sheet: number, idx: number): CellEntry; + /** Reads the cell's stored formula in A1 notation (leading `=` included); `formula` is `null` for a non-formula cell. */ + getFormula(sheet: number, row: number, col: number): FormulaResult; + /** Reads the cell's formula in R1C1 notation relative to the cell (no leading `=`); `null` for a non-formula cell. */ + getFormulaR1C1(sheet: number, row: number, col: number): FormulaResult; + /** Enumerates the populated cells of `range` row-major, one page at a time. Pass the previous `nextCursor` as `cursor` to resume; `limit` 0 / omitted means the maximum (65,536). */ + getCellsInRange(sheet: number, range: MergeRange, cursor?: number | null, limit?: number | null): CellsInRangeResult; + /** Reads the text Excel displays for the cell, from its value and XF number format under the workbook's date system. */ + getDisplayText(sheet: number, row: number, col: number): DisplayTextResult; + /** Renders `value` under the stored-form number format `formatCode` (empty means General). */ + formatValue(value: Value, formatCode: string): DisplayTextResult; definedNameCount(): NumberResult; definedNameAt(idx: number): DefinedNameEntry; tableCount(): NumberResult; @@ -2134,6 +2840,24 @@ export interface Workbook { getSheetRowOverrides(sheet: number): RowsResult; /** Sets / replaces the row height override at `row`. */ setRowHeight(sheet: number, row: number, height: number): Status; + /** Removes the height override at `row`, returning it to the sheet default height. */ + clearRowHeight(sheet: number, row: number): Status; + /** Reads the sheet's default column / row metrics (``). */ + getSheetFormatDefaults(sheet: number): SheetFormatDefaultsResult; + /** Replaces the sheet's default column / row metrics. A `has*` flag left out is inferred from the matching value. */ + setSheetFormatDefaults(sheet: number, defaults: Partial): Status; + /** Reads the rectangle covering `range` in points from the top-left of A1. `mode` is a geometry mode: 0 display (screen figure, 96 DPI), 1 print (the DPI-stable figure pagination breaks on). */ + getCellRectPt(sheet: number, range: MergeRange, mode: GeometryMode): CellRectResult; + /** Effective width of column `col` in points; 0 when hidden. */ + getColumnWidthPt(sheet: number, col: number, mode: GeometryMode): NumberResult; + /** Effective height of row `row` in points; 0 when hidden. */ + getRowHeightPt(sheet: number, row: number): NumberResult; + /** Reads the character-unit to point conversion for the workbook's Normal font. */ + getWidthModel(sheet: number, mode: GeometryMode): WidthModelResult; + /** Converts a column width in characters to points. */ + columnCharsToPt(sheet: number, mode: GeometryMode, chars: number): NumberResult; + /** Inverse of `columnCharsToPt`. */ + columnPtToChars(sheet: number, mode: GeometryMode, pt: number): NumberResult; /** Sets / replaces the row hidden flag at `row`. */ setRowHidden(sheet: number, row: number, hidden: boolean): Status; /** Sets / replaces the row outline level at `row` (clamped to 0..255). */ @@ -2224,6 +2948,24 @@ export interface Workbook { /** Returns the named-style xf record at `index`. Output shape mirrors * `getCellXf`. */ getCellStyleXf(index: number): CellXfResult; + /** Adds or replaces the `` entry named `record.name`. Absent + * fields default to `xfId` 0, a custom style (`0xFFFFFFFF`), level 0 and + * both flags false. */ + setCellStyle(record: CellStyleRecord): Status; + /** Removes the named cell style; cell formats that used it fall back to Normal. */ + removeCellStyle(name: string): Status; + + // Theme, colour resolution, effective style. + /** Reads the theme's 12 scheme colours and typefaces. */ + getTheme(): ThemeResult; + /** Writes the theme's 12 scheme colours (0xFFRRGGBB, `a:clrScheme` order; alpha ignored). */ + setThemeColors(colors: number[]): Status; + /** Writes the theme's major / minor typefaces. */ + setThemeFonts(fonts: ThemeFonts): Status; + /** Resolves a colour specification to the colour Excel renders. */ + resolveColor(spec: ColorSpec, context: ColorContext): ResolvedColorResult; + /** Reads the formatting a cell shows: its xf with colours resolved against the theme and palette. */ + getEffectiveStyle(sheet: number, row: number, col: number): EffectiveStyleResult; // Sheet UI features (merges, comments, hyperlinks, validations). /** Adds a merge range to `sheet`. */ @@ -2236,6 +2978,8 @@ export interface Workbook { clearMerges(sheet: number): Status; /** Returns every merge range on `sheet` as a JS array. */ getMerges(sheet: number): ListResult; + /** Returns every merge on `sheet` that intersects `range` (inclusive), in storage order. */ + getMergesInRange(sheet: number, range: MergeRange): ListResult; /** Returns the cell comment at `(sheet, row, col)`, or `null` when absent. */ getComment(sheet: number, row: number, col: number): CommentEntry | null; /** Returns a comment lookup result that distinguishes absence from an invalid sheet or handle. */ @@ -2286,6 +3030,57 @@ export interface Workbook { removeValidationAt(sheet: number, index: number): Status; /** Drops every validation rule on `sheet`. */ clearValidations(sheet: number): Status; + /** Checks `value` against the data-validation rule covering `(row, col)` without storing it. */ + validateValue(sheet: number, row: number, col: number, value: Value): ValidationOutcomeResult; + /** Enumerates the populated cells whose value fails their validation rule, one page at a time; `cursor` and `limit` follow `getCellsInRange`. */ + listInvalidCells(sheet: number, cursor?: number | null, limit?: number | null): CellsInRangeResult; + + /** Reads the sheet AutoFilter; `autoFilter` is `null` when the sheet has none. */ + getAutoFilter(sheet: number): AutoFilterResult; + /** Replaces the sheet AutoFilter. Row visibility is unchanged until `applyAutoFilter`. */ + setAutoFilter(sheet: number, autoFilter: AutoFilter): Status; + /** Removes the sheet AutoFilter; a no-op when there is none. */ + removeAutoFilter(sheet: number): Status; + /** Hides the body rows the criteria exclude and shows the ones they match. */ + applyAutoFilter(sheet: number): Status; + /** Drops every column criterion and shows all body rows. */ + clearAutoFilter(sheet: number): Status; + /** Evaluates the sheet AutoFilter without changing the sheet. */ + evaluateAutoFilter(sheet: number): AutoFilterEvalResult; + /** Table counterpart of `getAutoFilter`; `tableIdx` is the `tableAt` index. */ + getTableAutoFilter(table: number): AutoFilterResult; + setTableAutoFilter(table: number, autoFilter: AutoFilter): Status; + removeTableAutoFilter(table: number): Status; + applyTableAutoFilter(table: number): Status; + clearTableAutoFilter(table: number): Status; + evaluateTableAutoFilter(table: number): AutoFilterEvalResult; + + /** Returns every threaded comment on `sheet` in file order: each thread's opening comment followed by its replies. */ + getThreadedComments(sheet: number): ThreadedCommentsResult; + /** Adds a threaded comment. An empty `parentId` opens a new thread at `row`/`col`; otherwise it replies to that thread. */ + addThreadedComment(sheet: number, comment: Partial & Pick): Status; + /** Replaces the text and mentions of the comment with `id`. */ + editThreadedComment(sheet: number, id: string, text: string, mentions: Mention[]): Status; + /** Sets the resolved state of the thread opened by `threadId`. */ + setThreadResolved(sheet: number, threadId: string, done: boolean): Status; + /** Removes the comment with `id`; removing an opening comment removes its whole thread. */ + removeThreadedComment(sheet: number, id: string): Status; + /** Returns the workbook's person list in file order. */ + getPersons(): PersonsResult; + addPerson(person: Person): Status; + /** Removes the person with `id`; rejected while a comment or mention still names them. */ + removePerson(id: string): Status; + + /** Sniffs the format and pixel size of PNG, JPEG, GIF or BMP bytes from the header alone. */ + probeImage(bytes: Uint8Array): ImageInfoResult; + /** Lists the anchored objects of the sheet's drawing in document order. */ + listDrawingObjects(sheet: number): ListResult; + /** Returns a copy of the media bytes of the picture `objectId`. */ + getImage(sheet: number, objectId: number): ImageResult; + /** Inserts an image as a new anchored picture and returns its object id. */ + insertImage(sheet: number, bytes: Uint8Array, opts?: ImageInsertOptions): InsertImageResult; + /** Removes the picture `objectId`; other drawing content is kept. */ + removeImage(sheet: number, objectId: number): Status; // Conditional formatting (read / mutate). /** Returns every CF rule on `sheet` in flattened priority order. The @@ -2462,6 +3257,23 @@ declare const _default: { LogLevel: typeof LogLevel; CalcMode: typeof CalcMode; SheetVisibility: typeof SheetVisibility; + GeometryMode: typeof GeometryMode; + DisplayStatus: typeof DisplayStatus; + ColorContext: typeof ColorContext; + ImageFormat: typeof ImageFormat; + DrawingObjectKind: typeof DrawingObjectKind; + AnchorKind: typeof AnchorKind; + AnchorEditAs: typeof AnchorEditAs; + FilterKind: typeof FilterKind; + FilterOperator: typeof FilterOperator; + DynamicFilterType: typeof DynamicFilterType; + SortBy: typeof SortBy; + SortMethod: typeof SortMethod; + DateTimeGrouping: typeof DateTimeGrouping; + ValidationErrorStyle: typeof ValidationErrorStyle; + ColorResolution: typeof ColorResolution; + ThemeSource: typeof ThemeSource; + EffectiveStyleSource: typeof EffectiveStyleSource; ExternalLinkKind: typeof ExternalLinkKind; ErrorCode: typeof ErrorCode; WorkbookFormat: typeof WorkbookFormat; diff --git a/packages/npm-native/index.mjs b/packages/npm-native/index.mjs index a722c5589..9e476f116 100644 --- a/packages/npm-native/index.mjs +++ b/packages/npm-native/index.mjs @@ -207,6 +207,114 @@ export const CalcMode = Object.freeze({ Auto: 0, Manual: 1, AutoNoTable: 2 }); /** `fm_sheet_visibility_t` ordinals (mirror of OOXML ``). */ export const SheetVisibility = Object.freeze({ Visible: 0, Hidden: 1, VeryHidden: 2 }); +/** `fm_geometry_mode_t` ordinals: which column-width figure a geometry call uses. */ +export const GeometryMode = Object.freeze({ Display: 0, Print: 1 }); + +/** `fm_display_status_t` ordinals for `getDisplayText` / `formatValue`. */ +export const DisplayStatus = Object.freeze({ Ok: 0, Overflow: 1, InvalidFormat: 2 }); + +/** `fm_filter_kind_t` ordinals: criterion of an AutoFilter column. */ +export const FilterKind = Object.freeze({ None: 0, Values: 1, Custom: 2, Top10: 3, Dynamic: 4, Color: 5, Icon: 6 }); + +/** `fm_filter_operator_t` ordinals: comparison operator of a custom AutoFilter condition. */ +export const FilterOperator = Object.freeze({ + Equal: 0, + LessThan: 1, + LessThanOrEqual: 2, + NotEqual: 3, + GreaterThanOrEqual: 4, + GreaterThan: 5, +}); + +/** `fm_dynamic_filter_type_t` ordinals: dynamic AutoFilter type, in `ST_DynamicFilterType` schema order. */ +export const DynamicFilterType = Object.freeze({ + Null: 0, + AboveAverage: 1, + BelowAverage: 2, + Tomorrow: 3, + Today: 4, + Yesterday: 5, + NextWeek: 6, + ThisWeek: 7, + LastWeek: 8, + NextMonth: 9, + ThisMonth: 10, + LastMonth: 11, + NextQuarter: 12, + ThisQuarter: 13, + LastQuarter: 14, + NextYear: 15, + ThisYear: 16, + LastYear: 17, + YearToDate: 18, + Q1: 19, + Q2: 20, + Q3: 21, + Q4: 22, + M1: 23, + M2: 24, + M3: 25, + M4: 26, + M5: 27, + M6: 28, + M7: 29, + M8: 30, + M9: 31, + M10: 32, + M11: 33, + M12: 34, +}); + +/** `fm_sort_by_t` ordinals: what a sort condition sorts by. */ +export const SortBy = Object.freeze({ Value: 0, CellColor: 1, FontColor: 2, Icon: 3 }); + +/** `fm_sort_method_t` ordinals: sort method of an AutoFilter sort state. */ +export const SortMethod = Object.freeze({ None: 0, PinYin: 1, Stroke: 2 }); + +/** `fm_date_time_grouping_t` ordinals: granularity of an AutoFilter date-group item. */ +export const DateTimeGrouping = Object.freeze({ Year: 0, Month: 1, Day: 2, Hour: 3, Minute: 4, Second: 5 }); + +/** `fm_validation_error_style_t` ordinals: data-validation error style. */ +export const ValidationErrorStyle = Object.freeze({ Stop: 0, Warning: 1, Information: 2 }); + +/** `fm_image_format_t` ordinals: raster format of an image; `Unknown` marks "no image bytes found". */ +export const ImageFormat = Object.freeze({ Unknown: 0, Png: 1, Jpeg: 2, Gif: 3, Bmp: 4 }); + +/** `fm_drawing_object_kind_t` ordinals: what a drawing anchor holds. */ +export const DrawingObjectKind = Object.freeze({ + Picture: 0, + Shape: 1, + Chart: 2, + Group: 3, + Connector: 4, + GraphicFrame: 5, + Other: 6, +}); + +/** `fm_anchor_kind_t` ordinals: the anchor element type of a drawing object. */ +export const AnchorKind = Object.freeze({ OneCell: 0, TwoCell: 1, Absolute: 2 }); + +/** `fm_anchor_edit_as_t` ordinals: how a drawing object follows row and column edits. */ +export const AnchorEditAs = Object.freeze({ TwoCell: 0, OneCell: 1, Absolute: 2 }); + +/** `fm_color_context_t` ordinals: where a colour is used. */ +export const ColorContext = Object.freeze({ Font: 0, FillForeground: 1, FillBackground: 2, Border: 3 }); + +/** `fm_color_resolution_t` ordinals: how a colour was resolved. */ +export const ColorResolution = Object.freeze({ + Exact: 0, + DefaultTheme: 1, + IndexOutOfRange: 2, + ThemeUnparseable: 3, + AutoContext: 4, +}); + +/** Where `getTheme` read the theme from (the C `out_source` values). */ +export const ThemeSource = Object.freeze({ Part: 0, Default: 1, Unparseable: 2 }); + +/** Where `getEffectiveStyle` took its xf from. */ +export const EffectiveStyleSource = Object.freeze({ Cell: 0, Row: 1, Column: 2, Default: 3 }); + /** External-link kinds (mirror of `formulon::io::ExternalLinkRecord::Kind`). */ export const ExternalLinkKind = Object.freeze({ Unknown: 0, ExternalBook: 1, Ole: 2, Dde: 3 }); @@ -262,6 +370,23 @@ export default { LogLevel, CalcMode, SheetVisibility, + GeometryMode, + DisplayStatus, + ColorContext, + ImageFormat, + DrawingObjectKind, + AnchorKind, + AnchorEditAs, + FilterKind, + FilterOperator, + DynamicFilterType, + SortBy, + SortMethod, + DateTimeGrouping, + ValidationErrorStyle, + ColorResolution, + ThemeSource, + EffectiveStyleSource, ExternalLinkKind, ErrorCode, WorkbookFormat, diff --git a/packages/npm-native/package.json b/packages/npm-native/package.json index fc27f470d..6334d4c7f 100644 --- a/packages/npm-native/package.json +++ b/packages/npm-native/package.json @@ -1,6 +1,6 @@ { "name": "@libraz/formulon-native", - "version": "0.12.0", + "version": "0.13.0", "description": "Excel 365 calculation engine -- native N-API binding", "license": "Apache-2.0", "author": "libraz", diff --git a/packages/npm-native/test/input_validation.test.mjs b/packages/npm-native/test/input_validation.test.mjs new file mode 100644 index 000000000..40cf0c17b --- /dev/null +++ b/packages/npm-native/test/input_validation.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const addonPath = + process.env.FORMULON_NODE_ADDON ?? + [ + path.resolve(testDir, '..', 'dist', 'prebuilds', `${process.platform}-${process.arch}`, 'formulon.node'), + path.resolve(testDir, '..', 'dist', 'formulon.node'), + path.resolve(testDir, '..', 'formulon.node'), + ].find((candidate) => existsSync(candidate)); +assert.ok(addonPath, 'native addon not found; set FORMULON_NODE_ADDON or stage dist/formulon.node'); +const requireAddon = createRequire(import.meta.url); +const addon = requireAddon(addonPath); + +function cellNumber(wb, row = 0, col = 0) { + const result = wb.getValue(0, row, col); + assert.equal(result.status.ok, true, JSON.stringify(result.status)); + assert.equal(result.value.kind, 1); + return result.value.number; +} + +test('coercion objects and symbols are rejected before mutating a cell', () => { + const source = ` + const addon = require(process.env.FORMULON_NODE_ADDON); + const wb = addon.Workbook.createDefault(); + const failures = []; + function probe(label, call) { + try { + call(); + failures.push(label + ':returned'); + } catch (error) { + if (!(error instanceof TypeError)) failures.push(label + ':' + error.name); + } + const cell = wb.getValue(0, 0, 0); + if (!cell.status.ok || cell.value.kind !== 1 || cell.value.number !== 42) { + failures.push(label + ':mutated'); + } + } + if (!wb.setNumber(0, 0, 0, 42).ok) failures.push('setup'); + probe('valueOf', () => wb.setNumber(0, 0, 0, { valueOf() { throw new Error('valueOf'); } })); + probe('symbol-number', () => wb.setNumber(0, 0, 0, Symbol('number'))); + probe('toString', () => wb.setText(0, 0, 0, { toString() { throw new Error('toString'); } })); + probe('symbol-string', () => wb.setText(0, 0, 0, Symbol('string'))); + probe('detached', () => wb.setNumber.call({}, 0, 0, 0, 7)); + const forged = Object.create(addon.Workbook.prototype); + probe('forged', () => wb.setNumber.call(forged, 0, 0, 0, 7)); + wb.dispose(); + if (failures.length) { + console.error(JSON.stringify(failures)); + process.exitCode = 1; + } + `; + const result = spawnSync(process.execPath, ['--input-type=commonjs', '-e', source], { + env: { ...process.env, FORMULON_NODE_ADDON: addonPath }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, `subprocess exited ${result.status}: ${result.stderr}`); +}); + +test('module-level numeric and string positions reject coercion and overflow', () => { + const source = ` + const addon = require(process.env.FORMULON_NODE_ADDON); + const failures = []; + function probe(label, expected, call) { + try { + call(); + failures.push(label + ':returned'); + } catch (error) { + if (!(error instanceof expected)) failures.push(label + ':' + error.name); + } + } + probe('status-symbol', TypeError, () => addon.statusString(Symbol('status'))); + probe('status-fraction', RangeError, () => addon.statusString(1.5)); + probe('status-overflow', RangeError, () => addon.statusString(2 ** 31)); + probe('error-symbol', TypeError, () => addon.errorDisplayName(Symbol('error'))); + probe('error-nan', RangeError, () => addon.errorDisplayName(Number.NaN)); + probe('log-symbol', TypeError, () => addon.setLogMinLevel(Symbol('log'))); + probe('log-underflow', RangeError, () => addon.setLogMinLevel(-(2 ** 31) - 1)); + probe('formula-symbol', TypeError, () => addon.evalFormula(Symbol('formula'))); + probe('formula-object', TypeError, () => addon.evalFormula({ toString() { throw new Error('toString'); } })); + if (failures.length) { + console.error(JSON.stringify(failures)); + process.exitCode = 1; + } + `; + const result = spawnSync(process.execPath, ['--input-type=commonjs', '-e', source], { + env: { ...process.env, FORMULON_NODE_ADDON: addonPath }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, `subprocess exited ${result.status}: ${result.stderr}`); +}); + +test('integer positional arguments reject wrapping and non-finite values', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setNumber(0, 0, 0, 42).ok, true); + for (const value of [2 ** 32, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => wb.setNumber(value, 0, 0, 7), RangeError, `sheet=${String(value)}`); + assert.equal(cellNumber(wb), 42, `invalid sheet changed A1 for ${String(value)}`); + } + for (const value of [2 ** 32, 2 ** 31, -(2 ** 31) - 1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => wb.setError(0, 0, 0, value), RangeError, `errorCode=${String(value)}`); + assert.equal(cellNumber(wb), 42, `invalid error code changed A1 for ${String(value)}`); + } + } finally { + wb.dispose(); + } +}); + +test('sheet, structural, enum, and read positions reject non-number primitives', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.throws(() => wb.sheetName(Symbol('sheet')), TypeError); + assert.throws(() => wb.insertRows(0, 0, 1.5), RangeError); + assert.throws(() => wb.setCalcMode(1.5), RangeError); + assert.throws(() => wb.getValue(Symbol('sheet'), 0, 0), TypeError); + assert.throws(() => wb.functionMetadata('SUM', 1.5), RangeError); + assert.throws(() => wb.resolveColor({ kind: 1, rgb: 0 }, 2 ** 31), RangeError); + assert.throws( + () => wb.getCellsInRange(0, { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }, Symbol('cursor')), + TypeError, + ); + } finally { + wb.dispose(); + } +}); + +test('paging cursor and limit reject values outside native integer ranges', () => { + const wb = addon.Workbook.createDefault(); + const range = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; + try { + for (const value of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2 ** 53, 2 ** 64]) { + assert.throws(() => wb.getCellsInRange(0, range, value), RangeError, `get cursor=${String(value)}`); + assert.throws(() => wb.listInvalidCells(0, value), RangeError, `invalid cursor=${String(value)}`); + } + for (const value of [2 ** 32, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => wb.getCellsInRange(0, range, undefined, value), RangeError, `get limit=${String(value)}`); + assert.throws(() => wb.listInvalidCells(0, undefined, value), RangeError, `invalid limit=${String(value)}`); + } + assert.doesNotThrow(() => wb.getCellsInRange(0, range, Number.MAX_SAFE_INTEGER)); + assert.doesNotThrow(() => wb.listInvalidCells(0, Number.MAX_SAFE_INTEGER)); + } finally { + wb.dispose(); + } +}); + +test('missing optional positional arguments retain their existing defaults', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setIterative(true).ok, true); + assert.equal(wb.setSheetVisibility(0).ok, true); + assert.equal(wb.functionMetadata('SUM').ok, true); + const range = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; + assert.doesNotThrow(() => wb.getCellsInRange(0, range, undefined, undefined)); + assert.doesNotThrow(() => wb.listInvalidCells(0, undefined, undefined)); + assert.doesNotThrow(() => wb.getCellsInRange(0, range, null, null)); + assert.doesNotThrow(() => wb.listInvalidCells(0, null, null)); + } finally { + wb.dispose(); + } +}); + +test('getCellsInRange reads its range argument like the merge methods', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setNumber(0, 0, 0, 7).ok, true); + const nullish = wb.getCellsInRange(0, null); + assert.equal(nullish.status.ok, true); + assert.equal(nullish.cells.length, 1); + assert.equal(wb.getMergesInRange(0, null).status.ok, true); + assert.throws(() => wb.getCellsInRange(0, 5), TypeError); + assert.throws(() => wb.getMergesInRange(0, 5), TypeError); + } finally { + wb.dispose(); + } +}); diff --git a/packages/npm-native/test/nested_cells_validation.test.mjs b/packages/npm-native/test/nested_cells_validation.test.mjs new file mode 100644 index 000000000..b31558c00 --- /dev/null +++ b/packages/npm-native/test/nested_cells_validation.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import { assert, getModule } from './smoke_support.mjs'; + +function withWorkbook(Workbook, fn) { + const wb = Workbook.createDefault(); + try { + return fn(wb); + } finally { + wb.dispose(); + } +} + +function validRuns() { + return [ + { sb: 0, eb: 2, text: 'トウキョウ' }, + { sb: 2, eb: 3, text: 'ト' }, + ]; +} + +function validRange() { + return { firstRow: 0, firstCol: 0, lastRow: 4, lastCol: 4 }; +} + +test('cell phonetic run integers reject narrowing before replacing existing runs', async () => { + const mod = await getModule(); + const invalid = [2 ** 32, 1.5, Number.NaN, Number.POSITIVE_INFINITY]; + for (const value of invalid) { + withWorkbook(mod.Workbook, (wb) => { + assert.ok(wb.setText(0, 0, 0, '東京都').ok); + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, validRuns()).ok); + const before = wb.getCellPhoneticRuns(0, 0, 0).runs; + for (const key of ['sb', 'eb']) { + assert.throws( + () => wb.setCellPhoneticRuns(0, 0, 0, [{ ...validRuns()[0], [key]: value }, validRuns()[1]]), + RangeError, + `setCellPhoneticRuns ${key}=${String(value)}`, + ); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, before); + } + }); + } +}); + +test('cell phonetic run text rejects coercible non-strings like the WASM surface', async () => { + const mod = await getModule(); + withWorkbook(mod.Workbook, (wb) => { + assert.ok(wb.setText(0, 0, 0, '東京都').ok); + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, validRuns()).ok); + const before = wb.getCellPhoneticRuns(0, 0, 0).runs; + for (const value of [42, true, { toString: () => 'ト' }]) { + assert.throws( + () => wb.setCellPhoneticRuns(0, 0, 0, [{ ...validRuns()[0], text: value }, validRuns()[1]]), + TypeError, + `setCellPhoneticRuns text=${String(value)}`, + ); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, before); + } + const bytes = new TextEncoder().encode('トウキョウ'); + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, [{ ...validRuns()[0], text: bytes }, validRuns()[1]]).ok); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, before); + }); +}); + +test('cell phonetic properties reject narrowing before changing the stored properties', async () => { + const mod = await getModule(); + const invalid = [2 ** 32, 1.5, Number.NaN, Number.POSITIVE_INFINITY]; + for (const value of invalid) { + withWorkbook(mod.Workbook, (wb) => { + assert.ok(wb.setText(0, 0, 0, '大阪').ok); + assert.ok(wb.setCellPhoneticProperties(0, 0, 0, { fontId: 3, type: 2, alignment: 2 }).ok); + const before = wb.getCellPhoneticProperties(0, 0, 0); + for (const key of ['fontId', 'type', 'alignment']) { + assert.throws( + () => wb.setCellPhoneticProperties(0, 0, 0, { fontId: 3, type: 2, alignment: 2, [key]: value }), + RangeError, + `setCellPhoneticProperties ${key}=${String(value)}`, + ); + const after = wb.getCellPhoneticProperties(0, 0, 0); + assert.deepEqual( + { fontId: after.fontId, type: after.type, alignment: after.alignment }, + { fontId: before.fontId, type: before.type, alignment: before.alignment }, + ); + } + }); + } +}); + +test('cell nested value integers reject narrowing before formatValue reads them', async () => { + const mod = await getModule(); + const invalid = [2 ** 32, 1.5, Number.NaN, Number.POSITIVE_INFINITY]; + for (const value of invalid) { + withWorkbook(mod.Workbook, (wb) => { + assert.throws( + () => wb.formatValue({ kind: value, number: 1, boolean: 0, text: '', errorCode: 0 }, 'General'), + RangeError, + `formatValue kind=${String(value)}`, + ); + assert.throws( + () => wb.formatValue({ kind: 2, number: 0, boolean: value, text: '', errorCode: 0 }, 'General'), + RangeError, + `formatValue boolean=${String(value)}`, + ); + assert.throws( + () => wb.formatValue({ kind: 4, number: 0, boolean: 0, text: '', errorCode: value }, 'General'), + RangeError, + `formatValue errorCode=${String(value)}`, + ); + }); + } +}); + +test('cell nested value symbols are rejected before formatValue', async () => { + const mod = await getModule(); + withWorkbook(mod.Workbook, (wb) => { + const symbol = Symbol('cell value integer'); + assert.throws( + () => wb.formatValue({ kind: symbol, number: 1, boolean: 0, text: '', errorCode: 0 }, 'General'), + TypeError, + ); + assert.throws( + () => wb.formatValue({ kind: 2, number: 0, boolean: symbol, text: '', errorCode: 0 }, 'General'), + TypeError, + ); + assert.throws( + () => wb.formatValue({ kind: 4, number: 0, boolean: 0, text: '', errorCode: symbol }, 'General'), + TypeError, + ); + }); +}); + +test('cell range integers reject narrowing before getCellsInRange touches the workbook', async () => { + const mod = await getModule(); + const invalid = [2 ** 32, 1.5, Number.NaN, Number.POSITIVE_INFINITY]; + for (const value of invalid) { + withWorkbook(mod.Workbook, (wb) => { + assert.ok(wb.setNumber(0, 0, 0, 7).ok); + for (const key of ['firstRow', 'firstCol', 'lastRow', 'lastCol']) { + assert.throws( + () => wb.getCellsInRange(0, { ...validRange(), [key]: value }), + RangeError, + `getCellsInRange ${key}=${String(value)}`, + ); + } + }); + } +}); + +test('cell range getters stop before getCellsInRange calls the C API', async () => { + const mod = await getModule(); + withWorkbook(mod.Workbook, (wb) => { + assert.ok(wb.setNumber(0, 0, 0, 7).ok); + const range = validRange(); + Object.defineProperty(range, 'firstRow', { + enumerable: true, + get() { + throw new Error('cell range getter'); + }, + }); + assert.throws(() => wb.getCellsInRange(0, range), /cell range getter/); + }); +}); diff --git a/packages/npm-native/test/nested_layout_validation.test.mjs b/packages/npm-native/test/nested_layout_validation.test.mjs new file mode 100644 index 000000000..c0427894b --- /dev/null +++ b/packages/npm-native/test/nested_layout_validation.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const addonPath = + process.env.FORMULON_NODE_ADDON ?? + [ + path.resolve(testDir, '..', 'dist', 'prebuilds', `${process.platform}-${process.arch}`, 'formulon.node'), + path.resolve(testDir, '..', 'dist', 'formulon.node'), + path.resolve(testDir, '..', 'formulon.node'), + ].find((candidate) => existsSync(candidate)); +assert.ok(addonPath, 'native addon not found; set FORMULON_NODE_ADDON or stage dist/formulon.node'); +const addon = createRequire(import.meta.url)(addonPath); + +const RANGE = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; + +function protectionSnapshot(wb) { + const result = wb.getSheetProtection(0); + assert.equal(result.status.ok, true, JSON.stringify(result.status)); + return structuredClone(result.protection); +} + +test('sheet protection rejects every narrow nested integer before mutation', () => { + const wb = addon.Workbook.createDefault(); + try { + const keys = [ + 'enabled', + 'sheet', + 'objects', + 'scenarios', + 'formatCells', + 'formatColumns', + 'formatRows', + 'insertColumns', + 'insertRows', + 'insertHyperlinks', + 'deleteColumns', + 'deleteRows', + 'selectLockedCells', + 'selectUnlockedCells', + 'sort', + 'autoFilter', + 'pivotTables', + ]; + for (const key of keys) { + const before = protectionSnapshot(wb); + assert.throws(() => wb.setSheetProtection(0, { [key]: 1.5 }), RangeError, key); + assert.deepEqual(protectionSnapshot(wb), before, `${key} changed protection`); + } + const beforeSpin = protectionSnapshot(wb); + assert.throws(() => wb.setSheetProtection(0, { spinCount: 2 ** 32 }), RangeError); + assert.deepEqual(protectionSnapshot(wb), beforeSpin); + } finally { + wb.dispose(); + } +}); + +test('sheet protection stops at a throwing nested getter before native mutation', () => { + const wb = addon.Workbook.createDefault(); + try { + const before = protectionSnapshot(wb); + const spec = { enabled: 1, algorithmName: 'SHA-512' }; + Object.defineProperty(spec, 'formatRows', { + enumerable: true, + get() { + throw new Error('protection getter'); + }, + }); + assert.throws(() => wb.setSheetProtection(0, spec), /protection getter/); + assert.deepEqual(protectionSnapshot(wb), before); + } finally { + wb.dispose(); + } +}); + +test('sheet format defaults stops at a throwing nested getter before native mutation', () => { + const wb = addon.Workbook.createDefault(); + try { + const before = wb.getSheetFormatDefaults(0); + const spec = { defaultColWidth: 12.5 }; + Object.defineProperty(spec, 'defaultRowHeight', { + enumerable: true, + get() { + throw new Error('format getter'); + }, + }); + assert.throws(() => wb.setSheetFormatDefaults(0, spec), /format getter/); + assert.deepEqual(wb.getSheetFormatDefaults(0), before); + } finally { + wb.dispose(); + } +}); + +test('cell geometry rejects fractional nested range coordinates before the query', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.throws(() => wb.getCellRectPt(0, { ...RANGE, firstRow: 1.5 }, 0), RangeError); + assert.throws(() => wb.getCellRectPt(0, { ...RANGE, lastCol: Number.NaN }, 0), RangeError); + const range = {}; + Object.defineProperty(range, 'firstRow', { + enumerable: true, + get() { + throw new Error('geometry getter'); + }, + }); + assert.throws(() => wb.getCellRectPt(0, range, 0), /geometry getter/); + } finally { + wb.dispose(); + } +}); + +test('partial recalc rejects fractional nested viewport coordinates before recalc', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.ok(wb.setNumber(0, 0, 0, 1).ok); + assert.ok(wb.setFormula(0, 0, 1, '=A1+1').ok); + assert.throws(() => wb.partialRecalc({ ...RANGE, sheet: 0, firstRow: 1.5 }), RangeError); + const viewport = { sheet: 0, firstRow: 0, lastRow: 0, firstCol: 0, lastCol: 0 }; + Object.defineProperty(viewport, 'lastCol', { + enumerable: true, + get() { + throw new Error('viewport getter'); + }, + }); + assert.throws(() => wb.partialRecalc(viewport), /viewport getter/); + const value = wb.getValue(0, 0, 1); + assert.equal(value.status.ok, true, JSON.stringify(value.status)); + assert.equal(value.value.kind, 0); + assert.equal(value.value.number, 0); + } finally { + wb.dispose(); + } +}); diff --git a/packages/npm-native/test/nested_numeric_validation.test.mjs b/packages/npm-native/test/nested_numeric_validation.test.mjs new file mode 100644 index 000000000..36e5d8e84 --- /dev/null +++ b/packages/npm-native/test/nested_numeric_validation.test.mjs @@ -0,0 +1,274 @@ +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { assert, getModule } from './smoke_support.mjs'; + +const RANGE = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; + +function dataBarRule(overrides = {}) { + return { + sqref: [RANGE], + type: 3, + dataBar: { + min: { type: 3 }, + max: { type: 4 }, + ...overrides, + }, + }; +} + +async function getValidationModule() { + if (process.env.FORMULON_NODE_ADDON) { + const addon = await import(pathToFileURL(process.env.FORMULON_NODE_ADDON).href); + return addon.default ?? addon; + } + return getModule(); +} + +test('nested style integers reject narrowing before addXf mutates the workbook', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const before = wb.xfCount().value; + assert.throws(() => wb.addXf({ horizontalAlign: 256 }), RangeError); + assert.equal(wb.xfCount().value, before); + + assert.throws(() => wb.addXf({ numFmtId: 65536 }), RangeError); + assert.equal(wb.xfCount().value, before); + + assert.throws(() => wb.addXf({ fontIndex: 2 ** 32 }), RangeError); + assert.equal(wb.xfCount().value, before); + + const beforeFonts = wb.fontCount().value; + assert.throws(() => wb.addFont({ underline: 256 }), RangeError); + assert.equal(wb.fontCount().value, beforeFonts); + + const beforeFills = wb.fillCount().value; + assert.throws(() => wb.addFill({ pattern: 256 }), RangeError); + assert.equal(wb.fillCount().value, beforeFills); + + const beforeBorders = wb.borderCount().value; + assert.throws(() => wb.addBorder({ left: { style: 256 } }), RangeError); + assert.equal(wb.borderCount().value, beforeBorders); + + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, -1]) { + assert.throws(() => wb.addXf({ horizontalAlign: value }), RangeError, `horizontalAlign=${String(value)}`); + assert.equal(wb.xfCount().value, before); + } + + const omitted = wb.addXf({ horizontalAlign: null, verticalAlign: undefined }); + assert.ok(omitted.status.ok, JSON.stringify(omitted.status)); + assert.equal(wb.xfCount().value, before); + } finally { + wb.dispose(); + } +}); + +test('nested conditional-format integers reject narrowing before addConditionalFormat mutates', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const beforeDxfs = wb.dxfCount().value; + assert.throws(() => wb.addDxf({ numFmt: { numFmtId: 65536 } }), RangeError); + assert.equal(wb.dxfCount().value, beforeDxfs); + + assert.equal(wb.getConditionalFormats(0).length, 0); + assert.throws(() => wb.addConditionalFormat(0, dataBarRule({ minLengthPct: 256 })), RangeError); + assert.equal(wb.getConditionalFormats(0).length, 0); + + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, -1]) { + assert.throws( + () => wb.addConditionalFormat(0, dataBarRule({ minLengthPct: value })), + RangeError, + `minLengthPct=${String(value)}`, + ); + assert.equal(wb.getConditionalFormats(0).length, 0); + } + + const omitted = wb.addConditionalFormat(0, dataBarRule({ minLengthPct: null, maxLengthPct: undefined })); + assert.ok(omitted.status.ok, JSON.stringify(omitted)); + const stored = wb.getConditionalFormats(0); + assert.equal(stored.length, 1); + assert.equal(stored[0].dataBar.minLengthPct, 10); + assert.equal(stored[0].dataBar.maxLengthPct, 90); + } finally { + wb.dispose(); + } +}); + +test('nested style and conditional-format fields require primitive numeric values', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const beforeXfs = wb.xfCount().value; + assert.throws(() => wb.addXf({ horizontalAlign: '1' }), TypeError); + assert.equal(wb.xfCount().value, beforeXfs); + + assert.throws(() => wb.addXf({ horizontalAlign: Symbol('numeric') }), TypeError); + assert.equal(wb.xfCount().value, beforeXfs); + + assert.throws(() => wb.addConditionalFormat(0, dataBarRule({ minLengthPct: '10' })), TypeError); + assert.equal(wb.getConditionalFormats(0).length, 0); + + assert.throws(() => wb.addConditionalFormat(0, dataBarRule({ minLengthPct: Symbol('numeric') })), TypeError); + assert.equal(wb.getConditionalFormats(0).length, 0); + } finally { + wb.dispose(); + } +}); + +test('nested readers preserve the original getter error and abort the whole mutation', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const beforeXfs = wb.xfCount().value; + const style = {}; + Object.defineProperty(style, 'horizontalAlign', { + enumerable: true, + get() { + throw new Error('style getter'); + }, + }); + assert.throws(() => wb.addXf(style), /style getter/); + assert.equal(wb.xfCount().value, beforeXfs); + + const rule = dataBarRule(); + Object.defineProperty(rule.dataBar, 'minLengthPct', { + enumerable: true, + get() { + throw new Error('conditional-format getter'); + }, + }); + assert.throws(() => wb.addConditionalFormat(0, rule), /conditional-format getter/); + assert.equal(wb.getConditionalFormats(0).length, 0); + } finally { + wb.dispose(); + } +}); + +test('style and conditional-format readers snapshot stateful fields once', async () => { + const mod = await getValidationModule(); + const wb = mod.Workbook.createDefault(); + try { + let horizontalReads = 0; + const statefulXf = {}; + Object.defineProperty(statefulXf, 'horizontalAlign', { + get() { + horizontalReads += 1; + return horizontalReads === 1 ? 1 : undefined; + }, + }); + const xf = wb.addXf(statefulXf); + assert.ok(xf.status.ok, JSON.stringify(xf.status)); + assert.equal(horizontalReads, 1); + const storedXf = wb.getCellXf(xf.index); + assert.equal(storedXf.horizontalAlign, 1); + assert.equal(storedXf.hasHorizontalAlign, true); + + const firstDxf = wb.addDxf({ font: { name: 'first' } }); + const secondDxf = wb.addDxf({ font: { name: 'second' } }); + assert.ok(firstDxf.status.ok, JSON.stringify(firstDxf.status)); + assert.ok(secondDxf.status.ok, JSON.stringify(secondDxf.status)); + let dxfReads = 0; + const rule = dataBarRule(); + Object.defineProperty(rule, 'dxfId', { + get() { + dxfReads += 1; + return dxfReads === 1 ? secondDxf.index : undefined; + }, + }); + const cf = wb.addConditionalFormat(0, rule); + assert.ok(cf.status.ok, JSON.stringify(cf.status)); + assert.equal(dxfReads, 1); + assert.equal(wb.getConditionalFormats(0)[0].dxfId, secondDxf.index); + + const beforeMemory = wb.memoryUsage(); + const lateFont = { name: 'late font '.repeat(1024), bold: true }; + Object.defineProperty(lateFont, 'color', { + get() { + throw new Error('late font getter'); + }, + }); + const beforeFonts = wb.fontCount().value; + for (let i = 0; i < 8; i += 1) { + assert.throws(() => wb.addFont(lateFont), /late font getter/); + } + assert.equal(wb.fontCount().value, beforeFonts); + assert.equal(wb.memoryUsage(), beforeMemory); + } finally { + wb.dispose(); + } +}); + +test('conditional-format arrays skip nullish entries and keep later records', async () => { + const mod = await getValidationModule(); + const wb = mod.Workbook.createDefault(); + try { + const added = wb.addConditionalFormat(0, { + // Include null, undefined, and a hole before the valid range. The old + // reader skipped those entries and still passed the later range to C. + sqref: Object.assign(new Array(4), { 0: null, 1: undefined, 3: RANGE }), + type: 3, + dataBar: { min: { type: 3 }, max: { type: 4 } }, + }); + assert.ok(added.status.ok, JSON.stringify(added.status)); + const stored = wb.getConditionalFormats(0); + assert.equal(stored.length, 1); + assert.equal(stored[0].sqref.length, 1); + assert.deepEqual(stored[0].sqref[0], RANGE); + } finally { + wb.dispose(); + } +}); + +test('named-style and theme integer fields reject before their C mutations', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const styleCount = wb.cellStyleCount().value; + for (const value of [2 ** 32, Number.NaN, 1.5, -1]) { + assert.throws(() => wb.setCellStyle({ name: 'invalid', xfId: value }), RangeError); + assert.equal(wb.cellStyleCount().value, styleCount); + } + + const before = wb.getTheme().colors; + for (const value of [Symbol('theme'), Number.NaN, 1.5, -1, 2 ** 32]) { + const values = Array.from({ length: 12 }, (_, i) => (i === 0 ? value : before[i])); + assert.throws( + () => wb.setThemeColors(values), + typeof value === 'symbol' ? TypeError : /must be a number|outside uint32/, + ); + assert.deepEqual(wb.getTheme().colors, before); + } + } finally { + wb.dispose(); + } +}); + +test('style and CF reader defaults survive nullish fields and valid u8 boundaries', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const font = wb.addFont({ name: 'Reader defaults', size: null, colorArgb: undefined, underline: null }); + assert.ok(font.status.ok, JSON.stringify(font.status)); + const storedFont = wb.getFont(font.index); + assert.equal(storedFont.size, 11); + assert.equal(storedFont.colorArgb >>> 0, 0xff000000); + + const cf = wb.addConditionalFormat( + 0, + dataBarRule({ + fill: { r: 255, g: 0, b: 1 }, + minLengthPct: 0, + maxLengthPct: 100, + }), + ); + assert.ok(cf.status.ok, JSON.stringify(cf)); + const stored = wb.getConditionalFormats(0)[0].dataBar; + assert.equal(stored.fill.r, 255); + assert.equal(stored.fill.a, 255); + assert.equal(stored.minLengthPct, 0); + assert.equal(stored.maxLengthPct, 100); + } finally { + wb.dispose(); + } +}); diff --git a/packages/npm-native/test/nested_pivot_validation.test.mjs b/packages/npm-native/test/nested_pivot_validation.test.mjs new file mode 100644 index 000000000..00f46b691 --- /dev/null +++ b/packages/npm-native/test/nested_pivot_validation.test.mjs @@ -0,0 +1,270 @@ +import test from 'node:test'; +import { assert, getModule } from './smoke_support.mjs'; + +function statusOf(result) { + return typeof result?.ok === 'boolean' ? result : result?.status; +} + +function must(result, what) { + const status = statusOf(result); + assert.ok(status?.ok, `${what}: ${JSON.stringify(status)}`); + return result; +} + +function makePivot(Workbook) { + const wb = Workbook.createDefault(); + const cache = must(wb.pivotCacheCreate(0), 'pivotCacheCreate').index; + must( + wb.pivotCacheSetWorksheetSource(cache, { present: true, ref: 'A1:B3', sheet: 'Sheet1' }), + 'pivotCacheSetWorksheetSource', + ); + must(wb.pivotCacheFieldAdd(cache, 'Region'), 'pivotCacheFieldAdd Region'); + must(wb.pivotCacheFieldAdd(cache, 'Amount'), 'pivotCacheFieldAdd Amount'); + for (const [region, amount] of [ + ['East', 10], + ['West', 30], + ]) { + const record = must(wb.pivotCacheRecordAdd(cache), 'pivotCacheRecordAdd').index; + must(wb.pivotCacheRecordSetText(cache, record, 0, region), 'pivotCacheRecordSetText'); + must(wb.pivotCacheRecordSetNumber(cache, record, 1, amount), 'pivotCacheRecordSetNumber'); + } + const pivot = must(wb.pivotCreate(0, 'Pivot1', cache, 0, 4), 'pivotCreate').index; + const region = must(wb.pivotFieldAdd(0, pivot, { sourceName: 'Region', axis: 0 }), 'region field').index; + const amount = must(wb.pivotFieldAdd(0, pivot, { sourceName: 'Amount', axis: 2 }), 'amount field').index; + must(wb.pivotSetRowFieldOrder(0, pivot, [region]), 'pivotSetRowFieldOrder'); + must( + wb.pivotDataFieldAdd(0, pivot, { name: 'Sum of Amount', fieldIndex: amount, aggregation: 0 }), + 'pivotDataFieldAdd', + ); + return { wb, pivot, region, amount }; +} + +function withPivot(Workbook, fn) { + const fixture = makePivot(Workbook); + try { + return fn(fixture); + } finally { + fixture.wb.dispose(); + } +} + +function pivotGrid(wb, pivot) { + const layout = wb.pivotLayout(0, pivot); + assert.ok(layout.status.ok, JSON.stringify(layout.status)); + const rows = new Map(); + for (const cell of layout.cells) { + const value = cell.value; + const text = value.kind === 1 ? String(value.number) : value.kind === 3 ? value.text : ''; + if (!rows.has(cell.row)) rows.set(cell.row, new Array(layout.cols).fill('')); + rows.get(cell.row)[cell.col - layout.left] = text; + } + return [...rows.keys()].sort((a, b) => a - b).map((row) => rows.get(row).join('|')); +} + +const BASE_GRID = ['行ラベル|Sum of Amount', 'East|10', 'West|30', '総計|40']; + +test('pivot nested integer fields reject narrowing before their C mutations', async () => { + const mod = await getModule(); + const invalid = [2 ** 32, 1.5, Number.NaN, Number.POSITIVE_INFINITY]; + + for (const value of invalid) { + withPivot(mod.Workbook, ({ wb, pivot }) => { + const before = wb.pivotFieldCount(0, pivot).value; + assert.throws( + () => wb.pivotFieldAdd(0, pivot, { sourceName: 'Region', axis: value }), + RangeError, + `pivotFieldAdd axis=${String(value)}`, + ); + assert.equal(wb.pivotFieldCount(0, pivot).value, before); + }); + + withPivot(mod.Workbook, ({ wb, pivot, amount }) => { + const before = wb.pivotDataFieldCount(0, pivot).value; + assert.throws( + () => wb.pivotDataFieldAdd(0, pivot, { name: 'Bad', fieldIndex: value, aggregation: 0 }), + RangeError, + `pivotDataFieldAdd fieldIndex=${String(value)}`, + ); + assert.equal(wb.pivotDataFieldCount(0, pivot).value, before); + + assert.throws( + () => wb.pivotDataFieldSet(0, pivot, 0, { name: 'Bad', fieldIndex: amount, aggregation: value }), + RangeError, + `pivotDataFieldSet aggregation=${String(value)}`, + ); + assert.equal(wb.pivotDataFieldCount(0, pivot).value, before); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + + for (const key of ['showAs', 'showAsBaseField', 'showAsBaseItem']) { + assert.throws( + () => + wb.pivotDataFieldSet(0, pivot, 0, { + name: 'Bad', + fieldIndex: amount, + aggregation: 0, + [key]: value, + }), + RangeError, + `pivotDataFieldSet ${key}=${String(value)}`, + ); + assert.equal(wb.pivotDataFieldCount(0, pivot).value, before); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + } + }); + } +}); + +test('pivot nested integer fields reject wrong primitives without changing the pivot', async () => { + const mod = await getModule(); + const symbol = Symbol('pivot numeric'); + withPivot(mod.Workbook, ({ wb, pivot }) => { + const fieldCount = wb.pivotFieldCount(0, pivot).value; + assert.throws(() => wb.pivotFieldAdd(0, pivot, { sourceName: 'Region', axis: symbol }), TypeError); + assert.equal(wb.pivotFieldCount(0, pivot).value, fieldCount); + + const dataFieldCount = wb.pivotDataFieldCount(0, pivot).value; + assert.throws(() => wb.pivotDataFieldAdd(0, pivot, { name: 'Bad', fieldIndex: symbol, aggregation: 0 }), TypeError); + assert.equal(wb.pivotDataFieldCount(0, pivot).value, dataFieldCount); + + const filterCount = wb.pivotFilterCount(0, pivot).value; + assert.throws( + () => + wb.pivotFilterAdd(0, pivot, { + axis: 0, + fieldName: 'Region', + type: 1, + valueKind: symbol, + valueDouble: 15, + }), + TypeError, + ); + assert.equal(wb.pivotFilterCount(0, pivot).value, filterCount); + }); +}); + +test('pivot filters validate axis, value kind, and data-field index before mutation', async () => { + const mod = await getModule(); + const invalidSpecs = [ + { axis: 2 ** 32 }, + { type: 2 ** 32 }, + { valueKind: 1.5 }, + { valueInt: 1.5 }, + { valueHighKind: Number.NaN }, + { valueHighInt: Number.POSITIVE_INFINITY }, + { dataFieldIndex: 2 ** 32 }, + ]; + for (const override of invalidSpecs) { + withPivot(mod.Workbook, ({ wb, pivot }) => { + const before = wb.pivotFilterCount(0, pivot).value; + assert.throws( + () => + wb.pivotFilterAdd(0, pivot, { + axis: 0, + fieldName: 'Region', + type: 1, + valueKind: 1, + valueDouble: 15, + ...override, + }), + RangeError, + `pivotFilterAdd ${JSON.stringify(override)}`, + ); + assert.equal(wb.pivotFilterCount(0, pivot).value, before); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + }); + } +}); + +test('pivot order arrays validate every element before replacing the order', async () => { + const mod = await getModule(); + const invalid = [2 ** 32 + 1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Symbol('pivot order')]; + for (const value of invalid) { + withPivot(mod.Workbook, ({ wb, pivot }) => { + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + assert.throws( + () => wb.pivotSetRowFieldOrder(0, pivot, [value]), + typeof value === 'symbol' ? TypeError : RangeError, + `pivotSetRowFieldOrder element=${String(value)}`, + ); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + }); + } + + withPivot(mod.Workbook, ({ wb, pivot, region }) => { + assert.ok(wb.pivotSetRowFieldOrder(0, pivot, [region]).ok); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + }); +}); + +test('pivot order stops at a throwing array getter before changing the report', async () => { + const mod = await getModule(); + withPivot(mod.Workbook, ({ wb, pivot }) => { + const indices = []; + Object.defineProperty(indices, 0, { + enumerable: true, + get() { + throw new Error('pivot order getter'); + }, + }); + assert.throws(() => wb.pivotSetRowFieldOrder(0, pivot, indices), /pivot order getter/); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + }); +}); + +test('pivot nested string getters stop before changing the report', async () => { + const mod = await getModule(); + withPivot(mod.Workbook, ({ wb, pivot }) => { + const fieldCount = wb.pivotFieldCount(0, pivot).value; + const spec = { axis: 0 }; + Object.defineProperty(spec, 'sourceName', { + enumerable: true, + get() { + throw new Error('pivot field getter'); + }, + }); + assert.throws(() => wb.pivotFieldAdd(0, pivot, spec), /pivot field getter/); + assert.equal(wb.pivotFieldCount(0, pivot).value, fieldCount); + assert.deepEqual(pivotGrid(wb, pivot), BASE_GRID); + }); +}); + +test('pivot cache source snapshots each nested getter once before mutation', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const cache = wb.pivotCacheCreate(0).index; + let reads = 0; + const source = { present: true, sheet: 'Sheet1' }; + Object.defineProperty(source, 'ref', { + enumerable: true, + get() { + reads += 1; + if (reads > 1) throw new Error('pivot cache source getter read twice'); + return 'A1:B3'; + }, + }); + const result = wb.pivotCacheSetWorksheetSource(cache, source); + assert.ok(result.ok, JSON.stringify(result)); + assert.equal(reads, 1); + assert.equal(wb.pivotCacheGetWorksheetSource(cache).ref, 'A1:B3'); + } finally { + wb.dispose(); + } +}); + +test('pivot optional strings and nullish numeric defaults keep the valid contract', async () => { + const mod = await getModule(); + withPivot(mod.Workbook, ({ wb, pivot, amount }) => { + const result = wb.pivotDataFieldAdd(0, pivot, { + name: 'Count of Amount', + fieldIndex: amount, + aggregation: 1, + numberFormat: null, + showAs: undefined, + showAsBaseField: null, + showAsBaseItem: undefined, + }); + assert.ok(result.status.ok, JSON.stringify(result.status)); + assert.equal(wb.pivotDataFieldCount(0, pivot).value, 2); + }); +}); diff --git a/packages/npm-native/test/nested_print_validation.test.mjs b/packages/npm-native/test/nested_print_validation.test.mjs new file mode 100644 index 000000000..7fc1451bb --- /dev/null +++ b/packages/npm-native/test/nested_print_validation.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const addonPath = + process.env.FORMULON_NODE_ADDON ?? + [ + path.resolve(testDir, '..', 'dist', 'prebuilds', `${process.platform}-${process.arch}`, 'formulon.node'), + path.resolve(testDir, '..', 'dist', 'formulon.node'), + path.resolve(testDir, '..', 'formulon.node'), + ].find((candidate) => existsSync(candidate)); +assert.ok(addonPath, 'native addon not found; set FORMULON_NODE_ADDON or stage dist/formulon.node'); +const addon = createRequire(import.meta.url)(addonPath); + +function readSetup(wb) { + const result = wb.getSheetPageSetup(0); + assert.equal(result.status.ok, true, JSON.stringify(result.status)); + return result; +} + +function readMargins(wb) { + const result = wb.getSheetPageMargins(0); + assert.equal(result.status.ok, true, JSON.stringify(result.status)); + return result; +} + +test('page setup narrow fields reject lossy values before native mutation', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setSheetPageSetup(0, { scale: 125 }).ok, true); + for (const value of [2 ** 32 + 100, 100.5, Number.NaN, Number.POSITIVE_INFINITY, -1]) { + assert.throws(() => wb.setSheetPageSetup(0, { scale: value }), RangeError, `scale=${String(value)}`); + assert.equal(readSetup(wb).scale, 125, `invalid scale changed the stored value: ${String(value)}`); + } + assert.throws(() => wb.setSheetPageSetup(0, { scale: Symbol('scale') }), TypeError); + assert.equal(readSetup(wb).scale, 125); + } finally { + wb.dispose(); + } +}); + +test('all page setup integer fields share the checked reader', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setSheetPageSetup(0, { scale: 125 }).ok, true); + const before = readSetup(wb); + for (const key of ['orientation', 'paperSize', 'fitToWidth', 'fitToHeight']) { + assert.throws(() => wb.setSheetPageSetup(0, { [key]: 2 ** 32 + 1 }), RangeError, key); + assert.equal(readSetup(wb)[key], before[key], `${key} failure changed the stored value`); + } + } finally { + wb.dispose(); + } +}); + +test('reader preserves fractional doubles and rejects pending getters before C mutation', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.ok(wb.setSheetPageMargins(0, { left: 0.25, top: 1.5 }).ok); + const margins = readMargins(wb); + assert.equal(margins.left, 0.25); + assert.equal(margins.top, 1.5); + + const setup = {}; + Object.defineProperty(setup, 'scale', { + enumerable: true, + get() { + throw new Error('scale getter'); + }, + }); + assert.throws(() => wb.setSheetPageSetup(0, setup), /scale getter/); + assert.equal(readSetup(wb).scale, 100); + + const marginsWithGetter = {}; + Object.defineProperty(marginsWithGetter, 'left', { + enumerable: true, + get() { + throw new Error('left getter'); + }, + }); + assert.throws(() => wb.setSheetPageMargins(0, marginsWithGetter), /left getter/); + const unchanged = readMargins(wb); + assert.equal(unchanged.left, 0.25); + assert.equal(unchanged.top, 1.5); + } finally { + wb.dispose(); + } +}); + +test('print option and header/footer readers stop before native mutation on getter failure', () => { + const wb = addon.Workbook.createDefault(); + try { + const options = {}; + Object.defineProperty(options, 'gridLines', { + enumerable: true, + get() { + throw new Error('gridLines getter'); + }, + }); + assert.throws(() => wb.setSheetPrintOptions(0, options), /gridLines getter/); + + const headerFooter = {}; + Object.defineProperty(headerFooter, 'oddHeader', { + enumerable: true, + get() { + throw new Error('oddHeader getter'); + }, + }); + assert.throws(() => wb.setSheetHeaderFooter(0, headerFooter), /oddHeader getter/); + assert.equal(wb.getSheetHeaderFooterXml(0).xml, ''); + } finally { + wb.dispose(); + } +}); + +test('nested print readers snapshot each stateful getter once', () => { + const wb = addon.Workbook.createDefault(); + try { + assert.equal(wb.setSheetPageSetup(0, { paperSize: 42 }).ok, true); + let paperSizeReads = 0; + const firstPaperSize = {}; + Object.defineProperty(firstPaperSize, 'paperSize', { + get() { + paperSizeReads += 1; + return paperSizeReads === 1 ? 9 : undefined; + }, + }); + assert.equal(wb.setSheetPageSetup(0, firstPaperSize).ok, true); + assert.equal(paperSizeReads, 1); + assert.equal(readSetup(wb).paperSize, 9); + assert.equal(readSetup(wb).paperSizeStated, true); + + let omittedPaperSizeReads = 0; + const omittedPaperSize = {}; + Object.defineProperty(omittedPaperSize, 'paperSize', { + get() { + omittedPaperSizeReads += 1; + return omittedPaperSizeReads === 1 ? undefined : 9; + }, + }); + assert.equal(wb.setSheetPageSetup(0, omittedPaperSize).ok, true); + assert.equal(omittedPaperSizeReads, 1); + assert.equal(readSetup(wb).paperSize, 9); + assert.equal(readSetup(wb).paperSizeStated, true); + + let leftReads = 0; + const firstMargin = {}; + Object.defineProperty(firstMargin, 'left', { + get() { + leftReads += 1; + return leftReads === 1 ? 0.75 : undefined; + }, + }); + assert.equal(wb.setSheetPageMargins(0, firstMargin).ok, true); + assert.equal(leftReads, 1); + assert.equal(readMargins(wb).left, 0.75); + + let gridReads = 0; + const firstGrid = {}; + Object.defineProperty(firstGrid, 'gridLines', { + get() { + gridReads += 1; + return gridReads === 1; + }, + }); + assert.equal(wb.setSheetPrintOptions(0, firstGrid).ok, true); + assert.equal(gridReads, 1); + assert.match(wb.getSheetPrintOptionsXml(0).xml, /gridLines="true"/); + + let omittedGridReads = 0; + const omittedGrid = {}; + Object.defineProperty(omittedGrid, 'gridLines', { + get() { + omittedGridReads += 1; + return omittedGridReads === 1 ? undefined : false; + }, + }); + assert.equal(wb.setSheetPrintOptions(0, omittedGrid).ok, true); + assert.equal(omittedGridReads, 1); + assert.match(wb.getSheetPrintOptionsXml(0).xml, /gridLines="true"/); + + let headerFlagReads = 0; + const headerFlag = {}; + Object.defineProperty(headerFlag, 'differentFirst', { + get() { + headerFlagReads += 1; + return headerFlagReads === 1; + }, + }); + assert.equal(wb.setSheetHeaderFooter(0, headerFlag).ok, true); + assert.equal(headerFlagReads, 1); + assert.match(wb.getSheetHeaderFooterXml(0).xml, /differentFirst="true"/); + } finally { + wb.dispose(); + } +}); diff --git a/packages/npm-native/test/nested_ui_validation.test.mjs b/packages/npm-native/test/nested_ui_validation.test.mjs new file mode 100644 index 000000000..f6a063873 --- /dev/null +++ b/packages/npm-native/test/nested_ui_validation.test.mjs @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { assert, getModule, pkgRoot } from './smoke_support.mjs'; + +const RANGE = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; + +function statusOf(result) { + return typeof result.ok === 'boolean' ? result : result.status; +} + +test('validation and merge range integers reject narrowing before mutation', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const before = wb.getValidations(0).length; + for (const value of [256, Number.NaN, Number.POSITIVE_INFINITY, 1.5, -1]) { + assert.throws( + () => wb.addValidation(0, { ranges: [RANGE], type: value }), + RangeError, + `validation type=${String(value)}`, + ); + assert.equal(wb.getValidations(0).length, before); + } + + assert.throws( + () => wb.addValidation(0, { ranges: [{ ...RANGE, firstRow: 2 ** 32 }] }), + RangeError, + 'validation range firstRow', + ); + assert.equal(wb.getValidations(0).length, before); + } finally { + wb.dispose(); + } +}); + +test('filter kind is validated without aborting and date-group bytes do not wrap', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const before = wb.getAutoFilter(0).autoFilter; + assert.throws( + () => + wb.setAutoFilter(0, { + range: RANGE, + columns: [{ colId: 0, kind: 1, dateGroups: [{ month: 257 }] }], + }), + RangeError, + ); + assert.deepEqual(wb.getAutoFilter(0).autoFilter, before); + + const keep = wb.setAutoFilter(0, { range: RANGE, columns: [{ colId: 0, kind: 0 }] }); + assert.ok(statusOf(keep).ok, JSON.stringify(keep)); + assert.equal(wb.getAutoFilter(0).autoFilter.columns[0].kind, 0); + } finally { + wb.dispose(); + } +}); + +test('filter wrong primitive is caught in a child process instead of aborting Node', async () => { + await getModule(); + const moduleUrl = new URL('../dist/index.mjs', import.meta.url).href; + const script = ` + import mod from ${JSON.stringify(moduleUrl)}; + const wb = mod.Workbook.createDefault(); + try { + wb.setAutoFilter(0, { range: { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }, columns: [{ kind: 'bad' }] }); + process.exitCode = 2; + } catch (error) { + if (!(error instanceof TypeError)) process.exitCode = 3; + else if (wb.getAutoFilter(0).autoFilter !== null) process.exitCode = 4; + else console.log('caught'); + } finally { + wb.dispose(); + } + `; + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', script], { + cwd: pkgRoot, + encoding: 'utf8', + }); + assert.equal(child.signal, null, `child signal=${child.signal}\n${child.stderr}`); + assert.equal(child.status, 0, `child status=${child.status}\n${child.stderr}`); + assert.match(child.stdout, /caught/); +}); + +test('threaded mention and image integer fields reject before C mutation', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const beforeComments = wb.getThreadedComments(0).comments.length; + assert.throws( + () => + wb.addThreadedComment(0, { + id: '{AAAAAAAA-0000-0000-0000-000000000001}', + personId: '{11111111-1111-1111-1111-111111111111}', + row: 2 ** 32, + mentions: [{ personId: 'p', mentionId: 'm', start: 2 ** 32 }], + }), + RangeError, + ); + assert.equal(wb.getThreadedComments(0).comments.length, beforeComments); + + assert.throws(() => wb.insertImage(0, new Uint8Array([1, 2, 3]), { rowOffEmu: Number.NaN }), RangeError); + assert.equal(wb.listDrawingObjects(0).length, 0); + + assert.doesNotThrow(() => + wb.insertImage(0, new Uint8Array([1, 2, 3]), { + rowOffEmu: -(2 ** 63), + colOffEmu: 2 ** 63 - 1024, + }), + ); + + assert.throws(() => wb.insertImage(0, new Uint8Array([1, 2, 3]), { widthEmu: 2 ** 63 }), RangeError); + assert.equal(wb.listDrawingObjects(0).length, 0); + } finally { + wb.dispose(); + } +}); + +test('UI reader stops at a throwing getter and leaves the workbook unchanged', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const filter = { range: RANGE, columns: [{}] }; + Object.defineProperty(filter.columns[0], 'kind', { + enumerable: true, + get() { + throw new Error('filter getter'); + }, + }); + assert.throws(() => wb.setAutoFilter(0, filter), /filter getter/); + assert.equal(wb.getAutoFilter(0).autoFilter, null); + + const validation = { ranges: [RANGE] }; + Object.defineProperty(validation, 'type', { + enumerable: true, + get() { + throw new Error('validation getter'); + }, + }); + assert.throws(() => wb.addValidation(0, validation), /validation getter/); + assert.equal(wb.getValidations(0).length, 0); + } finally { + wb.dispose(); + } +}); + +test('nullish UI fields retain their defaults', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + try { + const result = wb.setAutoFilter(0, { + range: { ...RANGE, lastCol: 1 }, + columns: [ + { colId: null, kind: undefined }, + { colId: 1, kind: 1, dateGroups: [{ month: null }] }, + ], + sort: null, + }); + assert.ok(statusOf(result).ok, JSON.stringify(result)); + const stored = wb.getAutoFilter(0).autoFilter; + assert.equal(stored.columns[0].colId, 0); + assert.equal(stored.columns[0].kind, 0); + assert.equal(stored.columns[1].dateGroups[0].month, 0); + } finally { + wb.dispose(); + } +}); diff --git a/packages/npm-native/test/smoke.test.mjs b/packages/npm-native/test/smoke.test.mjs index 1efa6b505..ae25d6522 100644 --- a/packages/npm-native/test/smoke.test.mjs +++ b/packages/npm-native/test/smoke.test.mjs @@ -1,3727 +1,16 @@ -// // Smoke tests for the staged @libraz/formulon-native npm package. -// -// These tests intentionally import the *staged* dist/ artefacts (resolved -// via the package's own package.json "main" field), not the raw build/ -// output. That way we catch staging mistakes -- missing files, wrong -// relative paths, broken "exports" entries -- before the tarball ships. -// -// Run via `make node-test` (which runs `make node-package` first). - -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { inflateRawSync } from 'node:zlib'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const pkgRoot = path.resolve(__dirname, '..'); -const pkgJsonPath = path.join(pkgRoot, 'package.json'); - -// Resolve the staged entry point through the package's own package.json -// "main" field. Fails (rather than skips) if the package isn't staged -- -// that's the whole point of running these tests against dist/. -async function loadStagedModule() { - const raw = await readFile(pkgJsonPath, 'utf8'); - const pkg = JSON.parse(raw); - const main = pkg.main; - if (!main) { - throw new Error(`package.json is missing "main": ${pkgJsonPath}`); - } - const mainPath = path.resolve(pkgRoot, main); - // Node ESM requires file:// URLs for absolute paths on Windows; - // pathToFileURL is the portable form. - return import(pathToFileURL(mainPath).href); -} - -let modPromise; -function getModule() { - if (!modPromise) { - modPromise = loadStagedModule(); - } - return modPromise; -} - -function appendEmptyZipEntry(bytes, name) { - const input = new Uint8Array(bytes); - const view = new DataView(input.buffer, input.byteOffset, input.byteLength); - let eocd = input.length - 22; - while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1; - assert.ok(eocd >= 0, 'missing ZIP end record'); - const count = view.getUint16(eocd + 10, true); - const centralSize = view.getUint32(eocd + 12, true); - const centralOffset = view.getUint32(eocd + 16, true); - const encodedName = new TextEncoder().encode(name); - const u16 = (out, n) => out.push(n & 0xff, (n >>> 8) & 0xff); - const u32 = (out, n) => out.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff); - const bytesTo = (out, source) => { - source.forEach((b) => { - out.push(b); - }); - }; - const local = []; - u32(local, 0x04034b50); - u16(local, 20); - u16(local, 0); - u16(local, 0); - u16(local, 0); - u16(local, 0); - u32(local, 0); - u32(local, 0); - u32(local, 0); - u16(local, encodedName.length); - u16(local, 0); - bytesTo(local, encodedName); - const central = []; - u32(central, 0x02014b50); - u16(central, 20); - u16(central, 20); - u16(central, 0); - u16(central, 0); - u16(central, 0); - u16(central, 0); - u32(central, 0); - u32(central, 0); - u32(central, 0); - u16(central, encodedName.length); - u16(central, 0); - u16(central, 0); - u16(central, 0); - u16(central, 0); - u32(central, 0); - u32(central, centralOffset); - bytesTo(central, encodedName); - const out = []; - bytesTo(out, input.slice(0, centralOffset)); - bytesTo(out, local); - bytesTo(out, input.slice(centralOffset, centralOffset + centralSize)); - bytesTo(out, central); - u32(out, 0x06054b50); - u16(out, 0); - u16(out, 0); - u16(out, count + 1); - u16(out, count + 1); - u32(out, centralSize + central.length); - u32(out, centralOffset + local.length); - u16(out, 0); - return Uint8Array.from(out); -} - -test('default export exposes Workbook + evalFormula + version', async () => { - const mod = await getModule(); - assert.equal(typeof mod.Workbook, 'function'); - assert.equal(typeof mod.evalFormula, 'function'); - assert.equal(typeof mod.version, 'function'); - assert.equal(typeof mod.statusString, 'function'); - assert.equal(typeof mod.ValueKind, 'object'); - assert.equal(typeof mod.PivotCellKind, 'object'); -}); - -test('all declared runtime constants resolve from the staged ESM entry point', async () => { - const mod = await getModule(); - const declarations = await readFile(path.join(pkgRoot, 'index.d.ts'), 'utf8'); - const names = [...declarations.matchAll(/^export const (\w+)/gm)].map((match) => match[1]); - assert.ok(names.length > 0, 'expected runtime constant declarations'); - for (const name of names) { - assert.ok(name in mod, `missing runtime export declared in index.d.ts: ${name}`); - } - assert.equal(mod.PivotAxis.Row, 0); - assert.equal(mod.PIVOT_SHOW_AS_BASE_PREVIOUS, 1048828); - // Named constants a consumer needs in order to interpret shared record - // fields (`Value.errorCode`, `ExternalLinkRecord.kind`) or to drive the - // calc policy. The WASM package exports the same names and ordinals; - // `check_binding_drift dts-enums` holds the two in step. - assert.equal(mod.ErrorCode.Div0, 1); - assert.equal(mod.ErrorCode.Unknown, 16); - assert.equal(mod.CalcMode.Manual, 1); - assert.equal(mod.ExternalLinkKind.Dde, 3); - for (const name of ['ErrorCode', 'CalcMode', 'ExternalLinkKind']) { - assert.ok(Object.isFrozen(mod[name]), `${name} must be frozen`); - } -}); - -test('default export type and runtime default export declare the same keys', async () => { - const mod = await getModule(); - const declarations = await readFile(path.join(pkgRoot, 'index.d.ts'), 'utf8'); - const typeBlock = declarations.match(/declare const _default: \{([\s\S]*?)\n\};/); - assert.ok(typeBlock, 'expected a `declare const _default: {...}` block in index.d.ts'); - const declared = new Set([...typeBlock[1].matchAll(/^\s{2}(\w+):/gm)].map((match) => match[1])); - const runtime = new Set(Object.keys(mod.default)); - const missingFromType = [...runtime].filter((key) => !declared.has(key)); - const missingFromRuntime = [...declared].filter((key) => !runtime.has(key)); - assert.deepEqual(missingFromType, [], 'runtime default export keys absent from the _default type'); - assert.deepEqual(missingFromRuntime, [], '_default type keys absent from the runtime default export'); - assert.equal(mod.default.PivotAxis.Row, 0); - assert.equal(typeof mod.default.errorDisplayName, 'function'); -}); - -test('every named export is reachable through the default export', async () => { - const mod = await getModule(); - const named = Object.keys(mod).filter((key) => key !== 'default'); - const missing = named.filter((key) => !(key in mod.default)); - assert.deepEqual(missing, [], 'named exports absent from the default export object'); -}); - -test('setLogMinLevel is a module-level control and validates its ordinal', async () => { - const mod = await getModule(); - assert.equal(typeof mod.setLogMinLevel, 'function'); - assert.equal(typeof mod.setLogSink, 'function'); - // Process-wide state, so it must not be a Workbook method. - const wb = mod.Workbook.createDefault(); - assert.equal(typeof wb.setLogMinLevel, 'undefined'); - wb.dispose(); - - for (const level of Object.values(mod.LogLevel)) { - assert.ok(mod.setLogMinLevel(level).ok, `level=${level}`); - } - for (const bad of [-1, 5, 99]) { - const r = mod.setLogMinLevel(bad); - assert.equal(r.ok, false, `level=${bad} should be rejected`); - assert.equal(r.status, 2); - } - assert.ok(mod.setLogMinLevel(mod.LogLevel.Off).ok); -}); - -test('setLogSink accepts a function and null, and rejects a non-function', async () => { - const mod = await getModule(); - assert.ok(mod.setLogSink(() => {}).ok); - assert.ok(mod.setLogSink(null).ok); - assert.ok(mod.setLogSink().ok); - const bad = mod.setLogSink(42); - assert.equal(bad.ok, false); - assert.equal(bad.status, 7001); - assert.ok(mod.setLogSink(null).ok); -}); - -// Sink delivery goes through a thread-safe function, so records land on a -// later turn of the libuv loop rather than inside the native call. Poll -// with a bounded budget instead of guessing a single turn. -const SINK_SETTLE_MS = 500; - -async function waitForRecords(records, wanted) { - const deadline = Date.now() + SINK_SETTLE_MS; - while (records.length < wanted && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - return records; -} - -test('a registered sink receives the record as raw bytes', async () => { - const mod = await getModule(); - const records = []; - assert.ok(mod.setLogSink((record) => records.push(record)).ok); - assert.ok(mod.setLogMinLevel(mod.LogLevel.Warn).ok); - try { - const wb = mod.Workbook.createDefault(); - // A structured reference the XLSB encoder cannot lower, - // which makes the writer emit a per-cell warn record. - assert.ok(wb.setFormula(0, 0, 0, '=SUM(T[C])').ok); - assert.ok(wb.recalc().ok); - assert.ok(wb.saveAs(mod.WorkbookFormat.Xlsb).status.ok); - wb.dispose(); - await waitForRecords(records, 1); - assert.ok(records.length > 0, 'expected at least one record'); - for (const record of records) { - assert.ok(ArrayBuffer.isView(record), 'record must be a byte view'); - } - const text = records.map((r) => new TextDecoder().decode(r)).join(''); - assert.match(text, /xlsb\.writer\.formula_downgraded/); - assert.match(text, /"level":"warn"/); - } finally { - assert.ok(mod.setLogMinLevel(mod.LogLevel.Off).ok); - assert.ok(mod.setLogSink(null).ok); - } -}); - -test('the default threshold delivers nothing to a registered sink', async () => { - const mod = await getModule(); - const records = []; - assert.ok(mod.setLogSink((record) => records.push(record)).ok); - try { - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setFormula(0, 0, 0, '=SUM(T[C])').ok); - assert.ok(wb.recalc().ok); - assert.ok(wb.saveAs(mod.WorkbookFormat.Xlsb).status.ok); - wb.dispose(); - // Wait the same budget the positive case needs, so silence here is a - // result rather than an artefact of not having waited long enough. - await new Promise((resolve) => setTimeout(resolve, SINK_SETTLE_MS)); - assert.deepEqual(records, []); - } finally { - assert.ok(mod.setLogSink(null).ok); - } -}); - -test('a throwing log sink does not leave a pending exception at the ThreadSafeFunction boundary', async () => { - const mod = await getModule(); - let calls = 0; - assert.ok( - mod.setLogSink(() => { - calls += 1; - throw new Error('sink failure'); - }).ok, - ); - assert.ok(mod.setLogMinLevel(mod.LogLevel.Warn).ok); - try { - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setFormula(0, 0, 0, '=SUM(T[C])').ok); - assert.ok(wb.recalc().ok); - assert.ok(wb.saveAs(mod.WorkbookFormat.Xlsb).status.ok); - wb.dispose(); - // Wait for the ThreadSafeFunction drain to run the sink. - await new Promise((resolve) => setTimeout(resolve, SINK_SETTLE_MS)); - assert.ok(calls > 0, 'expected the sink to have been invoked'); - } finally { - assert.ok(mod.setLogMinLevel(mod.LogLevel.Off).ok); - assert.ok(mod.setLogSink(null).ok); - } - - // An uncaught exception at the drain boundary would have crashed the - // process by now; the engine must still be usable afterwards. - const wb2 = mod.Workbook.createDefault(); - try { - assert.ok(wb2.setFormula(0, 0, 0, '=1+1').ok); - assert.ok(wb2.recalc().ok); - assert.equal(wb2.getValue(0, 0, 0).value.number, 2); - } finally { - wb2.dispose(); - } -}); - -test('memoryUsage and the Python wheel agree that the estimate grows', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const empty = wb.memoryUsage(); - for (let row = 0; row < 2000; row += 1) { - wb.setNumber(0, row, 0, row); - } - assert.ok(wb.memoryUsage() > empty); - wb.dispose(); -}); - -// Parses the staged `export const X: Readonly<{ A: 0; ... }>` declarations -// into {name: {member: ordinal}}. Name presence alone is not enough: a -// staged bundle carrying a stale ordinal ships a constant that silently -// means something else, and nothing upstream of dist/ can see that. -function declaredOrdinalTables(dts) { - const tables = {}; - const blocks = dts.matchAll(/^export const (\w+): Readonly<\{([^}]*)\}>;/gm); - for (const [, name, body] of blocks) { - const members = {}; - for (const [, member, value] of body.matchAll(/(\w+):\s*(-?\d+)/g)) { - members[member] = Number(value); - } - tables[name] = members; - } - return tables; -} - -test('staged constant tables carry the ordinals the staged .d.ts declares', async () => { - const mod = await getModule(); - const dts = await readFile(path.join(pkgRoot, 'dist', 'index.d.ts'), 'utf8'); - const tables = declaredOrdinalTables(dts); - assert.ok(Object.keys(tables).length > 0, 'expected declared ordinal tables'); - for (const [name, members] of Object.entries(tables)) { - const runtime = mod[name]; - assert.ok(runtime, `missing runtime table ${name}`); - assert.deepEqual({ ...runtime }, members, `${name} ordinals differ from dist/index.d.ts`); - assert.ok(Object.isFrozen(runtime), `${name} must be frozen`); - } -}); - -test('version() returns a non-empty string', async () => { - const mod = await getModule(); - const v = mod.version(); - assert.equal(typeof v, 'string'); - assert.ok(v.length > 0, `expected non-empty version, got ${JSON.stringify(v)}`); -}); - -test('errorDisplayName returns Excel literals', async () => { - const mod = await getModule(); - assert.equal(mod.errorDisplayName(1), '#DIV/0!'); - assert.equal(mod.errorDisplayName(999), '#UNKNOWN!'); -}); - -// One-shot evaluation is anchored at Sheet1!A1 and read-only on every -// shipped surface, so these must agree value-for-value with the WASM -// package and the Python wheel. The anchor-referencing cases are the ones -// that diverge if a surface writes the formula into A1 and recalcs. -const ONE_SHOT_CASES = [ - { formula: '=1+2', kind: 1, number: 3 }, - { formula: '=A1', kind: 1, number: 0 }, - { formula: '=COUNTA(A1)', kind: 1, number: 0 }, - { formula: '=ISBLANK(A1)', kind: 2, boolean: 1 }, - { formula: '=SUM(A1:A3)', kind: 1, number: 0 }, - { formula: '=SEQUENCE(3)', kind: 1, number: 1 }, - { formula: '=ROWS(SEQUENCE(3))', kind: 1, number: 3 }, -]; -test('evalFormula evaluates read-only against a blank anchor', async () => { - const mod = await getModule(); - for (const spec of ONE_SHOT_CASES) { - const r = mod.evalFormula(spec.formula); - assert.ok(r.status.ok, `${spec.formula}: ${JSON.stringify(r.status)}`); - assert.equal(r.value.kind, spec.kind, spec.formula); - if (spec.number !== undefined) { - assert.equal(r.value.number, spec.number, spec.formula); - } - if (spec.boolean !== undefined) { - assert.equal(r.value.boolean, spec.boolean, spec.formula); - } - // Writing into the anchor and recalcing would make these #REF!. - assert.equal(r.value.errorCode, 0, spec.formula); - } -}); - -test("evalFormula('=1+2') returns NUMBER 3", async () => { - const mod = await getModule(); - const r = mod.evalFormula('=1+2'); - assert.ok(r.status.ok, `status=${JSON.stringify(r.status)}`); - assert.equal(r.value.kind, mod.ValueKind.Number); - assert.equal(r.value.number, 3); -}); - -test("evalFormula('=#REF!') surfaces ERROR value (not failed status)", async () => { - const mod = await getModule(); - const r = mod.evalFormula('=#REF!'); - assert.ok(r.status.ok, `status=${JSON.stringify(r.status)}`); - assert.equal(r.value.kind, mod.ValueKind.Error); -}); - -test('Workbook.createDefault + setNumber + getValue round-trip', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.equal(wb.sheetCount().value, 1); - const setRc = wb.setNumber(0, 0, 0, 42); - assert.ok(setRc.ok, `setNumber: ${JSON.stringify(setRc)}`); - const r = wb.getValue(0, 0, 0); - assert.ok(r.status.ok, `getValue: ${JSON.stringify(r.status)}`); - assert.equal(r.value.kind, mod.ValueKind.Number); - assert.equal(r.value.number, 42); -}); - -test('paginate exposes a status envelope and page geometry', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // Asserts the envelope's shape, not the page geometry: how many rows fit a - // page is the print engine's business and is pinned by the native print - // suite against Excel. Row 400 is far enough down to force a break under - // any plausible body height. - assert.ok(wb.setNumber(0, 0, 0, 1).ok); - assert.ok(wb.setNumber(0, 400, 0, 2).ok); - const result = wb.paginate(0); - assert.ok(result.status.ok, `paginate: ${JSON.stringify(result.status)}`); - assert.ok(result.pageCount >= 2, `pageCount: ${result.pageCount}`); - assert.deepEqual(result.printArea, []); - assert.ok(result.horizontalBreaks.length > 0); - assert.deepEqual( - result.horizontalBreaks, - [...new Set(result.horizontalBreaks)].sort((a, b) => a - b), - ); - assert.deepEqual(result.verticalBreaks, []); -}); - -test('print settings: raw XML round-trips and rejects malformed fragments', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // An absent element reads back as the empty string, never null. - assert.equal(wb.getSheetPageSetupXml(0).xml, ''); - - const fragment = ''; - assert.ok(wb.setSheetPageSetupXml(0, fragment).ok); - assert.equal(wb.getSheetPageSetupXml(0).xml, fragment); - - // Two top-level elements, the wrong root name, and a truncated fragment - // are all rejected at set time rather than producing a file Excel repairs. - for (const bad of ['', '', ' { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok( - wb.setSheetPageSetupXml(0, '').ok, - ); - assert.ok(wb.setSheetPageSetup(0, { orientation: 2 }).ok); - - const xml = wb.getSheetPageSetupXml(0).xml; - assert.match(xml, /orientation="landscape"/); - // Attributes the engine does not model must survive a patch. - assert.match(xml, /horizontalDpi="600"/); - assert.match(xml, /copies="3"/); - assert.match(xml, /paperSize="9"/); - - const read = wb.getSheetPageSetup(0); - assert.ok(read.status.ok); - assert.equal(read.orientation, 2); - assert.equal(read.paperSize, 9); - assert.equal(read.orientationStated, true); - - assert.ok(wb.setSheetPageMargins(0, { left: 0.25, top: 1.5 }).ok); - const margins = wb.getSheetPageMargins(0); - assert.ok(margins.status.ok); - assert.equal(margins.left, 0.25); - assert.equal(margins.leftStated, true); - // An unstated margin reports the OOXML default with its flag clear. - assert.equal(margins.right, 0.7); - assert.equal(margins.rightStated, false); -}); - -test('print settings: fitToPage keeps the rest of sheetPr', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setSheetSheetPrXml(0, '').ok); - assert.ok(wb.setSheetFitToPage(0, true).ok); - const xml = wb.getSheetSheetPrXml(0).xml; - assert.match(xml, /codeName="Sheet1"/); - assert.match(xml, //); - assert.match(xml, /fitToPage="true"/); -}); - -test('print settings: header/footer sections take decoded text', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setSheetHeaderFooter(0, { oddHeader: '&C\u5e33\u7968', oddFooter: '&R&P / &N' }).ok); - // The codes reach the file escaped; a parser hands back `&C...`. - assert.equal( - wb.getSheetHeaderFooterXml(0).xml, - '&C\u5e33\u7968&R&P / &N', - ); - - // Omitting a key leaves that section alone; an empty string clears it. - assert.ok(wb.setSheetHeaderFooter(0, { oddFooter: '' }).ok); - assert.equal( - wb.getSheetHeaderFooterXml(0).xml, - '&C\u5e33\u7968', - ); -}); - -test('print settings: print area, titles and manual breaks', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setSheetPrintArea(0, 'A1:F8').ok); - assert.equal(wb.getSheetPrintArea(0).ranges, 'A1:F8'); - assert.equal(wb.setSheetPrintArea(0, 'not-a-range').ok, false); - assert.equal(wb.getSheetPrintArea(0).ranges, 'A1:F8'); - assert.ok(wb.setSheetPrintArea(0, '').ok); - assert.equal(wb.getSheetPrintArea(0).ranges, ''); - - assert.ok(wb.setSheetPrintTitles(0, '1:2', 'A:A').ok); - const titles = wb.getSheetPrintTitles(0); - assert.equal(titles.repeatRows, '1:2'); - assert.equal(titles.repeatCols, 'A:A'); - - assert.ok(wb.addSheetRowBreak(0, 40, true).ok); - assert.ok(wb.addSheetRowBreak(0, 10, true).ok); - const breaks = wb.getSheetRowBreaks(0); - assert.ok(breaks.status.ok); - // Kept sorted by index regardless of insertion order. - assert.deepEqual( - breaks.breaks.map((b) => b.id), - [10, 40], - ); - assert.equal(breaks.breaks[0].max, 16383); - assert.equal(breaks.breaks[0].manual, true); - - assert.ok(wb.removeSheetRowBreak(0, 10).ok); - assert.equal(wb.getSheetRowBreaks(0).breaks.length, 1); - assert.ok(wb.clearSheetBreaks(0).ok); - assert.equal(wb.getSheetRowBreaks(0).breaks.length, 0); -}); - -test('print settings: a change is visible to paginate without a save cycle', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - for (let row = 0; row < 200; row += 1) { - for (let col = 0; col < 20; col += 1) { - assert.ok(wb.setNumber(0, row, col, 1).ok); - } - } - const portrait = wb.paginate(0).pageCount; - assert.ok(wb.setSheetPageSetup(0, { orientation: 2 }).ok); - assert.notEqual(wb.paginate(0).pageCount, portrait); -}); - -test('setRangeXfIndex applies one xf across a rectangle', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const border = wb.addBorder({ - left: { style: 1, colorArgb: 0xff000000 }, - right: { style: 1, colorArgb: 0xff000000 }, - top: { style: 1, colorArgb: 0xff000000 }, - bottom: { style: 1, colorArgb: 0xff000000 }, - diagonal: { style: 0, colorArgb: 0 }, - diagonalUp: false, - diagonalDown: false, - }); - assert.ok(border.status.ok); - const xf = wb.addXf({ fontIndex: 0, fillIndex: 0, borderIndex: border.index, numFmtId: 0 }); - assert.ok(xf.status.ok); - - assert.ok(wb.setRangeXfIndex(0, 0, 0, 2, 2, xf.index).ok); - // Every cell in the rectangle is materialised, including ones that held - // no value, so the box renders. - for (const [row, col] of [ - [0, 0], - [1, 1], - [2, 2], - ]) { - assert.equal(wb.getCellXfIndex(0, row, col).xfIndex, xf.index); - } - assert.equal(wb.getCellXfIndex(0, 3, 3).xfIndex, 0); -}); - -test('pivotCount + pivotLayout expose PivotTable projection status', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.equal(wb.pivotCount(0).value, 0); - - const missing = wb.pivotLayout(0, 0); - assert.equal(missing.status.ok, false); - assert.notEqual(missing.status.status, 0); - assert.equal(missing.top, 0); - assert.equal(missing.left, 0); - assert.equal(missing.rows, 0); - assert.equal(missing.cols, 0); - assert.deepEqual(missing.cells, []); -}); - -test("Workbook.createDefault + setFormula '=1+2' + recalc -> 3", async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setFormula(0, 0, 0, '=1+2').ok); - assert.ok(wb.recalc().ok); - const r = wb.getValue(0, 0, 0); - assert.ok(r.status.ok); - assert.equal(r.value.kind, mod.ValueKind.Number); - assert.equal(r.value.number, 3); -}); - -test('recalcParallel evaluates a wide DAG and reports bounded telemetry', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const width = 128; - for (let row = 0; row < width; row += 1) { - assert.ok(wb.setNumber(0, row, 0, row + 1).ok); - assert.ok(wb.setFormula(0, row, 1, `=A${row + 1}*2`).ok); - } - - const serial = wb.recalcParallel(1); - assert.ok(serial.status.ok, `recalcParallel(1): ${JSON.stringify(serial.status)}`); - assert.equal(serial.stats.workerThreadsStarted, 0); - assert.equal(serial.stats.workerThreadsUsed, 0); - assert.equal(serial.stats.cellsEvaluated, width); - for (let row = 0; row < width; row += 1) { - const value = wb.getValue(0, row, 1); - assert.ok(value.status.ok, `serial value row ${row}: ${JSON.stringify(value.status)}`); - assert.equal(value.value.kind, mod.ValueKind.Number); - assert.equal(value.value.number, (row + 1) * 2); - } - - for (let row = 0; row < width; row += 1) { - assert.ok(wb.setNumber(0, row, 0, row + 2).ok); - } - const parallel = wb.recalcParallel(8); - assert.ok(parallel.status.ok, `recalcParallel(8): ${JSON.stringify(parallel.status)}`); - if (parallel.stats.workerThreadsStarted <= 1) { - assert.equal(parallel.stats.workerThreadsUsed, 0); - assert.equal(parallel.stats.parallelSteps, 0); - assert.ok(parallel.stats.serialFallbackSteps > 0, `expected serial fallback: ${JSON.stringify(parallel)}`); - } else { - assert.ok(parallel.stats.parallelSteps > 0, `expected parallel steps: ${JSON.stringify(parallel)}`); - assert.ok(parallel.stats.workerThreadsUsed > 0, `expected an effective worker: ${JSON.stringify(parallel)}`); - assert.ok(parallel.stats.workerThreadsStarted <= 8); - assert.ok(parallel.stats.workerThreadsUsed <= parallel.stats.workerThreadsStarted); - } - assert.equal(parallel.stats.cellsEvaluated, width); - for (let row = 0; row < width; row += 1) { - const value = wb.getValue(0, row, 1); - assert.ok(value.status.ok, `parallel value row ${row}: ${JSON.stringify(value.status)}`); - assert.equal(value.value.kind, mod.ValueKind.Number); - assert.equal(value.value.number, (row + 2) * 2); - } - - const assertInvalid = (input, label) => { - const invalid = input === undefined ? wb.recalcParallel() : wb.recalcParallel(input); - assert.equal(invalid.status.ok, false, label); - assert.notEqual(invalid.status.status, 0, label); - assert.equal(invalid.stats.cellsEvaluated, 0, label); - assert.equal(invalid.stats.sccsProcessed, 0, label); - assert.equal(invalid.stats.parallelSteps, 0, label); - assert.equal(invalid.stats.serialFallbackSteps, 0, label); - assert.equal(invalid.stats.cycleRecoveries, 0, label); - assert.equal(invalid.stats.workerThreadsStarted, 0, label); - assert.equal(invalid.stats.workerThreadsUsed, 0, label); - return invalid; - }; - assertInvalid(undefined, 'missing threadCount'); - assertInvalid(null, 'null threadCount'); - assertInvalid(NaN, 'NaN threadCount'); - assertInvalid(Infinity, 'Infinity threadCount'); - assertInvalid(-Infinity, '-Infinity threadCount'); - assertInvalid(1.5, 'fractional threadCount'); - assertInvalid(-1, 'negative threadCount'); - // Rejected by the binding itself, before any C ABI call: the message is - // binding-authored and the context is empty, never a value built from - // the threadCount the caller passed (the Status contract's promise for - // a binding-raised failure). - const invalid = assertInvalid(9, 'threadCount above cap'); - assert.equal(invalid.status.message, 'recalcParallel: `threadCount` must be an integer in 0..8'); - assert.equal(invalid.status.context, ''); - wb.dispose(); -}); - -test("evaluateFormulaArray('=SEQUENCE(2,3)') returns a 2x3 grid", async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const r = wb.evaluateFormulaArray(0, 0, 0, '=SEQUENCE(2,3)'); - assert.ok(r.status.ok); - assert.equal(r.rows, 2); - assert.equal(r.cols, 3); - assert.equal(r.cells.length, 2); - assert.equal(r.cells[0].length, 3); - // Row-major 1..6. - assert.equal(r.cells[0][0].kind, mod.ValueKind.Number); - assert.equal(r.cells[0][0].number, 1); - assert.equal(r.cells[1][2].number, 6); -}); - -test("evaluateFormulaArray('=1+2') reports a 1x1 scalar array", async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const r = wb.evaluateFormulaArray(0, 0, 0, '=1+2'); - assert.ok(r.status.ok); - assert.equal(r.rows, 1); - assert.equal(r.cols, 1); - assert.equal(r.cells[0][0].kind, mod.ValueKind.Number); - assert.equal(r.cells[0][0].number, 3); -}); - -test('Workbook.createEmpty + addSheet -> sheetCount/sheetName work', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createEmpty(); - assert.ok(wb.addSheet('Sheet1').ok); - assert.equal(wb.sheetCount().value, 1); - const sn = wb.sheetName(0); - assert.ok(sn.status.ok); - assert.equal(sn.value, 'Sheet1'); -}); - -test('Unicode sheet identity rejects folded duplicate and permits casing rename', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createEmpty(); - assert.ok(wb.addSheet('Ä').ok); - assert.ok(wb.addSheet('Ö').ok); - assert.equal(wb.addSheet('ä').ok, false); - const collision = wb.renameSheet(1, 'ä'); - assert.equal(collision.ok, false); - assert.notEqual(collision.status, 0); - assert.equal(wb.sheetName(0).value, 'Ä'); - assert.equal(wb.sheetName(1).value, 'Ö'); - assert.ok(wb.renameSheet(0, 'ä').ok); - const sn = wb.sheetName(0); - assert.ok(sn.status.ok); - assert.equal(sn.value, 'ä'); - assert.equal(wb.sheetName(1).value, 'Ö'); - wb.dispose(); -}); - -test('save() returns Uint8Array; loadBytes round-trips the value', async () => { - const mod = await getModule(); - const wb1 = mod.Workbook.createDefault(); - assert.ok(wb1.setNumber(0, 0, 0, 42).ok); - const sr = wb1.save(); - assert.ok(sr.status.ok, `save: ${JSON.stringify(sr.status)}`); - assert.ok(sr.bytes instanceof Uint8Array, 'expected Uint8Array'); - assert.ok(sr.bytes.length > 0, 'expected non-empty save buffer'); - - const wb2 = mod.Workbook.loadBytes(sr.bytes); - const r = wb2.getValue(0, 0, 0); - assert.ok(r.status.ok, `loaded getValue: ${JSON.stringify(r.status)}`); - assert.equal(r.value.kind, mod.ValueKind.Number); - assert.equal(r.value.number, 42); -}); - -test('loadBytes rejects invalid input with a fresh diagnostic', async () => { - const mod = await getModule(); - const loaded = mod.Workbook.loadBytes(new Uint16Array([1])); - assert.equal(loaded.isValid(), false); - assert.match(mod.lastErrorMessage(), /NULL or empty input/); -}); - -// -- Expanded surface coverage ----------------------------------------- -// These tests exercise the methods the addon mirrors from the embind -// binding. They are deliberately shallow: each call should round-trip -// some observable state without crashing the Node process. - -test('isValid returns true for a live workbook', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.equal(wb.isValid(), true); -}); - -test('dispose deterministically releases a workbook and is idempotent', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.equal(wb.isValid(), true); - assert.equal(wb.dispose(), undefined); - assert.equal(wb.isValid(), false); - assert.equal(wb.getValue(0, 0, 0).status.ok, false); - assert.equal(wb.dispose(), undefined); -}); - -test('memoryUsage grows with the workbook and reads zero after dispose', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - - const empty = wb.memoryUsage(); - assert.ok(empty > 0, `an empty workbook still owns storage, got ${empty}`); - - // Distinct strings so the shared-string storage cannot fold them into - // one entry and hide the growth. - for (let row = 0; row < 2000; row += 1) { - wb.setText(0, row, 0, `payload-${row}-${'x'.repeat(64)}`); - } - const filled = wb.memoryUsage(); - assert.ok(filled > empty, `expected growth, got ${empty} -> ${filled}`); - - wb.dispose(); - assert.equal(wb.memoryUsage(), 0); -}); - -test('memoryUsage is stable across repeated calls and survives a recalc', async () => { - // The external-memory report is computed as a delta against the last - // reported figure, so a repeated call on an unchanged workbook must be - // a no-op rather than double-counting, and the operations that trigger - // their own report must not disturb the estimate either. - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - for (let row = 0; row < 500; row += 1) { - wb.setFormula(0, row, 0, `=${row}+1`); - } - - const first = wb.memoryUsage(); - assert.equal(wb.memoryUsage(), first); - - assert.equal(wb.recalc().ok, true); - const afterRecalc = wb.memoryUsage(); - assert.ok(afterRecalc >= first, `recalc must not shrink the estimate: ${first} -> ${afterRecalc}`); - assert.equal(wb.memoryUsage(), afterRecalc); - - wb.dispose(); -}); - -test('Workbook factories return instances of the exported Workbook class', async () => { - const mod = await getModule(); - const defaultBook = mod.Workbook.createDefault(); - const emptyBook = mod.Workbook.createEmpty(); - assert.ok(defaultBook instanceof mod.Workbook); - assert.ok(emptyBook instanceof mod.Workbook); -}); - -test('addMerge + getMerges round-trip a single range', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const range = { firstRow: 1, firstCol: 1, lastRow: 2, lastCol: 3 }; - const ar = wb.addMerge(0, range); - assert.ok(ar.ok, `addMerge: ${JSON.stringify(ar)}`); - const list = wb.getMerges(0); - assert.ok(Array.isArray(list), `expected Array, got ${typeof list}`); - assert.equal(list.length, 1); - assert.deepEqual( - { - firstRow: list[0].firstRow, - firstCol: list[0].firstCol, - lastRow: list[0].lastRow, - lastCol: list[0].lastCol, - }, - range, - ); -}); - -test('addHyperlink + getHyperlinks round-trip', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.equal(wb.getHyperlinks(0).length, 0); - // Three entries with progressively more optional fields populated. - assert.ok(wb.addHyperlink(0, 1, 2, 'https://example.com/', '', '', '').ok); - assert.ok(wb.addHyperlink(0, 3, 4, 'mailto:hello@example.com', 'Hello', '', '').ok); - assert.ok(wb.addHyperlink(0, 5, 6, '', 'See X', 'Internal link', 'Sheet1!A1').ok); - assert.ok(wb.addHyperlinkRange(0, 7, 8, 9, 10, 'https://example.com/range', 'Range', 'Range tip', '').ok); - const list = wb.getHyperlinks(0); - assert.equal(list.length, 4); - assert.equal(list[0].row, 1); - assert.equal(list[0].col, 2); - assert.equal(list[0].lastRow, 1); - assert.equal(list[0].lastCol, 2); - assert.equal(list[0].target, 'https://example.com/'); - assert.equal(list[0].display, ''); - assert.equal(list[0].tooltip, ''); - assert.equal(list[1].target, 'mailto:hello@example.com'); - assert.equal(list[1].display, 'Hello'); - assert.equal(list[2].display, 'See X'); - assert.equal(list[2].tooltip, 'Internal link'); - assert.equal(list[2].location, 'Sheet1!A1'); - assert.deepEqual( - { - row: list[3].row, - col: list[3].col, - lastRow: list[3].lastRow, - lastCol: list[3].lastCol, - target: list[3].target, - display: list[3].display, - tooltip: list[3].tooltip, - }, - { - row: 7, - col: 8, - lastRow: 9, - lastCol: 10, - target: 'https://example.com/range', - display: 'Range', - tooltip: 'Range tip', - }, - ); - // Sheet-out-of-range is rejected. - assert.ok(!wb.addHyperlink(999, 0, 0, 'https://x/', '', '', '').ok); - // clearHyperlinks drops everything. - assert.ok(wb.clearHyperlinks(0).ok); - assert.equal(wb.getHyperlinks(0).length, 0); -}); - -test('removeHyperlink / removeHyperlinkAt / clearHyperlinks surface on an empty sheet', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // No-op variants on an empty list still return kOk. - assert.ok(wb.removeHyperlink(0, 0, 0).ok); - assert.ok(wb.clearHyperlinks(0).ok); - // Out-of-range index is rejected. - assert.ok(!wb.removeHyperlinkAt(0, 0).ok); - // Sheet-index-out-of-range is rejected on every variant. - assert.ok(!wb.removeHyperlink(999, 0, 0).ok); - assert.ok(!wb.removeHyperlinkAt(999, 0).ok); - assert.ok(!wb.clearHyperlinks(999).ok); - // The hyperlink list is still empty afterwards. - assert.equal(wb.getHyperlinks(0).length, 0); -}); - -test('removeMerge + removeMergeAt + clearMerges step-wise prune the merge list', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const a = { firstRow: 0, firstCol: 0, lastRow: 1, lastCol: 1 }; - const b = { firstRow: 4, firstCol: 4, lastRow: 5, lastCol: 5 }; - assert.ok(wb.addMerge(0, a).ok); - assert.ok(wb.addMerge(0, b).ok); - // removeMerge with an overlap that hits `a` only. - const overlap = { firstRow: 0, firstCol: 0, lastRow: 0, lastCol: 0 }; - assert.ok(wb.removeMerge(0, overlap).ok); - let list = wb.getMerges(0); - assert.equal(list.length, 1); - assert.equal(list[0].firstRow, 4); - // removeMergeAt drops the survivor by index. - assert.ok(wb.removeMergeAt(0, 0).ok); - list = wb.getMerges(0); - assert.equal(list.length, 0); - // clearMerges is a no-op on an empty list and stays kOk. - assert.ok(wb.addMerge(0, a).ok); - assert.ok(wb.addMerge(0, b).ok); - assert.ok(wb.clearMerges(0).ok); - list = wb.getMerges(0); - assert.equal(list.length, 0); -}); - -test('setSheetZoom + getSheetView surface the new zoom', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setSheetZoom(0, 175).ok); - const v = wb.getSheetView(0); - assert.ok(v.status.ok, `getSheetView: ${JSON.stringify(v.status)}`); - assert.equal(v.view.zoomScale, 175); - // Default freeze / tab-hidden state survives a zoom change. - assert.equal(v.view.freezeRows, 0); - assert.equal(v.view.freezeCols, 0); - assert.equal(v.view.tabHidden, 0); -}); - -test('sheet layout getters preserve explicit width presence flags', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setColumnWidth(0, 0, 0, 0).ok); - assert.ok(wb.setColumnHidden(0, 1, 1, true).ok); - assert.ok(wb.setRowHeight(0, 3, 30).ok); - - const columns = wb.getSheetColumns(0); - assert.ok(columns.status.ok, `getSheetColumns: ${JSON.stringify(columns.status)}`); - const widthZero = columns.columns.find((column) => column.first === 0 && column.last === 0); - assert.ok(widthZero); - assert.equal(widthZero.width, 0); - assert.equal(widthZero.hasWidth, 1); - assert.equal(widthZero.hasStyle, 0); - const hidden = columns.columns.find((column) => column.first === 1 && column.last === 1); - assert.ok(hidden); - assert.equal(hidden.hidden, 1); - assert.equal(hidden.hasWidth, 0); - - const rows = wb.getSheetRowOverrides(0); - assert.ok(rows.status.ok, `getSheetRowOverrides: ${JSON.stringify(rows.status)}`); - const row = rows.rows.find((entry) => entry.row === 3); - assert.ok(row); - assert.equal(row.height, 30); - assert.equal(row.hasStyle, 0); - assert.equal(row.styleXf, 0); -}); - -test('getSheetView surfaces display / orientation defaults', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const v = wb.getSheetView(0); - assert.ok(v.status.ok, `getSheetView: ${JSON.stringify(v.status)}`); - assert.equal(v.view.showGridLines, 1); - assert.equal(v.view.showRowColHeaders, 1); - assert.equal(v.view.showZeros, 1); - assert.equal(v.view.rightToLeft, 0); - assert.equal(v.view.tabSelected, 0); - assert.equal(v.view.viewMode, ''); -}); - -test('setSheetShowGridLines / setSheetRightToLeft / setSheetTabSelected / setSheetViewMode round-trip', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - assert.ok(wb.setSheetShowGridLines(0, false).ok); - assert.ok(wb.setSheetShowRowColHeaders(0, false).ok); - assert.ok(wb.setSheetShowZeros(0, false).ok); - assert.ok(wb.setSheetRightToLeft(0, true).ok); - assert.ok(wb.setSheetTabSelected(0, true).ok); - assert.ok(wb.setSheetViewMode(0, 'pageBreakPreview').ok); - const v = wb.getSheetView(0); - assert.ok(v.status.ok, `getSheetView: ${JSON.stringify(v.status)}`); - assert.equal(v.view.showGridLines, 0); - assert.equal(v.view.showRowColHeaders, 0); - assert.equal(v.view.showZeros, 0); - assert.equal(v.view.rightToLeft, 1); - assert.equal(v.view.tabSelected, 1); - assert.equal(v.view.viewMode, 'pageBreakPreview'); -}); - -test('insertRows shifts an existing literal forward', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // Populate row 0; insert one row at row 0; the literal is now at row 1. - assert.ok(wb.setNumber(0, 0, 0, 99).ok); - assert.ok(wb.insertRows(0, 0, 1).ok); - const r0 = wb.getValue(0, 0, 0); - assert.ok(r0.status.ok); - // The freshly-inserted row 0 is blank. - assert.equal(r0.value.kind, mod.ValueKind.Blank); - const r1 = wb.getValue(0, 1, 0); - assert.ok(r1.status.ok); - assert.equal(r1.value.kind, mod.ValueKind.Number); - assert.equal(r1.value.number, 99); -}); - -test('setCellXfIndex + getCellXfIndex round-trip the xf id', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // Seed the cell so the xf attaches to a stored slot. - assert.ok(wb.setNumber(0, 0, 0, 1).ok); - // xfIndex 0 is always the default xf and is guaranteed to be valid. - assert.ok(wb.setCellXfIndex(0, 0, 0, 0).ok); - const r = wb.getCellXfIndex(0, 0, 0); - assert.ok(r.status.ok, `getCellXfIndex: ${JSON.stringify(r.status)}`); - assert.equal(typeof r.xfIndex, 'number'); - assert.equal(r.xfIndex, 0); -}); - -test('style building blocks: addFont -> addXf -> setCellXfIndex -> getXf', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - // A fresh workbook seeds Excel's minimum style table: one default font, - // one empty border, one default xf, and the two fills Excel reserves at - // the front of every file (`none`, `gray125`). A caller's first record - // therefore lands after them instead of displacing `none`. - assert.equal(wb.fontCount().value, 1); - assert.equal(wb.fillCount().value, 2); - assert.equal(wb.borderCount().value, 1); - assert.equal(wb.xfCount().value, 1); - - const fontResult = wb.addFont({ - name: 'Arial', - size: 12, - bold: true, - italic: false, - strike: false, - underline: 0, - colorArgb: 0xff112233, - }); - assert.ok(fontResult.status.ok, `addFont: ${JSON.stringify(fontResult.status)}`); - // Linear-search dedup: the second call with the same payload returns the same index. - const fontDup = wb.addFont({ - name: 'Arial', - size: 12, - bold: true, - italic: false, - strike: false, - underline: 0, - colorArgb: 0xff112233, - }); - assert.equal(fontDup.index, fontResult.index); - assert.equal(wb.fontCount().value, 2); - - const fill = wb.addFill({ pattern: 1, fgArgb: 0xffff0000, bgArgb: 0xff000000 }); - assert.ok(fill.status.ok); - const border = wb.addBorder({ - left: { style: 1, colorArgb: 0xff000000 }, - right: { style: 1, colorArgb: 0xff000000 }, - top: { style: 1, colorArgb: 0xff000000 }, - bottom: { style: 1, colorArgb: 0xff000000 }, - diagonal: { style: 0, colorArgb: 0 }, - diagonalUp: false, - diagonalDown: false, - }); - assert.ok(border.status.ok); - - const builtin = wb.addNumFmt('General'); - assert.ok(builtin.status.ok); - assert.equal(builtin.numFmtId, 0); - - const custom = wb.addNumFmt('"USD" #,##0'); - assert.ok(custom.status.ok); - assert.ok(custom.numFmtId >= 164, `expected custom id >= 164, got ${custom.numFmtId}`); - - const xf = wb.addXf({ - fontIndex: fontResult.index, - fillIndex: fill.index, - borderIndex: border.index, - numFmtId: custom.numFmtId, - horizontalAlign: 1, - verticalAlign: 2, - wrapText: true, - }); - assert.ok(xf.status.ok, `addXf: ${JSON.stringify(xf.status)}`); - - assert.ok(wb.setNumber(0, 0, 0, 1).ok); - assert.ok(wb.setCellXfIndex(0, 0, 0, xf.index).ok); - - const reread = wb.getCellXf(xf.index); - assert.ok(reread.status.ok); - assert.equal(reread.fontIndex, fontResult.index); - assert.equal(reread.numFmtId, custom.numFmtId); - assert.equal(reread.wrapText, true); - assert.equal(reread.hasAlignment, true); - assert.equal(reread.hasHorizontalAlign, true); - assert.equal(reread.hasVerticalAlign, true); - assert.equal(reread.hasWrapText, true); - assert.equal(reread.hasJustifyLastLine, false); - - const optional = wb.addXf({ - fontIndex: fontResult.index, - fillIndex: fill.index, - borderIndex: border.index, - numFmtId: custom.numFmtId, - textRotation: 255, - indent: 0, - relativeIndent: -3, - shrinkToFit: false, - readingOrder: 0, - justifyLastLine: true, - }); - assert.ok(optional.status.ok, `optional alignment: ${JSON.stringify(optional.status)}`); - const optionalRead = wb.getCellXf(optional.index); - assert.ok(optionalRead.status.ok); - assert.equal(optionalRead.textRotation, 255); - assert.equal(optionalRead.indent, 0); - assert.equal(optionalRead.relativeIndent, -3); - assert.equal(optionalRead.shrinkToFit, false); - assert.equal(optionalRead.readingOrder, 0); - assert.equal(optionalRead.justifyLastLine, true); - assert.equal(optionalRead.hasAlignment, true); - assert.equal(optionalRead.hasJustifyLastLine, true); - assert.equal(wb.addXf(optionalRead).index, optional.index); - - const omitted = wb.addXf({ - fontIndex: fontResult.index, - fillIndex: fill.index, - borderIndex: border.index, - numFmtId: custom.numFmtId, - }); - assert.ok(omitted.status.ok); - const omittedRead = wb.getCellXf(omitted.index); - assert.ok(omittedRead.status.ok); - assert.equal(omittedRead.hasAlignment, false); - - const explicitEmpty = wb.addXf({ - fontIndex: fontResult.index, - fillIndex: fill.index, - borderIndex: border.index, - numFmtId: custom.numFmtId, - hasAlignment: true, - }); - assert.ok(explicitEmpty.status.ok); - assert.notEqual(explicitEmpty.index, omitted.index); - const explicitEmptyRead = wb.getCellXf(explicitEmpty.index); - assert.ok(explicitEmptyRead.status.ok); - assert.equal(explicitEmptyRead.hasAlignment, true); - - const rfont = wb.getFont(fontResult.index); - assert.ok(rfont.status.ok); - assert.equal(rfont.name, 'Arial'); - assert.equal(rfont.bold, true); - - const rfill = wb.getFill(fill.index); - assert.ok(rfill.status.ok); - assert.equal(rfill.pattern, 1); - - const rborder = wb.getBorder(border.index); - assert.ok(rborder.status.ok); - assert.equal(rborder.left.style, 1); - assert.equal(rborder.diagonalUp, false); - - const rfmt = wb.getNumFmt(custom.numFmtId); - assert.ok(rfmt.status.ok); - assert.equal(rfmt.formatCode, '"USD" #,##0'); -}); - -test('selector colours survive get/add identity and save/load', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const theme = { kind: 2, rgb: 0, theme: 3, tint: 0.5, indexed: 0 }; - const indexed = { kind: 3, rgb: 0, theme: 0, tint: 0, indexed: 9 }; - const automatic = { kind: 4, rgb: 0, theme: 0, tint: 0, indexed: 0 }; - - const font = wb.addFont({ name: 'SelectorFont', size: 11, colorArgb: 0x01020304, color: theme }); - assert.ok(font.status.ok); - const gotFont = wb.getFont(font.index); - assert.ok(gotFont.status.ok); - assert.equal(gotFont.color.kind, 2); - assert.equal(gotFont.color.theme, 3); - assert.equal(gotFont.color.tint, 0.5); - assert.equal(gotFont.colorArgb, 0x01020304); - const fontAgain = wb.addFont(gotFont); - assert.ok(fontAgain.status.ok); - assert.equal(fontAgain.index, font.index); - - const fill = wb.addFill({ - pattern: 1, - fgArgb: 0x05060708, - bgArgb: 0x090a0b0c, - fg: indexed, - bg: automatic, - }); - assert.ok(fill.status.ok); - const gotFill = wb.getFill(fill.index); - assert.ok(gotFill.status.ok); - assert.equal(gotFill.fg.kind, 3); - assert.equal(gotFill.fg.indexed, 9); - assert.equal(gotFill.bg.kind, 4); - const fillAgain = wb.addFill(gotFill); - assert.ok(fillAgain.status.ok); - assert.equal(fillAgain.index, fill.index); - - const border = wb.addBorder({ - left: { style: 1, colorArgb: 0x01020304, color: theme }, - right: { style: 1, colorArgb: 0x05060708, color: indexed }, - top: { style: 1, colorArgb: 0x090a0b0c, color: automatic }, - bottom: { style: 0, colorArgb: 0 }, - diagonal: { style: 0, colorArgb: 0 }, - }); - assert.ok(border.status.ok); - const gotBorder = wb.getBorder(border.index); - assert.ok(gotBorder.status.ok); - assert.equal(gotBorder.left.color.kind, 2); - assert.equal(gotBorder.left.color.theme, 3); - assert.equal(gotBorder.right.color.kind, 3); - assert.equal(gotBorder.right.color.indexed, 9); - assert.equal(gotBorder.top.color.kind, 4); - const borderAgain = wb.addBorder(gotBorder); - assert.ok(borderAgain.status.ok); - assert.equal(borderAgain.index, border.index); - - const dxf = wb.addDxf({ - font: { name: 'DxfSelector', size: 9, colorArgb: 0x11121314, color: automatic }, - fill: { pattern: 1, fgArgb: 0x15161718, fg: indexed }, - border: { left: { style: 1, colorArgb: 0x191a1b1c, color: theme } }, - }); - assert.ok(dxf.status.ok); - const gotDxf = wb.getDxf(dxf.index); - assert.ok(gotDxf.status.ok); - assert.equal(gotDxf.font.color.kind, 4); - assert.equal(gotDxf.fill.fg.kind, 3); - assert.equal(gotDxf.border.left.color.kind, 2); - const dxfAgain = wb.addDxf(gotDxf); - assert.ok(dxfAgain.status.ok); - assert.equal(dxfAgain.index, dxf.index); - - const saved = wb.save(); - assert.ok(saved.status.ok); - const loaded = mod.Workbook.loadBytes(saved.bytes); - const loadedFont = loaded.getFont(font.index); - const loadedFill = loaded.getFill(fill.index); - const loadedBorder = loaded.getBorder(border.index); - const loadedDxf = loaded.getDxf(dxf.index); - assert.ok(loadedFont.status.ok); - assert.ok(loadedFill.status.ok); - assert.ok(loadedBorder.status.ok); - assert.ok(loadedDxf.status.ok); - assert.equal(loadedFont.color.kind, 2); - assert.equal(loadedFill.fg.kind, 3); - assert.equal(loadedBorder.left.color.kind, 2); - assert.equal(loadedDxf.font.color.kind, 4); - loaded.dispose(); - wb.dispose(); -}); - -test('dxf alignment and protection XML survive get/add identity and save/load', async () => { - const mod = await getModule(); - const wb = mod.Workbook.createDefault(); - const alignmentXml = ''; - const protectionXml = '