ручной рефакторинг

This commit is contained in:
2025-07-28 17:03:01 +03:00
parent d8e6dcd7d5
commit b98dfeef20
55 changed files with 844 additions and 1416 deletions

View File

@@ -0,0 +1,676 @@
import {
DEFAULT_FUNCTIONS,
ExcelFunction,
VOLATILE_FUNCTIONS,
} from './functions'
// Регулярные выражения для ссылок
const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+\b/g // SheetName!A1
const LOCAL_REF_RE = /\b[A-Z]+[0-9]+\b/g // A1, B2, etc.
const RANGE_RE = /\b[A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g // A1:B2, D5:D7, etc.
const QUALIFIED_RANGE_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g // SheetName!A1:B2
// Типы для значений ячеек
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.getCell(match).evaluate()
// Только строки оборачиваем в кавычки, числа и булевы значения - нет
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.getCell(qualifiedRef).evaluate()
// Только строки оборачиваем в кавычки, числа и булевы значения - нет
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.getCell(qualifiedName).evaluate()
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.has(qualifiedName)) {
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()
}
}
removeFunction(name: string): void {
delete this._functions[name.toUpperCase()]
}
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 columnToIndex(column: string): number {
let result = 0
for (let i = 0; i < column.length; i++) {
result = result * 26 + (column.charCodeAt(i) - 65 + 1)
}
return result - 1
}
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 parseCellName(cellName: string): { column: string; row: number } {
const match = cellName.match(/^([A-Z]+)(\d+)$/)
if (!match) {
throw new Error(`Invalid cell name: ${cellName}`)
}
return {
column: match[1],
row: parseInt(match[2], 10),
}
}
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 }

View File

@@ -0,0 +1,121 @@
export type CellValue = string | number | boolean | null | undefined
export interface ExcelFunction {
(...args: any[]): CellValue
}
// Волатильные функции - пересчитываются при любом изменении
export const VOLATILE_FUNCTIONS = new Set(['СЛУЧ', 'СЛУЧМЕЖДУ', 'СЛУЧДР'])
export const DEFAULT_FUNCTIONS: Record<string, ExcelFunction> = {
СЛУЧ: () => Math.random(),
СЛУЧМЕЖДУ: (низ: number, верх: number) =>
Math.floor(Math.random() * (верх - низ + 1)) + низ,
СЛУЧДР: (низ: number, верх: number, знаков: number = 0) => {
// Проверка валидности входных параметров
if (
typeof низ !== 'number' ||
typeof верх !== 'number' ||
typeof знаков !== 'number'
) {
return '#ЗНАЧ!' // Ошибка значения
}
if (!isFinite(низ) || !isFinite(верх) || !isFinite(знаков)) {
return '#ЧИСЛО!' // Ошибка числа
}
// Если низ больше верха, меняем местами
if (низ > верх) {
;[низ, верх] = [верх, низ]
}
// Если низ равен верху, возвращаем это значение
if (низ === верх) {
const множитель = Math.pow(10, Math.max(0, Math.floor(знаков)))
return Math.round(низ * множитель) / множитель
}
// Ограничиваем количество знаков после запятой (максимум 15 для точности JavaScript)
const ограниченныеЗнаки = Math.max(0, Math.min(15, Math.floor(знаков)))
// Генерируем случайное число
const случайное = Math.random() * (верх - низ) + низ
const множитель = Math.pow(10, ограниченныеЗнаки)
// Округляем и возвращаем результат
const результат = Math.round(случайное * множитель) / множитель
// Проверяем, что результат в допустимых пределах
return Math.max(низ, Math.min(верх, результат))
},
СУММ: (...args: any[]) => {
const flatArgs = args.flat()
return flatArgs.reduce(
(sum, val) => sum + (typeof val === 'number' ? val : 0),
0
)
},
СРЗНАЧ: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter(val => typeof val === 'number')
return validArgs.length > 0
? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length
: 0
},
СЧЁТ: (...args: any[]) => {
const flatArgs = args.flat()
return flatArgs.filter(val => typeof val === 'number').length
},
МАКС: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter(val => typeof val === 'number')
return validArgs.length > 0 ? Math.max(...validArgs) : 0
},
МИН: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter(val => typeof val === 'number')
return validArgs.length > 0 ? Math.min(...validArgs) : 0
},
ЕСЛИ: (условие: boolean, истина: CellValue, ложь: CellValue) =>
условие ? истина : ложь,
И: (...args: boolean[]) => args.every(Boolean),
ИЛИ: (...args: boolean[]) => args.some(Boolean),
НЕ: (value: boolean) => !value,
СЦЕПИТЬ: (...args: string[]) => args.join(''),
ДЛСТР: (текст: string) => (текст ? текст.length : 0),
ПРОПИСН: (текст: string) => (текст ? текст.toUpperCase() : ''),
СТРОЧН: (текст: string) => (текст ? текст.toLowerCase() : ''),
СЖПРОБЕЛЫ: (текст: string) => (текст ? текст.trim() : ''),
// Функции округления
ОКРУГЛ: (число: number, разряды: number = 0) => {
const factor = Math.pow(10, разряды)
return Math.round(число * factor) / factor
},
ОКРУГЛВВЕРХ: (число: number, разряды: number = 0) => {
const factor = Math.pow(10, разряды)
return Math.ceil(число * factor) / factor
},
ОКРУГЛВНИЗ: (число: number, разряды: number = 0) => {
const factor = Math.pow(10, разряды)
return Math.floor(число * factor) / factor
},
ОКРВВЕРХ: (число: number, кратное: number = 1) => {
if (кратное === 0) return 0
return Math.ceil(число / кратное) * кратное
},
ОКРВНИЗ: (число: number, кратное: number = 1) => {
if (кратное === 0) return 0
return Math.floor(число / кратное) * кратное
},
ЦЕЛОЕ: (число: number) => Math.floor(число),
ОТБР: (число: number, разряды: number = 0) => {
const factor = Math.pow(10, разряды)
return Math.trunc(число * factor) / factor
},
ОКРУГЛТ: (число: number, кратное: number) => {
if (кратное === 0) return 0
return Math.round(число / кратное) * кратное
},
}