Implement and test adjustment of named tables, ranges and merged cells

This commit is contained in:
Martin Aspeli
2013-10-06 23:38:21 +01:00
parent aa8cda5e2b
commit a37c969e79
5 changed files with 224 additions and 44 deletions
+6 -6
View File
@@ -115,12 +115,12 @@ attach it to an email or do whatever you want with it.
* Column (array) and table (array-of-objects) insertions cause rows and cells to
be inserted or removed. When this happens, only a limited number of
adjustments are made:
* Merged cells to the right of cells where insertions or deletions are made
are moved right or left, appropriately. This may not work well if cells
are merged across rows, unless all rows have the same number of
insertions.
* Merged cells or named tables below rows where further rows are inserted
are moved down.
* Merged cells and named cells/ranges to the right of cells where insertions
or deletions are made are moved right or left, appropriately. This may
not work well if cells are merged across rows, unless all rows have the
same number of insertions.
* Merged cells, named tables or named cells/ranges below rows where further
rows are inserted are moved down.
Formulae are not adjusted.
* As a corollary to this, it is not always easy to build formulae that refer
to cells in a table (e.g. summing all rows) where the exact number of rows
+136 -32
View File
@@ -44,9 +44,11 @@ module.exports = (function() {
var rels = etree.parse(self.archive.file("_rels/.rels").asText()).getroot(),
workbookPath = rels.find("Relationship[@Type='" + DOCUMENT_RELATIONSHIP + "']").attrib.Target;
self.workbookPath = workbookPath;
self.prefix = path.dirname(workbookPath);
self.workbook = etree.parse(self.archive.file(workbookPath).asText()).getroot();
self.workbookRels = etree.parse(self.archive.file(path.join(self.prefix, '_rels', path.basename(workbookPath) + '.rels')).asText()).getroot();
self.sheets = self.loadSheets(self.prefix, self.workbook, self.workbookRels);
self.sharedStringsPath = path.join(self.prefix, self.workbookRels.find("Relationship[@Type='" + SHARED_STRINGS_RELATIONSHIP + "']").attrib.Target);
self.sharedStrings = [];
@@ -60,16 +62,15 @@ module.exports = (function() {
* Interpolate values for the sheet with the given number (1-based) or
* name (if a string) using the given substitutions (an object).
*/
Workbook.prototype.substitute = function(sheet, substitutions) {
Workbook.prototype.substitute = function(sheetName, substitutions) {
var self = this;
var sheetFilename = self.getSheetFilename(sheet),
root = etree.parse(self.archive.file(sheetFilename).asText()).getroot();
var sheet = self.loadSheet(sheetName);
var sheetData = root.find("sheetData"),
var sheetData = sheet.root.find("sheetData"),
currentRow = null,
totalRowsInserted = 0,
namedTables = self.loadTables(root, sheetFilename),
namedTables = self.loadTables(sheet.root, sheet.filename),
rows = [];
sheetData.findall("row").forEach(function(row) {
@@ -118,7 +119,7 @@ module.exports = (function() {
if(newCellsInserted !== 0) {
appendCell = false; // don't double-insert cells
cellsInserted += newCellsInserted;
self.pushRight(root, cell.attrib.r, newCellsInserted);
self.pushRight(self.workbook, sheet.root, cell.attrib.r, newCellsInserted);
}
} else if(placeholder.full && placeholder.type === "normal" && substitution instanceof Array) {
appendCell = false; // don't double-insert cells
@@ -128,7 +129,7 @@ module.exports = (function() {
if(newCellsInserted !== 0) {
cellsInserted += newCellsInserted;
self.pushRight(root, cell.attrib.r, newCellsInserted);
self.pushRight(self.workbook, sheet.root, cell.attrib.r, newCellsInserted);
}
} else {
string = self.substituteScalar(cell, string, placeholder, substitution);
@@ -157,7 +158,7 @@ module.exports = (function() {
rows.push(row);
++totalRowsInserted;
});
self.pushDown(root, namedTables, currentRow, newTableRows.length);
self.pushDown(self.workbook, sheet.root, namedTables, currentRow, newTableRows.length);
}
}); // rows loop
@@ -169,7 +170,8 @@ module.exports = (function() {
self.substituteTableColumnHeaders(namedTables, substitutions);
// Write back the modified XML trees
self.archive.file(sheetFilename, etree.tostring(root));
self.archive.file(sheet.filename, etree.tostring(sheet.root));
self.archive.file(self.workbookPath, etree.tostring(self.workbook));
self.writeSharedStrings();
self.writeTables(namedTables);
};
@@ -249,22 +251,51 @@ module.exports = (function() {
return idx;
};
// Get sheet filename
Workbook.prototype.getSheetFilename = function(sheet) {
// Get a list of sheet ids, names and filenames
Workbook.prototype.loadSheets = function(prefix, workbook, workbookRels) {
var self = this;
var sheets = [];
workbook.findall("sheets/sheet").forEach(function(sheet) {
var sheetId = sheet.attrib.sheetId,
relId = sheet.attrib['r:id'],
relationship = workbookRels.find("Relationship[@Id='" + relId + "']"),
filename = path.join(prefix, relationship.attrib.Target);
sheets.push({
id: parseInt(sheetId, 10),
name: sheet.attrib.name,
filename: filename
});
});
return sheets;
};
// Get sheet a sheet, including filename and name
Workbook.prototype.loadSheet = function(sheet) {
var self = this;
var sheetReference = (typeof(sheet) === "number") ?
self.workbook.find("sheets/sheet[@sheetId='" + sheet + "']") :
self.workbook.find("sheets/sheet[@name='" + sheet + "']");
var info = null;
if(sheetReference === null) {
throw new Error("Sheet " + sheet + "not found");
for(var i = 0; i < self.sheets.length; ++i) {
if((typeof(sheet) === "number" && self.sheets[i].id === sheet) || (self.sheets[i].name === sheet)) {
info = self.sheets[i];
break;
}
}
var sheetId = sheetReference.attrib['r:id'],
relationship = self.workbookRels.find("Relationship[@Id='" + sheetId + "']");
if(info === null) {
throw new Error("Sheet " + sheet + " not found");
}
return path.join(self.prefix, relationship.attrib.Target);
return {
filename: info.filename,
name: info.name,
id: info.id,
root: etree.parse(self.archive.file(info.filename).asText()).getroot()
};
};
// Load tables for a given sheet
@@ -392,18 +423,26 @@ module.exports = (function() {
return matches;
};
// Split a reference into an object with keys `row` and `col`.
// Split a reference into an object with keys `row` and `col` and,
// optionally, `table`, `rowAbsolute` and `colAbsolute`.
Workbook.prototype.splitRef = function(ref) {
var match = ref.match(/([A-Z]+)([0-9]+)/);
var match = ref.match(/(?:(.+)!)?(\$)?([A-Z]+)(\$)?([0-9]+)/);
return {
col: match[1],
row: parseInt(match[2], 10)
table: match[1] || null,
colAbsolute: Boolean(match[2]),
col: match[3],
rowAbsolute: Boolean(match[4]),
row: parseInt(match[5], 10)
};
};
// Join an object with keys `row` and `col` into a single reference string
Workbook.prototype.joinRef = function(ref) {
return ref.col.toUpperCase() + Number(ref.row).toString();
return (ref.table? ref.table + "!" : "") +
(ref.colAbsolute? "$" : "") +
ref.col.toUpperCase() +
(ref.rowAbsolute? "$" : "" )+
Number(ref.row).toString();
};
// Get the next column's cell reference given a reference like "B2".
@@ -456,6 +495,11 @@ module.exports = (function() {
return str;
};
// Is ref a range?
Workbook.prototype.isRange = function(ref) {
return ref.indexOf(':') !== -1;
};
// Is ref inside the table defined by startRef and endRef?
Workbook.prototype.isWithin = function(ref, startRef, endRef) {
var self = this;
@@ -711,9 +755,9 @@ module.exports = (function() {
return range.start + ":" + range.end;
};
// Look for any merged cell definitions to the right of `currentCell` and
// push right by `numCols`.
Workbook.prototype.pushRight = function(sheet, currentCell, numCols) {
// Look for any merged cell or named range definitions to the right of
// `currentCell` and push right by `numCols`.
Workbook.prototype.pushRight = function(workbook, sheet, currentCell, numCols) {
var self = this;
var cellRef = self.splitRef(currentCell),
@@ -739,12 +783,43 @@ module.exports = (function() {
}
});
// TODO: Named cells/ranges
// Named cells/ranges
workbook.findall("definedNames/definedName").forEach(function(name) {
var ref = name.text;
if(self.isRange(ref)) {
var namedRange = self.splitRange(ref),
namedStart = self.splitRef(namedRange.start),
namedStartCol = self.charToNum(namedStart.col),
namedEnd = self.splitRef(namedRange.end),
namedEndCol = self.charToNum(namedEnd.col);
if(namedStart.row === currentRow && currentCol < namedStartCol) {
namedStart.col = self.numToChar(namedStartCol + numCols);
namedEnd.col = self.numToChar(namedEndCol + numCols);
name.text = self.joinRange({
start: self.joinRef(namedStart),
end: self.joinRef(namedEnd),
});
}
} else {
var namedRef = self.splitRef(ref),
namedCol = self.charToNum(namedRef.col);
if(namedRef.row === currentRow && currentCol < namedCol) {
namedRef.col = self.numToChar(namedCol + numCols);
name.text = self.joinRef(namedRef);
}
}
});
};
// Look for any merged cell or named table definitions below `currentRow`
// and push down by `numRows` (used when rows are inserted).
Workbook.prototype.pushDown = function(sheet, tables, currentRow, numRows) {
// Look for any merged cell, named table or named range definitions below
// `currentRow` and push down by `numRows` (used when rows are inserted).
Workbook.prototype.pushDown = function(workbook, sheet, tables, currentRow, numRows) {
var self = this;
// Update merged cells below this row
@@ -790,7 +865,36 @@ module.exports = (function() {
});
// TODO: Named cells/ranges
// Named cells/ranges
workbook.findall("definedNames/definedName").forEach(function(name) {
var ref = name.text;
if(self.isRange(ref)) {
var namedRange = self.splitRange(ref),
namedStart = self.splitRef(namedRange.start),
namedEnd = self.splitRef(namedRange.end);
if(namedStart.row > currentRow) {
namedStart.row += numRows;
namedEnd.row += numRows;
name.text = self.joinRange({
start: self.joinRef(namedStart),
end: self.joinRef(namedEnd),
});
}
} else {
var namedRef = self.splitRef(ref),
namedCol = self.charToNum(namedRef.col);
if(namedRef.row > currentRow) {
namedRef.row += numRows;
name.text = self.joinRef(namedRef);
}
}
});
};
return Workbook;
+32 -4
View File
@@ -366,7 +366,7 @@ describe("CRUD operations", function() {
});
it("moves named tables and merged cells", function(done) {
it("moves named tables, named cells and merged cells", function(done) {
fs.readFile(path.join(__dirname, 'templates', 'test-named-tables.xlsx'), function(err, data) {
expect(err).toBeNull();
@@ -390,12 +390,40 @@ describe("CRUD operations", function() {
archive = new zip(newData, {base64: false, checkCRC32: true});
var sharedStrings = etree.parse(t.archive.file("xl/sharedStrings.xml").asText()).getroot(),
sheet1 = etree.parse(t.archive.file("xl/worksheets/sheet1.xml").asText()).getroot();
sheet1 = etree.parse(t.archive.file("xl/worksheets/sheet1.xml").asText()).getroot(),
workbook = etree.parse(t.archive.file("xl/workbook.xml").asText()).getroot(),
table1 = etree.parse(t.archive.file("xl/tables/table1.xml").asText()).getroot(),
table2 = etree.parse(t.archive.file("xl/tables/table2.xml").asText()).getroot(),
table3 = etree.parse(t.archive.file("xl/tables/table3.xml").asText()).getroot();
// TODO: Tests
// Named ranges have moved
expect(workbook.find("./definedNames/definedName[@name='BelowTable']").text).toEqual("Tables!$B$18");
expect(workbook.find("./definedNames/definedName[@name='Moving']").text).toEqual("Tables!$G$8");
expect(workbook.find("./definedNames/definedName[@name='RangeBelowTable']").text).toEqual("Tables!$B$19:$C$19");
expect(workbook.find("./definedNames/definedName[@name='RangeRightOfTable']").text).toEqual("Tables!$E$14:$F$14");
expect(workbook.find("./definedNames/definedName[@name='RightOfTable']").text).toEqual("Tables!$F$8");
// Merged cells have moved
expect(sheet1.find("./mergeCells/mergeCell[@ref='B2:C2']")).not.toBeNull(); // title - unchanged
expect(sheet1.find("./mergeCells/mergeCell[@ref='B10:C10']")).toBeNull(); // pushed down
expect(sheet1.find("./mergeCells/mergeCell[@ref='B12:C12']")).not.toBeNull(); // pushed down
expect(sheet1.find("./mergeCells/mergeCell[@ref='E7:F7']")).toBeNull(); // pushed down and accross
expect(sheet1.find("./mergeCells/mergeCell[@ref='G8:H8']")).not.toBeNull(); // pushed down and accross
// Table ranges and autofilter definitions have moved
expect(table1.attrib.ref).toEqual("B4:C6"); // Grown
expect(table1.find("./autoFilter").attrib.ref).toEqual("B4:C6"); // Grown
expect(table2.attrib.ref).toEqual("B8:E10"); // Grown and pushed down
expect(table2.find("./autoFilter").attrib.ref).toEqual("B8:E10"); // Grown and pushed down
expect(table3.attrib.ref).toEqual("C14:D16"); // Grown and pushed down
expect(table3.find("./autoFilter").attrib.ref).toEqual("C14:D16"); // Grown and pushed down
// XXX: For debugging only
fs.writeFileSync('test.xlsx', newData, 'binary');
// fs.writeFileSync('test.xlsx', newData, 'binary');
done();
});
+50 -2
View File
@@ -137,16 +137,50 @@ describe("Helpers", function() {
});
describe('isRange', function() {
it("Returns true if there is a colon", function() {
var t = new XlsxTemplate();
expect(t.isRange("A1:A2")).toEqual(true);
expect(t.isRange("$A$1:$A$2")).toEqual(true);
expect(t.isRange("Table!$A$1:$A$2")).toEqual(true);
});
it("Returns false if there is not a colon", function() {
var t = new XlsxTemplate();
expect(t.isRange("A1")).toEqual(false);
expect(t.isRange("$A$1")).toEqual(false);
expect(t.isRange("Table!$A$1")).toEqual(false);
});
});
describe('splitRef', function() {
it("splits single digit and letter values", function() {
var t = new XlsxTemplate();
expect(t.splitRef("A1")).toEqual({col: "A", row: 1});
expect(t.splitRef("A1")).toEqual({table: null, col: "A", colAbsolute: false, row: 1, rowAbsolute: false});
});
it("splits multiple digit and letter values", function() {
var t = new XlsxTemplate();
expect(t.splitRef("AB12")).toEqual({col: "AB", row: 12});
expect(t.splitRef("AB12")).toEqual({table: null, col: "AB", colAbsolute: false, row: 12, rowAbsolute: false});
});
it("splits absolute references", function() {
var t = new XlsxTemplate();
expect(t.splitRef("$AB12")).toEqual({table: null, col: "AB", colAbsolute: true, row: 12, rowAbsolute: false});
expect(t.splitRef("AB$12")).toEqual({table: null, col: "AB", colAbsolute: false, row: 12, rowAbsolute: true});
expect(t.splitRef("$AB$12")).toEqual({table: null, col: "AB", colAbsolute: true, row: 12, rowAbsolute: true});
});
it("splits references with tables", function() {
var t = new XlsxTemplate();
expect(t.splitRef("Table one!AB12")).toEqual({table: "Table one", col: "AB", colAbsolute: false, row: 12, rowAbsolute: false});
expect(t.splitRef("Table one!$AB12")).toEqual({table: "Table one", col: "AB", colAbsolute: true, row: 12, rowAbsolute: false});
expect(t.splitRef("Table one!$AB12")).toEqual({table: "Table one", col: "AB", colAbsolute: true, row: 12, rowAbsolute: false});
expect(t.splitRef("Table one!AB$12")).toEqual({table: "Table one", col: "AB", colAbsolute: false, row: 12, rowAbsolute: true});
expect(t.splitRef("Table one!$AB$12")).toEqual({table: "Table one", col: "AB", colAbsolute: true, row: 12, rowAbsolute: true});
});
});
@@ -191,6 +225,20 @@ describe("Helpers", function() {
expect(t.joinRef({col: "AB", row: 12})).toEqual("AB12");
});
it("joins multiple digit and letter values and absolute references", function() {
var t = new XlsxTemplate();
expect(t.joinRef({col: "AB", colAbsolute: true, row: 12, rowAbsolute: false})).toEqual("$AB12");
expect(t.joinRef({col: "AB", colAbsolute: true, row: 12, rowAbsolute: true})).toEqual("$AB$12");
expect(t.joinRef({col: "AB", colAbsolute: false, row: 12, rowAbsolute: false})).toEqual("AB12");
});
it("joins multiple digit and letter values and tables", function() {
var t = new XlsxTemplate();
expect(t.joinRef({table: "Table one", col: "AB", colAbsolute: true, row: 12, rowAbsolute: false})).toEqual("Table one!$AB12");
expect(t.joinRef({table: "Table one", col: "AB", colAbsolute: true, row: 12, rowAbsolute: true})).toEqual("Table one!$AB$12");
expect(t.joinRef({table: "Table one", col: "AB", colAbsolute: false, row: 12, rowAbsolute: false})).toEqual("Table one!AB12");
});
});
describe('nexCol', function() {
Binary file not shown.