from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
if TYPE_CHECKING:
from edgar.files.html import BaseNode
import re
from functools import lru_cache
from edgar.richtools import rich_to_text
@dataclass
class ProcessedTable:
"""Represents a processed table ready for rendering"""
headers: Optional[list[str]]
data_rows: list[list[str]]
column_alignments: list[str] # "left" or "right" for each column
# Looks for actual numeric data values, currency, or calculations
data_indicators = [
r'\$\s*\d', # Currency with numbers
r'\d+(?:,\d{3})+', # Numbers with thousands separators
r'\d+\s*[+\-*/]\s*\d+', # Basic calculations
r'\(\s*\d+(?:,\d{3})*\s*\)', # Parenthesized numbers
]
data_pattern = '|'.join(data_indicators)
def is_number(s: str) -> bool:
"""
Check if a string represents a number in common financial formats.
Handles:
- Regular numbers (123, -123, 123.45)
- Currency ($123, $123.45)
- Parenthetical negatives ((123), (123.45))
- Thousands separators (1,234, 1,234.56)
- Mixed formats ($1,234.56)
- Various whitespace
- En/Em dashes for negatives
- Multiple decimal formats (123.45, 123,45)
Args:
s: String to check
Returns:
bool: True if string represents a valid number
"""
if not s or s.isspace():
return False
# Convert unicode minus/dash characters to regular minus
s = s.replace('−', '-').replace('–', '-').replace('—', '-')
# Handle parenthetical negatives
s = s.strip()
if s.startswith('(') and s.endswith(')'):
s = '-' + s[1:-1]
# Remove currency symbols and whitespace
s = s.replace('$', '').replace(' ', '')
# Handle European number format (convert 123,45 to 123.45)
if ',' in s and '.' not in s and len(s.split(',')[1]) == 2:
s = s.replace(',', '.')
else:
# Remove thousands separators
s = s.replace(',', '')
try:
float(s)
return True
except ValueError:
return False
class TableProcessor:
@staticmethod
def process_table(node) -> Optional[ProcessedTable]:
"""Process table node into a format ready for rendering"""
if not isinstance(node.content, list) or not node.content:
return None
def process_cell_content(content: Union[str, 'BaseNode']) -> str:
"""Process cell content to handle HTML breaks and cleanup"""
if isinstance(content, str):
content = content.replace('
', '\n').replace('
', '\n')
lines = [line.strip() for line in content.split('\n')]
return '\n'.join(line for line in lines if line)
else:
# Recursively process nested nodes
processed_table = content.render(500)
return rich_to_text(processed_table)
# Process all rows into virtual columns
virtual_rows = []
max_cols = max(sum(cell.colspan for cell in row.cells) for row in node.content)
# Convert all rows to virtual columns first
for row in node.content:
virtual_row = [""] * max_cols
current_col = 0
for cell in row.cells:
content = process_cell_content(cell.content)
if '\n' not in content and cell.is_currency and content.replace(',', '').replace('.', '').isdigit():
content = f"${float(content.replace(',', '')):,.2f}"
if cell.colspan > 1:
virtual_row[current_col + 1] = content
else:
virtual_row[current_col] = content
current_col += cell.colspan
virtual_rows.append(virtual_row)
# Analyze and remove empty columns
empty_cols = []
for col in range(max_cols):
if all(row[col].strip() == "" for row in virtual_rows):
empty_cols.append(col)
# Process empty columns
cols_to_remove = TableProcessor._get_columns_to_remove(empty_cols, max_cols)
# Create optimized rows, filtering out empty ones
optimized_rows = []
for virtual_row in virtual_rows:
has_content = any(col.strip() for col in virtual_row)
if not has_content:
continue
optimized_row = [col for idx, col in enumerate(virtual_row) if idx not in cols_to_remove]
optimized_rows.append(optimized_row)
if not optimized_rows:
return None
# Detect headers
header_rows, data_start_idx = TableProcessor._analyze_table_structure(optimized_rows)
# Detect and fix misalignment in all rows
fixed_rows = TableProcessor._detect_and_fix_misalignment(optimized_rows, data_start_idx)
# Use the fixed header portion for processing headers
headers = None
if header_rows:
fixed_headers = fixed_rows[:data_start_idx] # Take header portion from fixed rows
headers = TableProcessor._merge_header_rows(fixed_headers)
# Determine column alignments
col_count = len(optimized_rows[0])
alignments = TableProcessor._determine_column_alignments(
optimized_rows, data_start_idx, col_count)
# Format data rows
formatted_rows = TableProcessor._format_data_rows(
optimized_rows[data_start_idx:])
return ProcessedTable(
headers=headers,
data_rows=formatted_rows,
column_alignments=alignments
)
@staticmethod
def _is_date_header(text: str) -> bool:
"""Detect if text looks like a date header (year, quarter, month)"""
text = text.lower().strip()
# Year patterns
if text.isdigit() and len(text) == 4:
return True
# Quarter patterns
quarter_patterns = ['q1', 'q2', 'q3', 'q4', 'first quarter', 'second quarter',
'third quarter', 'fourth quarter']
if any(pattern in text for pattern in quarter_patterns):
return True
# Month patterns
months = ['january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december',
'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
return any(month in text for month in months)
@staticmethod
def _analyze_row(row: list) -> dict:
"""Analyze characteristics of a row"""
return {
'empty_first': not bool(row[0].strip()),
'date_headers': sum(1 for cell in row if TableProcessor._is_date_header(cell)),
'financial_values': sum(1 for i, cell in enumerate(row)
if TableProcessor._is_financial_value(cell, row, i)),
'financial_metrics': sum(1 for cell in row if TableProcessor._is_financial_metric(cell)),
'empty_cells': sum(1 for cell in row if not cell.strip()),
'dollar_signs': sum(1 for cell in row if cell.strip() == '$'),
'total_cells': len(row)
}
@staticmethod
@lru_cache(maxsize=None)
def _get_period_header_pattern() -> re.Pattern:
"""Create regex pattern for common financial period headers"""
# Base components
periods = r'(?:three|six|nine|twelve|[1-4]|first|second|third|fourth)'
timeframes = r'(?:month|quarter|year|week)'
ended_variants = r'(?:ended|ending|end|period)'
as_of_variants = r'(?:as\s+of|at|as\s+at)'
# Enhanced date pattern
months = r'(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)'
day = r'\d{1,2}'
year = r'(?:19|20)\d{2}'
date = fr'{months}\s*\.?\s*{day}\s*,?\s*{year}'
# Combine into patterns
patterns = [
# Standard period headers
fr'{periods}\s+{timeframes}\s+{ended_variants}(?:\s+{date})?',
fr'(?:fiscal\s+)?{timeframes}\s+{ended_variants}',
fr'{timeframes}\s+{ended_variants}(?:\s+{date})?',
# Balance sheet date headers
fr'{as_of_variants}\s+{date}',
# Multiple dates in sequence (common in headers)
fr'{date}(?:\s*(?:and|,)\s*{date})*',
# Single date with optional period specification
fr'(?:{ended_variants}\s+)?{date}'
]
# Combine all patterns
combined_pattern = '|'.join(f'(?:{p})' for p in patterns)
return re.compile(combined_pattern, re.IGNORECASE)
@staticmethod
def _contains_data(self, text):
# Check if the string contains data indicators
return bool(re.search(data_pattern, text))
@staticmethod
def _analyze_table_structure(rows: list) -> tuple[list, int]:
"""
Analyze table structure to determine headers and data rows.
Returns (header_rows, data_start_index)
"""
if not rows:
return [], 0
row_analyses = [TableProcessor._analyze_row(row) for row in rows[:4]]
period_pattern = TableProcessor._get_period_header_pattern()
# Pattern 1: Look for period headers
for i, row in enumerate(rows[:3]): # Check first 4 rows
header_text = ' '.join(cell.strip() for cell in row).lower()
has_period_header = period_pattern.search(header_text)
contains_data = bool(re.search(data_pattern, header_text))
if has_period_header and not contains_data:
# Found a period header, check if next row has years or is part of header
if i + 1 < len(rows):
next_row = rows[i + 1]
next_text = ' '.join(cell.strip() for cell in next_row)
# Check if next row has years or quarter references
if (any(str(year) in next_text for year in range(2010, 2030)) or
any(q in next_text.lower() for q in ['q1', 'q2', 'q3', 'q4'])):
return rows[:i + 2], i + 2
return rows[:i + 1], i + 1
# Pattern 2: $ symbols in their own columns
for i, analysis in enumerate(row_analyses):
if analysis['dollar_signs'] > 0:
# If we see $ symbols, previous row might be header
if i > 0:
return rows[:i], i
return [], i
# Pattern 3: Look for transition from text to numbers with $ alignment
for i in range(len(rows) - 1):
curr_analysis = TableProcessor._analyze_row(rows[i])
next_analysis = TableProcessor._analyze_row(rows[i + 1])
if (curr_analysis['financial_values'] == 0 and
next_analysis['financial_values'] > 0 and
next_analysis['dollar_signs'] > 0):
return rows[:i + 1], i + 1
# Default to no headers if no clear pattern found
return [], 0
# In TableProcessor class
@staticmethod
def _detect_and_fix_misalignment(virtual_rows: list[list[str]], data_start_idx: int) -> list[list[str]]:
"""
Detect and fix misalignment between date headers and numeric data columns.
Returns corrected virtual rows.
"""
if not virtual_rows or data_start_idx >= len(virtual_rows):
return virtual_rows
# Get header row (assumes dates are in the last header row)
header_idx = data_start_idx - 1
if header_idx < 0:
return virtual_rows
header_row = virtual_rows[data_start_idx - 1]
# Find date columns in header
date_columns = []
for i, cell in enumerate(header_row):
if TableProcessor._is_date_header(cell):
date_columns.append(i)
if not date_columns:
return virtual_rows # No date headers found
# Find numeric columns in first few data rows
numeric_columns = set()
for row in virtual_rows[data_start_idx:data_start_idx + 3]: # Check first 3 data rows
for i, cell in enumerate(row):
if TableProcessor._is_financial_value(cell, row, i):
numeric_columns.add(i)
# Detect misalignment
if date_columns and numeric_columns:
# Check if dates are shifted right compared to numeric columns
dates_shifted = all(
(i + 1) in numeric_columns
for i in date_columns
)
if dates_shifted:
# Fix alignment by shifting only the row containing dates
fixed_rows = virtual_rows.copy()
# Find and fix only the row containing the dates
for row_idx, row in enumerate(virtual_rows):
if row_idx < data_start_idx: # Only check header rows
# Check if this row contains the dates by counting date headers
date_count = sum(1 for cell in row if TableProcessor._is_date_header(cell))
if date_count >= 2: # If multiple dates found, this is our target row
new_row = [""] * len(row) # Start with empty row
for i in range(len(row) - 1):
new_row[i + 1] = row[i] # Copy each value one position right
fixed_rows[row_idx] = new_row
break # Only fix one row
return fixed_rows
return virtual_rows
@staticmethod
def _get_columns_to_remove(empty_cols: list[int], max_cols: int) -> set[int]:
cols_to_remove = set()
# Handle leading empty columns
for col in range(max_cols):
if col in empty_cols:
cols_to_remove.add(col)
else:
break
# Handle trailing empty columns
for col in reversed(range(max_cols)):
if col in empty_cols:
cols_to_remove.add(col)
else:
break
# Handle consecutive empty columns in the middle
i = 0
while i < max_cols - 1:
if i in empty_cols and (i + 1) in empty_cols:
consecutive_empty = 0
j = i
while j < max_cols and j in empty_cols:
consecutive_empty += 1
j += 1
cols_to_remove.update(range(i + 1, i + consecutive_empty))
i = j
else:
i += 1
return cols_to_remove
@staticmethod
def _merge_header_rows(header_rows: list[list[str]]) -> list[str]:
"""Merge multiple header rows into one"""
if not header_rows:
return []
merged = []
for col_idx in range(len(header_rows[0])):
parts = []
for row in header_rows:
text = row[col_idx].strip()
if text and text != '$': # Skip empty cells and lone $ symbols
parts.append(text)
merged.append('\n'.join(parts))
return merged
@staticmethod
def _determine_column_alignments(rows: list[list[str]],
data_start_idx: int,
col_count: int) -> list[str]:
"""Determine alignment for each column"""
alignments = []
for col_idx in range(col_count):
# First column always left-aligned
if col_idx == 0:
alignments.append("left")
continue
# Check if column contains numbers
is_numeric = False
for row in rows[data_start_idx:]:
cell = row[col_idx].strip()
if cell and cell != '$':
if TableProcessor._is_financial_value(cell, row, col_idx):
is_numeric = True
break
alignments.append("right" if is_numeric else "left")
return alignments
@staticmethod
def _is_financial_value(text: str, row: list, col_idx: int) -> bool:
"""
Check if text represents a financial value, considering layout context
Takes the full row and column index to check for adjacent $ symbols
"""
text = text.strip()
# If it's a $ symbol by itself, not a financial value
if text == '$':
return False
# Check if it's a number
is_numeric = is_number(text)
if not is_numeric:
return False
# Look for $ in adjacent columns (considering empty columns in between)
# Look left for $
left_idx = col_idx - 1
while left_idx >= 0:
left_cell = row[left_idx].strip()
if left_cell == '$':
return True
elif left_cell: # If we hit any non-empty cell that's not $, stop looking
break
left_idx -= 1
return is_numeric # If we found a number but no $, still treat as financial value
@staticmethod
def _is_financial_metric(text: str) -> bool:
"""Check if text represents a common financial metric"""
text = text.lower().strip()
metrics = [
'revenue', 'sales', 'income', 'earnings', 'profit', 'loss',
'assets', 'liabilities', 'equity', 'cash', 'expenses',
'cost', 'margin', 'ebitda', 'eps', 'shares', 'tax',
'operating', 'net', 'gross', 'total', 'capital',
'depreciation', 'amortization', 'interest', 'debt'
]
return any(metric in text for metric in metrics)
@staticmethod
def _format_data_rows(rows: list[list[str]]) -> list[list[str]]:
"""Format data rows for display"""
formatted_rows = []
for row in rows:
formatted_row = []
for col_idx, cell in enumerate(row):
content = cell.strip()
if col_idx > 0: # Don't format first column
# Handle parenthesized numbers
if content.startswith('(') and content.endswith(')'):
content = f"-{content[1:-1]}"
formatted_row.append(content)
formatted_rows.append(formatted_row)
return formatted_rows
class ColumnOptimizer:
"""Optimizes column widths for table rendering"""
def __init__(self, total_width: int = 100, min_data_col_width: int = 15,
max_left_col_ratio: float = 0.5, target_left_col_ratio: float = 0.4):
self.total_width = total_width
self.min_data_col_width = min_data_col_width
self.max_left_col_ratio = max_left_col_ratio # Maximum portion of total width for left column
self.target_left_col_ratio = target_left_col_ratio # Target portion for left column
def _measure_content_width(self, content: str) -> int:
"""Measure the display width of content, handling multiline text"""
if not content:
return 0
lines = content.split('\n')
return max(len(line) for line in lines)
def _wrap_text(self, text: str, max_width: int) -> str:
"""
Wrap text to specified width, preserving existing line breaks and word boundaries.
If text already contains line breaks, preserve the original formatting.
"""
if not text or len(text) <= max_width:
return text
# If text already contains line breaks, preserve them
if '\n' in text:
return text
# Special handling for financial statement line items
if ',' in text and ':' in text:
# Split into main description and details
parts = text.split(':', 1)
if len(parts) == 2:
desc, details = parts
wrapped_desc = self._wrap_text(desc.strip(), max_width)
wrapped_details = self._wrap_text(details.strip(), max_width)
return f"{wrapped_desc}:\n{wrapped_details}"
words = text.split()
lines = []
current_line = []
current_length = 0
for word in words:
word_length = len(word)
# Handle very long words
if word_length > max_width:
# If we have a current line, add it first
if current_line:
lines.append(' '.join(current_line))
current_line = []
current_length = 0
# Split long word across lines
while word_length > max_width:
lines.append(word[:max_width - 1] + '-')
word = word[max_width - 1:]
word_length = len(word)
if word:
current_line = [word]
current_length = word_length
continue
if current_length + word_length + (1 if current_line else 0) <= max_width:
current_line.append(word)
current_length += word_length + (1 if current_length else 0)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
current_length = word_length
if current_line:
lines.append(' '.join(current_line))
return '\n'.join(lines)
def optimize_columns(self, table: ProcessedTable) -> tuple[list[int], ProcessedTable]:
"""
Optimize column widths and wrap text as needed.
Returns (column_widths, modified_table)
"""
col_count = len(table.data_rows[0]) if table.data_rows else 0
if not col_count:
return [], table
# Calculate maximum left column width based on total width
max_left_col_width = int(self.total_width * self.max_left_col_ratio)
target_left_col_width = int(self.total_width * self.target_left_col_ratio)
# Initialize widths array
widths = [0] * col_count
# First pass: calculate minimum required widths for data columns
for col in range(1, col_count):
col_content_width = self.min_data_col_width
if table.headers:
col_content_width = max(col_content_width,
self._measure_content_width(table.headers[col]))
# Check numeric data width
for row in table.data_rows:
if col < len(row):
col_content_width = max(col_content_width,
self._measure_content_width(row[col]))
widths[col] = col_content_width
# Calculate available space for left column
data_cols_width = sum(widths[1:])
available_left_width = self.total_width - data_cols_width
# Determine left column width
left_col_max_content = 0
if table.headers and table.headers[0]:
left_col_max_content = self._measure_content_width(table.headers[0])
for row in table.data_rows:
if row:
left_col_max_content = max(left_col_max_content,
self._measure_content_width(row[0]))
# Set left column width based on constraints
if left_col_max_content <= target_left_col_width:
widths[0] = left_col_max_content
else:
widths[0] = min(max_left_col_width,
max(target_left_col_width, available_left_width))
# If we still exceed total width, redistribute data column space
total_width = sum(widths)
if total_width > self.total_width:
excess = total_width - self.total_width
data_cols = len(widths) - 1
reduction_per_col = excess // data_cols
# Reduce data columns while ensuring minimum width
for i in range(1, len(widths)):
if widths[i] - reduction_per_col >= self.min_data_col_width:
widths[i] -= reduction_per_col
# Apply width constraints and wrap text
modified_table = self._apply_column_constraints(table, widths)
return widths, modified_table
def _apply_column_constraints(self, table: ProcessedTable, widths: list[int]) -> ProcessedTable:
"""Apply width constraints to table content, wrapping text as needed"""
# Wrap headers if present
wrapped_headers = None
if table.headers:
wrapped_headers = [
self._wrap_text(header, widths[i])
for i, header in enumerate(table.headers)
]
# Wrap data in first column only
wrapped_rows = []
for row in table.data_rows:
wrapped_row = list(row) # Make a copy
wrapped_row[0] = self._wrap_text(row[0], widths[0])
wrapped_rows.append(wrapped_row)
return ProcessedTable(
headers=wrapped_headers,
data_rows=wrapped_rows,
column_alignments=table.column_alignments
)