amendsheet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COMPARISON.md ADDED
@@ -0,0 +1,47 @@
1
+ # Round-trip fidelity
2
+
3
+ Read a real `.xlsx`, write it back, and check that nothing was lost. Measured over 73 files — ordinary workbooks plus ones carrying charts, pivot tables, drawings, macros and defined names — against a pinned version of the incumbent. Generated by `npm run harness:doc`; do not edit by hand.
4
+
5
+ ## Results
6
+
7
+ | Library | Clean | Lossy | Failed | Rewrote |
8
+ | --- | --: | --: | --: | --: |
9
+ | amendsheet (this library) | 73 | 0 | 0 | 0 |
10
+ | amendsheet (this library), after editing one cell | 73 | 0 | 0 | 0 |
11
+ | exceljs@4.4.0 | 25 | 46 | 2 | 71 |
12
+
13
+ - **Clean** — every part and every cell value survived. **Lossy** — at least one part or value was dropped or altered. **Failed** — the library threw instead of producing a file.
14
+ - **Rewrote** — files where a part that was never touched came back with different bytes. A clean file can still be rewritten: it means the library reserialised parts it did not need to, which is where an unmodelled detail quietly goes missing.
15
+
16
+ ## Where the loss falls
17
+
18
+ **exceljs@4.4.0**
19
+
20
+ | Feature | Files damaged |
21
+ | --- | --: |
22
+ | charts | 12 |
23
+ | drawings | 12 |
24
+ | definedNames | 8 |
25
+ | colWidths | 4 |
26
+ | pivotTables | 3 |
27
+ | pivotCaches | 3 |
28
+ | customXml | 2 |
29
+ | vbaMacros | 1 |
30
+ | mergedCells | 1 |
31
+ | hyperlinks | 1 |
32
+
33
+ ## Method
34
+
35
+ - Each library reads the file and writes it back through its own API. The output is unzipped and compared to the input part by part, and every cell value is compared after a second read, so a value that survives the file but decodes differently still counts as a loss.
36
+ - Parts that legitimately differ on every write are excluded: document timestamps (`docProps/core.xml`, `docProps/app.xml`) and the calculation chain, which a spreadsheet rebuilds anyway.
37
+ - Formatting is compared per cell, by the style each cell resolves to, not by counting entries in the style registry — deduping or pruning an unused format is not a loss.
38
+ - The "after editing one cell" pass changes a single cell and asks the same question, so a writer that spills changes across the document beyond the one edit shows up.
39
+
40
+ ## Reproduce
41
+
42
+ ```
43
+ npm run fixtures:real # fetch the corpus
44
+ npm run harness # the full per-file report
45
+ npm run harness:doc # regenerate this file
46
+ ```
47
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jacob Roberson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,358 @@
1
+ # AmendSheet
2
+
3
+ Amend `.xlsx` files. Everything you didn't touch stays exactly as it was.
4
+
5
+ Reads and edits spreadsheets in Node and the browser, checked in both. The
6
+ contents of any part the library doesn't interpret get written back byte for
7
+ byte, so charts, pivot tables, drawings and macros all survive a read and a
8
+ save. Changing one cell won't quietly throw away the rest of the workbook.
9
+
10
+ The ZIP container itself is rebuilt, so an untouched file comes back the same
11
+ document but not the same bytes: compression, timestamps and entry order are
12
+ the writer's own.
13
+
14
+ One dependency, `fflate`, for the ZIP container. There is no transitive tree to
15
+ audit.
16
+
17
+ ## Fidelity
18
+
19
+ The claim is measured, not asserted. `npm run harness` reads 73 real files —
20
+ Apache POI's test data plus hand-built edge cases — writes each back, and
21
+ compares the result part by part and value by value. Nothing is lost and no
22
+ untouched part is rewritten, before an edit or after one.
23
+
24
+ For a reference point the same files go through `exceljs@4.4.0`, a mature and
25
+ much broader library: it keeps 25 of them intact and drops a part or a value in
26
+ 46, most often a chart, a drawing or a defined name. That is the cost of
27
+ rebuilding the whole document on write, and it is the one thing this library sets
28
+ out not to do. The full table and the method are in
29
+ [`COMPARISON.md`](COMPARISON.md), regenerated with `npm run harness:doc`.
30
+
31
+ ## On AI
32
+
33
+ Heads up: this was built with heavy use of AI assistance, partly as a test of
34
+ what that produces when held to normal standards. Tests are written first,
35
+ `./verify.sh` gates every commit, and the test files are real spreadsheets this
36
+ library had no hand in creating.
37
+
38
+ ## Use
39
+
40
+ ```ts
41
+ import { readWorkbook } from 'amendsheet'
42
+
43
+ const workbook = readWorkbook(bytes)
44
+
45
+ const sheet = workbook.sheet('Summary') ?? workbook.sheets[0]
46
+
47
+ for (const cell of sheet.cells()) {
48
+ console.log(cell.reference, cell.value)
49
+ }
50
+
51
+ sheet.cell('B7')?.value
52
+ ```
53
+
54
+ `cells()` yields every cell the sheet stores, which includes cells holding only
55
+ formatting and cells that were cleared. Those arrive as `kind: 'empty'`, so
56
+ filter on `kind` if you only want content.
57
+
58
+ Editing:
59
+
60
+ ```ts
61
+ const workbook = readWorkbook(bytes)
62
+ const sheet = workbook.sheet('Summary') ?? workbook.sheets[0]
63
+
64
+ sheet.set('B7', 42)
65
+ sheet.set('C1', 'a new cell')
66
+ sheet.set('D2', new Date('2024-01-01'))
67
+ sheet.set('E1', null) // clears the value, keeps the formatting
68
+ sheet.set('F9', { formula: 'SUM(F1:F8)' })
69
+ sheet.set('G1', 1234.5, { numberFormat: '"$"#,##0.00' })
70
+
71
+ const out = workbook.toBytes() // synchronous
72
+ await writeFile('out.xlsx', out)
73
+ ```
74
+
75
+ `set` takes a number, string, boolean, `Date`, `{ formula }`, or `null` to clear
76
+ a cell. Pass `{ numberFormat }` to choose a format code; without one the cell
77
+ keeps whatever formatting it had. If the
78
+ cell or its row isn't there yet, both get created, and the declared dimension
79
+ grows to cover them. The style of a replaced cell is kept, so its formatting
80
+ survives the edit. Writing a `Date` into a cell with no date format applies one,
81
+ reusing a format the file already has where possible.
82
+
83
+ Formatting goes through the same options, and composes into one cell format:
84
+
85
+ ```ts
86
+ const workbook = readWorkbook(bytes)
87
+ const sheet = workbook.sheet('Summary') ?? workbook.sheets[0]
88
+
89
+ sheet.set('A1', 1234.5, {
90
+ numberFormat: '"$"#,##0.00',
91
+ font: { bold: true, color: 'FF0000' },
92
+ fill: { type: 'solid', color: 'FFFF00' },
93
+ border: { all: { style: 'thin' } },
94
+ alignment: { horizontal: 'center', wrapText: true },
95
+ protection: { locked: false },
96
+ })
97
+
98
+ // format() changes only the formatting, so a formula cell keeps its expression.
99
+ sheet.format('B2', { font: { italic: true }, border: { bottom: { style: 'medium' } } })
100
+ ```
101
+
102
+ A `font` merges onto the cell's current one, so `{ font: { bold: true } }` adds
103
+ bold without disturbing its size or colour. Besides `bold`, `italic`, `size`,
104
+ `color` and `name`, a font carries `strike`, `verticalAlign` (`superscript` or
105
+ `subscript`) and `underline`, which is `true` for a single underline or one of
106
+ `double`, `singleAccounting`, `doubleAccounting`. A `fill` is a discriminated
107
+ union: `{ type: 'solid', color }` for a plain background, or `{ type: 'pattern',
108
+ pattern, color, background }` for one of the ECMA-376 pattern types such as
109
+ `lightGrid`, where `color` is the pattern's foreground. A
110
+ `border` sets sides by name, or `all` at once, merging onto the sides the cell
111
+ already has. An `alignment` places the text — `horizontal`, `vertical`,
112
+ `wrapText`, `textRotation` (0–180, or 255 to stack top to bottom) and `indent` —
113
+ and merges the same way, so setting `wrapText` leaves a horizontal choice alone.
114
+ A `protection` sets `locked` and `hidden`, which take effect once the worksheet
115
+ itself is protected. A colour is an `RRGGBB` or `AARRGGBB` hex string, a theme
116
+ reference `{ theme, tint }` (an index into the workbook's colour scheme; `tint`
117
+ lightens or darkens it, -1 to 1), or an indexed palette entry `{ indexed }`. A
118
+ theme or indexed colour is read back and written as the reference it is, not
119
+ resolved to the hex it displays as, so editing a cell keeps the theme colour its
120
+ font already carried rather than dropping it.
121
+
122
+ `worksheet.protect()` turns that worksheet protection on — it is what makes a
123
+ cell's `locked` and `hidden` bite. With no argument it matches Excel's Protect
124
+ Sheet default: every cell locked, formatting, inserting, deleting, sorting and
125
+ filtering barred, and selecting cells still allowed. Pass options to name the
126
+ actions that stay permitted, each `true` to allow it:
127
+
128
+ ```ts
129
+ const sheet = readWorkbook(bytes).sheets[0]
130
+ sheet?.protect({ formatCells: true, sort: true, selectLockedCells: false })
131
+ ```
132
+
133
+ It replaces any protection the sheet already had. Passwords are not written, so
134
+ protection guards against accidental edits, not a determined one.
135
+ `worksheet.protection` reads the protection back in the same shape, or is
136
+ undefined when the sheet is not protected.
137
+
138
+ `worksheet.merge('A1:B2')` merges a range, joining any merges the sheet already
139
+ has. Excel shows only the top-left cell; the others keep their values, since a
140
+ merge does not clear them, and a write to one is refused the way it already was
141
+ for a merge the file came with.
142
+
143
+ `worksheet.setRowHeight(1, 30)` sizes a row in points, and
144
+ `worksheet.setColumnWidth('A', 24)` sizes a column in Excel's width units,
145
+ splitting a `cols` range that spans more than the one column so the rest keeps
146
+ its own width.
147
+
148
+ An edit the format cannot hold is refused by `set` itself, with an `XlsxError`
149
+ naming the cell. `NaN`, an infinity, a character XML has no way to encode, a
150
+ date outside the workbook's epoch, and overwriting the cell that defines a
151
+ shared formula are all refused this way. A refused edit is not recorded, so the
152
+ rest of the batch still writes and `cell()` never reports a value the file is
153
+ not going to receive.
154
+
155
+ `cell.value` is a discriminated union:
156
+
157
+ ```ts
158
+ type CellValue =
159
+ | { kind: 'number'; value: number }
160
+ | { kind: 'text'; value: string }
161
+ | { kind: 'boolean'; value: boolean }
162
+ | { kind: 'error'; value: string }
163
+ | { kind: 'empty' }
164
+ | { kind: 'date'; value: Date; serial: number }
165
+ ```
166
+
167
+ There is no date type in the file format. A date is a number with a date number
168
+ format applied, so `kind: 'date'` comes from resolving the cell's style. The
169
+ `serial` is kept so the stored value can go back unchanged.
170
+
171
+ Which format codes count as dates is a heuristic over the code, and it is not
172
+ frozen: a release may start or stop calling a given format a date, and a cell
173
+ can move between `number` and `date` without the type changing to warn you. Both
174
+ carry the same stored double, in `serial` or `value`, so read it by kind:
175
+
176
+ ```ts
177
+ const stored =
178
+ value.kind === 'date' ? value.serial : value.kind === 'number' ? value.value : undefined
179
+ ```
180
+
181
+ If you need a stable classification, read `cell.numberFormat` and decide for
182
+ yourself.
183
+
184
+ Dates are calendar dates, not instants. `new Date(2024, 0, 1)` writes the serial
185
+ for that day, and reading it back gives a `Date` whose `getDate()` is 1, in
186
+ whatever timezone the code runs in. Build dates the ordinary way rather than
187
+ with `Date.UTC`.
188
+
189
+ Reading a formula gives you `cell.formula`, which is either
190
+ `{ kind: 'expression', expression }` — the source without the leading `=` — or
191
+ `{ kind: 'shared', master }` for a cell that follows a shared formula, naming
192
+ the cell the expression is stored on. A shared formula is written once and
193
+ reused down a column, so the followers hold a cached value and nothing else.
194
+
195
+ Write one with `{ formula }` rather than a string beginning with `=`, so text
196
+ that happens to start with `=` stays text. Nothing here computes a result, so
197
+ the cell is written without one and the workbook is marked for recalculation.
198
+ Until something opens it, that cell reads back with a value of `kind: 'empty'`
199
+ and its expression in `cell.formula`.
200
+
201
+ ## Compatibility
202
+
203
+ What a minor release is allowed to do, so you know which branches are safe:
204
+
205
+ - **`XlsxErrorCode` is open.** New codes arrive in minor releases, because there
206
+ is no knowing today every way an xlsx can be malformed. Switch with a default.
207
+ An existing code will not change meaning without a major version.
208
+ `bad-reference` and `unwritable-value` mean the caller passed something the
209
+ library cannot use; every other code is about the file, or the runtime reading
210
+ it, not a value you passed.
211
+ - **`kind: 'date'` is not a frozen classification.** See above.
212
+ - **A `Date` written and read back is the same date.** A serial another
213
+ application wrote can carry finer resolution than a millisecond, which is all
214
+ a `Date` holds.
215
+
216
+ ## Not done yet
217
+
218
+ - Overwriting the anchor of a shared formula, an array formula or a data table
219
+ is refused rather than breaking the cells that spill from it. Writing into a
220
+ merged cell that is not the anchor is refused for the same reason: a value
221
+ there would never show.
222
+ - A digital signature survives as a part but any edit invalidates it, since the
223
+ package is rebuilt and the bytes it signed change.
224
+ - Nothing evaluates formulas, so a written one has no value until a spreadsheet
225
+ application opens the file.
226
+ - A part the edit does not touch is copied through still compressed, so a large
227
+ workbook is not fully decompressed to change one cell. A sheet is read and
228
+ patched in its bytes rather than decoded to one string, so the ceiling on a
229
+ single sheet is the largest buffer this runtime allocates rather than V8's
230
+ ~512MB string limit; past that a part is refused with `part-too-large`.
231
+ Nothing streams a sheet through in chunks, though, so it is still held whole
232
+ in memory.
233
+ - Charts, pivot tables and drawings are preserved but never created.
234
+ - `cell` exposes a cell's `font`, `fill`, `border` and `alignment`, but only the
235
+ parts this library models. A gradient fill is preserved in the file but not
236
+ reported. A theme colour is reported as its `{ theme, tint }` reference rather
237
+ than resolved to the hex it displays as, so reading the colour a theme names
238
+ would need the theme part this library does not yet interpret.
239
+ - A table grows to include a cell written directly below or to the right of it,
240
+ the way Excel would, adding a column when it grows sideways. Other ranges that
241
+ name cells are still copied, not adjusted: chart ranges, defined names and
242
+ conditional formatting keep the extent they had.
243
+ - Chartsheets and dialogsheets aren't listed in `sheets`, since they hold no
244
+ cells. They're still written back untouched.
245
+
246
+ ## Speed
247
+
248
+ `node scripts/bench.mjs` writes into a sheet of the given size, in the four
249
+ shapes that have caused trouble: replacing cells, appending rows, writing dates,
250
+ and reading between every write.
251
+
252
+ Each is linear in the number of edits, and tens of milliseconds against 10,000
253
+ rows on a laptop. Reading between writes used to be quadratic; the same run took
254
+ 7 minutes 46 seconds.
255
+
256
+ ## Layout
257
+
258
+ ```
259
+ src/lib/ the library
260
+ src/harness/ round-trip measurement
261
+ src/adapters/ libraries the harness measures
262
+ src/fixtures/ builds and fetches test files
263
+ fixtures/ the test files themselves
264
+ ```
265
+
266
+ ## Verify
267
+
268
+ ```bash
269
+ ./verify.sh
270
+ ```
271
+
272
+ Formats, lints, typechecks, greps for banned constructs, runs the tests with
273
+ coverage thresholds, and checks the built package. Run it before every commit.
274
+
275
+ ## How the tests are built
276
+
277
+ Coverage says a line ran, not that its output was right, so three things sit on
278
+ top of the ordinary tests.
279
+
280
+ **Invariants over fragments.** `src/testing/invariants.ts` holds the assertions
281
+ every write must satisfy: the sheet is well formed, cells sit inside rows, no
282
+ reference appears twice, and no part outside the edited sheet changed. Checking
283
+ for a substring passes on output that contains the right fragment inside a
284
+ broken document.
285
+
286
+ **Properties over examples.** `properties.test.ts` generates edits against all
287
+ 60 real files and asserts what must hold for any of them: the sheet still
288
+ parses, edited cells read back as written, untouched cells are untouched, and
289
+ the order edits were made in does not matter. The seed is fixed so a failure
290
+ reproduces, and a failing case is shrunk to the fewest edits that still fail.
291
+ Hand-written tests only cover failure modes somebody already imagined.
292
+
293
+ **Mutation testing.** `npm run mutate` breaks the library one edit at a time and
294
+ checks that some test notices. It is slow, so it is not part of `verify.sh`.
295
+ A survivor is either a real gap or a mutation that changes nothing; both need
296
+ reading.
297
+
298
+ ## Checking against something that isn't us
299
+
300
+ Every check above validates against this library's own reader, so a file we
301
+ write wrongly and read back wrongly looks right everywhere. `npm run external`
302
+ writes a value into each of the 60 real files and converts the result with
303
+ LibreOffice, which is a different OOXML implementation with its own opinions
304
+ about what is valid.
305
+
306
+ Each file is converted before and after the edit. A fixture LibreOffice can't
307
+ open to begin with proves nothing, so only one that opened before and fails
308
+ after counts. Currently 60 of 60 open after editing and LibreOffice finds the
309
+ written value in all of them.
310
+
311
+ It needs LibreOffice installed and takes a few minutes, so it isn't part of
312
+ `./verify.sh`.
313
+
314
+ `npm run browser` is the other outside check. It bundles the built library with
315
+ fflate, then reads a fixture, edits a cell and writes it back inside a headless
316
+ Chrome, confirming the code runs where no Node API exists. It drives Chrome over
317
+ the DevTools protocol directly, so it adds no dependency, and `./verify.sh` runs
318
+ it, skipping cleanly on a machine with no Chrome.
319
+
320
+ ## Round-trip harness
321
+
322
+ `npm run harness` reads every test file and reports what changed: missing ZIP
323
+ parts, markup features whose count dropped, and cell values that differ. It runs
324
+ twice. Once writing the file straight back out, which measures the container and
325
+ nothing else. Then again after writing a cell past the last row in use, which is
326
+ the measurement that matches the claim — every existing cell has to come back
327
+ unchanged, and the only parts allowed to differ are the sheet the edit landed
328
+ in, the four parts an edit legitimately rewrites (`styles.xml`,
329
+ `sharedStrings.xml`, `workbook.xml`, `[Content_Types].xml`), and
330
+ `calcChain.xml`, which is deleted so the reader recomputes the values a formula
331
+ edit invalidates.
332
+
333
+ Nothing is lost or rewritten across the 73 committed files in either pass, and
334
+ both gate `./verify.sh`.
335
+
336
+ ## Fixtures
337
+
338
+ - `fixtures/real/` has 60 files from Apache POI's test data, pinned to a commit.
339
+ Real spreadsheet applications wrote these. See `PROVENANCE.md`.
340
+ - `fixtures/quirks/` holds hand-built files for legal but unusual constructs:
341
+ inline strings, missing cell references, rows out of order, a `dimension` that
342
+ lies, the 1904 epoch, the 1900 leap-year bug, shared formulas, columns past Z,
343
+ XML entities, boolean and error cells.
344
+ - `fixtures/generated/` has feature-rich workbooks from another library, used as
345
+ a smoke test. Weaker than the real files, since no JavaScript library wrote
346
+ those.
347
+ - `fixtures/manual/` is for your own files. Gitignored.
348
+
349
+ `npm run fixtures` builds the generated and quirk files. `npm run fixtures:real`
350
+ pulls more from the pinned POI commit.
351
+
352
+ ## License
353
+
354
+ MIT, see `LICENSE`.
355
+
356
+ The files in `fixtures/real/` come from Apache POI and stay under Apache-2.0,
357
+ with that project's `LICENSE` and `NOTICE` kept alongside them. They are test
358
+ data only. The published package contains just `dist/`, so none of it ships.