/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
import { XMLParser } from 'fast-xml-parser';
import { XMLUtils, XMLOptions2 } from './xml-utils';
// const xmlparser = new XMLParser();
// const xmlparser1 = new XMLParser(XMLOptions);
const xmlparser2 = new XMLParser(XMLOptions2);
// import * as he from 'he';
import type { TwoCellAnchor, CellAnchor } from './drawing/drawing';
import { SharedStrings } from './shared-strings';
import { StyleCache } from './workbook-style';
import { Theme } from './workbook-theme';
import { Sheet, VisibleState } from './workbook-sheet';
import type { RelationshipMap } from './relationship';
import { ZipWrapper } from './zip-wrapper';
import type { CellStyle, ThemeColor } from 'treb-base-types';
import type { SerializedNamed } from 'treb-data-model';
import { type Metadata, ParseMetadataXML } from './metadata';
///////////////
import * as OOXML from 'ooxml-types';
import { ooxml_parser, IterateTags, MapTags, FirstTag } from './ooxml';
///////////////
/**
* @privateRemarks -- FIXME: not sure about the equal/equals thing. need to check.
*/
export const ConditionalFormatOperators: Record = {
greaterThan: '>',
greaterThanOrEqual: '>=',
// greaterThanOrEquals: '>=',
lessThan: '<',
lessThanOrEqual: '<=',
// lessThanOrEquals: '<=',
equal: '=',
notEqual: '<>',
};
//
// enums? really? in 2025? FIXME
//
export enum ChartType {
Null = 0,
Column,
Bar,
Line,
Scatter,
Donut,
Pie,
Bubble,
Box,
Histogram,
Unknown
}
export interface ChartSeries {
values?: string;
categories?: string;
bubble_size?: string;
/** special for histogram */
bin_count?: number;
title?: string;
}
type ChartFlags =
'stacked'
;
export interface ChartDescription {
title?: string;
type: ChartType;
flags?: ChartFlags[];
series?: ChartSeries[];
}
export interface AnchoredImageDescription {
type: 'image';
image?: Uint8Array;
filename?: string;
anchor: TwoCellAnchor,
}
export interface AnchoredChartDescription {
type: 'chart';
chart?: ChartDescription,
anchor: TwoCellAnchor,
}
export interface AnchoredTextBoxDescription {
type: 'textbox';
style?: CellStyle;
reference?: string;
paragraphs: {
style?: CellStyle,
content: {
text: string,
style?: CellStyle
reference?: boolean,
}[],
}[];
anchor: TwoCellAnchor,
}
export type AnchoredDrawingPart =
AnchoredChartDescription |
AnchoredTextBoxDescription |
AnchoredImageDescription ;
export interface TableFooterType {
type: 'label'|'formula';
value: string;
}
export interface TableDescription {
name: string;
display_name: string;
ref: string;
filterRef?: string;
totals_row_shown?: number; // number? it's 0 in the xml
totals_row_count?: number; // apparently when there _is_ a totals row, we have this attribute instead of the other one
rel?: string;
index?: number;
columns?: string[];
footers?: TableFooterType[];
// auto filter?
// column names?
// style?
}
export class Workbook {
public xml: any = {};
/** start with an empty strings table, if we load a file we will update it */
public shared_strings = new SharedStrings();
/** document styles */
public style_cache = new StyleCache(); // public temp
/** theme */
public theme = new Theme();
/* * defined names. these can be ranges or expressions. */
// public defined_names: Record = {};
public named: Array = [];
/** the workbook "rels" */
public rels: RelationshipMap = {};
/** metadata reference; new and WIP */
public metadata?: Metadata;
public sheets: Sheet[] = [];
public active_tab = 0;
public get sheet_count(): number {
return this.sheets.length;
}
constructor(public zip: ZipWrapper) {
}
/**
* given a path in the zip file, read and parse the rels file
*/
public ReadRels(path: string): RelationshipMap {
const rels: RelationshipMap = {};
const data = this.zip.Has(path) ? this.zip.Get(path) : '';
const root = ooxml_parser.parse(data);
if (root.Relationships) {
const relationships = root.Relationships as OOXML.Relationships;
IterateTags(relationships.Relationship, (relationship) => {
const id = relationship.$attributes?.Id;
if (id) {
rels[id] = {
id,
type: relationship.$attributes?.Type || '',
target: relationship.$attributes?.Target || '',
};
}
});
}
// console.info({rels});
return rels;
}
public Init() {
// read workbook rels
this.rels = this.ReadRels( 'xl/_rels/workbook.xml.rels');
// shared strings
let data = this.zip.Has('xl/sharedStrings.xml') ? this.zip.Get('xl/sharedStrings.xml') : '';
let parsed = ooxml_parser.parse(data || '');
if (parsed.sst) {
this.shared_strings.FromXML(parsed.sst as OOXML.SharedStringTable);
}
// metadata
if (this.zip.Has('xl/metadata.xml')) {
data = this.zip.Get('xl/metadata.xml');
parsed = ooxml_parser.parse(data || '');
if (parsed.metadata) {
this.metadata = ParseMetadataXML(parsed.metadata as OOXML.Metadata);
}
}
// theme
data = this.zip.Get('xl/theme/theme1.xml');
parsed = ooxml_parser.parse(data);
if (parsed.theme) {
this.theme.FromXML(parsed.theme as OOXML.Theme);
}
// styles
data = this.zip.Get('xl/styles.xml');
parsed = ooxml_parser.parse(data);
if (parsed.styleSheet) {
this.style_cache.FromXML(parsed.styleSheet as OOXML.StyleSheet, this.theme);
}
// read workbook
data = this.zip.Get('xl/workbook.xml');
parsed = ooxml_parser.parse(data);
// defined names
if (parsed.workbook) {
const wb = parsed.workbook as OOXML.Workbook;
this.named = MapTags(wb.definedNames?.definedName, defined_name => {
return {
name: defined_name.$attributes?.name || '',
expression: defined_name.$text || '',
local_scope: defined_name.$attributes?.localSheetId,
};
});
const view = FirstTag(wb.bookViews?.workbookView);
this.active_tab = view?.$attributes?.activeTab ?? 0;
IterateTags(wb.sheets.sheet, element => {
const name = element.$attributes?.name;
if (name) {
const state = element.$attributes?.state;
const rid = element.$attributes?.id ?? '';
const id = element.$attributes?.sheetId;
const worksheet_path = `xl/${this.rels[rid].target}`;
data = this.zip.Get(worksheet_path);
parsed = ooxml_parser.parse(data);
if (parsed.worksheet) {
const root = parsed.worksheet as OOXML.Worksheet;
const worksheet = new Sheet({
name, rid, id,
}, root);
switch (state) {
case 'hidden':
worksheet.visible_state = VisibleState.hidden;
break;
case 'veryHidden':
worksheet.visible_state = VisibleState.hidden;
break;
}
worksheet.shared_strings = this.shared_strings;
worksheet.path = worksheet_path;
worksheet.rels_path = worksheet.path.replace('worksheets', 'worksheets/_rels') + '.rels';
worksheet.rels = this.ReadRels(worksheet.rels_path);
worksheet.Parse();
this.sheets.push(worksheet);
}
}
});
}
}
public ReadTable(reference: string): TableDescription|undefined {
const data = this.zip.Get(reference.replace(/^../, 'xl'));
if (!data) {
return undefined;
}
const xml = xmlparser2.parse(data);
const name = xml.table?.a$?.name || '';
const table: TableDescription = {
name,
display_name: xml.table?.a$?.displayName || name,
ref: xml.table?.a$.ref || '',
totals_row_shown: Number(xml.table?.a$.totalsRowShown || '0') || 0,
totals_row_count: Number(xml.table?.a$.totalsRowCount || '0') || 0,
};
return table;
}
public ReadDrawing(reference: string): AnchoredDrawingPart[] | undefined {
const data = this.zip.Get(reference.replace(/^../, 'xl'));
if (!data) {
return undefined;
}
const xml = xmlparser2.parse(data);
const drawing_rels = this.ReadRels(reference.replace(/^..\/drawings/, 'xl/drawings/_rels') + '.rels');
const results: AnchoredDrawingPart[] = [];
const anchor_nodes = XMLUtils.FindAll(xml, 'xdr:wsDr/xdr:twoCellAnchor');
/* FIXME: move to drawing? */
const ParseAnchor = (node: any = {}): CellAnchor => {
const anchor: CellAnchor = {
column: node['xdr:col'] || 0,
column_offset: node['xdr:colOff'] || 0,
row: node['xdr:row'] || 0,
row_offset: node['xdr:rowOff'] || 0,
};
return anchor;
};
for (const anchor_node of anchor_nodes) {
const anchor: TwoCellAnchor = {
from: ParseAnchor(anchor_node['xdr:from']),
to: ParseAnchor(anchor_node['xdr:to']),
};
let chart_reference = XMLUtils.FindAll(anchor_node, `xdr:graphicFrame/a:graphic/a:graphicData/c:chart`)[0];
// check for an "alternate content" chart/chartex (wtf ms). we're
// supporting this for box charts only (atm)
if (!chart_reference) {
chart_reference = XMLUtils.FindAll(anchor_node, `mc:AlternateContent/mc:Choice/xdr:graphicFrame/a:graphic/a:graphicData/cx:chart`)[0];
}
if (chart_reference && chart_reference.a$ && chart_reference.a$['r:id']) {
const result: AnchoredChartDescription = { type: 'chart', anchor };
const chart_rel = drawing_rels[chart_reference.a$['r:id']];
if (chart_rel && chart_rel.target) {
result.chart = this.ReadChart(chart_rel.target);
}
results.push(result);
}
else {
const media_reference = XMLUtils.FindAll(anchor_node, `xdr:pic/xdr:blipFill/a:blip`)[0];
if (media_reference && media_reference.a$['r:embed']) {
const media_rel = drawing_rels[media_reference.a$['r:embed']];
// const chart_rel = drawing_rels[chart_reference.a$['r:id']];
// console.info("Maybe an image?", media_reference, media_rel)
if (media_rel && media_rel.target) {
if (/(?:jpg|jpeg|png|gif)$/i.test(media_rel.target)) {
// const result: AnchoredImageDescription = { type: 'image' };
const path = media_rel.target.replace(/^\.\./, 'xl');
const filename = path.replace(/^.*\//, '');
const result: AnchoredImageDescription = {
type: 'image', anchor, image: this.zip.GetBinary(path), filename
}
results.push(result);
}
}
}
else {
let style: CellStyle|undefined;
const sp = XMLUtils.FindAll(anchor_node, 'xdr:sp')[0];
if (sp) {
const reference = sp.a$?.textlink || undefined;
const sppr = XMLUtils.FindAll(sp, 'xdr:spPr')[0];
if (sppr) {
style = {};
const fill = sppr['a:solidFill'];
if (fill) {
if (fill['a:schemeClr']?.a$?.val) {
const m = (fill['a:schemeClr'].a$.val).match(/accent(\d+)/);
if (m) {
style.fill = { theme: Number(m[1]) + 3 }
if (fill['a:schemeClr']['a:lumOff']?.a$?.val) {
const num = Number(fill['a:schemeClr']['a:lumOff'].a$.val);
if (!isNaN(num)) {
(style.fill as ThemeColor).tint = num / 1e5;
}
}
}
}
}
}
const tx = XMLUtils.FindAll(sp, 'xdr:txBody')[0];
if (tx) {
const paragraphs: {
style?: CellStyle,
content: {
text: string,
style?: CellStyle
reference?: boolean;
}[],
}[] = [];
const p_list = XMLUtils.FindAll(tx, 'a:p');
for (const paragraph of p_list) {
const para: { text: string, style?: CellStyle, reference?: boolean }[] = [];
let style: CellStyle|undefined;
const fld = paragraph['a:fld'];
if (fld) {
if (fld.a$?.type === 'TxLink') {
const entry: {text: string, reference?: boolean, style?: CellStyle } = { text: `{${reference}}`, reference: true };
// format
const fmt = fld['a:rPr'];
if (fmt) {
entry.style = {};
if (fmt.a$?.b === '1') {
entry.style.bold = true;
}
if (fmt.a$?.i === '1') {
entry.style.italic = true;
}
}
para.push(entry);
}
}
const appr = paragraph['a:pPr'];
if (appr) {
style = {};
if (appr.a$?.algn === 'r') {
style.horizontal_align = 'right';
}
else if (appr.a$?.algn === 'ctr') {
style.horizontal_align = 'center';
}
}
let ar = paragraph['a:r'];
if (ar) {
if (!Array.isArray(ar)) {
ar = [ar];
}
for (const line of ar) {
const entry: { text: string, style?: CellStyle } = {
text: line['a:t'] || '',
};
// format
const fmt = line['a:rPr'];
if (fmt) {
entry.style = {};
if (fmt.a$?.b === '1') {
entry.style.bold = true;
}
if (fmt.a$?.i === '1') {
entry.style.italic = true;
}
}
para.push(entry);
}
}
paragraphs.push({ content: para, style });
}
results.push({
type: 'textbox',
style,
paragraphs,
anchor,
reference,
});
}
}
}
}
}
return results;
}
/**
*
* FIXME: this is using the old options with old structure, just have
* not updated it yet
*/
public ReadChart(reference: string): ChartDescription|undefined {
const data = this.zip.Get(reference.replace(/^../, 'xl'));
if (!data) { return undefined; }
// const xml = xmlparser1.parse(data);
const xml = xmlparser2.parse(data);
const result: ChartDescription = {
type: ChartType.Null
};
// console.info("RC", xml);
const title_node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:title');
if (title_node) {
// FIXME: other types of title? (...)
const node = XMLUtils.FindChild(title_node, 'c:tx/c:strRef/c:f');
if (node) {
if (typeof node === 'string') {
result.title = node;
}
else if (node.t$) {
result.title = node.t$; // why is this not quoted, if the later one is quoted? is this a reference?
}
}
else {
// there's a bug in FindAll -- seems to have to do with the nodes
// here being strings
const nodes: (string | { t$: string })[] = [];
const parents = XMLUtils.FindAll(title_node, 'c:tx/c:rich/a:p/a:r');
for (const entry of parents) {
if (entry['a:t']) {
nodes.push(entry['a:t'])
}
}
/*
const xx = XMLUtils.FindAll(title_node, 'c:tx/c:rich/a:p/a:r');
const yy = XMLUtils.FindAll(title_node, 'c:tx/c:rich/a:p/a:r/a:t');
console.info({xx, yy});
const nodes = XMLUtils.FindAll(title_node, 'c:tx/c:rich/a:p/a:r/a:t');
*/
result.title = '"' + nodes.map(node => {
return typeof node === 'string' ? node : (node.t$ || '');
}).join('') + '"';
}
}
const ParseSeries = (node: any, type?: ChartType): ChartSeries[] => {
const series: ChartSeries[] = [];
// const series_nodes = node.findall('./c:ser');
let series_nodes = node['c:ser'] || [];
if (!Array.isArray(series_nodes)) {
series_nodes = [series_nodes];
}
// console.info({SN: series_nodes});
for (const series_node of series_nodes) {
let index = series.length;
const order_node = series_node['c:order'];
if (order_node) {
index = Number(order_node.a$?.val||0) || 0;
}
const series_data: ChartSeries = {};
let title_node = XMLUtils.FindChild(series_node, 'c:tx/c:v');
if (title_node) {
const title = title_node;
if (title) {
series_data.title = `"${title}"`;
}
}
else {
title_node = XMLUtils.FindChild(series_node, 'c:tx/c:strRef/c:f');
if (title_node) {
series_data.title = title_node;
}
}
if (type === ChartType.Scatter || type === ChartType.Bubble) {
const x = XMLUtils.FindChild(series_node, 'c:xVal/c:numRef/c:f');
if (x) {
series_data.categories = x; // .text?.toString();
}
const y = XMLUtils.FindChild(series_node, 'c:yVal/c:numRef/c:f');
if (y) {
series_data.values = y; // .text?.toString();
}
if (type === ChartType.Bubble) {
const z = XMLUtils.FindChild(series_node, 'c:bubbleSize/c:numRef/c:f');
if (z) {
series_data.bubble_size = z; // .text?.toString();
}
}
}
else {
const value_node = XMLUtils.FindChild(series_node, 'c:val/c:numRef/c:f');
if (value_node) {
series_data.values = value_node; // .text?.toString();
}
let cat_node = XMLUtils.FindChild(series_node, 'c:cat/c:strRef/c:f');
if (!cat_node) {
cat_node = XMLUtils.FindChild(series_node, 'c:cat/c:numRef/c:f');
}
if (cat_node) {
series_data.categories = cat_node; // .text?.toString();
}
}
series[index] = series_data;
}
return series;
};
let node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:barChart');
if (node) {
result.type = ChartType.Bar;
// console.info("BD", node);
if (node['c:barDir']) {
if (node['c:barDir'].a$?.val === 'col') {
result.type = ChartType.Column;
if (node['c:grouping']?.a$?.val === 'stacked') {
if (!result.flags) {
result.flags = [];
}
result.flags.push('stacked');
}
}
}
result.series = ParseSeries(node);
}
if (!node) {
node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:lineChart');
if (node) {
result.type = ChartType.Line;
result.series = ParseSeries(node);
}
}
if (!node) {
node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:doughnutChart');
if (node) {
result.type = ChartType.Donut;
result.series = ParseSeries(node);
}
}
if (!node) {
node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:pieChart');
if (node) {
result.type = ChartType.Pie;
result.series = ParseSeries(node);
}
}
if (!node) {
node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:scatterChart');
if (node) {
result.type = ChartType.Scatter;
result.series = ParseSeries(node, ChartType.Scatter);
}
}
if (!node) {
node = XMLUtils.FindChild(xml, 'c:chartSpace/c:chart/c:plotArea/c:bubbleChart');
if (node) {
result.type = ChartType.Bubble;
result.series = ParseSeries(node, ChartType.Bubble);
// console.info("Bubble series?", result.series);
}
}
if (!node) {
// box plot uses "extended chart" which is totally different... but we
// might need it again later? for the time being it's just inlined
// hmmm also used for histogram... histograms aren't named, they are
// clustered column type with a binning element, which has some attributes
const ex_series = XMLUtils.FindAll(xml, 'cx:chartSpace/cx:chart/cx:plotArea/cx:plotAreaRegion/cx:series');
if (ex_series?.length) {
// testing seems to require looping, so let's try to merge loops
let clustered_column = true;
let histogram = true;
let box_whisker = true;
for (const series of ex_series) {
const layout = series.a$?.layoutId;
if (clustered_column && layout !== 'clusteredColumn') {
clustered_column = false;
}
if (box_whisker && layout !== 'boxWhisker') {
box_whisker = false;
}
if (clustered_column && histogram) {
const binning = XMLUtils.FindAll(series, `cx:layoutPr/cx:binning`);
if (!binning.length) {
histogram = false;
}
}
}
// ok that's what we know so far...
if (histogram) {
result.type = ChartType.Histogram;
result.series = [];
for (const series_entry of ex_series) {
if (series_entry.a$?.hidden === '1') {
continue;
}
const series: ChartSeries = {};
const data = XMLUtils.FindAll(xml, 'cx:chartSpace/cx:chartData/cx:data');
// so there are multiple series, and multiple datasets,
// but they are all merged together? no idea how this design
// works
const values_list: string[] = [];
for (const data_series of data) {
values_list.push(data_series['cx:numDim']?.['cx:f'] || '');
}
series.values = values_list.join(',');
const bin_count = XMLUtils.FindAll(series_entry, `cx:layoutPr/cx:binning/cx:binCount`);
if (bin_count[0]) {
const count = Number(bin_count[0].a$?.val || 0);
if (count) {
series.bin_count = count;
}
}
result.series.push(series);
}
const title = XMLUtils.FindAll(xml, 'cx:chartSpace/cx:chart/cx:title/cx:tx/cx:txData');
if (title) {
if (title[0]?.['cx:f']) {
result.title = title[0]['cx:f'];
}
else if (title[0]?.['cx:v']) {
result.title = '"' + title[0]['cx:v'] + '"';
}
}
console.info("histogram", result);
return result;
}
if (box_whisker) {
result.type = ChartType.Box;
result.series = [];
const data = XMLUtils.FindAll(xml, 'cx:chartSpace/cx:chartData/cx:data'); // /cx:data/cx:numDim/cx:f');
// console.info({ex_series, data})
for (const entry of ex_series) {
const series: ChartSeries = {};
const id = Number(entry['cx:dataId']?.a$?.val);
for (const data_series of data) {
if (Number(data_series.a$?.id) === id) {
series.values = data_series['cx:numDim']?.['cx:f'] || '';
break;
}
}
const label = XMLUtils.FindAll(entry, 'cx:tx/cx:txData');
if (label) {
if (label[0]?.['cx:f']) {
series.title = label[0]['cx:f'];
}
else if (label[0]?.['cx:v']) {
series.title = '"' + label[0]['cx:v'] + '"';
}
}
const title = XMLUtils.FindAll(xml, 'cx:chartSpace/cx:chart/cx:title/cx:tx/cx:txData');
if (title) {
if (title[0]?.['cx:f']) {
result.title = title[0]['cx:f'];
}
else if (title[0]?.['cx:v']) {
result.title = '"' + title[0]['cx:v'] + '"';
}
}
result.series.push(series);
}
// console.info({result});
return result;
}
}
}
if (!node) {
console.info("Chart type not handled", {xml});
result.type = ChartType.Unknown;
}
// console.info("RX?", result);
return result;
}
/** FIXME: accessor */
public GetNamedRanges() {
// ... what does this do, not do, or what is it supposed to do?
// note that this is called by the import routine, so it probably
// expects to do something
// return this.defined_names;
return this.named;
}
}