@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 +6 -0
- package/package.json +1 -1
- package/src/spreadsheet_commands.test.ts +109 -0
- package/src/spreadsheet_commands.ts +97 -1
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
|
@@ -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
|
};
|