import { DEFAULT_FUNCTIONS, ExcelFunction, VOLATILE_FUNCTIONS, } from './functions' // Ограничиваем поиск ячеек диапазоном A1-Z120 const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]([1-9]|[1-8][0-9]|150)\b/g const LOCAL_REF_RE = /\b[A-Z]([1-9]|[1-8][0-9]|150)\b/g const RANGE_RE = /\b[A-Z]([1-9]|[1-8][0-9]|150):[A-Z]([1-9]|[1-8][0-9]|150)\b/g const QUALIFIED_RANGE_RE = /\b[A-Za-z0-9_]+![A-Z]([1-9]|[1-8][0-9]|150):[A-Z]([1-9]|[1-8][0-9]|150)\b/g // Типы для значений ячеек export type CellValue = string | number | boolean | null | undefined // Утилитная функция для устранения машинной ошибки в числах с плавающей точкой // Применяется только при финальном выводе значений function fixFloatingPointError(value: CellValue): CellValue { if (typeof value === 'number' && isFinite(value)) { // Проверяем, является ли число "почти целым" или "почти простой дробью" const tolerance = 1e-10 // Проверка на целое число if (Math.abs(value - Math.round(value)) < tolerance) { return Math.round(value) } // Проверка на простые дроби (до 6 знаков после запятой) for (let precision = 1; precision <= 6; precision++) { const factor = Math.pow(10, precision) const rounded = Math.round(value * factor) / factor if (Math.abs(value - rounded) < tolerance) { return rounded } } // Для остальных случаев ограничиваем до разумного количества знаков return Math.round(value * 1e10) / 1e10 } return value } export class Cell { sheet: Sheet name: string raw: CellValue = null value: CellValue = null precedents: Set = new Set() dependents: Set = new Set() isVolatile: boolean = false constructor(sheet: Sheet, name: string) { this.sheet = sheet this.name = name } get qualifiedName(): string { return `${this.sheet.name}!${this.name}` } set(raw: CellValue): void { // Удаляем старые зависимости for (const ref of this.precedents) { this.sheet.workbook.getCell(ref).dependents.delete(this.qualifiedName) } // Убираем ячейку из списка волатильных если она там была if (this.isVolatile) { this.sheet.workbook.removeVolatileCell(this.qualifiedName) this.isVolatile = false } this.raw = raw this.precedents.clear() // Помечаем себя и всех зависимых ячеек "грязными" this.sheet.workbook.markDirty(this.qualifiedName) } evaluate(): CellValue { // Если не формула — просто возвращаем значение if (typeof this.raw !== 'string' || !this.raw.startsWith('=')) { this.value = this.raw return this.value } // Убираем знак равенства const expr = this.raw.substring(1) // Проверяем, содержит ли формула волатильные функции this.checkVolatileFunctions(expr) // Регистрируем зависимости на другие ячейки // Сначала обрабатываем квалифицированные ссылки (SheetName!A1) const qualifiedMatches = expr.match(QUALIFIED_REF_RE) if (qualifiedMatches) { for (const ref of qualifiedMatches) { this.precedents.add(ref) const cell = this.sheet.workbook.getCell(ref) cell.dependents.add(this.qualifiedName) } } // Затем обрабатываем локальные ссылки (A1, B2) const localMatches = expr.match(LOCAL_REF_RE) if (localMatches) { for (const ref of localMatches) { // Избегаем конфликтов с функциями (например, SUM, MAX) if (!this.isFunction(ref)) { const qualifiedRef = `${this.sheet.name}!${ref}` this.precedents.add(qualifiedRef) const cell = this.sheet.workbook.getCell(qualifiedRef) cell.dependents.add(this.qualifiedName) } } } try { // Заменяем ссылки на вычисленные значения let code = expr // ВАЖНО: Сначала заменяем диапазоны ячеек на массивы значений, // затем отдельные ячейки, чтобы избежать конфликтов code = code.replace(QUALIFIED_RANGE_RE, match => { const [sheetName, range] = match.split('!') const values = this.expandRange(range, sheetName) return `[${values.join(',')}]` }) code = code.replace(RANGE_RE, match => { const values = this.expandRange(match) return `[${values.join(',')}]` }) // Заменяем квалифицированные ссылки на ячейки code = code.replace(QUALIFIED_REF_RE, match => { try { const [sheetName] = match.split('!') const sheet = this.sheet.workbook.getSheet(sheetName) if (!sheet) { return '"#REF!"' } const cellValue = this.sheet.workbook.getValue(match) // Только строки оборачиваем в кавычки, числа и булевы значения - нет if (typeof cellValue === 'string') { return `"${cellValue}"` } else if ( typeof cellValue === 'number' || typeof cellValue === 'boolean' ) { return String(cellValue) } else { return '0' } } catch (error) { return '"#REF!"' } }) // Заменяем локальные ссылки на ячейки code = code.replace(LOCAL_REF_RE, match => { // Избегаем замены функций if (this.isFunction(match)) { return match } try { const qualifiedRef = `${this.sheet.name}!${match}` const cellValue = this.sheet.workbook.getValue(qualifiedRef) // Только строки оборачиваем в кавычки, числа и булевы значения - нет if (typeof cellValue === 'string') { return `"${cellValue}"` } else if ( typeof cellValue === 'number' || typeof cellValue === 'boolean' ) { return String(cellValue) } else { return '0' } } catch (error) { return '"#REF!"' } }) // Заменяем функции Excel const functions = this.sheet.workbook.getFunctions() for (const [funcName] of Object.entries(functions)) { // Экранируем специальные символы в названии функции для регулярного выражения const escapedFuncName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Для кириллических символов \b может не работать, используем более универсальное решение const funcRegex = new RegExp( `(?:^|[^\\w])(${escapedFuncName})\\s*\\(`, 'gi' ) const before = code code = code.replace(funcRegex, (match, p1) => { // p1 - это название функции, нужно сохранить символ перед функцией если он есть const prefix = match.substring(0, match.indexOf(p1)) return `${prefix}__functions__.${funcName}(` }) if (before !== code && expr.includes('ТЕСТ')) { // console.log(`Replaced ${funcName}:`, before, '->', code) } } // Создаём контекст для выполнения const context = { __functions__: functions, wb: this.sheet.workbook, } // Выполняем код в безопасном контексте this.value = this.evaluateExpression(code, context) return this.value } catch (error) { console.error(`Error evaluating formula in ${this.qualifiedName}:`, error) this.value = '#ERROR!' return this.value } } private checkVolatileFunctions(expr: string): void { // Проверяем, содержит ли формула волатильные функции const functions = this.sheet.workbook.getFunctions() let hasVolatileFunction = false for (const funcName of Object.keys(functions)) { if (VOLATILE_FUNCTIONS.has(funcName)) { // Экранируем специальные символы для регулярного выражения const escapedFuncName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Для кириллических символов используем более универсальное решение const funcRegex = new RegExp( `(?:^|[^\\w])${escapedFuncName}\\s*\\(`, 'gi' ) if (funcRegex.test(expr)) { hasVolatileFunction = true break } } } if (hasVolatileFunction && !this.isVolatile) { this.isVolatile = true this.sheet.workbook.addVolatileCell(this.qualifiedName) } else if (!hasVolatileFunction && this.isVolatile) { this.isVolatile = false this.sheet.workbook.removeVolatileCell(this.qualifiedName) } } private isFunction(ref: string): boolean { const functions = this.sheet.workbook.getFunctions() return Object.keys(functions).includes(ref.toUpperCase()) } private evaluateExpression(expr: string, context: any): CellValue { try { // Создаём функцию с контекстом const func = new Function(...Object.keys(context), `return ${expr}`) const result = func(...Object.values(context)) return result } catch (error) { throw new Error(`Invalid expression: ${expr}`) } } // Функция для развертывания диапазона в массив значений private expandRange(range: string, sheetName?: string): number[] { const parts = range.split(':') if (parts.length !== 2) return [] const startCell = parts[0] const endCell = parts[1] const startCol = startCell.match(/[A-Z]+/)?.[0] || '' const startRow = parseInt(startCell.match(/[0-9]+/)?.[0] || '0') const endCol = endCell.match(/[A-Z]+/)?.[0] || '' const endRow = parseInt(endCell.match(/[0-9]+/)?.[0] || '0') const startColIndex = startCol.charCodeAt(0) - 65 const endColIndex = endCol.charCodeAt(0) - 65 const values: number[] = [] for (let row = startRow; row <= endRow; row++) { for (let col = startColIndex; col <= endColIndex; col++) { const cellName = String.fromCharCode(65 + col) + row const qualifiedName = sheetName ? `${sheetName}!${cellName}` : `${this.sheet.name}!${cellName}` const cellValue = this.sheet.workbook.getValue(qualifiedName) const numValue = typeof cellValue === 'number' ? cellValue : parseFloat(String(cellValue)) || 0 values.push(numValue) } } return values } } export class Sheet { name: string workbook: Workbook cells: Map = new Map() constructor(name: string, workbook: Workbook) { this.name = name this.workbook = workbook } getCell(name: string): Cell { if (!this.cells.has(name)) { this.cells.set(name, new Cell(this, name)) } return this.cells.get(name)! } setCell(name: string, raw: CellValue): void { const cell = this.getCell(name) cell.set(raw) } getCellValue(name: string): CellValue { return this.getCell(name).value } getAllCells(): Map { return this.cells } toJSON(): Record { const result: Record = {} for (const [name, cell] of this.cells) { if (cell.raw !== null && cell.raw !== undefined && cell.raw !== '') { result[name] = cell.raw } } return result } fromJSON(data: Record): void { for (const [name, value] of Object.entries(data)) { this.setCell(name, value) } } } export class Workbook { sheets: Map = new Map() private _dirty: Set = new Set() private _functions: Record = {} constructor() { // Инициализируем функции через метод addFunction, чтобы обеспечить единообразие Object.entries(DEFAULT_FUNCTIONS).forEach(([name, func]) => { this.addFunction(name, func) }) } private _volatileCells: Set = new Set() // Список волатильных ячеек addSheet(name: string): Sheet { const sheet = new Sheet(name, this) this.sheets.set(name, sheet) return sheet } getSheet(name: string): Sheet | undefined { return this.sheets.get(name) } getCell(qualifiedName: string): Cell { const [sheetName, cellName] = qualifiedName.split('!') const sheet = this.sheets.get(sheetName) if (!sheet) { throw new Error(`Sheet "${sheetName}" not found`) } return sheet.getCell(cellName) } setCell(qualifiedName: string, raw: CellValue): void { const cell = this.getCell(qualifiedName) cell.set(raw) } markDirty(qualifiedName: string): void { const toVisit = new Set([qualifiedName]) while (toVisit.size > 0) { const current = toVisit.values().next().value! toVisit.delete(current) if (!this._dirty.has(current)) { this._dirty.add(current) // Добавляем всех зависимых ячеек for (const dep of this.getCell(current).dependents) { toVisit.add(dep) } } } // Также помечаем все волатильные ячейки как "грязные" for (const volatileCell of this._volatileCells) { if (!this._dirty.has(volatileCell)) { this._dirty.add(volatileCell) // Добавляем всех зависимых от волатильных ячеек for (const dep of this.getCell(volatileCell).dependents) { toVisit.add(dep) } } } // Обрабатываем зависимых от волатильных ячеек while (toVisit.size > 0) { const current = toVisit.values().next().value! toVisit.delete(current) if (!this._dirty.has(current)) { this._dirty.add(current) // Добавляем всех зависимых ячеек for (const dep of this.getCell(current).dependents) { toVisit.add(dep) } } } } private topologicalSort(cells: Set): string[] { const visited = new Set() const visiting = new Set() const result: string[] = [] const hasCycle = new Set() const visit = (cellName: string): boolean => { if (visiting.has(cellName)) { // Обнаружена циклическая зависимость hasCycle.add(cellName) return false } if (visited.has(cellName)) { return true } visiting.add(cellName) try { const cell = this.getCell(cellName) // Посещаем всех предшественников (ячейки, от которых зависит текущая) for (const precedent of cell.precedents) { if (cells.has(precedent)) { if (!visit(precedent)) { hasCycle.add(cellName) } } } } catch (error) { // Ячейка не существует или другая ошибка - игнорируем } visiting.delete(cellName) visited.add(cellName) result.push(cellName) return true } // Посещаем все ячейки for (const cellName of cells) { if (!visited.has(cellName)) { visit(cellName) } } // Если обнаружены циклы, выводим предупреждение if (hasCycle.size > 0) { console.warn( 'Обнаружены циклические зависимости в ячейках:', Array.from(hasCycle) ) } return result } private detectCycle(startCell: string): boolean { const visited = new Set() const visiting = new Set() const visit = (cellName: string): boolean => { if (visiting.has(cellName)) { return true // Цикл найден } if (visited.has(cellName)) { return false } visiting.add(cellName) try { const cell = this.getCell(cellName) for (const precedent of cell.precedents) { if (visit(precedent)) { return true } } } catch (error) { // Ячейка не существует - игнорируем } visiting.delete(cellName) visited.add(cellName) return false } return visit(startCell) } recalc(): void { if (this._dirty.size === 0) { return } // Выполняем топологическую сортировку для определения правильного порядка пересчета const sortedCells = this.topologicalSort(this._dirty) // Очищаем множество "грязных" ячеек this._dirty.clear() // Пересчитываем ячейки в правильном порядке for (const cellName of sortedCells) { try { const cell = this.getCell(cellName) // Проверяем на циклические зависимости перед вычислением if (this.detectCycle(cellName)) { console.error( `Циклическая зависимость обнаружена для ячейки ${cellName}` ) cell.value = '#CYCLE!' } else { cell.evaluate() } } catch (error) { console.error(`Ошибка при пересчете ячейки ${cellName}:`, error) try { const cell = this.getCell(cellName) cell.value = '#ERROR!' } catch (getError) { // Ячейка не существует - игнорируем } } } } getValue(qualifiedName: string): CellValue { // Исправление: пересчитываем если ВООБЩЕ есть грязные ячейки, // а не только если конкретная запрашиваемая ячейка грязная if (this._dirty.size > 0) { this.recalc() } const value = this.getCell(qualifiedName).value return fixFloatingPointError(value) } // Управление волатильными ячейками addVolatileCell(qualifiedName: string): void { this._volatileCells.add(qualifiedName) } removeVolatileCell(qualifiedName: string): void { this._volatileCells.delete(qualifiedName) } getVolatileCells(): Set { return new Set(this._volatileCells) } // Управление функциями addFunction(name: string, func: ExcelFunction): void { this._functions[name.toUpperCase()] = func // Если добавляем волатильную функцию, обновляем список if (VOLATILE_FUNCTIONS.has(name.toUpperCase())) { this.markAllVolatileCellsDirty() } } getFunctions(): Record { return this._functions } // Помечаем все волатильные ячейки как "грязные" private markAllVolatileCellsDirty(): void { for (const volatileCell of this._volatileCells) { this.markDirty(volatileCell) } } // Сериализация toJSON(): Record> { const result: Record> = {} for (const [name, sheet] of this.sheets) { result[name] = sheet.toJSON() } return result } fromJSON(data: Record>): void { for (const [sheetName, sheetData] of Object.entries(data)) { if (!this.sheets.has(sheetName)) { this.addSheet(sheetName) } this.sheets.get(sheetName)!.fromJSON(sheetData) } } // Очистка всех данных clear(): void { this.sheets.clear() this._dirty.clear() this._volatileCells.clear() } } // Утилиты для работы с координатами ячеек export class CellUtils { static indexToColumn(index: number): string { let result = '' while (index >= 0) { result = String.fromCharCode(65 + (index % 26)) + result index = Math.floor(index / 26) - 1 } return result } static createCellName(column: string | number, row: number): string { const col = typeof column === 'number' ? CellUtils.indexToColumn(column) : column return `${col}${row}` } } // Экспорт для удобства использования export { DEFAULT_FUNCTIONS, VOLATILE_FUNCTIONS }