@lotics/xlsx 0.1.7 → 0.1.8

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/AGENTS.md CHANGED
@@ -321,6 +321,12 @@ Each returns a `Command` (`{ description, execute, undo }`); run it and push it
321
321
  - Row/col insert/delete re-key populated cells and shift formula references (via
322
322
  `adjustMergedCells*` / `expandFormulaRangesForRowInsert`, also barrel-exported) — this is why they
323
323
  must be commands, not direct `SheetModel` edits.
324
+ - Row/col insert/delete also **clamp the used range**: before shifting, they drop "phantom" cells —
325
+ value-less, unstyled, General-format padding outside the bounding box of the cells that carry data
326
+ or formatting. Real files accumulate these (a bloated stored `<dimension>` or a stray fill leaves
327
+ empty cells far past the data), and left in place they inflate the used range that drives the canvas
328
+ render width and the exported `<dimension>`. Undo restores them, and cells with any value, formula,
329
+ style, number format, or hyperlink are never dropped.
324
330
 
325
331
  ### Export to `.xlsx` bytes — `xlsx_writer.ts` (barrel)
326
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/xlsx",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -27,10 +27,15 @@ import {
27
27
  setShowGridLinesCommand,
28
28
  setShowHeadersCommand,
29
29
  setHyperlinkCommand,
30
+ insertRowsCommand,
31
+ deleteRowsCommand,
32
+ insertColsCommand,
33
+ deleteColsCommand,
30
34
  } from "./spreadsheet_commands";
31
35
  import { CommandHistory } from "./command_history";
32
36
  import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
33
37
  import type { ChangeEvent } from "./workbook_model";
38
+ import { rowColToRef, refToRowCol } from "./excel_utils";
34
39
 
35
40
  function makeWorkbook(): WorkbookModel {
36
41
  const styles = new StyleRegistry();
@@ -770,3 +775,107 @@ describe("setHyperlinkCommand", () => {
770
775
  expect(wb.sheets[0].hyperlinks.has("A1")).toBe(false);
771
776
  });
772
777
  });
778
+
779
+ describe("structural ops clamp phantom cells", () => {
780
+ // A sheet whose real data ends at column C but that carries value-less,
781
+ // unstyled "phantom" cells out to column 40 in row 2 — the shape a bloated
782
+ // stored `<dimension>` (e.g. A1:XDD63) leaves behind. Left alone, these
783
+ // inflate the used range, driving the render width and exported dimension to
784
+ // the phantom extent.
785
+ function makeSheetWithJunkWidth(): WorkbookModel {
786
+ const wb = makeWorkbook();
787
+ const sheet = wb.sheets[0];
788
+ sheet.set("A1", "Name");
789
+ sheet.set("B1", "Qty");
790
+ sheet.set("C1", "Price");
791
+ sheet.set("A2", "Widget");
792
+ sheet.set("B2", 5);
793
+ sheet.set("C2", 10);
794
+ for (let c = 4; c <= 40; c++) sheet.set(rowColToRef(2, c), null);
795
+ return wb;
796
+ }
797
+
798
+ test("insert-rows drops phantom cells beyond the used-data columns", () => {
799
+ const wb = makeSheetWithJunkWidth();
800
+ const sheet = wb.sheets[0];
801
+ expect(sheet.getUsedRange().maxCol).toBe(40); // phantoms inflate it pre-op
802
+
803
+ insertRowsCommand(wb, 0, 2, 1).execute();
804
+
805
+ // Used range is clamped to the real data extent (column C = 3).
806
+ expect(sheet.getUsedRange().maxCol).toBe(3);
807
+ // No cell — in the inserted row or any other — exists beyond column C.
808
+ for (const ref of sheet.cells.keys()) {
809
+ expect(refToRowCol(ref)!.col).toBeLessThanOrEqual(3);
810
+ }
811
+ // Data is intact and shifted: row 2 is now blank, data moved to row 3.
812
+ expect(sheet.getCell("A3")?.value).toBe("Widget");
813
+ });
814
+
815
+ test("preserves an empty but styled cell — not a phantom", () => {
816
+ // A styles-wired sheet, as loaded from a file, so `set` can intern a style.
817
+ const styles = new StyleRegistry();
818
+ const sheet = new SheetModel("Sheet1", styles);
819
+ const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
820
+ sheet.set("A1", "data");
821
+ sheet.set("E1", null, { fontBold: true }); // empty yet meaningfully styled
822
+ expect(sheet.getCell("E1")?.styleIndex).not.toBe(0);
823
+
824
+ insertRowsCommand(wb, 0, 1, 1).execute();
825
+
826
+ // The styled empty cell survives (shifted to E2) and still extends the range.
827
+ expect(sheet.getUsedRange().maxCol).toBe(5);
828
+ expect(sheet.getCell("E2")?.styleIndex).not.toBe(0);
829
+ });
830
+
831
+ test("leaves phantom cells that sit inside the data bounding box", () => {
832
+ const wb = makeWorkbook();
833
+ const sheet = wb.sheets[0];
834
+ sheet.set("A1", "x");
835
+ sheet.set("C1", "y"); // data spans A1:C1
836
+ sheet.set("B1", null); // a phantom INSIDE the box — must not be dropped
837
+
838
+ insertRowsCommand(wb, 0, 1, 1).execute();
839
+
840
+ // B shifts to B2 and remains present; the box is unchanged.
841
+ expect(sheet.getCell("B2")).toBeDefined();
842
+ expect(sheet.getUsedRange().maxCol).toBe(3);
843
+ });
844
+
845
+ test("delete-rows also clamps phantom cells", () => {
846
+ const wb = makeSheetWithJunkWidth();
847
+ const sheet = wb.sheets[0];
848
+ deleteRowsCommand(wb, 0, 1, 1).execute();
849
+ expect(sheet.getUsedRange().maxCol).toBe(3);
850
+ });
851
+
852
+ test("insert-cols also clamps phantom cells", () => {
853
+ const wb = makeSheetWithJunkWidth();
854
+ const sheet = wb.sheets[0];
855
+ insertColsCommand(wb, 0, 1, 1).execute();
856
+ // Phantoms pruned; real data A:C shifted right to B:D.
857
+ expect(sheet.getUsedRange().maxCol).toBe(4);
858
+ });
859
+
860
+ test("delete-cols also clamps phantom cells", () => {
861
+ const wb = makeSheetWithJunkWidth();
862
+ const sheet = wb.sheets[0];
863
+ deleteColsCommand(wb, 0, 1, 1).execute();
864
+ // Phantoms pruned; B:C shifted left to A:B.
865
+ expect(sheet.getUsedRange().maxCol).toBe(2);
866
+ });
867
+
868
+ test("undo restores the pruned phantom cells", () => {
869
+ const wb = makeSheetWithJunkWidth();
870
+ const sheet = wb.sheets[0];
871
+ const beforeSize = sheet.cells.size;
872
+
873
+ const cmd = insertRowsCommand(wb, 0, 2, 1);
874
+ cmd.execute();
875
+ expect(sheet.getUsedRange().maxCol).toBe(3);
876
+
877
+ cmd.undo();
878
+ expect(sheet.cells.size).toBe(beforeSize);
879
+ expect(sheet.getUsedRange().maxCol).toBe(40);
880
+ });
881
+ });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { Command } from "./command_history";
11
11
  import { SheetModel } from "./workbook_model";
12
- import type { WorkbookModel, CellValue } from "./workbook_model";
12
+ import type { WorkbookModel, CellValue, CellModel } from "./workbook_model";
13
13
  import type { CellStyle, CellContent } from "./types";
14
14
  import type { ParsedConditionalFormat } from "./excel_cf_types";
15
15
  import type { FormulaEngine } from "./formula_engine";
@@ -319,6 +319,90 @@ export function unmergeCellsCommand(
319
319
  };
320
320
  }
321
321
 
322
+ // =============================================================================
323
+ // Phantom-cell clamping for structural row/column ops
324
+ // =============================================================================
325
+ //
326
+ // Real-world .xlsx files accumulate "phantom" cells: value-less, unformatted
327
+ // `<c>` elements that a stray fill-to-the-right or a bloated stored
328
+ // `<dimension>` left behind (e.g. a sheet whose data ends at column G but that
329
+ // carries empty cells out to column XDD). The parser materializes them
330
+ // faithfully, so they land in the model carrying no data and no formatting —
331
+ // yet they inflate the used range, which drives BOTH the canvas render width
332
+ // and the exported `<dimension>`. A file bloated to column 16,332 renders ~1.5M
333
+ // pixels wide.
334
+ //
335
+ // A structural row/column op is the natural normalization point (Excel itself
336
+ // tightens the used range when you insert/delete): before shifting cells, we
337
+ // drop phantom cells that lie OUTSIDE the bounding box of the cells that
338
+ // actually carry information, clamping the sheet to its real extent. We never
339
+ // drop a cell with a value, a formula, a non-default style, a number format, an
340
+ // error, rich text, or a hyperlink — only truly inert padding.
341
+
342
+ /**
343
+ * A cell carrying zero information: no value, no formula, the default (empty)
344
+ * style, the General number format, no error/rich-text, and plain empty
345
+ * content (so hyperlinks are excluded). Such a cell is indistinguishable from
346
+ * an absent cell — Excel renders and evaluates them identically.
347
+ */
348
+ function isInertCell(cell: CellModel): boolean {
349
+ return (
350
+ (cell.value === null || cell.value === "") &&
351
+ cell.formula === undefined &&
352
+ cell.styleIndex === 0 &&
353
+ (cell.originalXfIndex === undefined || cell.originalXfIndex === 0) &&
354
+ (cell.numFmtCode === "General" || cell.numFmtCode === "") &&
355
+ cell.richText === undefined &&
356
+ cell.error === undefined &&
357
+ cell.content.type === "plain" &&
358
+ cell.content.text === ""
359
+ );
360
+ }
361
+
362
+ /**
363
+ * Drop inert phantom cells that fall outside the bounding box of the sheet's
364
+ * meaningful (non-inert) cells, clamping the used range to its real extent.
365
+ * Returns the removed cells keyed by ref so the caller can restore them on undo.
366
+ */
367
+ function clampInertCells(sheet: SheetModel): Map<string, CellModel> {
368
+ let minRow = Infinity;
369
+ let maxRow = 0;
370
+ let minCol = Infinity;
371
+ let maxCol = 0;
372
+ let hasMeaningful = false;
373
+ for (const [ref, cell] of sheet.cells) {
374
+ if (isInertCell(cell)) continue;
375
+ const rc = refToRowColSafe(ref);
376
+ if (!rc) continue;
377
+ hasMeaningful = true;
378
+ if (rc.row < minRow) minRow = rc.row;
379
+ if (rc.row > maxRow) maxRow = rc.row;
380
+ if (rc.col < minCol) minCol = rc.col;
381
+ if (rc.col > maxCol) maxCol = rc.col;
382
+ }
383
+
384
+ const pruned = new Map<string, CellModel>();
385
+ for (const [ref, cell] of sheet.cells) {
386
+ if (!isInertCell(cell)) continue;
387
+ // No meaningful cells at all → every phantom is padding. Otherwise, only
388
+ // phantoms strictly outside the meaningful bounding box are padding; a
389
+ // phantom interspersed with real data is left untouched (it doesn't inflate
390
+ // the used range and dropping it would be a needless mutation).
391
+ if (!hasMeaningful) {
392
+ pruned.set(ref, cell);
393
+ continue;
394
+ }
395
+ const rc = refToRowColSafe(ref);
396
+ if (!rc) continue;
397
+ if (rc.row < minRow || rc.row > maxRow || rc.col < minCol || rc.col > maxCol) {
398
+ pruned.set(ref, cell);
399
+ }
400
+ }
401
+
402
+ for (const ref of pruned.keys()) sheet.cells.delete(ref);
403
+ return pruned;
404
+ }
405
+
322
406
  // =============================================================================
323
407
  // Insert Rows
324
408
  // =============================================================================
@@ -334,11 +418,13 @@ export function insertRowsCommand(
334
418
  // the inverse of `adjustMergedCellsForRowInsert` and avoids edge cases when
335
419
  // inserts split existing ranges.
336
420
  let mergeSnapshot: string[] = [];
421
+ let inertSnapshot = new Map<string, CellModel>();
337
422
 
338
423
  return {
339
424
  description: `Insert ${count} row${count > 1 ? "s" : ""} at ${at}`,
340
425
  execute() {
341
426
  mergeSnapshot = [...sheet.mergedCells];
427
+ inertSnapshot = clampInertCells(sheet);
342
428
  // Shift existing cells down
343
429
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
344
430
  for (const [ref] of sheet.cells) {
@@ -388,6 +474,7 @@ export function insertRowsCommand(
388
474
  sheet.rowHeights.set(r - count, h);
389
475
  }
390
476
  for (let r = at; r < at + count; r++) sheet.rowHeights.delete(r);
477
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
391
478
  workbook.emit({ type: "rows_deleted", sheet: sheetIndex, at, count });
392
479
  },
393
480
  };
@@ -413,11 +500,13 @@ export function deleteRowsCommand(
413
500
  }
414
501
  }
415
502
  let mergeSnapshot: string[] = [];
503
+ let inertSnapshot = new Map<string, CellModel>();
416
504
 
417
505
  return {
418
506
  description: `Delete ${count} row${count > 1 ? "s" : ""} at ${at}`,
419
507
  execute() {
420
508
  mergeSnapshot = [...sheet.mergedCells];
509
+ inertSnapshot = clampInertCells(sheet);
421
510
  // Delete cells in the range
422
511
  for (const ref of snapshot.keys()) sheet.cells.delete(ref);
423
512
  // Shift cells up
@@ -454,6 +543,7 @@ export function deleteRowsCommand(
454
543
  for (const [ref, snap] of snapshot) {
455
544
  sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
456
545
  }
546
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
457
547
  workbook.emit({ type: "rows_inserted", sheet: sheetIndex, at, count });
458
548
  },
459
549
  };
@@ -471,11 +561,13 @@ export function insertColsCommand(
471
561
  ): Command {
472
562
  const sheet = workbook.sheets[sheetIndex];
473
563
  let mergeSnapshot: string[] = [];
564
+ let inertSnapshot = new Map<string, CellModel>();
474
565
 
475
566
  return {
476
567
  description: `Insert ${count} column${count > 1 ? "s" : ""} at ${at}`,
477
568
  execute() {
478
569
  mergeSnapshot = [...sheet.mergedCells];
570
+ inertSnapshot = clampInertCells(sheet);
479
571
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
480
572
  for (const [ref] of sheet.cells) {
481
573
  const rc = refToRowColSafe(ref);
@@ -519,6 +611,7 @@ export function insertColsCommand(
519
611
  sheet.colWidths.set(c - count, w);
520
612
  }
521
613
  for (let c = at; c < at + count; c++) sheet.colWidths.delete(c);
614
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
522
615
  workbook.emit({ type: "cols_deleted", sheet: sheetIndex, at, count });
523
616
  },
524
617
  };
@@ -543,11 +636,13 @@ export function deleteColsCommand(
543
636
  }
544
637
  }
545
638
  let mergeSnapshot: string[] = [];
639
+ let inertSnapshot = new Map<string, CellModel>();
546
640
 
547
641
  return {
548
642
  description: `Delete ${count} column${count > 1 ? "s" : ""} at ${at}`,
549
643
  execute() {
550
644
  mergeSnapshot = [...sheet.mergedCells];
645
+ inertSnapshot = clampInertCells(sheet);
551
646
  for (const ref of snapshot.keys()) sheet.cells.delete(ref);
552
647
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
553
648
  for (const [ref] of sheet.cells) {
@@ -580,6 +675,7 @@ export function deleteColsCommand(
580
675
  for (const [ref, snap] of snapshot) {
581
676
  sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
582
677
  }
678
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
583
679
  workbook.emit({ type: "cols_inserted", sheet: sheetIndex, at, count });
584
680
  },
585
681
  };