657 lines
21 KiB
TypeScript
657 lines
21 KiB
TypeScript
import {
|
||
DEFAULT_FUNCTIONS,
|
||
ExcelFunction,
|
||
VOLATILE_FUNCTIONS,
|
||
} from './functions'
|
||
|
||
// Ограничиваем поиск ячеек диапазоном A1-Z120
|
||
const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+\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)\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 }
|