Files
protoc-frontend/src/feature/excelEngine/excelEngine.ts

657 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
DEFAULT_FUNCTIONS,
ExcelFunction,
VOLATILE_FUNCTIONS,
} from './functions'
// Ограничиваем поиск ячеек диапазоном A1-Z90
const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]([1-9]|[1-8][0-9]|90)\b/g
const LOCAL_REF_RE = /\b[A-Z]([1-9]|[1-8][0-9]|90)\b/g
const RANGE_RE = /\b[A-Z]([1-9]|[1-8][0-9]|90):[A-Z]([1-9]|[1-8][0-9]|90)\b/g
const QUALIFIED_RANGE_RE =
/\b[A-Za-z0-9_]+![A-Z]([1-9]|[1-8][0-9]|90):[A-Z]([1-9]|[1-8][0-9]|90)\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<string> = new Set()
dependents: Set<string> = 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<string, Cell> = 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<string, Cell> {
return this.cells
}
toJSON(): Record<string, CellValue> {
const result: Record<string, CellValue> = {}
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<string, CellValue>): void {
for (const [name, value] of Object.entries(data)) {
this.setCell(name, value)
}
}
}
export class Workbook {
sheets: Map<string, Sheet> = new Map()
private _dirty: Set<string> = new Set()
private _functions: Record<string, ExcelFunction> = {}
constructor() {
// Инициализируем функции через метод addFunction, чтобы обеспечить единообразие
Object.entries(DEFAULT_FUNCTIONS).forEach(([name, func]) => {
this.addFunction(name, func)
})
}
private _volatileCells: Set<string> = 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>): string[] {
const visited = new Set<string>()
const visiting = new Set<string>()
const result: string[] = []
const hasCycle = new Set<string>()
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<string>()
const visiting = new Set<string>()
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<string> {
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<string, ExcelFunction> {
return this._functions
}
// Помечаем все волатильные ячейки как "грязные"
private markAllVolatileCellsDirty(): void {
for (const volatileCell of this._volatileCells) {
this.markDirty(volatileCell)
}
}
// Сериализация
toJSON(): Record<string, Record<string, CellValue>> {
const result: Record<string, Record<string, CellValue>> = {}
for (const [name, sheet] of this.sheets) {
result[name] = sheet.toJSON()
}
return result
}
fromJSON(data: Record<string, Record<string, CellValue>>): 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 }