Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"@borgar/fx": "~5.0.5",
"@borgar/simple-xml": "~2.2.2",
"@borgar/zip": "~1.0.0",
"@jsfkit/types": "~2.5.0",
"@jsfkit/types": "~2.6.0",
"@jsfkit/utils": "~1.2.0",
"numfmt": "~3.2.6"
},
Expand Down
208 changes: 208 additions & 0 deletions src/handler/worksheet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,211 @@ describe('handlerWorksheet selection', () => {
expect(sheet.views![0]?.activeRanges).toEqual([ 'A1:D4', 'C3:G8' ]);
});
});

describe('handlerWorksheet sheetViews', () => {
it('defaults to no frozen panes', () => {
const sheet = parseSheet(
SHEET_OPEN +
`<sheetViews>
<sheetView tabSelected = "1" workbookViewId="0" />
</sheetViews>` +
SHEET_CLOSE,
);
expect(sheet.views).toBeUndefined();
});

it('includes two frozen panes vertically split ', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="1" topLeftCell="C1" activePane="topRight" state="frozen"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
const view = sheet.views![0];
expect(view.panes).toEqual({
type: 'frozen',
columns: 1,
firstVisibleCell: 'C1',
activePane: 'topEnd',
});
});

it('includes two frozen panes horizontally split ', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane ySplit="2" topLeftCell="A3" activePane="bottomLeft" state="frozen"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
const view = sheet.views![0];
expect(view.panes).toEqual({
type: 'frozen',
rows: 2,
firstVisibleCell: 'A3',
activePane: 'bottomStart',
});
});

it('includes four frozen panes', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" topLeftCell="A2" zoomScaleNormal="100" workbookViewId="42">
<pane xSplit="1" ySplit="2" topLeftCell="B9" activePane="bottomRight" state="frozen"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
const view = sheet.views![0];
expect(view.workbookView).toEqual(42);
expect(view.panes).toEqual({
type: 'frozen',
columns: 1,
rows: 2,
firstVisibleCell: 'B9',
activePane: 'bottomEnd',
});
});

it('parses "frozenSplit" panes', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="10" ySplit="19" topLeftCell="K20" activePane="bottomRight" state="frozenSplit"/>
<selection pane="topRight" activeCell="K1" sqref="K1"/>
<selection pane="bottomLeft" activeCell="A20" sqref="A20"/>
<selection pane="bottomRight"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
const view = sheet.views![0];
expect(view.workbookView).toEqual(0);
expect(view.panes).toEqual({
type: 'frozen',
columns: 10,
rows: 19,
firstVisibleCell: 'K20',
activePane: 'bottomEnd',
});
});

it('ignores default frozen panes', () => {
const sheetViews = `
<sheetViews>
<sheetView workbookViewId="0">
<pane xSplit="0" ySplit="0" topLeftCell="" activePane="topLeft" state="frozen"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toBeUndefined();
});

it('ignores frozen panes with a zero split but valid topLeftCell and activePane attributes', () => {
const sheetViews = `
<sheetViews>
<sheetView workbookViewId="0">
<pane xSplit="0" ySplit="0" topLeftCell="C3" activePane="bottomLeft" state="frozen"/>
</sheetView>
</sheetViews>`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toBeUndefined();
});

it('handles default selection in frozen panes', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="2" ySplit="2" topLeftCell="C3" state="frozen"/>
<selection pane="topLeft" activeCell="C1" sqref="C1"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views![0].activeCell).toEqual('C1');
});

it('handles selection in frozen panes', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="2" ySplit="2" topLeftCell="C3" activePane="bottomLeft" state="frozen"/>
<selection pane="topRight" activeCell="C1" sqref="C1"/>
<selection pane="bottomLeft" activeCell="A3" sqref="A3"/>
<selection pane="bottomRight" activeCell="C4" sqref="C4"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views![0].activeCell).toEqual('A3');
});

it('omits firstVisibleCell when a frozen pane has no topLeftCell', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="2" ySplit="2" activePane="bottomLeft" state="frozen"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
expect(sheet.views![0].panes).toBeDefined();
expect(sheet.views![0].panes!.firstVisibleCell).toBeUndefined();
});

it('omits activePane when a frozen pane uses default activePane="topLeft"', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="2" ySplit="2" activePane="topLeft" state="frozen"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
expect(sheet.views![0].panes).toBeDefined();
expect(sheet.views![0].panes!.activePane).toBeUndefined();
});

it('discards implicitly split panes (<pane> has no state attribute)', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane xSplit="6960" ySplit="3000" topLeftCell="F9" activePane="bottomRight"/>
<selection pane="topRight" activeCell="F1" sqref="F1"/>
<selection pane="bottomLeft" activeCell="A9" sqref="A9"/>
<selection pane="bottomRight" activeCell="F9" sqref="F9"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
expect(sheet.views![0]).toEqual({
activeCell: 'F9',
workbookView: 0,
});
});

it('discards explicitly split panes (<pane state="split">)', () => {
const sheetViews = `
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<pane state="split" xSplit="6960" ySplit="3000" topLeftCell="F9" activePane="bottomRight"/>
<selection pane="topRight" activeCell="F1" sqref="F1"/>
<selection pane="bottomLeft" activeCell="A9" sqref="A9"/>
<selection pane="bottomRight" activeCell="F9" sqref="F9"/>
</sheetView>
</sheetViews>
`;
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
expect(sheet.views).toHaveLength(1);
expect(sheet.views![0]).toEqual({
activeCell: 'F9',
workbookView: 0,
});
});
});
45 changes: 39 additions & 6 deletions src/handler/worksheet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GridSize, PageMargins, Worksheet, WorksheetLayoutScales, WorksheetView } from '@jsfkit/types';
import type { GridSize, PageMargins, Worksheet, WorksheetLayoutScales, WorksheetView, WorksheetViewFrozenPanes } from '@jsfkit/types';
import { Document, Element } from '@borgar/simple-xml';
import { attr, boolAttr, numAttr } from '../utils/attr.ts';
import { rle } from '../utils/rle.ts';
Expand All @@ -13,6 +13,15 @@ import { toInt } from '../utils/typecast.ts';
import { addProp } from '../utils/addProp.ts';
import { DEFAULT_PAGE_MARGINS } from '../constants.ts';

type ExcelFrozenPaneLocation = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
type JSFFrozenPaneLocation = NonNullable<WorksheetViewFrozenPanes['activePane']>;
const FROZEN_PANE_LOCATIONS: Record<ExcelFrozenPaneLocation, JSFFrozenPaneLocation> = {
topLeft: 'topStart',
topRight: 'topEnd',
bottomLeft: 'bottomStart',
bottomRight: 'bottomEnd',
};

/**
* Extracts zoom levels (layout scales) for the different view modes for a sheet.
*
Expand Down Expand Up @@ -62,10 +71,7 @@ export function handlerWorksheet (
hidden: context.sheetLinks.find(link => link.name === sheetName)?.hidden ?? 0,
};

// Store last selected cell and/or range (both optional) for each of the sheet's view. A sheet
// view may be split into four panes, although of course most aren't. But to cover that case we
// need to find the active pane then find its active cell. When there's only one pane (i.e. almost
// all spreadsheets), you look for the default pane, "topLeft".
// Store the sheet's views (layout, zoom level, selected cell/range, frozen panes).
const views: WorksheetView[] = [];
const sheetViews = dom.querySelectorAll('sheetViews > sheetView');
sheetViews.forEach(sheetView => {
Expand All @@ -74,8 +80,34 @@ export function handlerWorksheet (
if (activeLayout === 'normal' || activeLayout === 'pageLayout' || activeLayout === 'pageBreakPreview') {
view.activeLayout = activeLayout;
}

// Sheet views can be split into two panes (horizontally or vertically), or four panes
// (quadrants). When they're split, they can be "split panes" (four different views of the full
// sheet) or "frozen panes" (one view of the sheet but with fixed header rows and/or columns).
// Split panes have been around forever, but frozen panes are more common. Only frozen panes are
// supported here.
const pane = getFirstChild(sheetView, 'pane');
const activePane = pane ? attr(pane, 'activePane', 'topLeft') : 'topLeft';
const activePane = (
pane ? attr(pane, 'activePane', 'topLeft') : 'topLeft'
) as ExcelFrozenPaneLocation;
if (pane && (attr(pane, 'state') === 'frozen' || attr(pane, 'state') === 'frozenSplit')) {
const columnSplit = numAttr(pane, 'xSplit', 0);
const rowSplit = numAttr(pane, 'ySplit', 0);
const firstVisibleCell = attr(pane, 'topLeftCell');

// If a view contains frozen panes, find which row and column they're split on, where the
// non-frozen pane is scrolled to, and which pane is active.
if (columnSplit !== 0 || rowSplit !== 0) {
view.panes = { type: 'frozen' };
addProp(view.panes, 'columns', columnSplit, 0);
addProp(view.panes, 'rows', rowSplit, 0);
addProp(view.panes, 'firstVisibleCell', firstVisibleCell, '');
addProp(view.panes, 'activePane', FROZEN_PANE_LOCATIONS[activePane], 'topStart');
}
}

// Which cell/range is selected within which pane? If there are no frozen panes, OOXML pretends
// there's a single "topLeft" pane that defines the selection.
const selection = sheetView.children
.find(el => el.tagName === 'selection' && attr(el, 'pane', 'topLeft') === activePane);
if (selection) {
Expand All @@ -88,6 +120,7 @@ export function handlerWorksheet (
view.activeRanges = activeRanges;
}
}

addProp(view, 'showGridLines', boolAttr(sheetView, 'showGridLines'), true);
addProp(view, 'layoutScales', getLayoutScales(sheetView));

Expand Down
Binary file added tests/excel/freeze-panes.xlsx
Binary file not shown.
Loading