# 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]>.*?[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]>.*?[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]>.*?[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]>.*?[Ss][Uu][Pp]>', '', security_title).strip()
exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[Ss][Uu][Pp]>', '', exercisable_expiration).strip()
title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[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]>.*?[Ss][Uu][Pp]>', '', security_title).strip()
exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[Ss][Uu][Pp]>', '', exercisable_expiration).strip()
title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[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]>.*?[Ss][Uu][Pp]>', '', security_title).strip()
exercisable_expiration_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[Ss][Uu][Pp]>', '', exercisable_expiration).strip()
title_amount_underlying_clean = re.sub(r'<[Ss][Uu][Pp]>.*?[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()