Minor Changes
-
#78
2a46931Thanks @baseballyama! - Breaking:Chartsheet.properties.tabColorreplacestabColorRgb.Worksheets already expose
SheetProperties.tabColoras a fullColor(rgb / indexed / theme / auto / tint). Chartsheets carried a stringly-typedtabColorRgbinstead, which forced callers to special-case the two sheet kinds and silently dropped every non-RGB colour attribute Excel produces.The new field is a
Colorobject so both sheet kinds share one tab-colour model. Migration:// Before cs.properties = { tabColorRgb: "FF8800" }; // After cs.properties = { tabColor: { rgb: "FF8800" } };
Reads recover the additional
indexed/theme/auto/tintattributes that the old shape discarded. -
#78
2a46931Thanks @baseballyama! - fix: release streaming sinks when serialization fails, and bound the streaming write-only buffer against CJK payloads.When
saveWorkbookorcreateWriteOnlyWorkbook().finalize()threw partway through serialization, the underlying sink stayed open —toFilewould leave a half-written.xlsxon disk that callers could mistake for a successful save.BufferedSinkWriternow exposes an optionalabort(cause?)hook; the ZIP writer + workbook writers call it from a surroundingcatchso streaming destinations (toFile/toWritable) are released and the partial file is best-effort removed. The write-only workbook also exposes its ownabort(cause?)for callers driving it from a custom pipeline.The streaming write-only worksheet's pending-byte counter and the XML stream writer's flush threshold now use an accurate UTF-8 length (
utf8ByteLength) instead ofstring.length. The previous accounting undercounted CJK text by ~3×, letting the in-flight buffer grow well past the configured flush threshold; Japanese / Chinese workloads now flush at the documented ~64 KB ceiling.API:
BufferedSinkWriter.abort(cause?)is optional. Custom sinks don't need to implement it, but doing so letssaveWorkbookclean up streaming destinations on failure.WriteOnlyWorkbook.abort(cause?)is the matching escape hatch on the write-only API.XlsxSink.toBytesis now required at the type level. It was already required in practice — the writer threw at runtime when it was missing — and built-in sinks (toBuffer/toBlob/toArrayBuffer/toFile/toWritable) already implement it.
-
#78
2a46931Thanks @baseballyama! - fix: tighten three worksheet / cell mutation paths that previously produced silently-bad workbooks.-
copyRange/moveRangeno longer carryhyperlinkId/commentIdacross worksheets. Those fields are indexes into the source sheet'shyperlinks/legacyCommentsarrays and would point at unrelated records (or out of bounds) on the destination. Same-sheet copy / move still preserves them. -
setSheetStaterefuses to hide the last visible sheet. Excel rejects workbooks with every sheet hidden ("Excel cannot use the object linking and embedding features…"); raising at mutation time keeps the workbook recoverable instead of producing a save Excel will reject. -
makeTextRunthrowsOpenXmlSchemaErrorinstead ofTypeErrorso every public error path uses the documentedOpenXmlErrorsubclass hierarchy.
-
Patch Changes
-
#78
2a46931Thanks @baseballyama! - chore(ci): tighten the CI gate and surface CLAUDE.md hard rules in the linter.-
The test matrix now runs on macOS and Windows alongside Ubuntu. Path-separator and TextDecoder differences that ubuntu-only CI would silently miss are now exercised on every PR. Each OS installs
libxml2-utils/libxml2so the ECMA-376 conformance gate never silently degrades to "no schema validation ran". -
A dedicated
perfjob runspnpm test:perfwithPERF_GATE=1, promoting the throughput / heap thresholds intests/perf/from informational to fatal. CI runners are noisier than the M1 baseline the gates target — if a specific Node minor turns flaky, retune the thresholds invitest.perf.config.tsrather than reverting this gate. -
typescript/no-explicit-anyiserrorinsrc/andoffintests/(whereas anyis a legitimate way to exercise error paths). The remaining intentionalanyinsrc/schema/core.tsis annotated with// oxlint-disable-next-line typescript/no-explicit-anyand an explanation comment.
-
-
#78
2a46931Thanks @baseballyama! - refactor: route every XML writer through one canonicalescapeXmlAttr/escapeXmlTextpair insrc/utils/escape.ts.Before, twelve files each carried their own near-identical escape regex —
src/io/save.tsdeliberately skipped>while the rest escaped it, and three of them also escaped\r/\n/\tvia numeric character references. The discrepancies were quiet correctness bugs (attribute values containing]]>rendered differently across writers) and a maintenance hazard.The unified helpers escape
&,<,>, and"; whitespace bytes stay literal because our parser (fast-xml-parser) does not decode numeric character references and would otherwise break the round-trip.No user-visible behaviour change beyond consistent attribute escaping across every writer.
-
#78
2a46931Thanks @baseballyama! - perf: drop quadratic / linear-scan patterns on four read/write hot paths.-
The
loadWorkbookresolver indexes each sheet's rels file once via the newindexRelsByIdhelper instead of running a freshArray.find(r => r.id === relId)per table / comments / drawing / chart / picture cross-reference. Worksheets with many drawings or pivot tables load in O(refs) instead of O(refs × rels). -
containsCommentMarkerin the VML drawing classifier replaces a byte-by-byte JS loop with a single latin1String.indexOf— multi-megabyte legacy VML drawings load noticeably faster. -
The streaming
iterParsequeue uses a head pointer instead ofArray#shift(). A single SAX batch with hundreds of events (typical for a wide<row>) is now O(N) instead of O(N²); a stale dead-code branch in the prologue gate is gone too. -
serializeHyperlinksallocates rIds via aSetindex instead of nestedArray.some()calls. Worksheets with hundreds of hyperlinks (dashboards / link-heavy index sheets) finish save in O(N) rather than O(N²).
-
-
#78
2a46931Thanks @baseballyama! - refactor: collapsedescribeWorkbookinto a single pass, hardengetRowValues, and documentnormalizePath's..handling.describeWorkbookused to walk every cell three times — once forgetWorkbookStats, once forgetWorkbookCellsByKind, and once again for the per-sheet counts. The new implementation fuses all three into one pass and shares the value-kind classifier withcountCellsByKindvia the newly exportedclassifyCellValue(so the two never drift on edge cases likeDatevsduration).getRowValuesno longer derivesmaxColviaMath.max(...rowMap.keys()). The spread is limited by V8's argument-count cap (~125 k), whichgetRowValueswould silently blow past on a dense row near MAX_COL — the call now uses a linear scan to derive the max.normalizePathpicks up an explanatory comment for the..-when-out-is-empty case, so the next reader doesn't have to second-guess whether path-traversal is possible (it isn't — the archive lookup catches escape attempts naturally). -
#78
2a46931Thanks @baseballyama! - fix: harden the ZIP64 read path against decompression bombs.Previously, archives whose End-of-Central-Directory carried ZIP64 sentinel values fell back to
fflate.unzipSync, which inflates every entry up front. A crafted ZIP64-shaped xlsx could exhaust memory before the per-entry / archive-total caps ran. The random-access reader now parses ZIP64 EOCD + the Zip64 Extended Information extra field directly, so the existing decompression-bomb guards apply to ZIP64 archives the same way they apply to ZIP32.loadWorkbook(decompressionLimits)defaults are unchanged; only the underlying enforcement path is stricter.