Skip to content

Commit 0c81d1f

Browse files
authored
Add freeze panes to worksheet views (#89)
Support for frozen panes (aka freeze panes) was added to the JSF format in jsfkit/types#59. This adds support for exporting freeze panes from .xlsx files to JSF-formatted JSON. Excel allows a worksheet view to be divided into panes: two panes stacked vertically, two panes stacked horizontally, or four panes that divide the sheet into quadrants. Excel supports splitting or freezing panes, but only the latter is supported in JSF. This is deliberate, because split panes is rarely used these days and likely only kept for backwards compatibility. Frozen panes lock top rows and/or left-most columns in place while the rest of the sheet scrolls beneath them. Dividers can't be dragged to resize panes and the frozen regions can't be scrolled. Pane sizes are measured in row and/or column offsets. xlsx-convert will now change OOXML like this: ```xml <sheetView tabSelected="1" workbookViewId="0"> <pane state="frozen" xSplit="1" ySplit="2" topLeftCell="C1" activePane="bottomRight"/> </sheetView> ``` To JSF like this: ```jsonc { // WorksheetView object // ... "panes": { "type": "frozen", "columns": "1", "rows": "2", "firstVisibleCell": "C1", "activePane": "bottomEnd" } } ``` This requires a new release of the jsfkit/types package with the changes from jsfkit/types#59. Until then the typecheck CI job will fail.
2 parents 2f17758 + 6ca1737 commit 0c81d1f

7 files changed

Lines changed: 1312 additions & 11 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"@borgar/fx": "~5.0.5",
8686
"@borgar/simple-xml": "~2.2.2",
8787
"@borgar/zip": "~1.0.0",
88-
"@jsfkit/types": "~2.5.0",
88+
"@jsfkit/types": "~2.6.0",
8989
"@jsfkit/utils": "~1.2.0",
9090
"numfmt": "~3.2.6"
9191
},

src/handler/worksheet.spec.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,211 @@ describe('handlerWorksheet selection', () => {
9696
expect(sheet.views![0]?.activeRanges).toEqual([ 'A1:D4', 'C3:G8' ]);
9797
});
9898
});
99+
100+
describe('handlerWorksheet sheetViews', () => {
101+
it('defaults to no frozen panes', () => {
102+
const sheet = parseSheet(
103+
SHEET_OPEN +
104+
`<sheetViews>
105+
<sheetView tabSelected = "1" workbookViewId="0" />
106+
</sheetViews>` +
107+
SHEET_CLOSE,
108+
);
109+
expect(sheet.views).toBeUndefined();
110+
});
111+
112+
it('includes two frozen panes vertically split ', () => {
113+
const sheetViews = `
114+
<sheetViews>
115+
<sheetView tabSelected="1" workbookViewId="0">
116+
<pane xSplit="1" topLeftCell="C1" activePane="topRight" state="frozen"/>
117+
</sheetView>
118+
</sheetViews>`;
119+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
120+
expect(sheet.views).toHaveLength(1);
121+
const view = sheet.views![0];
122+
expect(view.panes).toEqual({
123+
type: 'frozen',
124+
columns: 1,
125+
firstVisibleCell: 'C1',
126+
activePane: 'topEnd',
127+
});
128+
});
129+
130+
it('includes two frozen panes horizontally split ', () => {
131+
const sheetViews = `
132+
<sheetViews>
133+
<sheetView tabSelected="1" workbookViewId="0">
134+
<pane ySplit="2" topLeftCell="A3" activePane="bottomLeft" state="frozen"/>
135+
</sheetView>
136+
</sheetViews>`;
137+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
138+
expect(sheet.views).toHaveLength(1);
139+
const view = sheet.views![0];
140+
expect(view.panes).toEqual({
141+
type: 'frozen',
142+
rows: 2,
143+
firstVisibleCell: 'A3',
144+
activePane: 'bottomStart',
145+
});
146+
});
147+
148+
it('includes four frozen panes', () => {
149+
const sheetViews = `
150+
<sheetViews>
151+
<sheetView tabSelected="1" topLeftCell="A2" zoomScaleNormal="100" workbookViewId="42">
152+
<pane xSplit="1" ySplit="2" topLeftCell="B9" activePane="bottomRight" state="frozen"/>
153+
</sheetView>
154+
</sheetViews>`;
155+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
156+
expect(sheet.views).toHaveLength(1);
157+
const view = sheet.views![0];
158+
expect(view.workbookView).toEqual(42);
159+
expect(view.panes).toEqual({
160+
type: 'frozen',
161+
columns: 1,
162+
rows: 2,
163+
firstVisibleCell: 'B9',
164+
activePane: 'bottomEnd',
165+
});
166+
});
167+
168+
it('parses "frozenSplit" panes', () => {
169+
const sheetViews = `
170+
<sheetViews>
171+
<sheetView tabSelected="1" workbookViewId="0">
172+
<pane xSplit="10" ySplit="19" topLeftCell="K20" activePane="bottomRight" state="frozenSplit"/>
173+
<selection pane="topRight" activeCell="K1" sqref="K1"/>
174+
<selection pane="bottomLeft" activeCell="A20" sqref="A20"/>
175+
<selection pane="bottomRight"/>
176+
</sheetView>
177+
</sheetViews>`;
178+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
179+
expect(sheet.views).toHaveLength(1);
180+
const view = sheet.views![0];
181+
expect(view.workbookView).toEqual(0);
182+
expect(view.panes).toEqual({
183+
type: 'frozen',
184+
columns: 10,
185+
rows: 19,
186+
firstVisibleCell: 'K20',
187+
activePane: 'bottomEnd',
188+
});
189+
});
190+
191+
it('ignores default frozen panes', () => {
192+
const sheetViews = `
193+
<sheetViews>
194+
<sheetView workbookViewId="0">
195+
<pane xSplit="0" ySplit="0" topLeftCell="" activePane="topLeft" state="frozen"/>
196+
</sheetView>
197+
</sheetViews>`;
198+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
199+
expect(sheet.views).toBeUndefined();
200+
});
201+
202+
it('ignores frozen panes with a zero split but valid topLeftCell and activePane attributes', () => {
203+
const sheetViews = `
204+
<sheetViews>
205+
<sheetView workbookViewId="0">
206+
<pane xSplit="0" ySplit="0" topLeftCell="C3" activePane="bottomLeft" state="frozen"/>
207+
</sheetView>
208+
</sheetViews>`;
209+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
210+
expect(sheet.views).toBeUndefined();
211+
});
212+
213+
it('handles default selection in frozen panes', () => {
214+
const sheetViews = `
215+
<sheetViews>
216+
<sheetView tabSelected="1" workbookViewId="0">
217+
<pane xSplit="2" ySplit="2" topLeftCell="C3" state="frozen"/>
218+
<selection pane="topLeft" activeCell="C1" sqref="C1"/>
219+
</sheetView>
220+
</sheetViews>
221+
`;
222+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
223+
expect(sheet.views![0].activeCell).toEqual('C1');
224+
});
225+
226+
it('handles selection in frozen panes', () => {
227+
const sheetViews = `
228+
<sheetViews>
229+
<sheetView tabSelected="1" workbookViewId="0">
230+
<pane xSplit="2" ySplit="2" topLeftCell="C3" activePane="bottomLeft" state="frozen"/>
231+
<selection pane="topRight" activeCell="C1" sqref="C1"/>
232+
<selection pane="bottomLeft" activeCell="A3" sqref="A3"/>
233+
<selection pane="bottomRight" activeCell="C4" sqref="C4"/>
234+
</sheetView>
235+
</sheetViews>
236+
`;
237+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
238+
expect(sheet.views![0].activeCell).toEqual('A3');
239+
});
240+
241+
it('omits firstVisibleCell when a frozen pane has no topLeftCell', () => {
242+
const sheetViews = `
243+
<sheetViews>
244+
<sheetView tabSelected="1" workbookViewId="0">
245+
<pane xSplit="2" ySplit="2" activePane="bottomLeft" state="frozen"/>
246+
</sheetView>
247+
</sheetViews>
248+
`;
249+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
250+
expect(sheet.views).toHaveLength(1);
251+
expect(sheet.views![0].panes).toBeDefined();
252+
expect(sheet.views![0].panes!.firstVisibleCell).toBeUndefined();
253+
});
254+
255+
it('omits activePane when a frozen pane uses default activePane="topLeft"', () => {
256+
const sheetViews = `
257+
<sheetViews>
258+
<sheetView tabSelected="1" workbookViewId="0">
259+
<pane xSplit="2" ySplit="2" activePane="topLeft" state="frozen"/>
260+
</sheetView>
261+
</sheetViews>
262+
`;
263+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
264+
expect(sheet.views).toHaveLength(1);
265+
expect(sheet.views![0].panes).toBeDefined();
266+
expect(sheet.views![0].panes!.activePane).toBeUndefined();
267+
});
268+
269+
it('discards implicitly split panes (<pane> has no state attribute)', () => {
270+
const sheetViews = `
271+
<sheetViews>
272+
<sheetView tabSelected="1" workbookViewId="0">
273+
<pane xSplit="6960" ySplit="3000" topLeftCell="F9" activePane="bottomRight"/>
274+
<selection pane="topRight" activeCell="F1" sqref="F1"/>
275+
<selection pane="bottomLeft" activeCell="A9" sqref="A9"/>
276+
<selection pane="bottomRight" activeCell="F9" sqref="F9"/>
277+
</sheetView>
278+
</sheetViews>
279+
`;
280+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
281+
expect(sheet.views).toHaveLength(1);
282+
expect(sheet.views![0]).toEqual({
283+
activeCell: 'F9',
284+
workbookView: 0,
285+
});
286+
});
287+
288+
it('discards explicitly split panes (<pane state="split">)', () => {
289+
const sheetViews = `
290+
<sheetViews>
291+
<sheetView tabSelected="1" workbookViewId="0">
292+
<pane state="split" xSplit="6960" ySplit="3000" topLeftCell="F9" activePane="bottomRight"/>
293+
<selection pane="topRight" activeCell="F1" sqref="F1"/>
294+
<selection pane="bottomLeft" activeCell="A9" sqref="A9"/>
295+
<selection pane="bottomRight" activeCell="F9" sqref="F9"/>
296+
</sheetView>
297+
</sheetViews>
298+
`;
299+
const sheet = parseSheet(SHEET_OPEN + sheetViews + SHEET_CLOSE);
300+
expect(sheet.views).toHaveLength(1);
301+
expect(sheet.views![0]).toEqual({
302+
activeCell: 'F9',
303+
workbookView: 0,
304+
});
305+
});
306+
});

src/handler/worksheet.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GridSize, PageMargins, Worksheet, WorksheetLayoutScales, WorksheetView } from '@jsfkit/types';
1+
import type { GridSize, PageMargins, Worksheet, WorksheetLayoutScales, WorksheetView, WorksheetViewFrozenPanes } from '@jsfkit/types';
22
import { Document, Element } from '@borgar/simple-xml';
33
import { attr, boolAttr, numAttr } from '../utils/attr.ts';
44
import { rle } from '../utils/rle.ts';
@@ -13,6 +13,15 @@ import { toInt } from '../utils/typecast.ts';
1313
import { addProp } from '../utils/addProp.ts';
1414
import { DEFAULT_PAGE_MARGINS } from '../constants.ts';
1515

16+
type ExcelFrozenPaneLocation = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
17+
type JSFFrozenPaneLocation = NonNullable<WorksheetViewFrozenPanes['activePane']>;
18+
const FROZEN_PANE_LOCATIONS: Record<ExcelFrozenPaneLocation, JSFFrozenPaneLocation> = {
19+
topLeft: 'topStart',
20+
topRight: 'topEnd',
21+
bottomLeft: 'bottomStart',
22+
bottomRight: 'bottomEnd',
23+
};
24+
1625
/**
1726
* Extracts zoom levels (layout scales) for the different view modes for a sheet.
1827
*
@@ -62,10 +71,7 @@ export function handlerWorksheet (
6271
hidden: context.sheetLinks.find(link => link.name === sheetName)?.hidden ?? 0,
6372
};
6473

65-
// Store last selected cell and/or range (both optional) for each of the sheet's view. A sheet
66-
// view may be split into four panes, although of course most aren't. But to cover that case we
67-
// need to find the active pane then find its active cell. When there's only one pane (i.e. almost
68-
// all spreadsheets), you look for the default pane, "topLeft".
74+
// Store the sheet's views (layout, zoom level, selected cell/range, frozen panes).
6975
const views: WorksheetView[] = [];
7076
const sheetViews = dom.querySelectorAll('sheetViews > sheetView');
7177
sheetViews.forEach(sheetView => {
@@ -74,8 +80,34 @@ export function handlerWorksheet (
7480
if (activeLayout === 'normal' || activeLayout === 'pageLayout' || activeLayout === 'pageBreakPreview') {
7581
view.activeLayout = activeLayout;
7682
}
83+
84+
// Sheet views can be split into two panes (horizontally or vertically), or four panes
85+
// (quadrants). When they're split, they can be "split panes" (four different views of the full
86+
// sheet) or "frozen panes" (one view of the sheet but with fixed header rows and/or columns).
87+
// Split panes have been around forever, but frozen panes are more common. Only frozen panes are
88+
// supported here.
7789
const pane = getFirstChild(sheetView, 'pane');
78-
const activePane = pane ? attr(pane, 'activePane', 'topLeft') : 'topLeft';
90+
const activePane = (
91+
pane ? attr(pane, 'activePane', 'topLeft') : 'topLeft'
92+
) as ExcelFrozenPaneLocation;
93+
if (pane && (attr(pane, 'state') === 'frozen' || attr(pane, 'state') === 'frozenSplit')) {
94+
const columnSplit = numAttr(pane, 'xSplit', 0);
95+
const rowSplit = numAttr(pane, 'ySplit', 0);
96+
const firstVisibleCell = attr(pane, 'topLeftCell');
97+
98+
// If a view contains frozen panes, find which row and column they're split on, where the
99+
// non-frozen pane is scrolled to, and which pane is active.
100+
if (columnSplit !== 0 || rowSplit !== 0) {
101+
view.panes = { type: 'frozen' };
102+
addProp(view.panes, 'columns', columnSplit, 0);
103+
addProp(view.panes, 'rows', rowSplit, 0);
104+
addProp(view.panes, 'firstVisibleCell', firstVisibleCell, '');
105+
addProp(view.panes, 'activePane', FROZEN_PANE_LOCATIONS[activePane], 'topStart');
106+
}
107+
}
108+
109+
// Which cell/range is selected within which pane? If there are no frozen panes, OOXML pretends
110+
// there's a single "topLeft" pane that defines the selection.
79111
const selection = sheetView.children
80112
.find(el => el.tagName === 'selection' && attr(el, 'pane', 'topLeft') === activePane);
81113
if (selection) {
@@ -88,6 +120,7 @@ export function handlerWorksheet (
88120
view.activeRanges = activeRanges;
89121
}
90122
}
123+
91124
addProp(view, 'showGridLines', boolAttr(sheetView, 'showGridLines'), true);
92125
addProp(view, 'layoutScales', getLayoutScales(sheetView));
93126

tests/excel/freeze-panes.xlsx

18.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)