/* * 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 * */ // --- treb imports ----------------------------------------------------------- import { ValueType, Cells, Style, type CellStyle, type PropertyKeys, type Color, Area, IsFlatDataArray, IsNestedRowArray, IsCellAddress, DOMContext, IsHTMLColor, IsThemeColor } from 'treb-base-types'; import { NumberFormatCache } from 'treb-format'; import { Measurement, ValidateURI } from 'treb-utils'; import type { TextPart , Cell, ICellAddress, CellSerializationOptions, CellValue, ImportedSheetData, Complex, DimensionedQuantity, IArea, Table, TableTheme, HorizontalAlign, VerticalAlign, Theme, ExtendedCelLStyle} from 'treb-base-types'; import { Get as GetFonrMetrics } from 'treb-grid/src/util/fontmetrics'; // --- local imports ---------------------------------------------------------- import type { FreezePane, SerializedSheet, ScrollOffset } from './sheet_types'; import type { SerializeOptions } from './serialize_options'; import type { GridSelection } from './sheet_selection'; import { CreateSelection } from './sheet_selection'; import { Annotation } from './annotation'; import type { ConditionalFormatList } from './conditional_format'; import type { DataValidation } from './data-validation'; // --- constants -------------------------------------------------------------- const DEFAULT_COLUMN_WIDTH = 100; // const DEFAULT_ROW_HEIGHT = 26; // not used because it's based on font (theoretically) const DEFAULT_ROW_HEADER_WIDTH = 60; // does this have optional ref/style because an older version inlined styles, // instead of using references? we can probably drop support for that because // if that was the case, it was a long time ago interface CellStyleRef { row: number; column: number; ref?: number; style?: CellStyle; rows?: number; } export class Sheet { // --- static members ------------------------------------------------------- public static base_id = 100; public static readonly default_sheet_name = 'Sheet1'; // FIXME: use the external measurement object (from utils) // private static measurement_canvas?: HTMLCanvasElement; /** * adding verbose flag so we can figure out who is publishing * (and stop -- part of the ExecCommand switchover) */ // public static readonly sheet_events = new EventSource(true, 'sheet-events'); // --- instance members ----------------------------------------------------- /** * in the old model, we had a concept of "default" style properties. we then * used that object for theming: we would set default properties when the theme * changed. * * the problem is that if there are multiple instances on a single page, with * different themes, they would clash. * * so the new concept is to have a default property set per instance, managed * by the grid instance. any sheets that are loaded in/created by grid will * get a reference to that property set, and grid can update it as desired. * * because it's a reference, it should be constant. * FIXME: move to model... */ public readonly default_style_properties: CellStyle; /* moved from grid */ public annotations: Annotation[] = []; // moved from layout public freeze: FreezePane = { rows: 0, columns: 0, }; /** testing */ // public scale = 1.0; public visible = true; /** standard width (FIXME: static?) */ public default_column_width = 100; /** standard height (FIXME: static?) */ public default_row_height = 25; /** cells data */ public readonly cells: Cells = new Cells(); /** * selection. moved to sheet to preserve selections in multiple sheets. * this instance should just be used to populate the actual selection, * not used as a reference. */ public selection: GridSelection = CreateSelection(); /** * cache scroll offset for flipping between sheets. should this be * persisted? (...) */ public scroll_offset: ScrollOffset = { x: 0, y: 0 }; /** * named ranges: name -> area * FIXME: this needs to move to an outer container, otherwise we * may get conflicts w/ multiple sheets. unless we want to allow that... */ // public named_ranges = new NamedRangeCollection(); public name = Sheet.default_sheet_name; public tab_color?: Color; public background_image?: string; protected _image: HTMLImageElement|undefined = undefined; /** * set this flag when we need to update conditional formats even * if they are not dirty (generally when one is deleted) */ public flush_conditional_formats = false; public get image(): HTMLImageElement|undefined { return this._image; } /** * @internal */ public conditional_formats: ConditionalFormatList = []; /** * @internal */ public data_validation: DataValidation[] = []; /** * @internal * * testing, not serialized atm */ public outline: number[] | undefined; /** internal ID */ // tslint:disable-next-line: variable-name private id_: number; // tslint:disable-next-line:variable-name // private row_height_: number[] = []; private row_height_map: Map = new Map(); // tslint:disable-next-line:variable-name private column_width_: number[] = []; /** * optionally, custom row headers (instead of 1...2...3...) * FIXME: should maybe be a function instead? * FIXME: why is this any type? just sloppiness? */ private row_headers: string[] = []; /** * optionally, custom column headers (instead of A...B...C...) * FIXME: should maybe be a function instead? * FIXME: why is this any type? just sloppiness? */ private column_headers: string[] = []; /** size of header */ private row_header_width = 100; /** size of header */ private column_header_height = 25; // we cache composite styles so we don't wind up with objects // for every cell, when all we need is a single reference. private style_map: CellStyle[] = []; // we use json for comparison. it should be faster than the alternative // (even if that doesn't make sense). private style_json_map: string[] = []; // style now uses overlays, but we want to precalculate the // overlaid values. we need to hold on to the originals, in // the event something changes, so we can redo the calculation. // there's a default at the bottom that gets applied to everything. // (in Style). above that, we have the sheet style private sheet_style: CellStyle = {}; // then individual (applied) row and column styles (indexed by row/column) private row_styles: Record = {}; private column_styles: Record = {}; /* we used to have "alternate row" styles. it's clumsy, but it is a nice effect. we will add that back via a "pattern". not sure how the UI would work for this, but programatically it works. just rows atm, not columns. */ private row_pattern: CellStyle[] = []; // and finally any cell-specific styles. [FIXME: this is sparse] // [why FIXME? sparse is OK in js] private cell_style: CellStyle[][] = []; /** * applied conditional formats are stored them in this array; * they will be stacked on top of cell style when rendering. * conditional formats have top priority. [FIXME: what about tables?] */ private conditional_format_cache: ExtendedCelLStyle[][][] = []; /** * this is a list of cells we formatted on the last pass, so we can * compare when applying conditional formats . * * update: using areas */ private conditional_format_checklist: IArea[] = []; // --- accessors ------------------------------------------------------------ // public get column_header_count() { return this.column_header_count_; } public get header_offset(): { x: number, y: number } { return { x: this.row_header_width, y: this.column_header_height }; } /** accessor: now just a wrapper for the call on cells */ public get rows(): number { return this.cells.rows; } /** accessor: now just a wrapper for the call on cells */ public get columns(): number { return this.cells.columns; } public get id(): number { return this.id_; } public set id(id: number) { this.id_ = id; if (this.id >= Sheet.base_id) { Sheet.base_id = this.id + 1; } } /** * constructor is now protected. use a factory method (Blank or FromJSON). */ protected constructor(theme_style_properties: CellStyle) { this.default_style_properties = theme_style_properties; // FIXME: the below should be called in a separate 'init' method // that can be called after we change styles (since it will measure) this.default_column_width = DEFAULT_COLUMN_WIDTH; this.row_header_width = DEFAULT_ROW_HEADER_WIDTH; // this.UpdateDefaultRowHeight(); this.id_ = Sheet.base_id++; } // --- class methods -------------------------------------------------------- public static Reset(): void { this.base_id = 100; } /** * factory method creates a new sheet */ public static Blank(style_defaults: CellStyle, name?: string, rows = 30, columns = 20, theme?: Theme): Sheet { const sheet = new Sheet(style_defaults); if (theme) { sheet.UpdateDefaultRowHeight(theme); } if (name) { sheet.name = name; } rows = Math.max(rows, 1); columns = Math.max(columns, 1); sheet.cells.EnsureCell({ row: rows - 1, column: columns - 1 }); return sheet; } /** * update old-style alignment constants to the new symbolic values. * updates in place. */ public static UpdateStyle(properties: CellStyle) { if (typeof properties.horizontal_align === 'number') { const members: HorizontalAlign[] = [ '', // Style.HorizontalAlign.None, 'left', // Style.HorizontalAlign.Left, 'center', // Style.HorizontalAlign.Center, 'right', // Style.HorizontalAlign.Right, ] properties.horizontal_align = members[properties.horizontal_align] || undefined; } if (typeof properties.vertical_align === 'number') { const members: VerticalAlign[] = [ '', // Style.VerticalAlign.None, 'top', // Style.VerticalAlign.Top, 'bottom', // Style.VerticalAlign.Bottom, 'middle', // Style.VerticalAlign.Middle, ] properties.vertical_align = members[properties.vertical_align] || undefined; } } /** * deserialize json representation. returns new instance or updates * passed instance. * * FIXME: why not make this an instance method, always call on new instance? * * @param hints UpdateHints supports partial deserialization/replacement * if we know there are only minor changes (as part of undo/redo, probably) */ public static FromJSON(json: string | Partial, style_defaults: CellStyle, sheet?: Sheet): Sheet { const source: SerializedSheet = (typeof json === 'string') ? JSON.parse(json) : json as SerializedSheet; const unflatten_numeric_array = (target: number[], data: Record) => { // , default_value: number) => { Object.keys(data).forEach((key) => { const index = Number(key) || 0; target[index] = data[key]; }); }; if (!sheet) { sheet = new Sheet(style_defaults); } if (source.default_column_width) { sheet.default_column_width = source.default_column_width; } if (source.default_row_height) { sheet.default_row_height = source.default_row_height; } if (source.conditional_formats) { sheet.conditional_formats = source.conditional_formats; } sheet.data_validation = (source.data_validations || []).map(validation => ({ ...validation, target: (validation.target||[]).map(target => new Area(target.start, target.end)), })); // persist ID, name if (source.id) { sheet.id = source.id; } if (source.name) { sheet.name = source.name; } if (source.tab_color) { sheet.tab_color = source.tab_color; } if (source.background_image) { sheet.background_image = source.background_image; } // FIXME: this should only be done on load (and possibly paste). // we don't need to do it on every parse, which also happens on // undo and some other things. const patch_style = (style: CellStyle) => { // this part is for back compat with older color schemes, it // could theoretically come out if we don't care (or maybe have a tool) // UPDATE for updated font properties const ref = (style as CellStyle & { text_color?: string; background?: string; border_top_color?: string; border_left_color?: string; border_bottom_color?: string; border_right_color?: string; font_bold?: boolean; font_italic?: boolean; font_underline?: boolean; font_strike?: boolean; font_size_value?: number; font_size_unit?: 'pt' | 'px' | 'em' | '%'; }); this.UpdateStyle(ref); if (ref.font_size_value || ref.font_size_unit) { ref.font_size = { unit: ref.font_size_unit || 'pt', value: ref.font_size_value || 10, }; ref.font_size_unit = undefined; ref.font_size_value = undefined; } if (ref.font_bold) { ref.bold = true; ref.font_bold = undefined; } if (ref.font_italic) { ref.italic = true; ref.font_italic = undefined; } if (ref.font_underline) { ref.underline = true; ref.font_underline = undefined; } if (ref.font_strike) { ref.strike = true; ref.font_strike = undefined; } if (ref.text_color) { if (ref.text_color !== 'none') { ref.text = { text: ref.text_color }; } ref.text_color = undefined; // will get cleared, eventually } if (ref.background) { if (ref.background !== 'none') { ref.fill = { text: ref.background }; } ref.background = undefined; // ibid } if (ref.border_top_color) { if (ref.border_top_color !== 'none') { ref.border_top_fill = { text: ref.border_top_color }; } ref.border_top_color = undefined; } if (ref.border_left_color) { if (ref.border_left_color !== 'none') { ref.border_left_fill = { text: ref.border_left_color }; } ref.border_left_color = undefined; } if (ref.border_bottom_color) { if (ref.border_bottom_color !== 'none') { ref.border_bottom_fill = { text: ref.border_bottom_color }; } ref.border_bottom_color = undefined; } if (ref.border_right_color) { if (ref.border_right_color !== 'none') { ref.border_right_fill = { text: ref.border_right_color }; } ref.border_right_color = undefined; } }; // use the new name, if available; fall back to the old name, and because // that's now optional, add a default. const cell_style_refs = source.styles || source.cell_style_refs || []; /* const cell_style_refs = source.cell_style_refs; */ for (const entry of cell_style_refs) { patch_style(entry); } // styles (part 1) -- moved up in case we use inlined style refs // so this is converting "ref" (number) to "style" (properties)... // in the same object. why do we do this here, and early? sheet.cell_style = []; if (cell_style_refs) { (source.cell_styles || []).forEach((cell_style: CellStyleRef) => { if (typeof cell_style.ref === 'number') { cell_style.style = JSON.parse(JSON.stringify(cell_style_refs[cell_style.ref])); // clone } }); } // data: cells (moved after style) sheet.cells.FromJSON(source.data); if (source.rows) sheet.cells.EnsureRow(source.rows - 1); if (source.columns) sheet.cells.EnsureColumn(source.columns - 1); // new style stuff // different handling for nested, flat, but we only have to // check once because data is either nested or it isn't. if (source.data) { if (IsFlatDataArray(source.data)) { for (const entry of source.data) { if (entry.style_ref) { if (!sheet.cell_style[entry.column]) sheet.cell_style[entry.column] = []; sheet.cell_style[entry.column][entry.row] = // entry.style; JSON.parse(JSON.stringify(cell_style_refs[entry.style_ref])); // clone } } } else { if (IsNestedRowArray(source.data)) { for (const block of source.data) { const row = block.row; for (const entry of block.cells) { const column = entry.column; if (entry.style_ref) { if (!sheet.cell_style[column]) sheet.cell_style[column] = []; sheet.cell_style[column][row] = // entry.style; JSON.parse(JSON.stringify(cell_style_refs[entry.style_ref])); // clone } } } } else { for (const block of source.data) { const column = block.column; for (const entry of block.cells) { const row = entry.row; if (entry.style_ref) { if (!sheet.cell_style[column]) sheet.cell_style[column] = []; sheet.cell_style[column][row] = // entry.style; JSON.parse(JSON.stringify(cell_style_refs[entry.style_ref])); // clone } } } } } } // freeze sheet.freeze.rows = 0; sheet.freeze.columns = 0; if (source.freeze) { sheet.freeze.rows = source.freeze.rows || 0; sheet.freeze.columns = source.freeze.columns || 0; } // scroll, optionally sheet.scroll_offset = source.scroll ? { ...source.scroll } : { x: 0, y: 0 }; // wrap up styles for (const cell_style of ((source.cell_styles || []) as CellStyleRef[])) { if (cell_style.style) { if (!sheet.cell_style[cell_style.column]) sheet.cell_style[cell_style.column] = []; sheet.cell_style[cell_style.column][cell_style.row] = cell_style.style; // update for blocks // these are styles, not references... not sure why we translated // (above) but if so, we probably need to clone if (cell_style.rows) { for (let r = 1; r < cell_style.rows; r++) { sheet.cell_style[cell_style.column][cell_style.row + r] = JSON.parse(JSON.stringify(cell_style.style)); } } } } sheet.sheet_style = source.sheet_style || {}; // sheet.row_styles = source.row_style; // sheet.column_styles = source.column_style; // these are NOT arrays atm. that might be a problem (might not). I think // this was accidental. when running, we don't care, because empty array // indexes don't consume memory (AFAIK). when serializing, we do care, but // how we serialize shouldn't impact how we operate at runtime. // it breaks when we do patching (below), although we could just fix // patching. also TODO: merge patching with the map routine. sheet.column_styles = {}; sheet.row_styles = {}; const MapStyles = (source_list: Record, target_list: Record) => { for (const key of Object.keys(source_list)) { const index = Number(key); const value = source_list[index]; if (typeof value === 'number') { const properties = cell_style_refs[value]; if (properties) { target_list[index] = JSON.parse(JSON.stringify(properties)); // clone jic patch_style(target_list[index]); } } else if (value) { target_list[index] = value; patch_style(target_list[index]); } } }; MapStyles(source.row_style, sheet.row_styles); MapStyles(source.column_style, sheet.column_styles); /* for (const key of Object.keys(source.column_style)) { const index = Number(key); const value = source.column_style[index]; if (typeof value === 'number') { const properties = cell_style_refs[value]; if (properties) { sheet.column_styles[index] = JSON.parse(JSON.stringify(properties)); // clone jic } } else { sheet.column_styles[index] = value; } } */ sheet.row_pattern = source.row_pattern || []; // patch other styles patch_style(sheet.sheet_style || {}); for (const entry of sheet.row_pattern) { patch_style(entry); } /* for (const key of Object.keys(sheet.column_styles)) { patch_style(sheet.column_styles[key as any]); } for (const key of Object.keys(sheet.row_styles)) { patch_style(sheet.row_styles[key as any]); } */ // ok sheet.row_height_map = new Map(); if (source.row_height) { for (const [key, value] of Object.entries(source.row_height)) { sheet.row_height_map.set(Number(key), value); } } if (sheet.row_height_map.size > 0) { const max = Math.max(...sheet.row_height_map.keys()); sheet.cells.EnsureRow(max); } sheet.column_width_ = []; unflatten_numeric_array(sheet.column_width_, source.column_width || {}, ); if (sheet.column_width_.length) { sheet.cells.EnsureColumn(sheet.column_width_.length - 1); } // NOTE: we're padding out rows/columns here to be under annotations, // otherwise the pruning may have removed them. it would probably be // preferable to not prune them... that shouldn't add much extra data // because it would just be the number. // FIXME sheet.annotations = (source.annotations || []).map((entry) => new Annotation(entry)); if (source.selection) { // copy to ensure there's no link to random object sheet.selection = JSON.parse(JSON.stringify(source.selection)); } sheet.visible = true; // default if (typeof source.visible !== 'undefined') { sheet.visible = !!source.visible; } return sheet; } /** add a data validation. */ public AddValidation(validation: DataValidation) { this.data_validation.push({ ...validation, target: (validation.target||[]).map(target => new Area(target.start, target.end)), // ensure class instance }); } /** * remove validations from area. must be an exact match (FIXME). * if there are multiple areas, only remove the matching area. */ public RemoveValidations(area: IArea) { const check = new Area(area.start, area.end); this.data_validation = this.data_validation.filter(validation => { validation.target = validation.target.filter(compare => !check.Equals2(compare)); return validation.target.length > 0; }); } /** return data validation(s) that apply to a given address */ public GetValidation(address: ICellAddress) { // switch to imperative const list: DataValidation[] = []; for (const entry of this.data_validation) { for (const area of entry.target) { if ((area as Area).Contains(address)) { list.push(entry); break; } } } return list; } public Activate(DOM: DOMContext) { // load background image, if set if (this.background_image) { const resource = ValidateURI(this.background_image); if (resource) { this._image = DOM.Create('img'); this._image.src = resource; } // this._image = image_store.Get(this.background_image); } } // --- public methods ------------------------------------------------------- public MergeCells(area: Area): void { // FIXME: it's an error if this area includes some // (but not all) of another merge area. // ... // assuming we're good to go... area = area.Clone(); // so this needs the address, in order to test if it's the head; // but we know the head will always be the first one tested (correct?) const cells = [...this.cells.Iterate(area, true)]; for (const [index, cell] of cells.entries()) { cell.merge_area = area; cell.render_clean = []; if (index) { cell.Reset(); } } /* for (const {column, row, cell} of this.cells.IterateArea(area, true)) { cell.merge_area = area; cell.render_clean = []; // clear data in !head if (column !== area.start.column || row !== area.start.row) cell.Reset(); } */ /* this.cells.Apply(area, (cell, c, r) => { cell.merge_area = area; cell.render_clean = []; // clear data in !head if (c !== area.start.column || r !== area.start.row) cell.Reset(); }, true); */ } public UnmergeCells(area: Area): void { // this _must_ be the full merge area. to get it, just get // the merge property from a particular cell or cells. // let's check: for (const cell of this.cells.Iterate(area, false)) { if (!cell.merge_area || !area.Equals(cell.merge_area)) { console.warn('area mismatch'); return; } } /* let match = true; this.cells.Apply(area, (cell) => { match = match && !!cell.merge_area && area.Equals(cell.merge_area); }, false); if (!match) { console.warn('area mismatch'); return; } */ for (const cell of this.cells.Iterate(area, false)) { cell.merge_area = undefined; cell.render_clean = []; } /* this.cells.Apply(area, (cell) => { cell.merge_area = undefined; cell.render_clean = []; }, false); */ } /** * FIXME: this is called in the ctor, which made sense when sheets * were more ephemeral. now that we update a single instance, rather * than create new instances, we lose this behavior. we should call * this when we change sheet style. * * actually this should just move to theme, no? as long as sheet has * a reference to theme. for headless instances that would just use * theme defaults, which should be appropriate. * * I guess the original idea was that sheet style might be used to * base row height on sheet font size? not sure if that's how it actually * plays out, since this is only called in the ctor (or equivalent) or when * theme is updated (but not when sheet style is updated). might need some * thought. * */ public UpdateDefaultRowHeight(theme: Theme, scale = 1): void { // this guard is here because this is called by sheet directly, so maybe // in headless context? would make more sense to just not call it if (typeof window !== 'undefined') { const composite = Style.Composite([this.default_style_properties, this.sheet_style]); const font_info = Style.CompositeFont(theme.grid_cell_font_size, composite, scale, theme); const metrics = GetFonrMetrics(font_info.font, font_info.variants); const height = metrics.height * 1.25; // ?? // const measurement = Measurement.MeasureText(Style.Font2(composite, 1, theme).font, 'M'); // const height = Math.round(measurement.height * 1.4); // console.info({height, default: this.default_row_height}); if (this.default_row_height < height) { this.default_row_height = height; } } } /** * deprecated (or give me a reason to keep it) * KEEP IT: just maintain flexibility, it has very low cost */ public SetRowHeaders(headers: CellValue[]): void { this.row_headers = headers.map(value => value === undefined ? '' : value.toString()); if (this.row_headers) { this.cells.EnsureRow(this.row_headers.length - 1); } } /** * deprecated (or give me a reason to keep it) * KEEP IT: just maintain flexibility, it has very low cost */ public SetColumnHeaders(headers: CellValue[]): void { this.column_headers = headers.map(value => value === undefined ? '' : value.toString()); if (headers) { this.cells.EnsureColumn(headers.length - 1); } } /** * deprecated * KEEP IT: just maintain flexibility, it has very low cost */ public RowHeader(row: number): string | number { if (this.row_headers) { if (this.row_headers.length > row) return this.row_headers[row]; return ''; } return row + 1; } /** * deprecated * KEEP IT: just maintain flexibility, it has very low cost * (we did drop the multiple rows, though) */ public ColumnHeader(column: number): string { let s = ''; if (this.column_headers) { if (this.column_headers.length > column) return this.column_headers[column]; return ''; } for (; ;) { const c = column % 26; s = String.fromCharCode(65 + c) + s; column = Math.floor(column / 26); if (column) column--; else break; } return s; } public GetRowHeight(row: number): number { const height = this.row_height_map.get(row); if (typeof height === 'undefined') return this.default_row_height; return height; } public SetRowHeight(row: number, height: number): number { this.row_height_map.set(row, height); this.cells.EnsureRow(row); return height; } public GetColumnWidth(column: number): number { const width = this.column_width_[column]; if (typeof width === 'undefined') return this.default_column_width; return width; } public SetColumnWidth(column: number, width: number): number { this.column_width_[column] = width; this.cells.EnsureColumn(column); return width; } /** * returns set of properties in B that differ from A. returns * property values from B. * * this is the function I could never get to work inline for * CellStyle -- not sure why it works better with a generic * function (although the partial here is new, so maybe it's that?) * * seems to be related to * https://github.com/microsoft/TypeScript/pull/30769 * */ public Delta(A: T, B: T): Partial { const result: Partial = {}; // keys that are in either object. this will result in some // duplication, probably not too bad. could precompute array? (...) // you could do that using a composite object, but would be wasteful. // would look good in typescript but generate extra javascript. might // still be faster, though? (...) const keys = [...Object.keys(A), ...Object.keys(B)] as Array; // FIXME: should check if B[key] is undefined, in which case you don't // want it? (...) that seems appropriate, but since the method we are // replacing did not do that, I'm hesitant to do it now for (const key of keys) { const a = A[key]; const b = B[key]; // we are not checking for arrays, that's not a consideration atm if (typeof a === 'object' && typeof b === 'object') { // is this faster than checking properties? // especially if we know the list? if (JSON.stringify(a) !== JSON.stringify(b)) { result[key] = b; } } else if (a !== b) { result[key] = b; } //if (A[key] !== B[key]) { // result[key] = B[key]; //} } return result; } /** * updates cell styles. flushes cached style. * * @param delta merge with existing properties (we will win conflicts) * @param inline this is part of another operation, don't do any undo/state updates */ public UpdateCellStyle(address: ICellAddress, properties: CellStyle, delta = true): void { // so what this is doing is constructing two merge stacks: one including // the cell style, and one without. any deltas among the two are the cell // style. the aim here is to remove properties that would be duplicative // because they stack, so if the base sheet has color=red, there is no // reason to apply that to the cell as well. const { row, column } = address; if (!this.cell_style[column]) this.cell_style[column] = []; const underlying = this.CompositeStyleForCell(address, false, false, undefined, false); const merged = Style.Composite([ this.default_style_properties, underlying, Style.Merge(this.cell_style[column][row] || {}, properties, delta), ]); const composite = this.Delta(underlying, merged); /* // this is type "any" because of the assignment, below, which fails // otherwise. however this could be done with spread assignments? (...) // A: no, it's not merging them, it is looking for deltas. // ...but, what if you filtered? (...) [A] how? // I think the only way to do it with types would be to use delete, which // somehow seems wasteful and slow (although I have not validated that) const composite: any = {}; // find properties that are different, those will be the cell style. for (const key of Object.keys(merged) as Style.PropertyKeys[]) { if (merged[key] !== underlying[key]) { composite[key] = merged[key]; } } for (const key of Object.keys(underlying) as Style.PropertyKeys[]) { if (merged[key] !== underlying[key]) { composite[key] = merged[key]; } } */ this.cell_style[column][row] = composite; // merged; // targeted flush // this.CellData(address).FlushStyle(); this.BleedFlush({start: address, end: address}); } /** * invalidate sets the "render dirty" flag on cells, whether there * is any change or not. we are currently using it to force rendering * when border/background changes, and we need to handle bleed into * neighboring cells. */ public Invalidate(area: Area): void { // this.cells.Apply(this.RealArea(area), cell => cell.render_clean = []); for (const cell of this.cells.Iterate(this.RealArea(area), false)) { cell.render_clean = []; } } /** * * @param area * @param style * @param delta * @param render LEGACY PARAMETER NOT USED */ public UpdateAreaStyle(area?: Area, style: CellStyle = {}, delta = true): void { if (!area) return; if (area.entire_sheet) { this.UpdateSheetStyle(style, delta); } else if (area.entire_column) { for (let column = area.start.column; column <= area.end.column; column++) { this.UpdateColumnStyle(column, style, delta); } } else if (area.entire_row) { for (let row = area.start.row; row <= area.end.row; row++) { this.UpdateRowStyle(row, style, delta); } } else { // area.Array().forEach((address) => this.UpdateCellStyle(address, style, delta)); for (const address of area) { this.UpdateCellStyle(address, style, delta); } } } /** * checks if the given cell has been assigned a specific style, either for * the cell itself, or for row and column. */ public HasCellStyle(address: ICellAddress): boolean { return !!((this.cell_style[address.column] && this.cell_style[address.column][address.row]) || this.row_styles[address.row] || this.column_styles[address.column] || this.row_pattern.length); } /** * returns the next non-hidden column. so if you are column C (2) and columns * D, E, and F are hidden, then it will return 6 (G). */ public NextVisibleColumn(column: number): number { for (++column; this.column_width_[column] === 0; column++) { /* */ } return column; } /** * @see NextVisibleColumn * because this one goes left, it may return -1 meaning you are at the left edge */ public PreviousVisibleColumn(column: number): number { for (--column; column >= 0 && this.column_width_[column] === 0; column--) { /* */ } return column; } /** * @see NextVisibleColumn */ public NextVisibleRow(row: number): number { for (++row; this.GetRowHeight(row) === 0; row++) { /* */ } return row; } /** * @see PreviousVisibleColumn */ public PreviousVisibleRow(row: number): number { for (--row; row >= 0 && this.GetRowHeight(row) === 0; row--) { /* */ } return row; } /** * if this cell is part of a table, get row information -- is this * an alternate row, is it the header, is it the last (visible) row * * @param table * @param row * @returns */ public TableRow(table: Table, row: number): { alternate?: boolean; header?: boolean; last?: boolean; totals?: boolean; } { const result = { alternate: false, header: (row === table.area.start.row), last: false, totals: (table.totals_row && row === table.area.end.row), } // can short circuit here if (result.header || result.totals) { return result; } // oh these loops are a problem if the table is very large. need to // address, maybe we can cache? not sure. as a hint, we have a list // of non-default row heights. // how we handle last row depends on totals. if we have a totals // row, and it's visible, we don't need to do the "last row" thing. const totals_visible = (table.totals_row && (this.GetRowHeight(table.area.end.row) > 0)); // this one is probably ok if (!totals_visible) { let last = table.area.end.row; for ( ; last >= table.area.start.row; last-- ) { if (this.GetRowHeight(last)) { result.last = (last === row); break; } } } // this is an improvement if the table is mostly not hidden. but // if the table is mostly hidden we'll run into the same problem // again. not sure the most effective way to address this. we probably // need to cache this information somewhere. // ACTUALLY this does not work (at least not the way you think it // does). row_height_ is a sparse array so iterating will still // check every skipped index. // OK now it's a map const start = table.area.start.row + 1 ; // (table.headers ? 1 : 0); let delta = row - start; for (const [index, height] of this.row_height_map.entries()) { if (index < start) { continue; } if (index > table.area.end.row) { break; } if (!height) { delta--; } } result.alternate = (delta % 2 === 1); // this one looks bad /* let start = table.area.start.row + 1 ; // (table.headers ? 1 : 0); for ( ; start <= table.area.end.row; start++ ) { if (!this.GetRowHeight(start)) { continue; } result.alternate = !result.alternate; if (start === row) { break; } } */ return result; } /** * returns style properties for cells surrounding this cell, * mapped like a number pad: * * +---+---+---+ * | 7 | 8 | 9 | * +---+---+---+ * | 4 | X | 6 | * +---+---+---+ * | 1 | 2 | 3 | * +---+---+---+ * * presuming you already have X (5). this is called by renderer, we * move it here so we can inline the next/previous loops. * */ public SurroundingStyle(address: ICellAddress, table?: TableTheme): CellStyle[] { const map: CellStyle[] = [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}]; // FIXME: what about merges? (...) let column_right = address.column + 1; let column_left = address.column - 1; let row_below = address.row + 1; let row_above = address.row - 1; for (; this.column_width_[column_right] === 0; column_right++) { /* */ } for (; this.GetRowHeight(row_below) === 0; row_below++) { /* */ } for (; column_left >= 0 && this.column_width_[column_left] === 0; column_left--) { /* */ } for (; row_above >= 0 && this.GetRowHeight(row_above) === 0; row_above--) { /* */ } if (column_left >= 0 && row_above >= 0) { map[7] = this.CellStyleData({ row: row_above, column: column_left }, table) || {}; } if (column_left >= 0) { map[4] = this.CellStyleData({ row: address.row, column: column_left }, table) || {}; map[1] = this.CellStyleData({ row: row_below, column: column_left }, table) || {}; } if (row_above >= 0) { map[8] = this.CellStyleData({ row: row_above, column: address.column }, table) || {}; map[9] = this.CellStyleData({ row: row_above, column: column_right }, table) || {}; } map[6] = this.CellStyleData({ row: address.row, column: column_right }, table) || {}; map[2] = this.CellStyleData({ row: row_below, column: address.column }, table) || {}; map[3] = this.CellStyleData({ row: row_below, column: column_right }, table) || {}; return map; } /** * get style only. as noted in the comment to `CellData` there used to be * no case where this was useful without calculated value as well; but we * now have a case: fixing borders by checking neighboring cells. (testing). * * switching from null to undefined as "missing" type * * UPDATE: this is a convenient place to do table formatting. table * formatting is complicated because it's variable; it depends on row * visibility so we can't cache it. this is a good spot because we're * already calling this function when doing border rendering; we can call * it separately, if necessary, when rendering cells. * * table formats are applied on top of cell formats, after compositing, * and we don't preserve the style. * */ public CellStyleData(address: ICellAddress, default_table_theme?: TableTheme): CellStyle | undefined { // don't create if it doesn't exist const cell = this.cells.GetCell(address); if (!cell) { return undefined; } // composite style if necessary if (!cell.style) { const index = this.GetStyleIndex(this.CompositeStyleForCell(address)); cell.style = this.style_map[index]; } if (cell.table) { const table_theme = cell.table.theme || default_table_theme; if (table_theme) { let style = JSON.parse(JSON.stringify(cell.style)); const data = this.TableRow(cell.table, address.row); if (data.header) { if (table_theme.header) { style = Style.Composite([style, table_theme.header]); } } else if (data.totals) { // like headers, totals is outside of the alternating rows thing if (table_theme.total) { style = Style.Composite([style, table_theme.total]); } } else { if (data.alternate) { if (table_theme.odd) { style = Style.Composite([style, table_theme.odd]); } } else { if (table_theme.even) { style = Style.Composite([style, table_theme.even]); } } } /* if (data.last) { if (table_styles.footer) { style = Style.Composite([style, table_styles.footer]); } } */ return style; } } return cell.style; } /** * accessor to get cell style without row pattern -- for cut/copy * @param address */ public GetCopyStyle(address: ICellAddress): CellStyle { return this.CompositeStyleForCell(address, true, false, undefined, false); } /** * wrapper for getting all relevant render data. * TODO: merge in "FormattedValue". restructure data so we don't have * two caches (formatted and calculated). * * NOTE: we removed "GetCellStyle" in favor of this function. the rationale * is that there are no reasonable cases where someone looks up the style * without that being a next step to (or in reasonable proximity to) * rendering. so it's reasonable to call this function even if it's in * advance of rendering. * * NOTE: that applies to the "GetCellFormula" and "GetCellValue" functions * as well -- so remove those too. * * NOTE: actually GetCellFormula resolves array formulae, so maybe not -- * or the caller needs to check. * */ public CellData(address: ICellAddress): Cell { const cell = this.cells.EnsureCell(address); // if cell has rendered type (i.e. not undefined), then it has // complete render data and we can return it as-is. if (cell.rendered_type) return cell; // otherwise we need to render it. if we have a calculated value, use that. let type: ValueType; let value: CellValue; if (cell.calculated_type) { value = cell.calculated; type = cell.calculated_type; } else { value = cell.value; type = cell.type; } // do we have style for this cell? if not, we need to composite it. if (!cell.style) { const index = this.GetStyleIndex(this.CompositeStyleForCell(address)); cell.style = this.style_map[index]; } // why is this done here? shouldn't it be done by/in the renderer? if (!type || value === null || typeof value === 'undefined') { cell.formatted = ''; cell.rendered_type = ValueType.string; } else if (type === ValueType.number) { // IE11. not sure of the effect of this. if (isNaN(value as number)) { cell.formatted = // Style.Format(cell.style, value); // formats NaN (typeof cell.style.nan === 'undefined') ? 'NaN' : cell.style.nan; } else { cell.formatted = // Style.Format(cell.style, value); this.FormatNumber(value, cell.style.number_format); } cell.rendered_type = ValueType.number; } else if (type === ValueType.error) { cell.formatted = '#' + (value || 'ERR?'); cell.rendered_type = ValueType.error; } else if (type === ValueType.boolean) { cell.formatted = value.toString().toUpperCase(); // implicit locale? cell.rendered_type = ValueType.boolean; } else if (type === ValueType.formula && cell.calculated === undefined) { cell.formatted = ''; cell.rendered_type = ValueType.string; } else if (type === ValueType.complex) { // formatting complex value (note for searching) // here testing "mathematical italic small i", "𝑖", U+1D456 // // I'm not sure this is a good idea, the character might not be available // in a particular font (not sure if those are auto-filled or what) // // what we _should_ do is have a formatting flag (in text part) to // indicate italic, and then render a regular lower-case i in italic. // that also means that if you copy it as text, it's still just a regular // i and not a high-value unicode character. which is helpful. // OK we tried that and it looked like crap. I would like to go back // to using "𝑖" but I'm not sure... maybe a flag> // NOTE: all that moved to NumberFormat const complex = value as Complex; if (isNaN(complex.real) || isNaN(complex.imaginary)) { // render nan for nan values cell.formatted = // Style.Format(cell.style, value); // formats NaN (typeof cell.style.nan === 'undefined') ? 'NaN' : cell.style.nan; } else { const format = NumberFormatCache.Get(cell.style.number_format || '', true); cell.formatted = format.FormatComplex(complex); } cell.rendered_type = ValueType.complex; } else if (type === ValueType.dimensioned_quantity) { // is this really what we want? NaN mm? or can we just do NaN? // the reason for the question is that we want to move formatting // of DQ into format, in order that we can do logic on the formatting // side. but that won't work if we're short-circuiting here // actually I guess it's immaterial, NaN mm is effectively === to NaN ft if (isNaN((value as DimensionedQuantity).value)) { cell.formatted = // Style.Format(cell.style, value); // formats NaN (typeof cell.style.nan === 'undefined') ? 'NaN' : cell.style.nan; cell.formatted += (` ` + (value as DimensionedQuantity).unit); } else { const format = NumberFormatCache.Get(cell.style.number_format || '', true); cell.formatted = // Style.Format(cell.style, value); // this.FormatNumber((value as DimensionedQuantity).value, cell.style.number_format); // this.FormatNumber(value, cell.style.number_format); format.FormatDimensionedQuantity(value as DimensionedQuantity); } cell.rendered_type = ValueType.dimensioned_quantity; // who cares about rendered_type? (...) } else if (type === ValueType.function) { /* // FIXME: lock down this type (function) if ((cell.calculated as any)?.alt) { cell.formatted = (cell.calculated as any).alt.toString(); cell.rendered_type = ValueType.string; } else */ { cell.formatted = '𝑓()'; // FIXME cell.rendered_type = ValueType.string; } } else { // why is this being treated as a number? (...) // A: it's not, number format has a text section. defaults // to @ (just show the text), but could be different cell.formatted = this.FormatNumber(value, cell.style.number_format); cell.rendered_type = ValueType.string; } // now we can return it return cell; } /** * format number using passed format; gets the actual format object * and calls method. returns a string or array of text parts * (@see treb-format). */ public FormatNumber(value: CellValue, format = ''): string | TextPart[] { const formatted = NumberFormatCache.Get(format).FormatParts(value); if (!formatted.length) return ''; if (formatted.length === 1 && !formatted[0].flag) { return formatted[0].text || ''; } return formatted; } // no references... removing //public ColumnHeaderHeight(): number { // return this.column_header_height || this.default_row_height_x; //} /** * the only place this is called is in a method that shows/hides headers; * it sets the size either to 1 (hidden) or undefined, which uses the * defaults here. that suggests we should have a show/hide method instead. * * @param row_header_width * @param column_header_height */ public SetHeaderSize( row_header_width = DEFAULT_ROW_HEADER_WIDTH, column_header_height = this.default_row_height): void { this.row_header_width = row_header_width; this.column_header_height = column_header_height; } /* * * resize row to match character hight, taking into * account multi-line values. * * UPDATE: since the only caller calls with inline = true, removing * parameter, test, and extra behavior. * / public AutoSizeRow(row: number, theme?: Theme, allow_shrink = true, scale = 1): void { let height = this.default_row_height; const padding = 9; // 9? for (let column = 0; column < this.cells.columns; column++) { const cell = this.CellData({ row, column }); const style = JSON.parse(JSON.stringify(cell.style)); let text = cell.formatted || ''; if (typeof text !== 'string') { text = text.map((part) => part.text).join(''); } if (style && text && text.length) { const lines = text.split(/\n/); if (style.font_size) { if (style.font_size.unit === 'em') { const base = theme?.grid_cell_font_size || { unit: 'pt', value: 10 }; style.font_size.unit = base.unit; style.font_size.value *= base.value; } } else { style.font_size = theme?.grid_cell_font_size || { unit: 'pt', value: 10 }; } let target = style.font_size.value; if (style.font_size.unit === 'px') { target = Math.round((style.font_size.value||16) * 300 / 4) / 100; } const font_height = // Math.round(this.StyleFontSize(style, default_properties) * 1.5); // it's a start, we still need to measure properly Math.round(target * 1.5 * scale); height = Math.max(height, ((font_height || 10) + padding) * lines.length); } } if (!allow_shrink) { const test = this.GetRowHeight(row); if (test >= height) { return; } } this.SetRowHeight(row, height); } */ /** returns the style properties for a given style index */ public GetStyle(index: number): CellStyle { return this.style_map[index]; } /* * * if the cell is in an array, returns the array as an Area. * if not, returns falsy (null or undefined). * * FIXME: is this used? seems like the caller could do this * calculation. * * Answer was no, so removed * / public ContainingArray(address: ICellAddress): Area | undefined { const cell = this.cells.GetCell(address); if (cell) return cell.area; return undefined; } */ /** * * @param before_row insert before * @param count number to insert */ public InsertRows(before_row = 0, count = 1): boolean { // this needs to be shared between sheet/cells and the // outside spreadsheet logic. we should not be fixing references, // for example, because we don't have the graph. // we should definitely fix merge heads. also array heads. // also: you cannot insert rows that would break arrays. // if the new row(s) are inside of a merged cell, that cell // consumes the new row(s). // validate we won't break arrays. a new row would break an // array if before_row is in an array and (before_row-1) is // in the same array. if (before_row) { for (let column = 0; column < this.cells.columns; column++) { const cell1 = this.cells.GetCell({ row: before_row - 1, column }, false); if (cell1 && cell1.area) { const cell2 = this.cells.GetCell({ row: before_row, column }, false); if (cell2 && cell2.area && cell2.area.Equals(cell1.area)) { return false; // failed } } } } // this.named_ranges.PatchNamedRanges(0, 0, before_row, count); // ok we can insert... if (count < 0) { this.cells.DeleteRows(before_row, -count); } else { this.cells.InsertRows(before_row, count); } // now we have to fix arrays and merge heads. these lists will keep // track of the _new_ starting address. const merge_heads: Record = {}; const array_heads: Record = {}; // const table_heads: Record = {}; // now grab arrays and merge heads that are below the new rows // this should include merges that span the new range for (let row = before_row; row < this.cells.rows; row++) { for (let column = 0; column < this.cells.columns; column++) { const cell = this.cells.GetCell({ row, column }, false); if (cell) { /* if (cell.table) { const label = new Area(cell.table.area.start, cell.table.area.end).spreadsheet_label; if (!table_heads[label]) { table_heads[label] = cell.table; } } */ if (cell.area && !array_heads[cell.area.spreadsheet_label]) { array_heads[cell.area.spreadsheet_label] = cell.area; } if (cell.merge_area && !merge_heads[cell.merge_area.spreadsheet_label]) { merge_heads[cell.merge_area.spreadsheet_label] = cell.merge_area; } } } } // console.info("IR arrays", array_heads); // console.info("IR merges", merge_heads); for (const key of Object.keys(array_heads)) { const head = array_heads[key]; const patched = new Area( { row: head.start.row + count, column: head.start.column }, { row: head.end.row + count, column: head.end.column }); for (const address of patched) { const cell = this.cells.GetCell(address, true); cell.area = patched; } /* patched.Iterate((address) => { const cell = this.cells.GetCell(address, true); cell.area = patched; }); */ } /* for (const key of Object.keys(table_heads)) { const table = table_heads[key]; const patched_start = { ...table.area.start }; if (table.area.start.row >= before_row) patched_start.row += count; const patched = new Area( patched_start, { row: table.area.end.row + count, column: table.area.end.column }); table.area = { start: patched.start, end: patched.end }; // we don't need to reset table for cells that already have it, // but we do need to add it to new rows. could simplify. FIXME patched.Iterate((address) => { const cell = this.cells.GetCell(address, true); cell.table = table; }); } */ for (const key of Object.keys(merge_heads)) { const head = merge_heads[key]; const patched_start = { row: head.start.row, column: head.start.column }; if (head.start.row >= before_row) patched_start.row += count; const patched = new Area( patched_start, { row: head.end.row + count, column: head.end.column }); for (const address of patched) { const cell = this.cells.GetCell(address, true); cell.merge_area = patched; } /* patched.Iterate((address) => { const cell = this.cells.GetCell(address, true); cell.merge_area = patched; }); */ } // row styles const row_keys = Object.keys(this.row_styles); const new_row_style: Record = {}; row_keys.forEach((key) => { const index = Number(key); if (index < before_row) new_row_style[index] = this.row_styles[index]; else if (count < 0 && index < before_row - count) { /* ? */ } else new_row_style[index + count] = this.row_styles[index]; }); this.row_styles = new_row_style; // cell styles let args: Array = []; if (count < 0) { args = [before_row, -count]; } else { args = [before_row, 0]; for (let i = 0; i < count; i++) args.push(undefined); } // console.info('m5.1'); this.cell_style.forEach((column) => { if (column && column.length >= before_row) { // eslint-disable-next-line prefer-spread column.splice.apply(column, args as [number, number, CellStyle]); } }); // console.info('m6'); // row heights // row heights is now a map, so this has to change... // eslint-disable-next-line prefer-spread // this.row_height_.splice.apply(this.row_height_, args as [number, number, number]); const tmp: Map = new Map(); if (count > 0) { for (const [row, height] of this.row_height_map) { if (row >= before_row) { tmp.set(row + count, height); } else { tmp.set(row, height); } } } else if (count < 0) { for (const [row, height] of this.row_height_map) { if (row >= before_row) { if (row >= before_row - count) { tmp.set(row + count, height); } } else { tmp.set(row, height); } } } this.row_height_map = tmp; // invalidate style cache this.FlushCellStyles(); // console.info('m7'); return true; } /** * see InsertRow for details */ public InsertColumns(before_column = 0, count = 1): boolean { // check for array breaks if (before_column) { for (let row = 0; row < this.cells.rows; row++) { const cell1 = this.cells.GetCell({ row, column: before_column - 1 }, false); if (cell1 && cell1.area) { const cell2 = this.cells.GetCell({ row, column: before_column }, false); if (cell2 && cell2.area && cell2.area.Equals(cell1.area)) return false; // failed } } } // this.named_ranges.PatchNamedRanges(before_column, count, 0, 0); // ok we can insert... if (count < 0) { this.cells.DeleteColumns(before_column, -count); } else { this.cells.InsertColumns(before_column, count); } // now we have to fix arrays and merge heads. these lists will keep // track of the _new_ starting address. // NOTE: tables are handled by the grid routine. for a time we were // doing that here but it's easier to unify on the grid size, since // we may need to update column headers or remove the model reference. const merge_heads: Record = {}; const array_heads: Record = {}; // now grab arrays and merge heads that are below the new rows // this should include merges that span the new range for (let column = before_column; column < this.cells.columns; column++) { for (let row = 0; row < this.cells.rows; row++) { const cell = this.cells.GetCell({ row, column }, false); if (cell) { if (cell.area && !array_heads[cell.area.spreadsheet_label]) { array_heads[cell.area.spreadsheet_label] = cell.area; } if (cell.merge_area && !merge_heads[cell.merge_area.spreadsheet_label]) { merge_heads[cell.merge_area.spreadsheet_label] = cell.merge_area; } } } } for (const key of Object.keys(array_heads)) { const head = array_heads[key]; const patched = new Area( { row: head.start.row, column: head.start.column + count }, { row: head.end.row, column: head.end.column + count }); for (const address of patched) { const cell = this.cells.GetCell(address, true); cell.area = patched; } /* patched.Iterate((address) => { const cell = this.cells.GetCell(address, true); cell.area = patched; }); */ } for (const key of Object.keys(merge_heads)) { const head = merge_heads[key]; const patched_start = { row: head.start.row, column: head.start.column }; if (head.start.column >= before_column) patched_start.column += count; const patched = new Area( patched_start, { row: head.end.row, column: head.end.column + count }); for (const address of patched) { const cell = this.cells.GetCell(address, true); cell.merge_area = patched; } /* patched.Iterate((address) => { const cell = this.cells.GetCell(address, true); cell.merge_area = patched; }); */ } // column styles const column_keys = Object.keys(this.column_styles); const new_column_style: Record = {}; column_keys.forEach((key) => { const index = Number(key); if (index < before_column) new_column_style[index] = this.column_styles[index]; else if (count < 0 && index < before_column - count) { /* ? */ } else new_column_style[index + count] = this.column_styles[index]; }); this.column_styles = new_column_style; // cell styles let args: Array = []; if (count < 0) { args = [before_column, -count]; } else { args = [before_column, 0]; for (let i = 0; i < count; i++) args.push(undefined); } // eslint-disable-next-line prefer-spread this.cell_style.splice.apply(this.cell_style, args as [number, number, CellStyle[]]); // row heights // eslint-disable-next-line prefer-spread this.column_width_.splice.apply(this.column_width_, args as [number, number, number]); // invalidate style cache this.FlushCellStyles(); return true; } /** clear cells in area */ public ClearArea(area: Area): void { // this is not allowed if any of the cells are in // an array, and the array does not match the passed // array. // ... // assuming it's ok, : // area = this.RealArea(area); // this.cells.Apply(area, (cell) => cell.Reset()); for (const cell of this.cells.Iterate(this.RealArea(area), false)) { cell.Reset(); } } // ATM we have 4 methods to set value/values. we need a distinction for // arrays, but that could be a parameter. the single-value/multi-value // area functions could probably be consolidated, also the single-cell- // single-value function... you need logic either on the outside or the // inside, put that logic where it makes the most sense. // also some of this could be moved to the Cells class... if for no // other reason than to remove the iteration overhead public SetAreaValues2(area: Area, values: CellValue | CellValue[][]): void { // we don't want to limit this to the existing area, we only // want to remove infinities (if set). it's possible to expand // the grid here (maybe -- check option?) // actually, realarea already does exactly that -- which is not // what I thought. we may need a new, different method to clip. area = this.RealArea(area); this.cells.SetArea(area, values); } /** * set the area as an array formula, based in the top-left cell */ public SetArrayValue(area: Area, value: CellValue): void { area = this.RealArea(area); // this.cells.Apply(area, (element) => element.SetArray(area), true); for (const cell of this.cells.Iterate(area, true)) { cell.SetArray(area); } const cell = this.cells.GetCell(area.start, true); cell.SetArrayHead(area, value); } /** * set a single value in a single cell */ public SetCellValue(address: ICellAddress, value: CellValue): void { const cell = this.cells.GetCell(address, true); cell.Set(value); } /** * FIXME: does not need to be in sheet * * @param headers_only - only return tables if the cell is in the * header (first) row. useful if you only want to worry about headers. */ public TablesFromArea(area: IArea|ICellAddress, headers_only = false): Table[] { if (IsCellAddress(area)) { const cell = this.cells.GetCell(area, false); if (cell?.table) { if (!headers_only || (area.row === cell.table.area.start.row)) { return [cell.table]; } } return []; } const set: Set = new Set(); for (let row = area.start.row; row <= area.end.row; row++) { for (let column = area.start.column; column <= area.end.column; column++) { const cell = this.cells.GetCell({row, column}, false); if (cell?.table && !set.has(cell.table)) { if (!headers_only || (row === cell.table.area.start.row)) { set.add(cell.table); } } } } return Array.from(set.values()); } /** * returns the area bounding actual content * (i.e. flattening "entire row/column/sheet") * * FIXME: this does not clamp to actual cells... why not? * FIXME: so now we are (optionally) clamping end; should clamp start, too * * @param clamp -- new parameter will optionally clamp to actual sheet size */ public RealArea(area: Area, clamp = false): Area { const start = area.start; // this is a copy const end = area.end; // ditto if (area.entire_row) { start.column = 0; start.absolute_column = false; end.column = this.cells.columns - 1; end.absolute_column = false; } if (area.entire_column) { start.row = 0; start.absolute_row = false; end.row = this.cells.rows - 1; end.absolute_row = false; } if (clamp) { if (end.row >= this.rows) { end.row = this.rows - 1; end.absolute_row = false; } if (end.column >= this.columns) { end.column = this.columns - 1; end.absolute_column = false; } } return new Area(start, end); } /** specialization */ public GetCellStyle(area: ICellAddress, apply_theme?: boolean): CellStyle; /** specialization */ public GetCellStyle(area: IArea, apply_theme?: boolean): CellStyle[][]; /** extra specialization */ public GetCellStyle(area: K, apply_theme?: boolean): CellStyle|CellStyle[][]; /** * this is a new GetCellStyle function, used for external access * to style (for API access). there was an old GetCellStyle function * for rendering, but that's been removed (control+F for info). * * Q: does this include conditional formatting? (...) */ public GetCellStyle(area: ICellAddress|IArea, apply_theme = false): CellStyle|CellStyle[][] { if (IsCellAddress(area)) { return this.CompositeStyleForCell(area, true, false, apply_theme); } // the contract says this should return an array, not a single value. // // I can fix it, but will anyone break? (...) check the indent buttons // (update: looks OK) // if (area.start.row === area.end.row && area.start.column === area.end.column) { return [[this.CompositeStyleForCell(area.start, true, false, apply_theme)]]; } const result: CellStyle[][] = []; for (let r = area.start.row; r <= area.end.row; r++) { const row: CellStyle[] = []; for (let c = area.start.column; c <= area.end.column; c++) { row.push(this.CompositeStyleForCell({row: r, column: c}, true, false, apply_theme)); } result.push(row); } return result; } /// public FormattedCellValue(address: ICellAddress): CellValue { const cell = this.CellData(address); if (!cell) { return undefined; } if (typeof cell.formatted === 'string') return cell.formatted; if (cell.formatted) { return cell.formatted.map(part => { switch (part.flag) { case 1: return ' '; case 2: return ' '; // ?? default: return part.text; } }).join(''); } return cell.value; } public GetFormattedRange(from: ICellAddress, to: ICellAddress = from): CellValue | CellValue[][] { if (from.row === to.row && from.column === to.column) { return this.FormattedCellValue(from); } const result: CellValue[][] = []; // grab rows for (let row = from.row; row <= to.row; row++) { const target: CellValue[] = []; for (let column = from.column; column <= to.column; column++) { target.push(this.FormattedCellValue({ row, column })); } result.push(target); } return result; } /** * get all styles used in the sheet. this is used to populate color * and number format lists in the toolbar. we used to just serialize * the document and use that, but that's absurdly wasteful. for this * application we don't even need composites. * * although, this is a bit dangerous because you could (in theory) * modify the results in place. so maybe we should either duplicate or * just return the requested data... */ public NumberFormatsAndColors( color_map: Record, number_format_map: Record, ): void { const parse = (style: CellStyle) => { if (style.number_format) { number_format_map[style.number_format] = 1; } if (IsHTMLColor(style.text)) { color_map[style.text.text] = 1; } if (IsHTMLColor(style.fill)) { color_map[style.fill.text] = 1; } //if (style.background && style.background !== 'none') { // color_map[style.background] = 1; //} if (IsHTMLColor(style.border_top_fill)) { color_map[style.border_top_fill.text] = 1; } if (IsHTMLColor(style.border_left_fill)) { color_map[style.border_left_fill.text] = 1; } if (IsHTMLColor(style.border_right_fill)) { color_map[style.border_right_fill.text] = 1; } if (IsHTMLColor(style.border_bottom_fill)) { color_map[style.border_bottom_fill.text] = 1; } }; parse(this.sheet_style); for (const key in this.row_styles) { parse(this.row_styles[key]); } for (const key in this.column_styles) { parse(this.column_styles[key]); } for (const style of this.row_pattern) { parse(style); } for (const row of this.cell_style) { if (row) { for (const style of row) { if (style) { parse(style); } } } } } public CompressCellStyles(data: number[][]) { // we can almost certainly compress the cell style map (above) if there // are consistent areas. not sure what the optimal algorithms for this // are, but there are probably some out there. let's start naively and // see what we can get. // I think the real issue is imports from XLSX; we're getting a lot // of individual cell styles where there should probably be R/C styles. // actually we might be working against ourselves here if we are // removing populated cells from this array: because in that case we'll // get fewer contiguous blocks. perhaps we should have a "lookaround" // in the original array? (...) // OTOH this can never be _worse_ than the old method, and I don't think // it costs much more. so we'll stick with this for the time being, see // if we can further optimize later. // (note: tried passing the original array, and checking for overlap, // but ultimately savings was minimal. not worth it) const list: Array<{ row: number; column: number; ref: number, rows?: number }> = []; for (let c = 0; c < data.length; c++) { const column = data[c]; if (column) { for (let r = 0; r < column.length; r++) { const style = column[r]; if (style) { let k = r + 1; for (; k < column.length; k++) { if (column[k] !== style) { break; } } if ( k > r + 1 ){ list.push({ row: r, column: c, ref: style, rows: k - r }); } else { list.push({ row: r, column: c, ref: style }); } r = k - 1; } } } } return list; } /** * generates serializable object. given the new data semantics this * has to change a bit. here is what we are storing: * * all style data (sheet, row/column, alternate and cell) * raw value for cell * array head for arrays * row height and column width arrays * * because we have sparse arrays, we convert them to flat objects first. */ public toJSON(options: SerializeOptions = {}): SerializedSheet { // flatten height/width arrays const flatten_numeric_array = (arr: number[], default_value: number) => { const obj: Record = {}; for (let i = 0; i < arr.length; i++) { if ((typeof arr[i] !== 'undefined') && arr[i] !== default_value) obj[i] = arr[i]; } if (Object.keys(obj).length) return obj; return undefined; }; // flatten cell styles, which is a sparse array // UPDATE: ref table // NOTE: we originally did this (I think) because it's possible for a // cell to have a style but have no other data, and therefore not be // represented. but we should be able to store the data in the cell object // if we have it... let cell_style_refs = [{}]; // include an empty entry at zero const cell_style_map: Record = {}; const cell_reference_map: number[][] = []; // (1) create a map of cells -> references, and build the reference // table at the same time. preserve indexes? (...) // it would be nice if we could use some sort of numeric test, rather // than leaving empty indexes as undefined -- that requires a type test // (to avoid zeros). const empty_json = JSON.stringify({}); // actually we could just offset the index by 1... (see above) for (let c = 0; c < this.cell_style.length; c++) { const column = this.cell_style[c]; if (column) { cell_reference_map[c] = []; for (let r = 0; r < column.length; r++) { if (column[r]) { const style_as_json = Style.Serialize(column[r]); // JSON.stringify(column[r]); if (style_as_json !== empty_json) { let reference_index = cell_style_map[style_as_json]; if (typeof reference_index !== 'number') { cell_style_map[style_as_json] = reference_index = cell_style_refs.length; cell_style_refs.push(column[r]); } cell_reference_map[c][r] = reference_index; } } } } } // it might be more efficient to store cell styles separately from // cell data, as we might be able to compress it. it looks more like // an indexed image, and we likely don't have that many styles. /** * this assumes that "empty" style is at index 0 */ const StyleToRef = (style: CellStyle) => { const style_as_json = Style.Serialize(style); // JSON.stringify(style); if (style_as_json === empty_json) { return 0; } let reference_index = cell_style_map[style_as_json]; if (typeof reference_index !== 'number') { cell_style_map[style_as_json] = reference_index = cell_style_refs.length; cell_style_refs.push(style); } return reference_index; }; // ensure we're not linked cell_style_refs = JSON.parse(JSON.stringify(cell_style_refs)); // same here (note broken naming) const sheet_style = JSON.parse(JSON.stringify(this.sheet_style)); const row_pattern = JSON.parse(JSON.stringify(this.row_pattern)); // row and column styles are Record and not arrays. // I think they should probably be arrays. it's not critical but // using records (objects) converts keys to strings, which is sloppy. // const column_style: Array = []; // const row_style: Array = []; const column_style: Record = {}; const row_style: Record = {}; for (const key of Object.keys(this.column_styles)) { const index = Number(key); const style = this.column_styles[index]; if (style) { const reference = StyleToRef(style); if (reference) { column_style[index] = reference; } } } if (this.row_pattern && this.row_pattern.length && options.apply_row_pattern) { let count = this.rows + 1; for (const key of Object.keys(this.row_styles)) { const index = Number(key); if (!isNaN(index) && index >= count) { count = index + 1; } } for (let i = 0; i< count; i++) { const pattern = this.row_pattern[i % this.row_pattern.length]; const style = this.row_styles[i] || {}; const composite = Style.Composite([pattern, style]); const reference = StyleToRef(composite); if (reference) { row_style[i] = reference; } } } else { for (const key of Object.keys(this.row_styles)) { const index = Number(key); const style = this.row_styles[index]; if (style) { const reference = StyleToRef(style); if (reference) { row_style[index] = reference; } } } } /* const translate_border_color = (color: string | undefined, default_color: string | undefined): string | undefined => { if (typeof color !== 'undefined' && color !== 'none') { if (color === default_color) { return undefined; } else { return Measurement.MeasureColorARGB(color); } } return undefined; } */ const translate_border_fill = (color: Color = {}, default_color: Color = {}) => { const result: Color = { ...default_color, ...color, }; if (IsHTMLColor(result)) { result.text = Measurement.MeasureColorARGB(result.text); return result; } else if (IsThemeColor(result)) { return result; } return undefined; }; // translate, if necessary if (options.export_colors) { const style_list: CellStyle[] = []; for (const group of [ //row_style, column_style, // these are moved -> csr (which should be renamed) cell_style_refs, [sheet_style], row_pattern]) { if (Array.isArray(group)) { for (const entry of group) style_list.push(entry); } else { for (const key of Object.keys(group)) style_list.push(group[key]); } } for (const style of style_list as CellStyle[]) { // don't set "undefined" overrides. also, was this broken // wrt all the defaults from top? probably let fill = translate_border_fill(style.border_top_fill, Style.DefaultProperties.border_top_fill); if (fill !== undefined) { style.border_top_fill = fill; } fill = translate_border_fill(style.border_left_fill, Style.DefaultProperties.border_left_fill); if (fill !== undefined) { style.border_left_fill = fill; } fill = translate_border_fill(style.border_right_fill, Style.DefaultProperties.border_right_fill); if (fill !== undefined) { style.border_right_fill = fill; } fill = translate_border_fill(style.border_bottom_fill, Style.DefaultProperties.border_bottom_fill); if (fill !== undefined) { style.border_bottom_fill = fill; } if (IsHTMLColor(style.fill)) { style.fill.text = Measurement.MeasureColorARGB(style.fill.text); } //if (typeof style.background !== 'undefined' && style.background !== 'none') { // style.background = Measurement.MeasureColorARGB(style.background); //} if (IsHTMLColor(style.text)) { style.text.text = Measurement.MeasureColorARGB(style.text.text); } } } // FIXME: flatten row/column styles too // flatten data -- also remove unecessary fields (FIXME: you might // keep rendered data, so it doesn't have to do work on initial render?) const serialization_options: CellSerializationOptions = { calculated_value: !!options.rendered_values, preserve_type: !!options.preserve_type, expand_arrays: !!options.expand_arrays, decorated_cells: !!options.decorated_cells, nested: true, cell_style_refs: cell_reference_map, tables: !!options.tables, }; // the rows/columns we export can be shrunk to the actual used area, // subject to serialization option. const serialized_data = this.cells.toJSON(serialization_options); const data = serialized_data.data; let { rows, columns } = serialized_data; if (!options.shrink) { rows = this.rows; columns = this.columns; } else { // pad by 1 (2?) rows += 2; columns += 1; } // push out for annotations for (const annotation of this.annotations) { if (!annotation.data.extent) { this.CalculateAnnotationExtent(annotation); } if (annotation.data.extent) { rows = Math.max(rows, annotation.data.extent.row + 1); columns = Math.max(columns, annotation.data.extent.column + 1); } } // (3) (style) for anything that hasn't been consumed, create a // cell style map. FIXME: optional [?] /* const cell_styles: Array<{ row: number; column: number; ref: number }> = []; for (let c = 0; c < cell_reference_map.length; c++) { const column = cell_reference_map[c]; if (column) { for (let r = 0; r < column.length; r++) { if (column[r]) { cell_styles.push({ row: r, column: c, ref: column[r] }); } } } } const CS2 = this.CompressCellStyles(cell_reference_map); console.info({cs1: JSON.stringify(cell_styles), cs2: JSON.stringify(CS2)}); */ // using blocks. this is our naive method. we could do (at minimum) // testing row-dominant vs column-dominant and see which is better; // but that kind of thing adds time, so it should be optional. const cell_styles = this.CompressCellStyles(cell_reference_map); // if we serialize this when it has Area values (instead of IArea) it // will export incorrectly. is that an issue anywhere else? (...) const conditional_formats = this.conditional_formats.length ? JSON.parse(JSON.stringify(this.conditional_formats.map(format => ({...format, internal: undefined })))) : undefined; // yes, here. we should have a serialized type so we know to convert. TODO const data_validations = this.data_validation.length ? JSON.parse(JSON.stringify(this.data_validation)) : undefined; const row_height: Record = {}; for (const [key, value] of this.row_height_map.entries()) { row_height[key] = value; } const result: SerializedSheet = { // not used atm, but in the event we need to gate // or swap importers on versions in the future // FIXME: drop, in favor of container versioning. there's no point // in this submodule versioning (is there? ...) // version: (ModuleInfo as any).version, id: this.id, name: this.name, tab_color: this.tab_color, data, sheet_style, rows, columns, cell_styles, styles: cell_style_refs, row_style, column_style, conditional_formats, data_validations, row_pattern: row_pattern.length ? row_pattern : undefined, // why are these serialized? (...) export! default_row_height: this.default_row_height, default_column_width: this.default_column_width, row_height, // : flatten_numeric_array(this.row_height_, this.default_row_height), column_width: flatten_numeric_array(this.column_width_, this.default_column_width), selection: JSON.parse(JSON.stringify(this.selection)), annotations: JSON.parse(JSON.stringify(this.annotations)), }; // omit default (true) if (!this.visible) { result.visible = this.visible; } if (this.scroll_offset.x || this.scroll_offset.y) { result.scroll = this.scroll_offset; } if (this.background_image) { result.background_image = this.background_image; } // moved to outer container (data model) /* // omit if empty if (this.named_ranges.Count()) { result.named_ranges = JSON.parse(JSON.stringify(this.named_ranges.Map())); } */ // only put in freeze if used if (this.freeze.rows || this.freeze.columns) { result.freeze = this.freeze; } return result; } /* * export values and calcualted values; as for csv export (which is what it's for) * / public ExportValueData(transpose = false, dates_as_strings = false, export_functions = false): CellValue[][] { const arr: CellValue[][] = []; const data = this.cells.data; if (transpose) { const rowcount = data[0].length; // assuming it's a rectangle for (let r = 0; r < rowcount; r++) { const row: CellValue[] = []; for (const column of data) { const ref = column[r]; let value: CellValue; if (!export_functions && typeof ref.calculated !== 'undefined') value = ref.calculated; else if (typeof ref.value === 'undefined') value = ''; else value = ref.value; if (dates_as_strings && ref.style && typeof value === 'number') { const format = NumberFormatCache.Get(ref.style.number_format || ''); if (format.date_format) value = format.Format(value); } // if (dates_as_strings && ref.style && ref.style.date && typeof value === 'number') { // value = Style.Format(ref.style, value); // } row.push(value); } arr.push(row); } } else { for (const column_ref of data) { const column: CellValue[] = []; for (const ref of column_ref) { let value: CellValue; if (!export_functions && typeof ref.calculated !== 'undefined') value = ref.calculated; else if (typeof ref.value === 'undefined') value = ''; else value = ref.value; if (dates_as_strings && ref.style && typeof value === 'number') { const format = NumberFormatCache.Get(ref.style.number_format || ''); if (format.date_format) value = format.Format(value); } // if (dates_as_strings && ref.style && ref.style.date && typeof value === 'number') { // value = Style.Format(ref.style, value); // } column.push(value); } arr.push(column); } } return arr; } */ /** flushes ALL rendered styles and caches. made public for theme API */ public FlushCellStyles(): void { this.style_map = []; this.style_json_map = []; this.cells.FlushCellStyles(); } public ImportData(data: ImportedSheetData): void { const styles = data.styles; if (data.outline) { this.outline = data.outline; } // adding sheet style... // 0 is implicitly just a general style const sheet_style = data.sheet_style; if (sheet_style) { this.UpdateAreaStyle( new Area({ row: Infinity, column: Infinity }, { row: Infinity, column: Infinity }), styles[sheet_style]); } // and column styles... const column_styles = data.column_styles; if (column_styles) { for (let i = 0; i < column_styles.length; i++) { // 0 is implicitly just a general style if (column_styles[i]) { this.UpdateAreaStyle(new Area({ row: Infinity, column: i }, { row: Infinity, column: i }), styles[column_styles[i]]); } } } // and row styles... if (data.row_styles) { for (const [row, style] of data.row_styles.entries()) { if (style) { this.UpdateAreaStyle(new Area({ row, column: Infinity }), styles[style]); } } } // this.cells.FromJSON(cell_data); this.cells.FromJSON(data.cells); if (data.name) { this.name = data.name || ''; // wtf is this? } // patching from import for (const cell of this.cells.Iterate()) { if (cell.spill) { if (!cell.spill.start.sheet_id) { cell.spill.SetSheetID(this.id); } } } if (data.tab_color) { this.tab_color = data.tab_color; } // 0 is implicitly just a general style const cs = this.cell_style; for (const info of data.cells) { if (info.style_ref) { if (!cs[info.column]) cs[info.column] = []; cs[info.column][info.row] = styles[info.style_ref]; } } for (let i = 0; i < data.column_widths.length; i++) { if (typeof data.column_widths[i] !== 'undefined') { // OK this is unscaled, we are setting unscaled from source data this.SetColumnWidth(i, data.column_widths[i]); } } for (let i = 0; i < data.row_heights.length; i++) { if (typeof data.row_heights[i] !== 'undefined') { // OK this is unscaled, we are setting unscaled from source data this.SetRowHeight(i, data.row_heights[i]); } } for (const annotation of data.annotations || []) { this.annotations.push(new Annotation(annotation)); } for (const format of data.conditional_formats || []) { this.conditional_formats.push(format); } for (const validation of data.data_validations || []) { this.AddValidation(validation); } if (data.hidden) { this.visible = false; } } // --- protected ------------------------------------------------------------ /** * figure out the last row/column of the annotation. this * might set it to 0/0 if there's no rect, just make sure * that it gets cleared on layout changes. */ protected CalculateAnnotationExtent(annotation: Annotation): void { // this is much easier with layout, but we are leaving the old // coude to support older files -- OTOH, the layout will be created // at some point, we just need to make sure that happens before this // is called if (annotation.data.layout) { annotation.data.extent = { ...annotation.data.layout.br.address }; return; } // 1000 here is just sanity check, it might be larger const sanity = 1000; annotation.data.extent = { row: 0, column: 0 }; let right = annotation.rect?.right; if (right && this.default_column_width) { // also sanity check for (let i = 0; right >= 0 && i < sanity; i++) { right -= this.GetColumnWidth(i); // FIXME: check // it's ok, rect is scaled to unit if (right < 0) { annotation.data.extent.column = i; break; } } } let bottom = annotation.rect?.bottom; if (bottom && this.default_row_height) { for (let i = 0; bottom >= 0 && i < sanity; i++) { bottom -= this.GetRowHeight(i); // FIXME: check // it's ok, rect is scaled to unit if (bottom < 0) { annotation.data.extent.row = i; break; } } } } /* * * when checking style properties, check falsy but not '' or 0 * (also strict equivalence) * / protected StyleEquals(a: any, b: any): boolean { return a === b || ((a === false || a === null || a === undefined) && (b === false || b === null || b === undefined)); } */ /* protected Serialize() { return JSON.stringify(this); } */ /* protected Deserialize(data: SerializedSheet) { Sheet.FromJSON(data, this.default_style_properties, this); // some overlap here... consolidate? actually, doesn't // fromJSON call flush styles? [A: sometimes...] this.cells.FlushCachedValues(); this.FlushCellStyles(); } */ // --- private methods ------------------------------------------------------ /** * update style properties. merge by default. * * this method will reverse-override properties, meaning if you have set (for * example) a cell style to bold, then you set the whole sheet to unbold, we * expect that the unbold style will control. instead of explicitly setting * the cell style, we go up the chain and remove any matching properties. */ private UpdateSheetStyle(properties: CellStyle, delta = true) { this.sheet_style = Style.Merge(this.sheet_style, properties, delta); // reverse-override... // const keys = Object.keys(properties); const keys = Object.keys(properties) as PropertyKeys[]; // const keys = Object.keys(this.sheet_style) as Style.PropertyKeys[]; for (const style_column of this.cell_style) { if (style_column) { for (const style_ref of style_column) { if (style_ref) { keys.forEach((key) => delete style_ref[key]); } } } } for (const index of Object.keys(this.row_styles)) { keys.forEach((key) => delete this.row_styles[index as unknown as number][key]); } for (const index of Object.keys(this.column_styles)) { keys.forEach((key) => delete this.column_styles[index as unknown as number][key]); } // FIXME: ROW PATTERN this.FlushCellStyles(); // not targeted } /** * updates row properties. reverse-overrides cells (@see UpdateSheetStyle). * * we also need to ensure that the desired effect takes hold, meaning if * there's an overriding column property (columns have priority), we will * need to update the cell property to match the desired output. */ private UpdateRowStyle(row: number, properties: CellStyle, delta = true) { this.row_styles[row] = Style.Merge(this.row_styles[row] || {}, properties, delta); // reverse-override... remove matching properties from cells in this row // (we can do this in-place) // const keys = Object.keys(properties); const keys = Object.keys(properties) as PropertyKeys[]; // const keys = Object.keys(this.row_styles[row]) as Style.PropertyKeys[]; for (const column of this.cell_style) { if (column && column[row]) { // FIXME: we don't want to delete. reverse-add. keys.forEach((key) => delete column[row][key]); } } /* // // seems to be related to // https://github.com/microsoft/TypeScript/pull/30769 // // not clear why the behavior should be different, but // // "indexed access with generics now works differently inside & outside a function." // const FilteredAssign = (test: T, source: T, target: T, keys: Array): void => { for (const key of keys) { if (test[key] !== undefined) { target[key] = source[key]; } } }; */ // if there's a column style, it will override the row // style; so we need to set a cell style to compensate. // "override" because a reserved word in ts 4.3.2, possibly accidentally? // or possibly it was already a reserved word, and was handled incorrectly? // not sure. stop using it. // // Actually just by the by, if it does work as described in // // https://github.com/microsoft/TypeScript/issues/2000 // // then we should start using it where appropriate, because it is good. // just don't use it here as a variable name. for (let i = 0; i < this.cells.columns; i++) { if (this.column_styles[i]) { const column_style = this.column_styles[i]; const overrides: CellStyle = this.cell_style[i] ? this.cell_style[i][row] || {} : {}; for (const key of keys) { if (typeof column_style[key] !== 'undefined') { // what's the correct pattern (if any) for this? these // are the same type so the type of the indexed value should // be equivalent... no? maybe there is no correct way // eslint-disable-next-line @typescript-eslint/no-explicit-any (overrides as any)[key] = properties[key]; } } if (Object.keys(overrides).length) { if (!this.cell_style[i]) this.cell_style[i] = []; this.cell_style[i][row] = JSON.parse(JSON.stringify(overrides)); } } } // FIXME: ROW PATTERN // this.cells.Apply(this.RealArea(Area.FromRow(row)), (cell) => cell.FlushStyle()); for (const cell of this.cells.Iterate(this.RealArea(Area.FromRow(row)))) { cell.FlushStyle(); } } /** * updates column properties. reverse-overrides cells (@see UpdateSheetStyle). */ private UpdateColumnStyle(column: number, properties: CellStyle, delta = true) { this.column_styles[column] = Style.Merge(this.column_styles[column] || {}, properties, delta); // returning to this function after a long time. so what this is doing // is removing unecessary properties from style objects higher in the // style chain, if those properties are overridden. note that this doesn't // seem to prune now-empty styles, which it probably should... // in essence, we have a containing style object // { a: 1, c: 2 } // // then we iterate all cells in the column, and if there are any // matching properties they're deleted; so if a cell has // { a: 0, b: 1 } // // we drop the a property, so it becomes // { b: 1 } // // note you can drop and re-create the cell style object, because the cell's // reference is actually to a separate object (composited with the stack), // and the reference is cleared so the composite will be rebuilt when it's // needed next. // NOTE this was broken anyway; it wasn't taking the merge into account... // ALTHOUGH that breaks "remove-color" operations. I think the old way // took into account that the styles would be relatively in sync already. // reverse-override... I think we only need to override _cell_ values. const keys = Object.keys(properties) as PropertyKeys[]; // const keys = Object.keys(this.column_styles[column]) as Style.PropertyKeys[]; if (this.cell_style[column]) { for (const ref of this.cell_style[column]) { if (ref) { // FIXME: we don't want to delete. reverse-add. keys.forEach((key) => delete ref[key]); } } } // this.cells.Apply(this.RealArea(Area.FromColumn(column)), (cell) => cell.FlushStyle()); for (const cell of this.cells.Iterate(this.RealArea(Area.FromColumn(column)))) { cell.FlushStyle(); } // FIXME: ROW PATTERN } public BleedFlush(area: IArea) { const rows = [Math.max(0, area.start.row - 1), area.end.row + 1]; const cols = [Math.max(0, area.start.column - 1), area.end.column + 1]; for (let row = rows[0]; row <= rows[1]; row++) { for (let column = cols[0]; column <= cols[1]; column++) { // const cell = this.cells.EnsureCell({row, column}); this.cells.GetCell({row, column}, false)?.FlushStyle(); } } } public FlushConditionalFormats() { this.flush_conditional_formats = true; } /** * this version combines flushing the cache with building it, using * the application flag in the format objects. * * this function was set up to support comparing the two lists and * only flushing style if necessary; but that turns out to be so * much additional work that I'm not sure it's preferable to just * repaint. need to test. * * ...we're also probably looping unecessarily. since we're using * those leaf nodes we can probably check if the state changed, and * it not, skip the loop pass. I think we'd need to identify or map * the applications though (meaning use a stack that matches the list * of formats). or you could even recheck everything if one of them * changed, you'd still probably save a lot in cases where nothing * changed. * */ public ApplyConditionalFormats() { // we're not doing any pruning at the moment, so this is doing // a lot of unecessary looping -- we could start with one big // global check // ...we need to account for the case where a format is removed, // in that case we will need to update. flag? let updated = this.flush_conditional_formats; // maybe required for (const format of this.conditional_formats) { if (format.internal?.vertex?.updated) { updated = true; break; } } if (!updated) { // console.info('no updates'); // that should save 90% of the calculation, we'll still do // unecessary work but it's a step in the right direction. // note that this flag doesn't necessarily indicate anything // has changed -- it will get set if you do a global recalc, // because that marks everything as dirty. still a good step // though. return; } this.flush_conditional_formats = false; // unset const temp: ExtendedCelLStyle[][][] = []; const checklist: IArea[] = [...this.conditional_format_checklist]; this.conditional_format_checklist = []; // flush for (const format of this.conditional_formats) { if (format.internal?.vertex?.updated) { format.internal.vertex.updated = false; } // NOTE: if you go backwards, then you can short-circuit if a format // is already set. except then if you want to support "stop" rules, // that won't work. // // although you might still want to go backwards as it's easier to // apply stop rules in reverse (why? because if you are going backwards, // you can just drop everything on the stack when you see a // stop rule. if you go forwards, you need some sort of indicator // or flag). // there's more to this, because there are rules that apply to areas, // which might stop, and there's priority. so we probably need those // flags eventually. const area = JSON.parse(JSON.stringify(format.area)); if (area.start.row === null || area.end.row === null) { area.start.row = 0; area.end.row = this.cells.rows - 1; } if (area.start.column === null || area.end.column === null) { area.start.column = 0; area.end.column = this.cells.columns - 1; } const result = format.internal?.vertex?.result; if (format.type === 'gradient') { if (result && format.internal?.gradient) { const property: 'fill'|'text' = format.property ?? 'fill'; if (result.type === ValueType.array) { for (let row = area.start.row; row <= area.end.row; row++) { for (let column = area.start.column; column <= area.end.column; column++) { const value = result.value[column - area.start.column][row - area.start.row]; if (value.type === ValueType.number) { if (!temp[row]) { temp[row] = []; } if (!temp[row][column] ) { temp[row][column] = []; } const color = format.internal.gradient.Interpolate(value.value); temp[row][column].push({ [property]: color}); } } } } else if (result.type === ValueType.number) { const color = format.internal.gradient.Interpolate(result.value); for (let row = area.start.row; row <= area.end.row; row++) { if (!temp[row]) { temp[row] = []; } for (let column = area.start.column; column <= area.end.column; column++) { if (!temp[row][column] ) { temp[row][column] = []; } temp[row][column].push({ [property]: color}); } } } checklist.push(area); this.conditional_format_checklist.push(area); } } else if (format.type === 'data-bar') { if (result) { if (result.type === ValueType.array) { for (let row = area.start.row; row <= area.end.row; row++) { for (let column = area.start.column; column <= area.end.column; column++) { const value = result.value[column - area.start.column][row - area.start.row]; if (value.type === ValueType.array) { const [pct, zero] = value.value[0]; if (pct.type === ValueType.number && zero.type === ValueType.number) { if (!temp[row]) { temp[row] = []; } if (!temp[row][column] ) { temp[row][column] = []; } // const color = format.internal.gradient.Interpolate(value.value); // temp[row][column].push({ [property]: color}); temp[row][column].push({ databar: { value: pct.value, zero: zero.value, fill: format.fill, negative: format.negative, hide_values: format.hide_values, } }); } } } } } checklist.push(area); this.conditional_format_checklist.push(area); } } else { // handle types expression, cell-match and duplicate-values if (result) { if (result.type === ValueType.array) { for (let row = area.start.row; row <= area.end.row; row++) { for (let column = area.start.column; column <= area.end.column; column++) { const value = result.value[column - area.start.column][row - area.start.row]; if (value && (value.type === ValueType.boolean || value.type === ValueType.number) && !!value.value) { if (!temp[row]) { temp[row] = []; } if (!temp[row][column] ) { temp[row][column] = []; } temp[row][column].push(format.style); } } } } else { if (result.type === ValueType.boolean || result.type === ValueType.number) { if(result.value) { for (let row = area.start.row; row <= area.end.row; row++) { if (!temp[row]) { temp[row] = []; } for (let column = area.start.column; column <= area.end.column; column++) { if (!temp[row][column] ) { temp[row][column] = []; } temp[row][column].push(format.style); } } } } } checklist.push(area); this.conditional_format_checklist.push(area); } } } for (const area of checklist) { this.BleedFlush(area); } this.conditional_format_cache = temp; } private ConditionalFormatForCell(address: ICellAddress): CellStyle[] { if (this.conditional_format_cache[address.row]) { return this.conditional_format_cache[address.row][address.column] || []; } return []; } /** * generates the composite style for the given cell. this * should only be used to generate a cache of styles (Q: really? PERF?) * * the "apply_cell_style" parameter is used for testing when pruning. we * want to check what happens if the cell style is not applied; if nothing * happens, then we can drop the cell style (or the property in the style). */ private CompositeStyleForCell( address: ICellAddress, apply_cell_style = true, apply_row_pattern = true, apply_default = true, apply_conditional = true, ) { const { row, column } = address; const stack: CellStyle[] = []; if (apply_default) { stack.push(this.default_style_properties); } stack.push(this.sheet_style); if (apply_row_pattern && this.row_pattern.length) { stack.push(this.row_pattern[row % this.row_pattern.length]); } if (this.row_styles[row]) { stack.push(this.row_styles[row]); } if (this.column_styles[column]) { stack.push(this.column_styles[column]); } if (apply_cell_style && this.cell_style[column] && this.cell_style[column][row]) { stack.push(this.cell_style[column][row]); } if (apply_conditional) { stack.push(...this.ConditionalFormatForCell(address)); } return Style.Composite(stack); } /** * can we use the rendered JSON as a key, instead? */ private GetStyleIndex(style: CellStyle) { const json = JSON.stringify(style); for (let i = 0; i < this.style_json_map.length; i++) { if (json === this.style_json_map[i]) return i; // match } // ok we need to add it to the list. make sure to add a copy, // and add json to the json index. const new_index = this.style_map.length; this.style_map.push(JSON.parse(json)); this.style_json_map.push(json); return new_index; } }