# html_render.py - HTML rendering module for ownership forms import os import re from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from edgar.ownership.core import Ownership import pandas as pd from jinja2 import Environment, FileSystemLoader from edgar.ownership.core import format_numeric, format_price def _format_date(date_str: str) -> str: if not date_str or pd.isna(date_str): return "N/A" try: return pd.to_datetime(date_str).strftime('%m/%d/%Y') except (ValueError, TypeError): return str(date_str) # Return original if parsing fails def _escape_html(value: Any) -> str: """Escape HTML special characters in a string.""" if value is None or (isinstance(value, float) and pd.isna(value)): return "N/A" s_val = str(value) # Only escape HTML special characters (Jinja2 will handle this automatically in templates) # but we still need it for our cell content preparation return s_val.replace('&', '&').replace('<', '<').replace('>', '>') def format_owner_name(owner_name: Optional[str]) -> str: """Format owner name for display.""" if not owner_name: return "" return _escape_html(owner_name) def format_address(street1: Optional[str], street2: Optional[str], city: Optional[str], state: Optional[str], zip_code: Optional[str]) -> str: """Format address components into a HTML address string.""" parts = [] if street1: parts.append(_escape_html(street1)) if street2: parts.append(_escape_html(street2)) city_state_zip_line_parts = [] if city: city_state_zip_line_parts.append(_escape_html(city)) if state: city_state_zip_line_parts.append(_escape_html(state)) if zip_code: city_state_zip_line_parts.append(_escape_html(zip_code)) if city_state_zip_line_parts: parts.append(" ".join(city_state_zip_line_parts).strip()) return "
".join(part for part in parts if part) def ownership_to_html(ownership: 'Ownership') -> str: """Convert an Ownership object to HTML format matching official SEC layout. Args: ownership: Ownership object containing SEC form data Returns: HTML string representation """ # Set up Jinja2 environment template_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') env = Environment(loader=FileSystemLoader(template_dir)) template = env.get_template('ownership_form.html') # Extract basic information and prepare context form_type = ownership.form # Prepare context dictionary for template rendering context = {'form_type': form_type, 'html_title': f"SEC Form {form_type}", 'form_name_display': f"FORM {form_type}", 'form_title_display': { '3': "INITIAL STATEMENT OF BENEFICIAL OWNERSHIP OF SECURITIES", '4': "STATEMENT OF CHANGES IN BENEFICIAL OWNERSHIP", '5': "ANNUAL STATEMENT OF CHANGES IN BENEFICIAL OWNERSHIP" }.get(form_type, "STATEMENT OF OWNERSHIP"), 'issuer_name': _escape_html(ownership.issuer.name if ownership.issuer else "N/A"), 'ticker': _escape_html(ownership.issuer.ticker if ownership.issuer else "N/A"), 'reporting_period': _format_date(ownership.reporting_period) if ownership.reporting_period else ''} # Issuer information # Reporting owner information reporting_owner = ownership.reporting_owners.owners[0] if ownership.reporting_owners.owners else None if reporting_owner: context['reporting_owner_name_str'] = format_owner_name(reporting_owner.name) context['reporting_owner_address_str'] = format_address( reporting_owner.address.street1 if reporting_owner.address else None, reporting_owner.address.street2 if reporting_owner.address else None, reporting_owner.address.city if reporting_owner.address else None, reporting_owner.address.state_or_country if reporting_owner.address else None, reporting_owner.address.zipcode if reporting_owner.address else None ) # Reporting owner relationship context['is_director'] = 'X' if reporting_owner.is_director else '' context['is_officer'] = 'X' if reporting_owner.is_officer else '' context['is_ten_pct'] = 'X' if reporting_owner.is_ten_pct_owner else '' context['is_other'] = 'X' if reporting_owner.is_other else '' context['officer_title'] = _escape_html(reporting_owner.officer_title) if reporting_owner.officer_title else '' # Remarks context['remarks'] = _escape_html(ownership.remarks) if ownership.remarks else '' # Footnotes if ownership.footnotes and ownership.footnotes._footnotes: footnotes_list = [] for footnote in ownership.footnotes._footnotes: footnotes_list.append(f"

{_escape_html(footnote)}

") context['footnotes_html'] = "\n".join(footnotes_list) else: context['footnotes_html'] = '' # Signature if ownership.signatures and ownership.signatures.signatures: first_signature = ownership.signatures.signatures[0] if first_signature.signature: context['sig_name'] = _escape_html(first_signature.signature) if first_signature.date: if isinstance(first_signature.date, str): context['sig_date'] = _escape_html(_format_date(first_signature.date)) else: context['sig_date'] = _escape_html(str(first_signature.date)) # Process Table I - Non-Derivative Securities non_deriv_rows = [] non_derivative_table = getattr(ownership, 'non_derivative_table', None) if non_derivative_table: # Form 3 - Initial holdings if form_type == '3' and hasattr(non_derivative_table, 'holdings') and non_derivative_table.holdings is not None and not non_derivative_table.holdings.empty: for holding_tuple in non_derivative_table.holdings.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values holding = {col: getattr(holding_tuple, col, '') for col in holding_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(holding.get('Security', '')) shares_owned = format_numeric(str(holding.get('Shares', ''))) direct_indirect_code = str(holding.get('DirectIndirect', '')) nature_of_ownership = str(holding.get('NatureOfOwnership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() row = [ f'{security_title_clean}', f'{shares_owned}', f'{direct_indirect_code}', f'{nature_of_ownership}' ] non_deriv_rows.append(row) # Form 4/5 - Transactions elif form_type in ['4', '5'] and hasattr(non_derivative_table, 'transactions') and non_derivative_table.transactions is not None and not non_derivative_table.transactions.empty: for transaction_tuple in non_derivative_table.transactions.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values transaction = {col: getattr(transaction_tuple, col, '') for col in transaction_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(transaction.get('Security', '')) transaction_date = _format_date(transaction.get('Date', '')) deemed_date = _format_date(transaction.get('DeemedDate', '')) transaction_code = str(transaction.get('Code', '')) transaction_v = str(transaction.get('V', '')) shares = format_numeric(str(transaction.get('Shares', ''))) acquired_disposed = str(transaction.get('AcquiredDisposed', '')) price = format_price(transaction.get('Price', '')) owned_after_transaction = format_numeric(str(transaction.get('Remaining', ''))) direct_indirect_code = str(transaction.get('DirectIndirect', '')) nature_of_ownership = str(transaction.get('NatureOfOwnership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() row = [ f'{security_title_clean}', f'{transaction_date}', f'{deemed_date}', f'{transaction_code}', f'{transaction_v}', f'{shares}', f'{acquired_disposed}', f'{price}', f'{owned_after_transaction}', f'{direct_indirect_code}', f'{nature_of_ownership}' ] non_deriv_rows.append(row) # Process Holdings for Forms 4/5 if hasattr(non_derivative_table, 'holdings') and non_derivative_table.holdings is not None and not non_derivative_table.holdings.empty: for holding_tuple in non_derivative_table.holdings.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values holding = {col: getattr(holding_tuple, col, '') for col in holding_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(holding.get('Security', '')) shares_owned = format_numeric(str(holding.get('Shares', ''))) direct_indirect_code = str(holding.get('DirectIndirect', '')) nature_of_ownership = str(holding.get('NatureOfOwnership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() row = [ f'{security_title_clean}', '', # Transaction Date '', # Deemed Execution Date '', # Transaction Code '', # V '', # Amount '', # A/D '', # Price f'{shares_owned}', f'{direct_indirect_code}', f'{nature_of_ownership}' ] non_deriv_rows.append(row) # Add non_deriv_rows to context context['non_deriv_rows'] = non_deriv_rows # Process Table II - Derivative Securities deriv_rows = [] derivative_table = getattr(ownership, 'derivative_table', None) if derivative_table: # Form 3 - Initial derivative holdings if form_type == '3' and hasattr(derivative_table, 'holdings') and derivative_table.holdings is not None and not derivative_table.holdings.empty: for holding_tuple in derivative_table.holdings.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values holding = {col: getattr(holding_tuple, col, '') for col in holding_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(holding.get('Security', '')) conversion_price = format_price(holding.get('ExercisePrice', '')) exercisable_date = _format_date(holding.get('ExerciseDate', '')) expiration_date = _format_date(holding.get('ExpirationDate', '')) exercisable_expiration = f"{exercisable_date} - {expiration_date}" underlying_title = str(holding.get('Underlying', '')) underlying_shares = format_numeric(holding.get('UnderlyingShares', '')) title_amount_underlying = f"{underlying_title} - {underlying_shares}" direct_indirect_code = holding.get('DirectIndirect', '') nature_of_ownership = str(holding.get('Nature Of Ownership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', exercisable_expiration).strip() title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', title_amount_underlying).strip() row = [ f'{security_title_clean}', f'{conversion_price}', 'N/A', # Transaction Date (blank for F3 initial holding) 'N/A', # Deemed Execution Date (blank for F3 initial holding) 'N/A', # Transaction Code (blank for F3 initial holding) 'N/A', # V (blank for F3 initial holding) 'N/A', # Shares in transaction (blank for F3 initial holding) 'N/A', # A/D (blank for F3 initial holding) f'{exercisable_expiration_clean}', f'{title_amount_underlying_clean}', 'N/A', # Price (blank for F3 initial holding) 'N/A', # Amount owned after transaction (blank for F3 initial holding) f'{direct_indirect_code}', f'{nature_of_ownership}' ] deriv_rows.append(row) # Form 4/5 - Derivative Transactions elif form_type in ['4', '5'] and hasattr(derivative_table, 'transactions') and derivative_table.transactions is not None and not derivative_table.transactions.empty: for transaction_tuple in derivative_table.transactions.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values transaction = {col: getattr(transaction_tuple, col, '') for col in transaction_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(transaction.get('Security', '')) conversion_price = format_price(transaction.get('ExercisePrice', '')) transaction_date = _format_date(transaction.get('Date', '')) deemed_date = _format_date(transaction.get('DeemedDate', '')) transaction_code = str(transaction.get('Code', '')) transaction_v = str(transaction.get('V', '')) shares = format_numeric(str(transaction.get('Shares', ''))) acquired_disposed = str(transaction.get('AcquiredDisposed', '')) exercisable_date = _format_date(transaction.get('ExerciseDate', '')) expiration_date = _format_date(transaction.get('ExpirationDate', '')) exercisable_expiration = f"{exercisable_date} - {expiration_date}" underlying_title = str(transaction.get('Underlying', '')) underlying_shares = format_numeric(transaction.get('UnderlyingShares', '')) title_amount_underlying = f"{underlying_title} - {underlying_shares}" price = format_price(transaction.get('Price', '')) owned_after_transaction = format_numeric(str(transaction.get('Remaining', ''))) direct_indirect_code = str(transaction.get('DirectIndirect', '')) nature_of_ownership = str(transaction.get('NatureOfOwnership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', exercisable_expiration).strip() title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', title_amount_underlying).strip() row = [ f'{security_title_clean}', f'{conversion_price}', f'{transaction_date}', f'{deemed_date}', f'{transaction_code}', f'{transaction_v}', f'{shares}', f'{acquired_disposed}', f'{exercisable_expiration_clean}', f'{title_amount_underlying_clean}', f'{price}', f'{owned_after_transaction}', f'{direct_indirect_code}', f'{nature_of_ownership}' ] deriv_rows.append(row) # Process Holdings for Forms 4/5 if hasattr(derivative_table, 'holdings') and derivative_table.holdings: for holding_tuple in derivative_table.holdings.data.itertuples(index=False): # Convert tuple to dictionary for easier access with default values holding = {col: getattr(holding_tuple, col, '') for col in holding_tuple._fields} # Extract values using dictionary get() with defaults security_title = str(holding.get('Security', '')) conversion_price = format_price(holding.get('ExercisePrice', '')) exercisable_date = _format_date(holding.get('ExerciseDate', '')) expiration_date = _format_date(holding.get('ExpirationDate', '')) exercisable_expiration = f"{exercisable_date} - {expiration_date}" underlying_title = str(holding.get('Underlying', '')) underlying_shares = format_numeric(holding.get('UnderlyingShares', '')) title_amount_underlying = f"{underlying_title} - {underlying_shares}" owned_after_transaction = format_numeric(holding.get('Remaining', '')) direct_indirect_code = holding.get('DirectIndirect', '') nature_of_ownership = str(holding.get('NatureOfOwnership', '')) # Clean footnotes from values for table display security_title_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', security_title).strip() exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', exercisable_expiration).strip() title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?', '', title_amount_underlying).strip() row = [ f'{security_title_clean}', f'{conversion_price}', '', # Transaction Date '', # Deemed Execution Date '', # Transaction Code '', # V '', # Shares in transaction '', # A/D f'{exercisable_expiration_clean}', f'{title_amount_underlying_clean}', '', # Price f'{owned_after_transaction}', f'{direct_indirect_code}', f'{nature_of_ownership}' ] deriv_rows.append(row) # Add deriv_rows to context context['deriv_rows'] = deriv_rows # Render the template with the context return template.render(**context) def _parse_name(name: str): """Parse a full name into (last, first, middle) components""" if not name: return '', '', '' # Check for comma format: "Last, First Middle" if ',' in name: last, rest = name.split(',', 1) parts = rest.strip().split() first = parts[0] if parts else '' middle = ' '.join(parts[1:]) if len(parts) > 1 else '' else: # Assume format "First Middle Last" parts = name.split() last = parts[-1] if parts else '' first = parts[0] if parts else '' middle = ' '.join(parts[1:-1]) if len(parts) > 1 else '' return last.strip(), first.strip(), middle.strip()