Skip to content

Commit

Permalink
[IMP] tables: import table styles in xlsx
Browse files Browse the repository at this point in the history
This commit adds the conversion of table style from xlsx to
o-spreadsheet.

The xlsx pivots are now handled separately fro the tables, as they have
a slightly different configuration.

Task: 3789612
Part-of: #3799
  • Loading branch information
hokolomopo committed Apr 19, 2024
1 parent b48c3f7 commit cca91dd
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 333 deletions.
27 changes: 27 additions & 0 deletions src/types/xlsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import { ExcelFigureSize } from "./figure";
* - merge (string): §18.3.1.55 (mergeCell)
* - number format (XLSXNumFormat) : §18.8.30 (numFmt)
* - outline properties (XLSXOutlineProperties): §18.3.1.31 (outlinePr)
* - pivot table (XLSXPivotTable): §18.10.1.73 (pivotTableDefinition)
* - pivot table location (XLSXPivotTableLocation): §18.10.1.49 (location)
* - pivot table style info (XLSXPivotTableStyleInfo): §18.10.7.74 (pivotTableStyleInfo)
* - rows (XLSXRow): §18.3.1.73 (row)
* - sheet (XLSXWorksheet): §18.3.1.99 (worksheet)
* - sheet format (XLSXSheetFormat): §18.3.1.81 (sheetFormatPr)
Expand Down Expand Up @@ -230,6 +233,7 @@ export interface XLSXWorksheet {
figures: XLSXFigure[];
hyperlinks: XLSXHyperLink[];
tables: XLSXTable[];
pivotTables: XLSXPivotTable[];
}

export interface XLSXSheetView {
Expand Down Expand Up @@ -578,6 +582,29 @@ export interface XLSXTableStyleInfo {
showColumnStripes?: boolean;
}

export interface XLSXPivotTable {
name: string;
rowGrandTotals: boolean;
location: XLSXPivotTableLocation;
style?: XLSXPivotTableStyleInfo;
}

export interface XLSXPivotTableLocation {
ref: string;
firstHeaderRow: number;
firstDataRow: number;
firstDataCol: number;
}

export interface XLSXPivotTableStyleInfo {
name: string;
showRowHeaders: boolean;
showColHeaders: boolean;
showRowStripes: boolean;
showColStripes: boolean;
showLastColumn?: boolean;
}

export interface XLSXTableCol {
name: string;
id: string;
Expand Down
180 changes: 40 additions & 140 deletions src/xlsx/conversion/table_conversion.ts
Original file line number Diff line number Diff line change
@@ -1,161 +1,61 @@
import { deepEquals, positions, toCartesian, toXC, toZone, zoneToXc } from "../../helpers";
import { BorderDescr, CellData, Style, WorkbookData, Zone } from "../../types";
import { positions, toCartesian, toXC, toZone, zoneToXc } from "../../helpers";
import { DEFAULT_TABLE_CONFIG, TABLE_PRESETS } from "../../helpers/table_presets";
import { TableConfig, WorkbookData } from "../../types";
import { CellErrorType } from "../../types/errors";
import { SheetData } from "../../types/workbook_data";
import { XLSXImportData, XLSXTable, XLSXWorksheet } from "../../types/xlsx";
import { arrayToObject, objectToArray } from "../helpers/misc";

type CellMap = { [key: string]: CellData | undefined };

export const TABLE_HEADER_STYLE: Style = {
fillColor: "#000000",
textColor: "#ffffff",
bold: true,
};

export const TABLE_HIGHLIGHTED_CELL_STYLE: Style = {
bold: true,
};

export const TABLE_BORDER_STYLE: BorderDescr = { style: "thin", color: "#000000FF" };
import { XLSXImportData, XLSXPivotTable, XLSXTable, XLSXWorksheet } from "../../types/xlsx";

/**
* Convert the imported XLSX tables.
*
* We will create a Table if the imported table have filters, then apply a style in all the cells of the table
* and convert the table-specific formula references into standard references.
* Convert the imported XLSX tables and pivots convert the table-specific formula references into standard references.
*
* Change the converted data in-place.
*/
export function convertTables(convertedData: WorkbookData, xlsxData: XLSXImportData) {
for (const xlsxSheet of xlsxData.sheets) {
const sheet = convertedData.sheets.find((sheet) => sheet.name === xlsxSheet.sheetName);
if (!sheet) continue;
if (!sheet.tables) sheet.tables = [];

for (const table of xlsxSheet.tables) {
const sheet = convertedData.sheets.find((sheet) => sheet.name === xlsxSheet.sheetName);
if (!sheet || !table.autoFilter) continue;
if (!sheet.tables) sheet.tables = [];
sheet.tables.push({ range: table.ref });
sheet.tables.push({ range: table.ref, config: convertTableConfig(table) });
}

for (const pivotTable of xlsxSheet.pivotTables) {
sheet.tables.push({
range: pivotTable.location.ref,
config: convertPivotTableConfig(pivotTable),
});
}
}

applyTableStyle(convertedData, xlsxData);
convertTableFormulaReferences(convertedData.sheets, xlsxData.sheets);
}

/**
* Apply a style to all the cells that are in a table, and add the created styles in the converted data.
*
* In XLSXs, the style of the cells of a table are not directly in the sheet, but rather deduced from the style of
* the table that is defined in the table's XML file. The style of the table is a string referencing a standard style
* defined in the OpenXML specifications. As there are 80+ different styles, we won't implement every one of them but
* we will just define a style that will be used for all the imported tables.
*/
function applyTableStyle(convertedData: WorkbookData, xlsxData: XLSXImportData) {
const styles = objectToArray(convertedData.styles);
const borders = objectToArray(convertedData.borders);

for (let xlsxSheet of xlsxData.sheets) {
for (let table of xlsxSheet.tables) {
const sheet = convertedData.sheets.find((sheet) => sheet.name === xlsxSheet.sheetName);
if (!sheet) continue;
const tableZone = toZone(table.ref);

// Table style
for (let i = 0; i < table.headerRowCount; i++) {
applyStyleToZone(
TABLE_HEADER_STYLE,
{ ...tableZone, bottom: tableZone.top + i },
sheet.cells,
styles
);
}
for (let i = 0; i < table.totalsRowCount; i++) {
applyStyleToZone(
TABLE_HIGHLIGHTED_CELL_STYLE,
{ ...tableZone, top: tableZone.bottom - i },
sheet.cells,
styles
);
}
if (table.style?.showFirstColumn) {
applyStyleToZone(
TABLE_HIGHLIGHTED_CELL_STYLE,
{ ...tableZone, right: tableZone.left },
sheet.cells,
styles
);
}
if (table.style?.showLastColumn) {
applyStyleToZone(
TABLE_HIGHLIGHTED_CELL_STYLE,
{ ...tableZone, left: tableZone.right },
sheet.cells,
styles
);
}

// Table borders
// Borders at : table outline + col(/row) if showColumnStripes(/showRowStripes) + border above totalRow
for (let col = tableZone.left; col <= tableZone.right; col++) {
for (let row = tableZone.top; row <= tableZone.bottom; row++) {
const xc = toXC(col, row);
const cell = sheet.cells[xc];
const border = {
left:
col === tableZone.left || table.style?.showColumnStripes
? TABLE_BORDER_STYLE
: undefined,
right: col === tableZone.right ? TABLE_BORDER_STYLE : undefined,
top:
row === tableZone.top ||
table.style?.showRowStripes ||
row > tableZone.bottom - table.totalsRowCount
? TABLE_BORDER_STYLE
: undefined,
bottom: row === tableZone.bottom ? TABLE_BORDER_STYLE : undefined,
};
const newBorder = cell?.border ? { ...borders[cell.border], ...border } : border;
let borderIndex = borders.findIndex((border) => deepEquals(border, newBorder));
if (borderIndex === -1) {
borderIndex = borders.length;
borders.push(newBorder);
}
if (cell) {
cell.border = borderIndex;
} else {
sheet.cells[xc] = { border: borderIndex };
}
}
}
}
}

convertedData.styles = arrayToObject(styles);
convertedData.borders = arrayToObject(borders);
function convertTableConfig(table: XLSXTable): TableConfig {
const styleId = table.style?.name || "";
return {
hasFilters: table.autoFilter !== undefined,
numberOfHeaders: table.headerRowCount,
totalRow: table.totalsRowCount > 0,
firstColumn: table.style?.showFirstColumn || false,
lastColumn: table.style?.showLastColumn || false,
bandedRows: table.style?.showRowStripes || false,
bandedColumns: table.style?.showColumnStripes || false,
styleId: TABLE_PRESETS[styleId] ? styleId : DEFAULT_TABLE_CONFIG.styleId,
};
}

/**
* Apply a style to all the cells in the zone. The applied style WILL NOT overwrite values in existing style of the cell.
*
* If a style that was not in the styles array was applied, push it into the style array.
*/
function applyStyleToZone(appliedStyle: Style, zone: Zone, cells: CellMap, styles: Style[]) {
for (let col = zone.left; col <= zone.right; col++) {
for (let row = zone.top; row <= zone.bottom; row++) {
const xc = toXC(col, row);
const cell = cells[xc];
const newStyle = cell?.style ? { ...styles[cell.style], ...appliedStyle } : appliedStyle;
let styleIndex = styles.findIndex((style) => deepEquals(style, newStyle));
if (styleIndex === -1) {
styleIndex = styles.length;
styles.push(newStyle);
}
if (cell) {
cell.style = styleIndex;
} else {
cells[xc] = { style: styleIndex };
}
}
}
function convertPivotTableConfig(pivotTable: XLSXPivotTable): TableConfig {
return {
hasFilters: false,
numberOfHeaders: pivotTable.location.firstDataRow,
totalRow: pivotTable.rowGrandTotals,
firstColumn: true,
lastColumn: pivotTable.style?.showLastColumn || false,
bandedRows: pivotTable.style?.showRowStripes || false,
bandedColumns: pivotTable.style?.showColStripes || false,
styleId: DEFAULT_TABLE_CONFIG.styleId,
};
}

/**
Expand Down
68 changes: 52 additions & 16 deletions src/xlsx/extraction/pivot_extractor.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,67 @@
import { XLSXTable } from "../../types/xlsx";
import { XLSXPivotTable, XLSXPivotTableLocation, XLSXPivotTableStyleInfo } from "../../types/xlsx";
import { XlsxBaseExtractor } from "./base_extractor";

/**
* We don't really support pivot tables, we'll just extract them as Tables.
*/
export class XlsxPivotExtractor extends XlsxBaseExtractor {
getPivotTable(): XLSXTable {
getPivotTable(): XLSXPivotTable {
return this.mapOnElements(
// Use :root instead of "pivotTableDefinition" because others pivotTableDefinition elements are present inside the root
// pivotTableDefinition elements.
{ query: ":root", parent: this.rootFile.file.xml },
(pivotElement): XLSXTable => {
(pivotElement): XLSXPivotTable => {
return {
displayName: this.extractAttr(pivotElement, "name", { required: true }).asString(),
id: this.extractAttr(pivotElement, "name", { required: true }).asString(),
ref: this.extractChildAttr(pivotElement, "location", "ref", {
name: this.extractAttr(pivotElement, "name", { required: true }).asString(),
rowGrandTotals: this.extractAttr(pivotElement, "rowGrandTotals", {
default: true,
}).asBool(),
location: this.extractPivotLocation(pivotElement),
style: this.extractPivotStyleInfo(pivotElement),
};
}
)[0];
}

private extractPivotLocation(pivotElement: Element): XLSXPivotTableLocation {
return this.mapOnElements(
{ query: "location", parent: pivotElement },
(pivotStyleElement): XLSXPivotTableLocation => {
return {
ref: this.extractAttr(pivotStyleElement, "ref", { required: true }).asString(),
firstHeaderRow: this.extractAttr(pivotStyleElement, "firstHeaderRow", {
required: true,
}).asNum(),
firstDataRow: this.extractAttr(pivotStyleElement, "firstDataRow", {
required: true,
}).asNum(),
firstDataCol: this.extractAttr(pivotStyleElement, "firstDataCol", {
required: true,
}).asNum(),
};
}
)[0];
}

private extractPivotStyleInfo(pivotElement: Element): XLSXPivotTableStyleInfo | undefined {
return this.mapOnElements(
{ query: "pivotTableStyleInfo", parent: pivotElement },
(pivotStyleElement): XLSXPivotTableStyleInfo => {
return {
name: this.extractAttr(pivotStyleElement, "name", { required: true }).asString(),
showRowHeaders: this.extractAttr(pivotStyleElement, "showRowHeaders", {
required: true,
}).asBool(),
showColHeaders: this.extractAttr(pivotStyleElement, "showColHeaders", {
required: true,
}).asBool(),
showRowStripes: this.extractAttr(pivotStyleElement, "showRowStripes", {
required: true,
}).asBool(),
showColStripes: this.extractAttr(pivotStyleElement, "showColStripes", {
required: true,
}).asString()!,
headerRowCount: this.extractChildAttr(pivotElement, "location", "firstDataRow", {
default: 0,
}).asNum()!,
totalsRowCount: 1,
cols: [],
style: {
showFirstColumn: true,
showRowStripes: true,
},
}).asBool(),
showLastColumn: this.extractAttr(pivotStyleElement, "showLastColumn")?.asBool(),
};
}
)[0];
Expand Down
6 changes: 4 additions & 2 deletions src/xlsx/extraction/sheet_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
XLSXHyperLink,
XLSXImportFile,
XLSXOutlineProperties,
XLSXPivotTable,
XLSXRow,
XLSXSheetFormat,
XLSXSheetProperties,
Expand Down Expand Up @@ -58,7 +59,8 @@ export class XlsxSheetExtractor extends XlsxBaseExtractor {
cfs: this.extractConditionalFormats(),
figures: this.extractFigures(sheetElement),
hyperlinks: this.extractHyperLinks(sheetElement),
tables: [...this.extractTables(sheetElement), ...this.extractPivotTables()],
tables: this.extractTables(sheetElement),
pivotTables: this.extractPivotTables(),
isVisible: sheetWorkbookInfo.state === "visible" ? true : false,
};
}
Expand Down Expand Up @@ -191,7 +193,7 @@ export class XlsxSheetExtractor extends XlsxBaseExtractor {
);
}

private extractPivotTables(): XLSXTable[] {
private extractPivotTables(): XLSXPivotTable[] {
try {
return Object.values(this.relationships)
.filter((relationship) => relationship.type.endsWith("pivotTable"))
Expand Down
Binary file modified tests/__xlsx__/xlsx_demo_data.xlsx
Binary file not shown.
Loading

0 comments on commit cca91dd

Please sign in to comment.