ts-xlsx-edit
v0.1.0
Published
Read, edit, and save existing Excel (.xlsx) files in Node — a TypeScript port of openpyxl.
Downloads
204
Maintainers
Readme
ts-xlsx — Developer Guide
Published on npm as
ts-xlsx-edit(npm install ts-xlsx-edit). The barets-xlsxname was already taken; the repo and local directory stayts-xlsx, mirroring thets-pdf→ts-pdf-editprecedent.
A pure-TypeScript port of openpyxl: open an existing .xlsx, read/edit cells,
styles, formulas, sheets, merges, conditional formatting, data validation,
hyperlinks, defined names, images and core properties, and save it back
preserving every untouched part byte-for-byte. jszip is the only runtime
dependency. Node ≥ 18, ESM, no native modules, no DOM.
This document is for people working on ts-xlsx (maintenance, extension, and the
flowdot-documents engine that wraps it). For the public API surface, the exported
symbols in src/index.ts and their doc-comments are the reference; a short usage
example is at the end.
1. Architecture — the layer cake
ts-xlsx is a layered port. The bottom two layers are shared, near-verbatim, with the
sibling libraries ts-pptx and ts-docx; only the format-specific layers above them
are hand-written for SpreadsheetML.
public API src/{workbook,worksheet,cell}.ts + src/styles/values.ts
src/{util,numberFormats,numberFormat,datetimes}.ts
parts src/parts/*.ts — one class per OPC part type
oxml src/oxml/*.ts — typed wrappers over SpreadsheetML elements
opc src/opc/*.ts — OPC package: parts, rels, content-types, zip
xml src/xml/*.ts — lossless XML DOM (parse ⇄ serialize is byte-exact)What came verbatim from ts-pptx (do not fork casually):
src/xml/{dom,parser,serializer,escape,index}.ts— byte-identical across all three siblings. This is the lossless DOM whoseserialize(parse(bytes)) === bytesproperty underpins the whole fidelity story.src/opc/{serialized,content-types,oxml}.tsandsrc/oxml/base.ts— verbatim.src/opc/{package,rels}.ts— verbatim except the error class name (XlsxError).
What is format-specific (hand-written / adapted):
src/opc/constants.ts,src/opc/spec.ts— SpreadsheetML content types + relationship types + default-content-type table.src/opc/packuri.ts— copied, then patched (see §6, the absolute-rel-target fix).src/oxml/ns.ts— the SpreadsheetML namespace registry.src/oxml/{workbook,worksheet,sharedstrings,styles,chart}.ts— typed element wrappers.src/parts/*—WorkbookPart,WorksheetPart,SharedStringsPart,StylesPart,CorePropertiesPart,ImagePart,DrawingPart,ChartPart.- everything in the public API layer.
Part-type registry
src/index.ts wires content-type → part class into PartFactory.partTypeFor (mirrors
openpyxl's reader dispatch). When you add a new part class, register it there, or
the loader falls back to a generic XmlPart/Part and your typed accessors won't run.
2. The fidelity model (how byte-identity actually works)
This is the load-bearing invariant. Three rules, enforced mechanically:
Open → save with no edits is loss-free. Implemented in
OpcPackage(src/opc/package.ts):- every
Partkeeps its original blob; anXmlPartparses lazily on first access and re-serializes only if its document was marked dirty. Untouched parts returnoriginalBlobunchanged. [Content_Types].xmland each.relsitem are written back verbatim unless the package structure changed (markStructureDirty()) or a relationship was added/ removed. Rels regenerate in deterministic numeric-rId order only when dirty.- an opaque sweep collects every zip entry not reachable from the rels graph and
carries it through verbatim. openpyxl drops these (vendor XML, stray files);
ts-xlsx keeps them. See
tests/assets/orphan.xlsx+tests/m7.fidelity.test.ts. - output entry order follows the original archive; new items are appended.
- every
An edit touches only what it must. Because dirty-tracking is per-document, writing one cell re-serializes only that sheet part (plus the SST/styles parts if an edit genuinely reached them). The
mNtests assert the exact set of changed entries.Reads never mutate. Divergence from openpyxl's mutate-on-access model, required by rule 1.
Cellis a lazy proxy over(worksheet, coordinate): reading a value or a style resolves the backing<c>element if it exists and returnsnull/defaults if it doesn't — it never creates XML. Writing calls_ensureCellCtwhich materializes the row/cell. Seesrc/cell.ts.
If you add a feature, the litmus test is: does an unrelated open→save still produce
byte-identical output? Add an assertion to that effect (the mN suites show the
pattern).
3. SpreadsheetML specifics you must know
- Default namespace. Unlike PowerPoint (
p:) and Word (w:), worksheet/workbook elements live in the default namespace (<worksheet xmlns=".../spreadsheetml/2006/main">, so<row>,<c>are unprefixed).src/oxml/ns.tsmaps the canonical prefixmainto that URI, andnsdecls("main")emitsxmlns="…"(notxmlns:main).createElementresolves an in-scope default declaration and returns an unprefixed element — which is exactly what editing an existing sheet needs. Matching is always by namespace URI + local name, never by prefix string. - createElement attach-as-you-go discipline (TRAP).
createElement(tag, context)resolves the namespace from the context element's in-scope declarations. If you build a detached subtree (create children before the parent is attached to the tree that declares the default ns), the children come out wrongly prefixed (main:c,xdr:pic). Always append the parent into the live tree first, then create its children. Seesrc/oxml/styles.ts::getOrAddDxfFillandsrc/parts/drawing.ts::addPicAnchorfor the correct ordering. - Cell value typing (
@ton<c>):n(number, default — attribute omitted) ·s(shared-string index intosharedStrings.xml) ·inlineStr(<is><t>) ·str(formula string result) ·b(boolean<v>0|1) ·e(error string) ·d(ISO date, rare). A formula cell additionally carries<f>. Read logic:src/cell.tsget value. - openpyxl writes
inlineStrand nosharedStrings.xml; real Excel writest="s". ts-xlsx must handle both on read, and on write interns into the shared-string table (Excel-canonical), creating the SST part if the workbook lacks one. SeeWorkbook.internStringandSharedStringsPart.createNew. - Style indirection. A cell's
@sindexesstyles.xml's<cellXfs>; each<xf>referencesfontId/fillId/borderId/numFmtIdinto the<fonts>/<fills>/<borders>/<numFmts>tables. Editing a cell's font means find-or-add a font, then find-or-add an xf equal to the base xf with that fontId — never mutate a shared xf (it would restyle every cell that shares it). This is thecellXfs-reuse engine insrc/oxml/styles.ts(getOrAddXf+getOrAdd{Font,Fill,Border}); the dedup keeps the table from bloating (asserted intests/m3.styles.test.ts). - Serial dates. Excel stores dates as serial numbers governed by a number format and a
workbook epoch (1900, including the leap-year bug, or 1904).
src/datetimes.tsports openpyxl'sfrom_excel/to_excel;src/numberFormats.tsportsBUILTIN_FORMATS+is_date_format. All date math is done in UTC to avoid timezone drift on round-trip. - Formulas are stored, not evaluated (openpyxl's model — a formula engine is explicitly
out of scope). Writing a formula sets
calcPr/@fullCalcOnLoadand drops the stalecalcChainpart so Excel rebuilds it. SeeWorkbook.setFullCalcOnLoad/invalidateCalcChain. - Absolute relationship targets. Excel authors
.relswith absolute targets (Target="/xl/worksheets/sheet1.xml") where PowerPoint/Word use relative ones. This is the one patch to the otherwise-verbatim OPC layer (§6).
4. Directory map
src/
xml/ lossless DOM (verbatim from ts-pptx)
opc/ OPC package layer (constants/spec/packuri adapted; rest verbatim)
oxml/ SpreadsheetML element wrappers
ns.ts namespace registry (main = default ns)
base.ts OxmlWrapper + createElement (verbatim)
simpletypes.ts attribute converters (verbatim)
workbook.ts <workbook>: sheets, definedNames, calcPr, sheet element ops
worksheet.ts <worksheet>/<sheetData>/<row>/<c> + merges/cols/panes/CF/DV/hyperlinks/…
sharedstrings.ts <sst>/<si>
styles.ts <styleSheet>: numFmts/fonts/fills/borders/cellXfs/dxfs + reuse engine
chart.ts chart read parser (<chartSpace> → ChartInfo)
parts/ one class per OPC part type (register each in src/index.ts)
styles/values.ts Font/PatternFill/GradientFill/Border/Side/Alignment/Protection/Color
image/probe.ts PNG/JPEG/GIF/BMP/TIFF header probe (verbatim from ts-docx)
workbook.ts worksheet.ts cell.ts public API
util.ts coords (A1 ⇄ row/col, column letters) + EMU units
numberFormats.ts BUILTIN_FORMATS + is_date_format
numberFormat.ts formatValue() display renderer
datetimes.ts serial ⇄ Date
exc.ts XlsxError hierarchy
index.ts public exports + PartFactory registrations
tests/
xml/roundtrip.test.ts opc/roundtrip.test.ts backbone byte-identity
m1..m7 *.test.ts per-milestone API + fidelity
helpers/zip.ts allXlsxInputs(), zipEntries(), bytesEqual(), firstDiff()
assets/*.xlsx fixture corpus (see §7)
scripts/
_gen_fixtures.py openpyxl-authored fixtures
_gen_sharedstrings_fixture.py hand-authored Excel-style t="s" fixture
make-m1..m6-gate.mjs produce out/*.xlsx for manual Excel + openpyxl review
verify-pack.mjs publish tarball gate (dist-only)5. How to add a feature
- Model the XML in an oxml wrapper. Add read accessors + write helpers to the relevant
src/oxml/*.tsclass, extendingOxmlWrapper. Usefind(nsUri, localName)/findAllfor reads andcreateElement(attach-as-you-go, §3) for writes. Respect the element's child-order sequence — the*_CHILDRENarrays +getOrAddOrderedhelpers insert new children in schema order. - If it's a new part type, add a
src/parts/Xxx.ts(extendXmlPartfor XML,Partfor binary), astatic createNew(pkg)if it can be authored, and register its content type insrc/index.ts. - Expose it on the public class (
Workbook/Worksheet/Cell). Reads go through the wrapper and return plain data (never mutate). Writes route table/xf reuse throughWorkbookso the styles engine stays the single source of truth. - Test three ways: (a) round-trip through ts-xlsx (
edit → toBuffer → reopen → assert); (b) a fidelity assertion (the edit changes only the expected entries; unrelated open→save stays byte-identical); (c) cross-validate against openpyxl (§6).
6. The openpyxl oracle (how correctness is proven)
openpyxl 3.1.5 is available with a working interpreter at:
C:\Users\ellio\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exeIt is used exactly as ts-docx used python-docx:
- As a spec reference — its
styles/numbers.py,utils/datetime.py, etc. are the ported source of truth (values verified verbatim, not guessed). - As a cross-validation oracle — every milestone's gate script writes a workbook to
out/; opening it in openpyxl must succeed with no repair and read back the expected values/styles/structure. The full loop:
npm run build
node scripts/make-m3-gate.mjs # writes out/m3-styles.xlsx
python.exe -c "import openpyxl; wb=openpyxl.load_workbook('out/m3-styles.xlsx'); ..."Important divergence caught this way: the OPC PackURI.fromRelRef (copied verbatim from
ts-pptx) mis-resolved absolute rel targets. openpyxl/Excel write Target="/xl/…";
path.posix.join("/xl", "/xl/…") produced /xl/xl/…, so worksheet parts fell to
opaque-preservation (which masked it in the M0 byte-round-trip) and rId resolution threw at
read time. src/opc/packuri.ts now takes a leading-slash target as already
package-absolute. This is the only intentional edit to the otherwise-verbatim OPC layer.
7. Fixture corpus (tests/assets/)
- openpyxl-authored (
scripts/_gen_fixtures.py):basic(styled cells, merges, widths, freeze, multi-sheet, hidden sheet),formulas,types(dates/bools/unicode/repeated strings),chart(bar chart),image(embedded PNG),comments,rules(CF + data validation + hyperlink + defined name). - hand-authored, canonical Excel style (
scripts/_gen_sharedstrings_fixture.py):shared_strings.xlsx— thet="s"shared-string path plus a cached formula and a datenumFmt, which openpyxl does not emit. This is what exercises the dominant real-world representation. - orphan.xlsx —
basic.xlsx+ two entries unreachable from the rels graph (xl/vendorData/custom.xml,customNotes.txt). openpyxl drops both on re-save; ts-xlsx must keep them.
tests/helpers/zip.ts::allXlsxInputs() auto-discovers everything under tests/assets/ and
fixtures/, so dropping a genuine Excel-authored workbook in there immediately extends the
backbone round-trip + reads-never-mutate coverage.
Corpus caveat. The openpyxl-authored fixtures cannot fully exercise the "preserve what openpyxl drops" guarantee for pivot tables / native-Excel vendor XML. The chart/image/orphan fixtures cover the differentiator that automation can reach; adding a few genuine Excel files (with a pivot table) would strengthen it further.
8. Traps (learned the hard way)
- createElement on a detached subtree → wrong prefixes. Attach parent first (§3).
- Mutating a shared
<xf>/<font>restyles unrelated cells. Always find-or-add via the styles engine. - Absolute rel targets — don't "fix"
packuri.tsback to the verbatim ts-pptx version. - Local time in date math — always use UTC
getUTC*/Date.UTC(serials are TZ-agnostic). - Double-counting the
%literal in the number-format renderer — the%is already in the format's suffix; thepercentflag only scales by 100. sstcount hints are advisory; keepcount/uniqueCountconsistent but don't rely on them for correctness.
9. Build, test, publish
npm run build # tsc (strict) → dist/
npm test # vitest (76 tests)
npm run lint # eslint (flat config)
npm run verify-pack # asserts the npm tarball is dist-only (no source leakage)prepublishOnly runs clean → lint → test → build → verify-pack. Publishing is a tagged-
release flow (.github/workflows/publish.yml, OIDC trusted publisher). This repo is not
published from here — tagging vX.Y.Z is the maintainer's action.
10. Deferred (v1 boundaries, flagged deliberately)
.xls/BIFF read/write · a formula evaluation engine · chart authoring (existing
charts are read + preserved) · comment authoring and named-style authoring (existing
ones are read + applied) · copyWorksheet does not clone the source sheet's own drawing/
hyperlink relationships in v1 (cell content + structure are copied).
Appendix — usage at a glance
import { Workbook, Font, PatternFill } from "ts-xlsx-edit";
const wb = await Workbook.open("model.xlsx"); // path | Uint8Array | ArrayBuffer
const ws = wb.active; // or wb.get("Sheet1")
ws.cell("B2").value = 42; // or ws.cell(2, 2)
ws.cell("C1").value = "=SUM(A1:A10)"; // formula (sets fullCalcOnLoad)
ws.cell("B2").numberFormat = "#,##0.00";
ws.cell("A1").font = new Font({ bold: true, color: "CC0000" });
ws.cell("A1").fill = new PatternFill({ patternType: "solid", fgColor: "FFFF00" });
ws.mergeCells("A5:C5");
await wb.save("model-edited.xlsx"); // or const bytes = await wb.toBuffer();Everything except open/save/toBuffer is synchronous. MIT © FlowDot LLC; openpyxl's MIT
notice is reproduced in LICENSE.
