mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 21:12:16 +00:00
* [Feature] OpenBB V5: Initial `openbb-core` Refactor (#7482)
* initial refactor
* ruff does not dictate style
* unresolved imports in test action
* fix general linting
* resolve pandas imports for actions run
* httpx in test workflow
* pytest-asyncio in test workflow
* pandas.Index(...)
* max version for windows CI is 3.14
* remove temp refactor files that shouldn't have been committed:
* use ruff format instead of black
* formatting and typing
* tab space helper
* test fixtures..
* ty check
* also run integration test in CI
* remove that annoying labeler action
* [V5] `openbb-core`: 100% Test Coverage (#7484)
* increase test coverage to exercise 100% of the testable code path in openbb-core
* model_fields syntax cleanup
* set the environment variable for CI runs
* run codeql on v5 branch
* more CI env setup
* cleanup redundant files
* ruff
* dedupe workflow trigger
* some windows coverage missing
* ubuntu integration test job
* more windows coverage
* test_reference_generator
* test_docstring_generator
* remove line no references from test function docstrings
* clean useless comments from test files
* fix polars type check
* fix test_obbject
* fix test_obbject
* package builder fixes
* charting pyproject version
* fix docstring generator for nested models
* missing coverage
* [V5] `openbb-core`: Add Backwards-Compatible Layered Config System (openbb.toml) (#7492)
* add backwards-compatible layered config system
* fix path reference in windows test
* need python version-aware handling in docstring generator
* update for deprecated pydantic syntax
* ruff UP007
* test_system_settings
* remove v4 platform workflow
* [V5] Refactor `openbb-oecd` for full SDMX 2 API Exposure and V5 Packaging (#7413)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor openbb-platform-api for V5 operations and spec file handling
* delete poetry.lock
* add test workflow
* add temp branch id for test workflow
* add httpx to dev dependencies
* add a conftest.py
* tweak conftest
* tweak conftest
* python 3.10 issues
* more python 3.10
* more python 3.10
* more python 3.10
* more python 3.10
* fix url template resolution
* multi-spec support
* fix a windows path issue
* merge branch develop
* table builder, integration tests, and more
* linting
* merge branch develop
* bit of cleanup
* some more updates, cleanup, and apps.json
* codespell
* black
* integration test params and linting
* mypy
* pylint
* black
* more pylint
* recapture teest cassettes
* black again
* more linting
* table presentation choices
* black
* fix merge conflict
* split up metadata module and reformat cache to use msgpack
* mypy
* regenerate lock file
* use json and include dependencies to make openbb-oecd a standalone install
* pylint
* more linting
* serve metadata as dependency instead of assign and initialize
* fix integration_tests_testers to not count dependency injections as paramters
* add assets to git ignore
* ruff
* v5 refactor and publish workflow
* test coverage and ty fixes
* codespell
* update lock file
* workflow setup
* ty check
* workflow setup
* Scope PR to OECD changes only
Restore non-OECD files in the diff against v5 to their v5 state. The
branch was originally created off feature/cli-2 and inherited 186 files
of unrelated changes (cli, agent_server, mcp_server, platform_api,
core/, .github/workflows/* not oecd). Those have nothing to do with the
openbb-oecd v5 refactor and were polluting the PR diff.
* scope cleanup: include leaked v5-resident files (general-linting workflow, platform_api test fixtures)
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* fix docstring generator and body param unwrapping
* project ty.toml file
* unpack POST parameters from params: Model
* remove comment chatter
* [V5] `openbb-oecd`: Rename `oecd_utils` as `oecd` (#7509)
* rename oecd_utils to oecd and populate all models there when openbb-economy is not installed
* linting
* bump dependencies
* fix test drift
* linter
* FieldInfo import
* [V5] `openbb-imf`: Refactor for V5 (#7508)
* refactor imf extension for V5
* codespell
* fix some python 3.10 items
* return self on copy of metadata object
* [V5] `openbb-news`: Refactor News for V5 and Add Configurable RSS Endpoint (#7507)
* refactor news for v5 and add rss endpoint
* remove poetry.lock
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-quantitative`: Refactor for V5 (#7506)
* refactor quantitative for v5
* add factors endpoint and add a few charts
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-technical`: Refactor for V5 & Add More Quick Win Endpoints (#7502)
* refactor openbb-technical for v5
* forward refs
* linting
* no python 3.15 in test matrix
* reame
* ty
* move all params to body
---------
Co-authored-by: deeleeramone <>
* remove stockgrid provider (#7486)
* [V5] remove wsj provider and related code (#7485)
* remove wsj provider and related code
* purge from metadata
* actions
* actions
* openbb-core publish workflow
* delete biztoc (#7523)
* [V5] Remove `openbb-alpha-vantage` (#7522)
* delete alpha vantage
* more files
---------
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] Remove `openbb-benzinga` (#7521)
* delete benzinga
* more references
---------
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] `openbb-bls`: Refactor for V5 + Add Many Endpoints (#7518)
* refactor bls for v5, add 100 widgets
* some CI fixes
* no python 3.15
* fix missing dev group in pyproject.toml
* more pyproject fallout
* action
* refactor econometrics for v5 (#7505)
Co-authored-by: deeleeramone <>
* [V5] `openbb-famafrench`: Refactor for V5 (#7503)
* refactor famafrench for v5
* codespell
* finishing touches
---------
Co-authored-by: deeleeramone <>
* [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* cap test matrix at python 3.14
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* general-linting
* downgrade aiohttp in devtools for vcrpy
* some ty items and add requests-types stubs for ty in devtools
* markdown linting
* remove integration tests from extensions that were moved into the famafrench provider
* [V5] `openbb-congress-gov`: Refactor for V5 + Convert To Using Bulk Files Instead of `api.congress.gov`. (#7528)
* refactor congress-gov for v5 and use bulk files instead of API with credentials
* integration tests
* apps tab name
* convert caching to sql
* codespell
* [V5] [BugFix] Fix for Breaking Changes in FastAPI 0.137+ (#7537)
* fix route introspection for fastapi 0.137
* recapture oeccd gdp forecast test cassette
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-platform-api`: Refactor for V5 Spec, Config, Packaging, Test Coverage (#7494)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor openbb-platform-api for V5 operations and spec file handling
* delete poetry.lock
* add test workflow
* add temp branch id for test workflow
* add httpx to dev dependencies
* add a conftest.py
* tweak conftest
* tweak conftest
* python 3.10 issues
* more python 3.10
* more python 3.10
* more python 3.10
* more python 3.10
* fix url template resolution
* multi-spec support
* fix a windows path issue
* cap test matrix at python 3.14
* no 3.15 runners for platform-api
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
* markdown lint
* ignore get/post endpoints that are return SSE
* fix remaining fastapi 0.137 fallout
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* add full support for AsyncIterator response type for streaming output (#7530)
* [V5] Refactor Shared Pass Through Router Extensions For Hatchling (#7524)
* refactor shared pass-through routers for hatchling and ruff + ty
* add publish workflows
* [V5] `openbb-charting`: Refactor for V5 and Decouple (#7545)
* refactor charting for v5 patterns and decouple entirely
* coveragerc
* lint and tests
* fix tests
* configure dev group with openbb-technical
* ty
* fix cli test
* remove 3.15 from test matrix - dependencies are unstable
---------
Co-authored-by: deeleeramone <>
* fix merge widgets
* workflow cleanup"
* fixes for ty
* fogot that file
* general lint setup
* consolidate lint job
* remove seeking alpha provider (#7570)
* remove tradier provider (#7573)
* remove tiingo provider (#7574)
* remove intrinio provider (#7575)
Remove the openbb-intrinio provider extension from the repository.
Reason: proprietary vendor with no internal access to any endpoint;
cannot build, develop or maintain.
* remove yfinance provider from repo (#7576)
* fix(cli): resolve modular OpenAPI references (#7586)
Fixes #7585
* [V5] `openbb-cli`: Modular OpenAPI Refs (#7589)
* follow up to #7585
* add another codegen test
* unused ty ignore
* Update generated tests to get python parameter names instead of raw API-provider parameter names. (#7597)
* Update the record-vs-envelope heuristic in the payload processing code to not treat a record that happens to have a list datatype as an entire envelope. (#7598)
* Update class generation code to handle empty data that php-style serialization emits as []. (#7599)
* fix ty check failures exposed by newer version
* add drugs.com and brutalist as news rss feeds (#7602)
Co-authored-by: deeleeramone <>
* codespell
* missing test coverage that appeared in famafrench..
* plotly 7 handling for mapbox namespace change
* remove finviz (#7577)
* remove trading economics provider (#7578)
* remove openbb-multpl from repository (#7610)
* add httpx2 to devtools for starlette test client
* restore test coverage to 100% in openbb_core
* remove fmp provider extension from repository (#7665)
* unpin ruff
* fix charting conftest and missing coverage in 3.10
* add charting coverage
* fix new ty issues
* fix tag grouping in swagger docs
* [V5] `openbb-ecb`: Refactor for V5 and Expand Coverage (#7560)
* refactor openbb-ecb for v5 and expand coverage
* some updates
* tests
* workflow
* remove 3.15 from test matrix
* some more table schema updates
* remove some html chrome on embedded articles
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-federal-reserve`: Refactor for V5 + FFIEC & Regional Fed Routers (#7561)
* refactor for v5
* add workflows
* fix bhcpr schema
* fix pyproject.toml
* fix test
* codespell
* ty
* fix pyproject
* fix ci environment
* fix ci environment
* convert documents endpoints to use fedinprint service
* codeql fix
* fix ny publications and add ny nowcast
* [V5] `openbb-eia`: Refactor for V5 + Add Full API Implementation (#7584)
* eia refactor for v5
* add test and publish workflows
* data browser state
* fix workflows
* more workflow test items
* temp files in tests
* more fixes
* more browsers
* fix codeql issues
* fix mer state and ensure multi-user capability
* uv lock
* table bridge
* fix electricity browser state
* convert HTML widgets to iFrame
* fix recursion error in workspace mcp
* [V5] `openbb-government-us`: Refactor for V5 + Move `uscongress` (#7604)
* v5 government_us provider
* workflow files
* ty and add xlrd to pyproject
* shrink test cassettes
* dev dependencies
* wheel packaging and more tests
* python 3.10 thing..?
* fix test
* default state for multiselect dropdowns
* replace congress html widgets with iframes
* fallback to congress api for congress before 108
* ensure member passage voting count populates
* [V5] Feature: Add JODI World Oil & Gas Database (#7593)
* add the JODI oil and gas database as a provider/router extension
* lint fixes
* add grouping to apps.json
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-cftc`: Refactor for V5 + Add OIS, Forward Curve, and FX Trade Analysis (#7615)
* refactor openbb-cftc for v5
* add SHIBOR to fixings
---------
Co-authored-by: deeleeramone <>
* fix for plotly 7 breaking change to schemas
* [V5] `openbb-tmx`: Refactor extension for V5 (#7668)
* refactor tmx provider for v5
* fix tests..
* fix tests stack trace exposure
* fix workflow setup
* [V5] `openbb-nasdaq`: Refactor provider for V5. (#7670)
* refactor nasdaq provider for v5
* add workflows
* ty fix
* [V5] `openbb-fred`: Refactor as V5 extension. (#7669)
* refactor fred extension for v5
* fix markdown MD037 lint error in README
* codespell
* [V5] `openbb-cboe`: Refactor for V5 + Add Payoff Diagrams (#7617)
* refactor cboe for v5
* use zoneinfo instead
* fix test
* some fixes related to the cache access
* fix a couple of tests
* fix the test coverage
* [V5] `openbb-deribit`: Refactor provider for V5 (#7674)
* refactor openbb-deribit for v5
* restructure the optimizer around the published combos data and add integration test suites
* fix markdown lint errors
* remove openbb-econdb provider (#7675)
* codespell
* [V5] `openbb-sec`: Refactor for V5 (#7525)
* refactor openbb-sec for v5
* linting
* ty
* normalize compare_company_facts
* add workflows
* python 3.10 fix
* add more thorough url validation and tests
* ty check
* ty for python 3.10
* reconfigure with apps
* temp pyproject overrride
* pyproject
* pyproject
* pyproject
* codespell
* pyproject
* fix uk schema file links in schema_files
* test coverage
* test_cli
* port #7547 into this pr
* no hard coded prefix
* improve filing rendering capabilities
* fix xml text detection
* make codeql happy
* missed a file in the commit
* test coverage
* codespell
* fix flakey test
* some updates
* management_ownership --> management_profiles
* proxy statement markdown
* ruff
* fix test coverage
* more tests
* test coverage
* fix lint errors
* Add SEC adviser search, profile, and universe routes (#7559)
* Add SEC institutional data fetchers
* Add SEC filing index and Form D routes
* Add SEC investment adviser people data
* Expose SEC adviser and institutional routes
* fix(sec): address adviser review feedback
* refactor(sec): narrow adviser support to IAPD search
* feat(sec): add adviser firm and individual searches
* feat(sec): add adviser profile drill-down
* fix: repair pre-commit configuration
* feat(sec): add adviser universe route
* feat(sec): expose complete Form ADV data
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
* fix test coverage
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] `openbb-finra`: Refactor for V5 (#7676)
* refactor finra provider for v5
* add workflows
* fix tests
* fix lint errors
* [V5] Update Repository License to Apache 2.0 (#7677)
* change license to Apache 2.0 for V5
* codespell
* bump devtools and repoint pyproject.toml
* Enterprise Flask Migration Toolkit (#7264)
* feat: Add Flask-to-OpenBB enterprise migration toolkit
- Implement Router.from_flask() for zero-touch legacy API conversion
- Enable 4-hour migration vs 6-month traditional rewrite (80% cost reduction)
- Generate FastAPI endpoints from Flask routes with automatic type validation
- Support enterprise consulting services and custom integration workflows
Demonstrates OpenBB's competitive advantage for institutional client migrations.
Complements existing Workspace applications like Open Portfolio suite.
* chore: Resolve .gitignore conflict and add common ignores
* refactor: implement Phase 1 Flask integration architecture- Move Flask conversion logic to openbb_core.app.utils.flask- Add Flask detection to extension_loader - Integrate with OpenBB's core extension system- Add Flask dependency validation with sys.modules checkingAddresses @deeleeramone's architectural feedback for native OpenBB integration.
* docs: add Flask direct entry point demos- demo_direct_flask_entry_point.py: shows direct Flask instance reference- example_pyproject_flask_entry.toml: demonstrates pyproject.toml configurationSupports new architecture eliminating wrapper extensions.
* Implement Phase 1 Flask adapter per code review feedback
- Minimal working implementation with Flask detection and router creation
- Removes complex code generation logic for focused Phase 1 delivery
- Establishes foundation for Phase 2 route conversion features
* Fix Flask integration: Replace missing FlaskToOpenBBAdapter with WSGIMiddleware
- Remove FlaskToOpenBBAdapter which was not implemented
- Use FastAPI's WSGIMiddleware to mount Flask apps directly
- Update extension_loader to use returned Router object
- Convert utils.py to utils/__init__.py to support flask submodule
- Addresses PR review comment about unhandled AttributeError
* Address PR review feedback: fix Flask imports and remove unused files
- Revert .gitignore to upstream/develop
- Remove AI-generated demo/example files and flask_adapter extension
- Move Flask/werkzeug imports inside conditional blocks to avoid ImportError
- Use lazy imports via __getattr__ in utils/flask/__init__.py
- Add Flask tests with @pytest.mark.skipif markers
* Force APIRouter.include_router to propagate Mount paths instead of dropping them. (#1)
This change ensures that mounted ASGI applications (such as Flask apps wrapped in WSGIMiddleware) are not dropped when `APIRouter.include_router` is called.
Previously, while the initial load in `ExtensionLoader` worked as expected, the `Mount` objects were ignored by FastAPI's default inclusion logic. This caused the mounted elements to be lost during the final application assembly, resulting in 404 errors for the Flask routes.
This fix overrides the inclusion logic to manually propagate `Mount` objects downstream.
* feat(flask): Add metadata layer for OpenAPI documentation
Enhance Flask-to-OpenBB converter with structured docstring parsing:
- Parse Google-style docstrings (summary, description, params)
- Extract parameter descriptions from Args section
- Pass metadata to Router.command() for rich OpenAPI docs
This addresses PR feedback to provide tangible benefit by generating
comprehensive API documentation for migrated Flask routes.
* feat(flask): Implement manual OpenAPI spec generation and attachment
- Add FlaskParam and FlaskRouteInfo TypedDict for type-safe metadata
- Implement OpenAPISpecGenerator for Flask→OpenAPI conversion
- Extract route metadata via FlaskIntrospector
- Attach generated spec to router.openbb_openapi_spec_ext
- Add unit tests (3/3 passing)
Addresses WSGI/mount boundary issue where Router.command metadata
cannot propagate through WSGIMiddleware. Spec attached to router
instance for downstream OpenAPI assembly.
Resolves maintainer feedback on PR #7264
* feat(flask): Implement FlaskMountRegistry for multiple mount support
- Add FlaskMountRegistry class for centralized OpenAPI spec management
- Replace router.openbb_openapi_spec_ext with registry pattern
- Support multiple Flask apps at different mount prefixes
- Automatic path rewriting in aggregated OpenAPI specs
- Add registry test case (4/4 tests passing)
Addresses @deeleeramone's feedback on PR #7264 regarding router
attribute fragility and multiple mount support. Registry provides
lifecycle-independent state management and enables enterprise
scenarios with multiple Flask apps.
Resolves: OpenBB-finance/OpenBB#7264 (partial)
* feat(core): load Flask apps as openbb_core_extension WSGI sub-apps
A Flask app referenced directly by an openbb_core_extension entry point is
mounted via WSGIMiddleware and its routes are introspected into openapi.json
(operationId/widgetId/mcp_tool, parameters, request bodies, security, widget_config
and HTML widgets) so the widget factory and MCP server can discover them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(core): 100% coverage for flask utils + flask dev dependency
Add branch-coverage tests for the Flask integration package (introspection,
AST analysis, docstrings, OpenAPI generation, registry, loader, detection) and
declare flask as a dev dependency so the suite runs in CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(flask): treat f-string returns as HTML responses
* fix(flask): mount via a2wsgi instead of deprecated starlette WSGIMiddleware
- add openbb-core[flask] extra (flask, a2wsgi); a2wsgi resolved through require_optional
- run the WSGI serve test through mount_flask_extensions; drop httpx importorskip
- core CI installs [pandas,flask] and httpx2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(flask): cover failed entry point loads; narrow optional types for ty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(core): vectorize the date conversion in `basemodel_to_df` (#7622)
* perf(core): vectorize the date conversion in basemodel_to_df
basemodel_to_df ran three per-element Python passes over the date column of
every payload it converted. Series.apply(to_datetime) calls pandas.to_datetime
once per row, and because each call receives a lone scalar with no format hint,
pandas also re-runs its datetime format guesser on every row.
Parse the column in one call instead, and use vectorized comparisons for the
all-midnight test and the reduction to datetime.date.
The element-wise parse is kept as a fallback for one case that the vectorized
path cannot represent: a column mixing UTC offsets, such as a series crossing a
daylight-savings boundary. Parsing per element lets each value keep its own
offset and yields an object column of tz-aware Timestamps, whereas a single
to_datetime call over that column raises "Mixed timezones detected". That
behaviour is covered by test_to_df_daylight_savings and is preserved exactly.
Measured on a payload built through EquityHistoricalData, median of 9 under an
exclusive CPU lock:
250 daily bars 8.92 ms -> 1.46 ms
1,000 daily bars 32.93 ms -> 3.13 ms
5,000 daily bars 162.25 ms -> 12.23 ms
* fix a broken test - unrelated to this PR...
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix lint errors
* fix oecd lint error
* [V5] `openbb-mcp-server`: Refactor for v5 - Spec, Config, Packaging, Test Coverage (#7495)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor mcp server for v5
* test action
* fix tests
* tests
* test fixture name
* revert: drop unrelated provider/README changes from mcp-server refactor
Strip the changes to ``README.md`` and ``openbb_platform/providers/{imf,
tradingeconomics,yfinance}`` that snuck into 4ac3602 (refactor mcp server
for v5). Those files are out of scope for the mcp-server work and were
modified inadvertently — restore them to their pre-4ac3602 state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* tests and workflows
* fix workflow
* tests and workflows
* tests and workflows
* tests and workflows
* tests and workflows
* beartype shim for 3.15
* muck around with beartype for 3.15
* muck around with beartype for 3.15
* no 3.15 runners for mcp-server
* update skill files
* cap test matrix at python 3.14
* merge branch v5
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
* refactor for fastmcp4
* wrap markdown lines that are too long
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* update pre-commit, noxfile, and uv locks
* fixes for docstring generation
* test coverage
* fix cli renderer helper with figure object JSON
* fix the figure font when rendered
* refactor cookiecutter
* bump cookiecutter packagge version
* fix sec test by adding fixture
* fix desktop environments test
* fix(cli): inherit path-item-level OpenAPI parameters (#7606)
* fix(cli): inherit path-item-level OpenAPI parameters
OpenAPI 3.x lets a Path Item Object declare `parameters` shared by every
operation under that path, with operation-level entries overriding them by
(name, in). The codegen read only `op.get("parameters")`, so inherited
parameters were dropped silently — the generated command kept the URL
template but had nothing registered to fill it.
Real-world impact: the Xero Accounting API declares its required
`xero-tenant-id` header once per path item across all 138 paths. Before this
change `build_command_spec` produced 78 commands and none carried the
header, so every generated command 403s. Nothing warned.
Adds `operation_parameters()`, which merges the two levels and dereferences
both, and routes the pre-computed spec path (`build_command_spec`) and the
interactive path (`build_command_index` -> `build_parser_from_operation`)
through it. Unresolvable refs, non-dict entries, and nameless parameters are
skipped as before. Specs without path-item parameters are unaffected.
* fix(cli): narrow path-item inheritance to path and query parameters
Per review: headers do not translate into a Python interface function, and
outbound headers are transport configuration
(SystemSettings.PythonSettings.http.headers, the CLI's -H) or a security-scheme
concern — not a per-command argument.
Path-item `in: header` and `in: cookie` entries are now dropped rather than
inherited. Operation-level parameters are untouched, so an operation that
declares its own header keeps today's behavior.
Scope is now only what cannot be supplied any other way: `in: path` and
`in: query` parameters declared on the Path Item Object.
* fix(cli): merge allOf compositions in response schemas
Response-schema $refs were already expanded by deref_schema, but a schema
whose top level is an allOf composition was left unflattened. The properties
live inside the members, so a consumer reading them off the composition finds
none. fetcher_gen._data_schema looks for `properties.results`, gets nothing,
and falls through to _unwrap_schema_envelopes, which handles oneOf and anyOf
but not allOf. The generated Data model ends up with no fields.
Codat's listBills response is `allOf: [{properties: {results}}, {properties:
{_links, pageNumber, pageSize, totalResults}}]`, which produced an empty model.
merge_allof collapses a composition of object subschemas into one object
schema: properties unioned with the first declaration winning on conflict,
required unioned, and sibling keywords on the allOf node outranking members.
A composition that is not a plain intersection of object subschemas (a member
carrying its own oneOf/anyOf, or a non-object type) is returned unchanged
rather than flattened into something the spec does not describe. Nested
compositions are merged too, so a $ref inside a merged member still resolves.
Applied to extract_response_schema and extract_response_schemas.
Measured across five specs that use the pattern, counting operations whose
response schema exposes readable properties: 244 of 588 before, 394 after
(Codat Accounting 36 to 88, Codat Banking 0 to 8, Codat Commerce 1 to 11,
Rebilly 62 to 104, Box 145 to 183).
* fix(cli): merge allOf compositions in request body schemas
Same gap as the response side. request_body_parameters needs `type: object`
or `properties` to flatten a body into parameters, and an allOf composition
carries neither, so a POST or PUT generated from one gets no body parameters
at all.
extract_request_body_schema now runs merge_allof after deref_schema, matching
extract_response_schema.
Across the same five specs, counting request bodies that produce parameters:
138 of 206 before, 177 after (Codat Accounting 1 to 26 of its 30 bodies,
Rebilly 50 to 63, Box 87 to 88).
* fix(cli): stop annotation keywords blocking an allOf merge
_mergeable_allof_members required every member's keys to come from a small
whitelist, so any unrecognized keyword disqualified the whole composition.
Codat's PagingInfo carries `definitions` next to its properties, which meant
the live Codat-Accounting.yaml never merged and its response models stayed
empty. The APIs.guru copy of the same API omits that key, which is why the
earlier measurements looked clean.
The check is now a blocklist. A member blocks the merge when it carries its
own combinator (oneOf/anyOf/allOf/not), an unresolved $ref left by a cycle,
a scalar or array constraint (enum/const/items), or a type other than object.
Annotation keywords that do not affect the intersection (title, description,
examples, definitions, additionalProperties, vendor x- keys) no longer block
it, and are dropped from the merged result along with the members.
On the live Codat spec, operations whose response schema exposes readable
properties go from 36 of 127 to 114. Across the five specs measured earlier:
244 of 588 before, 488 after (was 394 under the whitelist).
Corpus check over 2,816 response and request-body extractions in 6 specs:
567 changed, every one attributable to an allOf in the pre-merge output,
0 unexplained. Twilio declares no allOf compositions and is unchanged.
* fix(cli): emit a name once when it is both a parameter and a body property
_signature_params appended request-body properties and then operation
parameters without deduplicating, so a name declared in both places produced
two function parameters with the same name and the generated module raised
SyntaxError.
Codat's push endpoints hit this: /push/bankAccounts/{accountId}/bankTransactions
declares accountId as a path parameter and repeats it in the request body, so
connections.push.bankAccounts.bankTransactions generated `accountId: str` and
`accountId: str = None` in one signature.
The operation parameter wins, since it carries the path placeholder and its own
required flag. The request payload is unchanged: _render_body_block derives body
fields from request_body_schema rather than from this list, so accountId is
still sent in the body.
This predates the path-item and allOf work in this PR. On origin/v5 the same
spec produces the same duplicate; on this branch the Codat spec generates 91
commands with 0 duplicated signatures, down from 1.
* fix(cli): use the safe identifier for keyword-named body properties
_render_body_block already writes `{'from': from_}`, keying the payload by the
wire name and reading a safe identifier, but _signature_params declared the raw
name. A body property named after a Python keyword therefore emitted
`from: str = None` and the generated module raised SyntaxError.
This did not fire before the allOf work in this PR, because a composed request
body produced no body parameters at all. Codat's Transfer body is such a
composition and has a `from` property, so connections.push.transfers started
generating invalid syntax once the body flattened.
_signature_params now emits safe_field_name(name)[0], the same helper
fetcher_gen and pydantic_gen already use. The body/query split in
_render_body_block compares against the safe names too: it previously matched
signature names against wire names, so `from_` was not recognized as a body
field and leaked into the query string as a `from_` parameter.
End to end on Codat-Accounting.yaml, generating the full extension and byte
compiling every emitted module: 136 files, 0 failures. origin/v5 emits 1 file
that does not compile (duplicate argument 'accountId').
Known gap left alone: an operation parameter named after a keyword would still
break, since the query and path lines use the raw name as a local variable.
None of the five specs checked declares one.
* fix(cli): do not treat instance data as schema, and sanitize POST identifiers
Two defects found by generating extensions from real specs and byte compiling
every emitted module.
merge_allof and deref_schema walked every value in a schema dict, including
keywords that hold instance data rather than subschemas. A `default` of
`{"allOf": [{"a": 1}, {"b": 2}]}` was rewritten to `{"type": "object"}`, and a
`$ref` key inside an example was resolved as if it were a reference. Both now
copy `default`, `example`, `examples`, `enum`, `const` and vendor `x-` values
through untouched.
_signature_params emitted operation parameter names verbatim, so a wire name
that is not a Python identifier produced invalid syntax. Rebilly declares an
`Organization-Id` header and a `REB-APIKEY` security header, which emitted
`REB-APIKEY: str = None`. It now uses safe_field_name, matching fetcher_gen,
and _render_body_block keys the query dict by the wire name while reading the
safe identifier so the request is unchanged.
This surfaced through the allOf work rather than being caused by it: a composed
request body used to yield no properties, so `has_body` was false in
package_gen and the command was generated as a GET fetcher, where identifiers
were already sanitized. With bodies flattening, 14 of Codat's 24 POST endpoints
correctly move to the POST path, which had never been exercised by these specs.
Generating the full extension and compiling every module:
Codat-Accounting.yaml v5: 1 failure branch: 0 (136 files)
Rebilly 2.1 v5: 14 failures branch: 1 (97 files)
The one remaining Rebilly failure is `async def 3dsecure(` in the router, which
fails identically on v5. Sanitizing that is a naming decision about the command
surface, so it is left alone here.
Known gap: a non-credential header parameter is still placed in the query
string on the POST path. Pre-existing, and unchanged by this PR.
* fix(cli): resolve signature collisions on the emitted identifier
The previous dedupe compared wire names, but the failure it prevents depends on
the sanitized identifier. Two wire names can collapse onto one (`from` and
`from_`, `Organization-Id` and `Organization_Id`), and operation_parameters
keeps a name declared twice under different `in` locations by design, so both
cases could still emit a duplicate argument.
_signature_params now tracks the identifiers it has emitted and skips anything
that would repeat one. Emission order is unchanged, so existing signatures are
byte-identical; only genuinely colliding names are dropped, and operation
parameters still win over body properties.
Neither collision occurs in Codat-Accounting.yaml or Rebilly 2.1. This closes
the class rather than the instance, since the instances are what this PR has
been finding.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure
(`async def 3dsecure(`, identical on v5).
* test(cli): cover the instance-data guard, and drop a redundant branch
Mutation checking the new tests (revert each fix, confirm its test fails)
found two problems with the previous commits.
The guard that stops merge_allof and deref_schema from walking into `default`,
`example`, `enum`, `const` and vendor values shipped with no test at all. It is
now covered from both directions: a default holding an `allOf` key keeps its
value, and a `$ref` inside a default stays a literal while a real reference
beside it still resolves.
The body-property loop also carried a redundant check. It tested the raw wire
name against the operation parameter names before testing the sanitized one,
but `operation_param_names` holds both forms and `safe_field_name` is idempotent
on identifiers, so the first test could never fire alone. Reverting it left
every test passing, which is what surfaced it. Removed, and the sanitized name
is now computed once.
All nine mutations are caught by the suite: both data guards, the allOf
blocklist in each direction, the response merge wiring, body/parameter
precedence, the keyword identifier, the body/query split and the
emitted-identifier dedupe.
cli/tests at 1,954 passed against the same 80 environment failures as the base.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure.
* fix(cli): route required path-item headers/cookies through credential handling
* fix(cli): narrow Popen.stdin/stdout for ty in run_in_obb fixture
---------
Co-authored-by: deeleeramone <>
* fix warnings raised by federal_reserve on some xlsx workbook parsing
* fix publish workflows
* test publish pipeline with openbb-core-v2.0.0rc0 (#7681)
* fix publish workflows with missing tag job, some tests and bump fastapi
* fix python 3.10 test failure in core
* actually fix python 3.10 test failure in core
* deincrement openbb-core version to allow release pattern
* bump core version (#7682)
* remove uv.sources from pyproject.toml files and point to openbb-core-2.0.0 on PyPI
* remove oecd cache asset that wasn't supposed to be commuitted
* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7684)
Co-authored-by: deeleeramone <>
* add the missing publish-openbb-platform-api.yml workflow file
* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7685)
Co-authored-by: deeleeramone <>
* fix the tag generation after publish
* [Release] `openbb-charting-v4.0.0` (#7686)
* up openbb-charting to 4.0.0 and fill missing test coverage in chart_style
* test coverage and format markdown files
---------
Co-authored-by: deeleeramone <>
* remove scikit-learn from dependencies, update tests, and format markdown files (#7687)
Co-authored-by: deeleeramone <>
* [Release] `openbb-cli-v2.0.0` (#7692)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* add missing publish-openbb-cli.yml workflow
* [Release] `openbb-cli-v2.0.0` (#7693)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* test fixes for cli...
* [Release] `openbb-cli-v.2.0.0` (#7694)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* fix hanging mcp server test
* [Release] `openbb-mcp-server-v2.0.0` (#7695)
Co-authored-by: deeleeramone <>
* update uv.lock (#7696)
Co-authored-by: deeleeramone <>
* release openbb-commodity-v2.0.0 (#7697)
Co-authored-by: deeleeramone <>
* update uv.lock (#7698)
Co-authored-by: deeleeramone <>
* update uv.lock (#7699)
Co-authored-by: deeleeramone <>
* update uv.lock (#7700)
Co-authored-by: deeleeramone <>
* [Release] `openbb-econometrics-v2.0.0` (#7701)
* update uv.lock
* update pyproject.toml
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7703)
Co-authored-by: deeleeramone <>
* update uv.lock (#7702)
Co-authored-by: deeleeramone <>
* update uv.lock (#7704)
Co-authored-by: deeleeramone <>
* update uv.lock (#7705)
Co-authored-by: deeleeramone <>
* update uv.lock (#7706)
Co-authored-by: deeleeramone <>
* [V5] Fix a Number of V5 Bugs (#7707)
* fix a number of v5 bugs
* fix lint error
* more lint
* fixes for quantitative, technical, charting, famafrench for version bumps
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7709)
Co-authored-by: deeleeramone <>
* update uv.lock (#7708)
Co-authored-by: deeleeramone <>
* update uv.lock (#7710)
Co-authored-by: deeleeramone <>
* update uv.lock (#7711)
Co-authored-by: deeleeramone <>
* [Release] `openbb-cboe-v2.0.0` (#7713)
Co-authored-by: deeleeramone <>
* update uv.lock (#7714)
Co-authored-by: deeleeramone <>
* update uv.lock (#7717)
Co-authored-by: deeleeramone <>
* [Release] `openbb-ecb-v2.0.0` (#7715)
* update uv.lock
* update uv.lock
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7718)
Co-authored-by: deeleeramone <>
* update uv.lock (#7719)
Co-authored-by: deeleeramone <>
* update uv.lock (#7720)
Co-authored-by: deeleeramone <>
* add missing publish-openbb-fred.yml to repo
* update uv.lock (#7723)
Co-authored-by: deeleeramone <>
* update uv.lock (#7722)
Co-authored-by: deeleeramone <>
* update uv.lock (#7716)
Co-authored-by: deeleeramone <>
* update uv.lock (#7724)
Co-authored-by: deeleeramone <>
* update uv.lock (#7726)
Co-authored-by: deeleeramone <>
* [Release] `openbb-fred-v2.0.0` (#7725)
* update uv.lock
* fix tests and dependencies
* fix test_fred_fetchers
---------
Co-authored-by: deeleeramone <>
* [Release] `openbb-imf-v3.0.0` (#7727)
* update uv.lock
* fix tests and dependencies
* fix test_fred_fetchers
* bump openbb-imf version to 3.0.0
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7729)
Co-authored-by: deeleeramone <>
* update uv.lock (#7728)
Co-authored-by: deeleeramone <>
* update uv.lock (#7730)
Co-authored-by: deeleeramone <>
* update uv.lock (#7731)
Co-authored-by: deeleeramone <>
* update uv.lock (#7732)
Co-authored-by: deeleeramone <>
* update uv.lock (#7733)
Co-authored-by: deeleeramone <>
* cookiecutter pyproject and lock
* update pyproject.toml (#7734)
Co-authored-by: deeleeramone <>
* update pyproject.toml (#7735)
Co-authored-by: deeleeramone <>
* update uv.lock (#7736)
Co-authored-by: deeleeramone <>
* fix some sec issues, allegedly (#7737)
Co-authored-by: deeleeramone <>
* update uv.lock (#7738)
Co-authored-by: deeleeramone <>
* update main pyproject.toml
* lint fixes
* update assets (#7739)
Co-authored-by: deeleeramone <>
* [Release] `openbb-v5.0.0` (#7740)
* update assets
* add missing workflow
---------
Co-authored-by: deeleeramone <>
---------
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
Co-authored-by: Dex <i@dex.moe>
Co-authored-by: Monte Hoover <monte.b.hoover@gmail.com>
Co-authored-by: Boris Li <65254053+BorisQuanLi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lucas Zimmermann <lucaszmayer@gmail.com>
8229 lines
337 KiB
Python
8229 lines
337 KiB
Python
"""General purpose HTML to Markdown conversion for SEC Filings."""
|
||
|
||
# flake8: noqa: PLR0911, PLR0912, PLR0913, PLR0914, PLR0915, PLR0916, PLR0917, PLR1702
|
||
|
||
import re
|
||
import warnings
|
||
from collections import Counter
|
||
from copy import copy
|
||
from typing import Any, cast
|
||
from urllib.parse import urljoin
|
||
|
||
from bs4 import BeautifulSoup, Comment, XMLParsedAsHTMLWarning
|
||
from bs4.element import NavigableString, Tag
|
||
|
||
# Suppress XML-as-HTML warnings (SEC files are often XML-like HTML)
|
||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||
|
||
|
||
# ============================================================================
|
||
# PUNCTUATION RULES FOR TEXT JOINING
|
||
# ============================================================================
|
||
|
||
NO_SPACE_BEFORE = {
|
||
".",
|
||
",",
|
||
":",
|
||
";",
|
||
"!",
|
||
"?",
|
||
")",
|
||
"]",
|
||
"}",
|
||
'"',
|
||
"'",
|
||
"\u201c",
|
||
"\u201d",
|
||
"\u2018",
|
||
"\u2019",
|
||
"%",
|
||
}
|
||
# Characters that shouldn't have space AFTER them
|
||
NO_SPACE_AFTER = {"(", "[", "{", '"', "'", "\u201c", "\u201d", "\u2018", "\u2019", "$"}
|
||
|
||
# Bullet characters used in SEC filings (superset of all variants)
|
||
BULLET_CHARS = {"•", "\x95", "·", "○", "►", "●", "Ø", "\u00d8", "-", "–", "—"}
|
||
|
||
# Invisible/whitespace characters to strip
|
||
INVISIBLE_CHARS = "\u200b\u200c\u200d\ufeff\xa0"
|
||
|
||
# Month names pattern for date parsing (full names and standard 3-letter abbreviations)
|
||
MONTHS_PATTERN = (
|
||
r"(?:January|February|March|April|May|June|July|August|"
|
||
r"September|October|November|December|"
|
||
r"Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)"
|
||
)
|
||
|
||
# Windows-1252 character mappings (both entity and byte forms)
|
||
WIN1252_MAP = {
|
||
# Entity form (&#NNN;)
|
||
"€": "€",
|
||
"‚": "‚",
|
||
"ƒ": "ƒ",
|
||
"„": "„",
|
||
"…": "…",
|
||
"†": "†",
|
||
"‡": "‡",
|
||
"ˆ": "ˆ",
|
||
"‰": "‰",
|
||
"Š": "Š",
|
||
"‹": "‹",
|
||
"Œ": "Œ",
|
||
"Ž": "Ž",
|
||
"‘": "'",
|
||
"’": "'",
|
||
"“": '"',
|
||
"”": '"',
|
||
"•": "•",
|
||
"–": "–",
|
||
"—": "—",
|
||
"˜": "˜",
|
||
"™": "™",
|
||
"š": "š",
|
||
"›": "›",
|
||
"œ": "œ",
|
||
"ž": "ž",
|
||
"Ÿ": "Ÿ",
|
||
# Byte form (raw bytes decoded as Latin-1)
|
||
chr(0x80): "€",
|
||
chr(0x82): "‚",
|
||
chr(0x83): "ƒ",
|
||
chr(0x84): "„",
|
||
chr(0x85): "…",
|
||
chr(0x86): "†",
|
||
chr(0x87): "‡",
|
||
chr(0x88): "ˆ",
|
||
chr(0x89): "‰",
|
||
chr(0x8A): "Š",
|
||
chr(0x8B): "‹",
|
||
chr(0x8C): "Œ",
|
||
chr(0x8E): "Ž",
|
||
chr(0x91): "'",
|
||
chr(0x92): "'",
|
||
chr(0x93): """, chr(0x94): """,
|
||
chr(0x95): "•",
|
||
chr(0x96): "–",
|
||
chr(0x97): "—",
|
||
chr(0x98): "˜",
|
||
chr(0x99): "™",
|
||
chr(0x9A): "š",
|
||
chr(0x9B): "›",
|
||
chr(0x9C): "œ",
|
||
chr(0x9E): "ž",
|
||
chr(0x9F): "Ÿ",
|
||
}
|
||
|
||
_FINANCIAL_SPACE_RE = re.compile(
|
||
r"^"
|
||
r"(\$?)" # optional $
|
||
r"\s*"
|
||
r"(\(?)" # optional (
|
||
r"\s*"
|
||
r"(\$?)" # optional $ after (
|
||
r"\s*"
|
||
r"([\d,]+\.?\d*)" # the number
|
||
r"\s*"
|
||
r"(\)?)" # optional )
|
||
r"\s*"
|
||
r"([%*]*(?:pts)?)" # optional % or pts suffix
|
||
r"$"
|
||
)
|
||
_DOLLAR_DASH_RE = re.compile(r"^\$\s*([\u2014\u2013\-])\s*(%?)$")
|
||
_DASH_PERCENT_RE = re.compile(r"^([\u2014\u2013\-])\s+(%)\s*$")
|
||
|
||
|
||
def strip_all(s):
|
||
"""Strip all whitespace and invisible characters from a string."""
|
||
return s.strip().strip(INVISIBLE_CHARS).strip()
|
||
|
||
|
||
def _normalize_financial_cell(cell):
|
||
"""Normalize one cell value to compact financial format.
|
||
|
||
'$ 1,787' -> '$1,787'
|
||
'( 135 )' -> '(135)'
|
||
'$ ( 471 )' -> '$(471)'
|
||
'( 0.2 ) %' -> '(0.2)%'
|
||
'$ —' -> '$—'
|
||
'— %' -> '—%'
|
||
"""
|
||
s = cell.strip()
|
||
if not s:
|
||
return cell
|
||
# Dollar-dash patterns: $ — -> $—
|
||
m = _DOLLAR_DASH_RE.match(s)
|
||
if m:
|
||
return "$" + m.group(1) + m.group(2)
|
||
# Dash-percent patterns: — % -> —%
|
||
m = _DASH_PERCENT_RE.match(s)
|
||
if m:
|
||
return m.group(1) + m.group(2)
|
||
# Numeric values with optional $, parens, %
|
||
m = _FINANCIAL_SPACE_RE.match(s)
|
||
if m:
|
||
dollar1, oparen, dollar2, number, cparen, suffix = m.groups()
|
||
# Only normalize if there were extraneous spaces in the original
|
||
compact = dollar1 + oparen + dollar2 + number + cparen + suffix
|
||
if compact != s:
|
||
return compact
|
||
return cell
|
||
|
||
|
||
def _normalize_financial_rows(rows):
|
||
"""Normalize all cells in all rows."""
|
||
return [[_normalize_financial_cell(c) for c in row] for row in rows]
|
||
|
||
|
||
def remove_empty_columns(rows):
|
||
"""Remove columns that are empty across all rows."""
|
||
if not rows:
|
||
return rows
|
||
num_cols = max(len(row) for row in rows)
|
||
cols_to_keep = []
|
||
for col_idx in range(num_cols):
|
||
has_any_content = any(
|
||
col_idx < len(row) and row[col_idx].strip().strip(INVISIBLE_CHARS)
|
||
for row in rows
|
||
)
|
||
if has_any_content:
|
||
cols_to_keep.append(col_idx)
|
||
result = [[row[i] if i < len(row) else "" for i in cols_to_keep] for row in rows]
|
||
return result
|
||
|
||
|
||
def is_data_row_header(row):
|
||
"""Check if a row from data array is a header/title row that should be skipped."""
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
if not non_empty:
|
||
return True # Empty row - skip
|
||
|
||
row_text = " ".join(non_empty)
|
||
|
||
# Title rows: "For the Years Ended...", "Unaudited", etc.
|
||
# BUT only if the row has NO numeric data values. Rows like
|
||
# "Three months ended April 30, | 10 | $273.42 | $2,681" are DATA
|
||
# rows whose first cell happens to contain "months ended" as a
|
||
# label — they must NOT be skipped.
|
||
if re.search(
|
||
r"(years?\s+ended|months?\s+ended|weeks?\s+ended|unaudited|as of)",
|
||
row_text,
|
||
re.I,
|
||
):
|
||
has_numeric_data = any(
|
||
re.match(
|
||
r"^[+\-]?[\$]?\(?\$?\s*[\d,]+\.?\d*\s*\)?%?$",
|
||
c.strip(),
|
||
)
|
||
for c in non_empty
|
||
if not re.match(r"^(19|20)\d{2}$", c.strip()) # Exclude years
|
||
)
|
||
if not has_numeric_data:
|
||
return True
|
||
|
||
# Check if this is a category header row (ALL CAPS words, no numbers)
|
||
# EQUIPMENT, OPERATIONS, FINANCIAL, SERVICES, ELIMINATIONS, CONSOLIDATED
|
||
all_caps_count = sum(1 for c in non_empty if re.match(r"^[A-Z]{2,}$", c))
|
||
|
||
if all_caps_count >= 2 and not any(re.search(r"\d", c) for c in non_empty):
|
||
return True
|
||
|
||
# Year-only rows: just years like 2025, 2024, 2023
|
||
year_count = sum(1 for c in non_empty if re.match(r"^(19|20)\d{2}$", c))
|
||
|
||
if year_count >= 2 and year_count == len(non_empty):
|
||
return True
|
||
|
||
# Period rows: "May 1999", "November 1998", "Q1 2024",
|
||
# "September 26, 2025", "Oct 27, 2024", etc.
|
||
# These are date/period headers that should be skipped if they're ALL dates
|
||
period_pattern = (
|
||
r"^(?:January|February|March|April|May|June|July|August|"
|
||
r"September|October|November|December|"
|
||
r"Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec|"
|
||
r"Q[1-4]|H[12])"
|
||
r"[\s.,]*(?:\d{1,2}[\s,]*)?\d{4}$"
|
||
)
|
||
period_count = sum(1 for c in non_empty if re.match(period_pattern, c, re.I))
|
||
|
||
return bool(period_count >= 2 and period_count == len(non_empty))
|
||
|
||
|
||
def _convert_image_to_html(img, base_url: str = "") -> str:
|
||
"""Convert an img element to HTML with size preservation.
|
||
|
||
Uses HTML <img> tag when size information is present to preserve
|
||
consistent rendering of checkmarks and icons.
|
||
"""
|
||
src = img.get("src", "")
|
||
alt = img.get("alt", "Image")
|
||
if not src:
|
||
return ""
|
||
|
||
# Resolve relative URLs
|
||
if base_url and not str(src).startswith(("http://", "https://", "data:")):
|
||
src = urljoin(base_url, str(src))
|
||
|
||
# Check for size information in style attribute
|
||
style = img.get("style", "")
|
||
width = None
|
||
height = None
|
||
|
||
if style:
|
||
# Parse width and height from style like "width:0.0950528in;height:0.0894847in"
|
||
width_match = re.search(r"width:\s*([0-9.]+(?:in|px|em|pt|%))", style)
|
||
height_match = re.search(r"height:\s*([0-9.]+(?:in|px|em|pt|%))", style)
|
||
if width_match:
|
||
width = width_match.group(1)
|
||
if height_match:
|
||
height = height_match.group(1)
|
||
|
||
# Also check for explicit width/height attributes
|
||
if not width and img.get("width"):
|
||
width = img.get("width")
|
||
if not any(c.isalpha() for c in str(width)):
|
||
width = f"{width}px"
|
||
if not height and img.get("height"):
|
||
height = img.get("height")
|
||
if not any(c.isalpha() for c in str(height)):
|
||
height = f"{height}px"
|
||
|
||
# If we have size info, use HTML img tag to preserve it
|
||
if width or height:
|
||
style_parts = []
|
||
if width:
|
||
style_parts.append(f"width:{width}")
|
||
if height:
|
||
style_parts.append(f"height:{height}")
|
||
style_str = ";".join(style_parts)
|
||
return f'<img src="{src}" alt="{alt}" style="{style_str}"/>'
|
||
|
||
# No size info, use standard markdown
|
||
return f""
|
||
|
||
|
||
def _count_data_columns(table):
|
||
"""Count the number of data columns in a table (from header row with years)."""
|
||
rows = table.find_all("tr")
|
||
for row in rows[:5]:
|
||
cells = row.find_all(["td", "th"])
|
||
non_empty = []
|
||
for cell in cells:
|
||
text = cell.get_text().replace("\u200b", "").replace("\xa0", " ").strip()
|
||
if text:
|
||
non_empty.append(text)
|
||
# Look for rows with multiple years
|
||
year_count = sum(1 for t in non_empty if re.match(r"^(19|20)\d{2}$", t))
|
||
if year_count >= 1:
|
||
return year_count
|
||
return 0
|
||
|
||
|
||
def _is_continuation_table(table, prev_table=None):
|
||
"""Check if this table is a continuation of a previous table.
|
||
|
||
Returns True if the table's first non-empty row is just a year like "2024" or "2023"
|
||
AND the column count matches the previous table (if provided).
|
||
|
||
Also returns True for "headerless continuations": tables that have no header
|
||
rows (no years), start directly with data rows (containing dollar/numeric values),
|
||
have the same expanded column count as prev_table, and are immediate DOM siblings.
|
||
"""
|
||
rows = table.find_all("tr")
|
||
is_year_only_start = False
|
||
for row in rows[:3]: # Check first 3 rows
|
||
cells = row.find_all(["td", "th"])
|
||
non_empty = []
|
||
for cell in cells:
|
||
text = cell.get_text().replace("\u200b", "").replace("\xa0", " ").strip()
|
||
if text:
|
||
non_empty.append(text)
|
||
if not non_empty:
|
||
continue
|
||
# If first non-empty row has just one cell and it's a year
|
||
if len(non_empty) == 1 and re.match(r"^(19|20)\d{2}$", non_empty[0]):
|
||
is_year_only_start = True
|
||
break
|
||
# If has multiple cells but first is a year and rest are empty/whitespace
|
||
break
|
||
|
||
if is_year_only_start:
|
||
# Original year-only continuation check
|
||
if prev_table is not None:
|
||
prev_cols = _count_data_columns(prev_table)
|
||
this_cols = _count_data_columns(table)
|
||
if prev_cols > 0 and this_cols > 0 and prev_cols != this_cols:
|
||
return False
|
||
return True
|
||
|
||
# ── Headerless continuation detection ──────────────────────────────
|
||
# A table that has NO header rows but matching column structure is
|
||
# likely a page-break continuation of the previous table.
|
||
if prev_table is None:
|
||
return False
|
||
|
||
# 1. Check that they are immediate DOM siblings (no significant content between)
|
||
if not _are_immediate_sibling_tables(prev_table, table):
|
||
return False
|
||
|
||
# 2. Must have matching expanded column counts
|
||
prev_width = _expanded_col_count(prev_table)
|
||
this_width = _expanded_col_count(table)
|
||
if prev_width == 0 or this_width == 0 or prev_width != this_width:
|
||
return False
|
||
|
||
# 3. Must have NO header row (no years or period phrases in the first
|
||
# non-empty row that would indicate an independent table)
|
||
for row in rows[:3]:
|
||
cells = row.find_all(["td", "th"])
|
||
non_empty = []
|
||
for cell in cells:
|
||
text = cell.get_text().replace("\u200b", "").replace("\xa0", " ").strip()
|
||
if text:
|
||
non_empty.append(text)
|
||
if not non_empty:
|
||
continue
|
||
# If ANY cell is a year or period phrase, it has its own header
|
||
for t in non_empty:
|
||
if re.match(r"^(19|20)\d{2}$", t):
|
||
return False
|
||
if re.search(
|
||
r"(months?\s+ended|year\s+ended|quarter\s+ended|weeks?\s+ended)",
|
||
t,
|
||
re.I,
|
||
):
|
||
return False
|
||
break
|
||
|
||
# 4. At least one of the first several rows must look like a data row
|
||
# (contains a dollar sign, or 2+ numeric/percentage cells).
|
||
# This allows section label rows (e.g., "Investment banking fees")
|
||
# to precede the actual data without blocking detection,
|
||
# while avoiding false merges on TOC tables (single page-number cells).
|
||
for row in rows[:10]:
|
||
cells = row.find_all(["td", "th"])
|
||
texts = []
|
||
for cell in cells:
|
||
text = cell.get_text().replace("\u200b", "").replace("\xa0", " ").strip()
|
||
if text:
|
||
texts.append(text)
|
||
if not texts:
|
||
continue
|
||
has_dollar = any("$" in t for t in texts)
|
||
if has_dollar:
|
||
return True
|
||
numeric_count = sum(
|
||
1
|
||
for t in texts
|
||
if t not in ("—", "-", "–")
|
||
and re.match(r"^[\$\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t)
|
||
)
|
||
if numeric_count >= 2:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def _are_immediate_sibling_tables(table_a, table_b):
|
||
"""Check if two tables are nearby in the DOM with no significant content between.
|
||
|
||
Tables may be in different wrapper divs (e.g. page-break divs) — walk
|
||
upward to find the containers, then check siblings between them.
|
||
"""
|
||
# Simple case: same parent
|
||
if table_a.parent is table_b.parent:
|
||
node = table_a.next_sibling
|
||
while node is not None and node is not table_b:
|
||
if hasattr(node, "name") and node.name:
|
||
text = node.get_text(strip=True)
|
||
if text and len(text) > 2:
|
||
return False
|
||
elif isinstance(node, str) and len(node.strip()) > 2:
|
||
return False
|
||
node = node.next_sibling
|
||
return node is table_b
|
||
|
||
# Different parents: walk up to find wrapper containers and check
|
||
# that the gap between them contains no significant content.
|
||
# Typical pattern: <div><table17/></div><hr/><div><table18/></div>
|
||
container_a = table_a.parent
|
||
container_b = table_b.parent
|
||
if container_a is None or container_b is None:
|
||
return False
|
||
# Both containers must share the same grandparent
|
||
if container_a.parent is not container_b.parent:
|
||
return False
|
||
# Table must be the last element in its container
|
||
# (no significant content after the table within the div)
|
||
node = table_a.next_sibling
|
||
while node is not None:
|
||
if hasattr(node, "name") and node.name:
|
||
text = node.get_text(strip=True)
|
||
if text and len(text) > 2:
|
||
return False
|
||
elif isinstance(node, str) and len(node.strip()) > 2:
|
||
return False
|
||
node = node.next_sibling
|
||
# Table_b must be the first significant element in its container
|
||
node = table_b.previous_sibling
|
||
while node is not None:
|
||
if hasattr(node, "name") and node.name:
|
||
text = node.get_text(strip=True)
|
||
if text and len(text) > 2:
|
||
return False
|
||
elif isinstance(node, str) and len(node.strip()) > 2:
|
||
return False
|
||
node = node.previous_sibling
|
||
# Check gap between the two containers (page number, hr, etc. are ok)
|
||
node = container_a.next_sibling
|
||
while node is not None and node is not container_b:
|
||
if hasattr(node, "name") and node.name:
|
||
text = node.get_text(strip=True)
|
||
# Allow page numbers, empty divs, <hr> separators
|
||
if node.name == "hr":
|
||
node = node.next_sibling
|
||
continue
|
||
if text and len(text) > 10:
|
||
# Substantial content between containers — not a continuation
|
||
return False
|
||
elif isinstance(node, str) and len(node.strip()) > 10:
|
||
return False
|
||
node = node.next_sibling
|
||
return node is container_b
|
||
|
||
|
||
def _expanded_col_count(table):
|
||
"""Get the expanded column count (sum of colspans) of a table's widest row."""
|
||
max_cols = 0
|
||
for row in table.find_all("tr")[:10]:
|
||
cells = row.find_all(["td", "th"])
|
||
total = sum(int(c.get("colspan", 1)) for c in cells)
|
||
max_cols = max(max_cols, total)
|
||
return max_cols
|
||
|
||
|
||
def _merge_continuation_tables(soup):
|
||
"""Merge consecutive tables where later ones are continuations."""
|
||
tables = soup.find_all("table", recursive=True)
|
||
tables_to_remove = set()
|
||
|
||
i = 0
|
||
while i < len(tables):
|
||
table = tables[i]
|
||
if table in tables_to_remove:
|
||
i += 1
|
||
continue
|
||
|
||
# Find consecutive continuation tables.
|
||
# Track last_ref so the proximity check is always between
|
||
# adjacent tables (not base-vs-distant) when chaining
|
||
# across multiple page-break boundaries.
|
||
continuations = []
|
||
j = i + 1
|
||
last_ref = table
|
||
while j < len(tables):
|
||
next_table = tables[j]
|
||
if (
|
||
next_table in tables_to_remove
|
||
): # pragma: no cover - unreachable: tables_to_remove only holds continuations of an earlier base; those form a contiguous run, so any forward table j>i already removed would force i into the same run and disqualify i as a base (verified by a 6-table chain trace)
|
||
j += 1
|
||
continue
|
||
# Check if next_table immediately follows (no significant content between)
|
||
# and is a continuation table
|
||
if _is_continuation_table(next_table, last_ref):
|
||
continuations.append(next_table)
|
||
last_ref = next_table
|
||
j += 1
|
||
else:
|
||
break
|
||
|
||
if continuations:
|
||
# For each continuation, prepend header copies and append to main table
|
||
for cont_table in continuations:
|
||
# Get all rows from continuation table
|
||
cont_rows = cont_table.find_all("tr")
|
||
# Find the main table's tbody or create one
|
||
tbody = table.find("tbody")
|
||
|
||
if tbody is None:
|
||
tbody = table
|
||
|
||
# Append continuation rows to main table
|
||
for cont_row in cont_rows:
|
||
# Clone the row and append
|
||
tbody.append(cont_row.extract())
|
||
|
||
# Mark for removal
|
||
tables_to_remove.add(cont_table)
|
||
|
||
i += 1
|
||
|
||
# Remove merged tables
|
||
for table in tables_to_remove:
|
||
table.decompose()
|
||
|
||
|
||
def is_header_element(tag) -> bool:
|
||
"""Check if element looks like a section header."""
|
||
if tag.name in ["h1", "h2", "h3", "h4", "h5", "h6"]:
|
||
return True
|
||
|
||
# Elements explicitly marked as body text by the reflow engine
|
||
# should never be promoted to headings.
|
||
if tag.get("data-body-text"):
|
||
return False
|
||
|
||
# Check for TOC section headers - elements with toc* anchor IDs
|
||
element_id = tag.get("id", "")
|
||
|
||
if element_id and element_id.startswith("toc"):
|
||
# Check if it contains bold/styled text indicating a section header
|
||
bold_child = tag.find(["b", "strong"]) or tag.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
if bold_child:
|
||
text = tag.get_text().strip()
|
||
|
||
if text and len(text) < 150:
|
||
return True
|
||
|
||
# Check for divs/paragraphs styled as headers (large font-size)
|
||
if tag.name in ["div", "p"]:
|
||
# Positioned layouts (Workiva) nest tables inside styled divs
|
||
# whose font-size or bold spans would otherwise false-positive.
|
||
if tag.find("table"):
|
||
return False
|
||
|
||
style = tag.get("style", "")
|
||
# Look for font-size in style attribute
|
||
font_match = re.search(r"font-size:\s*(\d+)(?:pt|px)", style)
|
||
|
||
if font_match:
|
||
font_size = int(font_match.group(1))
|
||
# Check if child spans have a SMALLER font size — if so, the
|
||
# container font-size is just a fallback/default (common in
|
||
# position-based layouts) and should NOT trigger header detection.
|
||
child_font = None
|
||
|
||
for span in tag.find_all("span", recursive=True):
|
||
s = span.get("style", "")
|
||
cm = re.search(r"font-size:\s*(\d+)(?:pt|px)", s)
|
||
|
||
if cm:
|
||
child_font = int(cm.group(1))
|
||
break
|
||
|
||
effective_font = child_font if child_font is not None else font_size
|
||
# 11pt+ is typically header-sized in SEC filings
|
||
if effective_font >= 11:
|
||
text = tag.get_text().strip()
|
||
# Should be reasonably short and have content
|
||
if text and len(text) < 150:
|
||
return True
|
||
|
||
# Only trigger when the div has no bare text nodes — positioned divs always wrap text in spans.
|
||
has_bare_text = any(
|
||
isinstance(c, NavigableString) and c.strip() for c in tag.children
|
||
)
|
||
if not has_bare_text:
|
||
# Check for <p>/<div> whose only children are <b>/<strong> tags
|
||
non_ws_children = [
|
||
c
|
||
for c in tag.children
|
||
if not (isinstance(c, NavigableString) and not c.strip())
|
||
]
|
||
all_bold_tags = (
|
||
non_ws_children
|
||
and all(
|
||
(hasattr(c, "name") and c.name in ("b", "strong"))
|
||
or (
|
||
hasattr(c, "name")
|
||
and c.name == "span"
|
||
and not c.get_text(strip=True)
|
||
)
|
||
for c in non_ws_children
|
||
)
|
||
# At least one child must be <b>/<strong>
|
||
and any(
|
||
hasattr(c, "name") and c.name in ("b", "strong")
|
||
for c in non_ws_children
|
||
)
|
||
)
|
||
if all_bold_tags and not tag.find("img"):
|
||
text = tag.get_text().strip()
|
||
if text and len(text) < 100 and re.search(r"[a-zA-Z]", text):
|
||
return True
|
||
|
||
text_spans = [
|
||
s
|
||
for s in tag.find_all("span", recursive=True)
|
||
if any(isinstance(c, NavigableString) and c.strip() for c in s.children)
|
||
and "display:inline-block" not in s.get("style", "").replace(" ", "")
|
||
]
|
||
if text_spans:
|
||
# Count bold vs total characters across text spans.
|
||
bold_chars = 0
|
||
total_chars = 0
|
||
|
||
for s in text_spans:
|
||
sty = s.get("style", "")
|
||
is_b = bool(
|
||
re.search(
|
||
r"font-weight:\s*(bold|bolder|[6-9]00)",
|
||
sty,
|
||
re.I,
|
||
)
|
||
)
|
||
for c in s.children:
|
||
if isinstance(c, NavigableString) and c.strip():
|
||
n = len(c.strip())
|
||
total_chars += n
|
||
if is_b:
|
||
bold_chars += n
|
||
|
||
mostly_bold = total_chars > 0 and bold_chars / total_chars >= 0.8
|
||
|
||
if mostly_bold:
|
||
text = tag.get_text().strip()
|
||
|
||
if text and len(text) < 80 and re.search(r"[a-zA-Z]", text):
|
||
return True
|
||
|
||
if tag.name in ["b", "strong"]:
|
||
text = tag.get_text().strip()
|
||
|
||
if re.match(r"^ITEM\s+\d", text, re.IGNORECASE):
|
||
return True
|
||
|
||
if re.match(r"^PART\s+[IVX]+", text, re.IGNORECASE):
|
||
return True
|
||
|
||
words = text.split()
|
||
|
||
if len(words) >= 2 and len(text) < 80 and text.isupper():
|
||
return True
|
||
# Single uppercase words that look like section titles
|
||
if len(words) == 1 and text.isupper() and len(text) >= 4:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def get_header_level(tag) -> int:
|
||
"""Determine header level."""
|
||
text = tag.get_text().strip()
|
||
if re.match(r"^PART\s+[IVX]+", text, re.IGNORECASE):
|
||
return 1
|
||
if re.match(r"^ITEM\s+\d", text, re.IGNORECASE):
|
||
return 2
|
||
if tag.name == "h1":
|
||
return 1
|
||
if tag.name == "h2":
|
||
return 2
|
||
if tag.name == "h3":
|
||
return 3
|
||
|
||
# Check font-size for styled divs/paragraphs
|
||
if tag.name in ["div", "p"]:
|
||
style = tag.get("style", "")
|
||
font_match = re.search(r"font-size:\s*(\d+)(?:pt|px)", style)
|
||
if font_match:
|
||
font_size = int(font_match.group(1))
|
||
if font_size >= 15:
|
||
return 2 # Large headers (15-16pt)
|
||
if font_size >= 11:
|
||
return 3 # Subsection headers (11-14pt)
|
||
return 3
|
||
|
||
|
||
# ============================================================================
|
||
# CONVERT_TABLE HELPER FUNCTIONS (extracted from convert_table)
|
||
# ============================================================================
|
||
|
||
|
||
def clean_table_cells(rows):
|
||
"""Merge currency symbols and parentheses with values.
|
||
|
||
Handles common SEC filing table patterns:
|
||
- $ in one cell, value in next: "$" + "12,211" → "$12,211"
|
||
- Opening paren with value: "(" + "2,257" + ")" → "(2,257)"
|
||
- Split negative with note: "(2,257" + ")(b)" → "(2,257)(b)"
|
||
- Value + trailing note: "7" + "(a)" → "7 (a)"
|
||
"""
|
||
cleaned = []
|
||
for row in rows:
|
||
new_row = list(row)
|
||
|
||
# Pass 1: Merge $ or ( prefix with following value
|
||
i = 0
|
||
while i < len(new_row):
|
||
cell = new_row[i].strip()
|
||
if cell in ["$", "(", "$(", "($"] and i + 1 < len(new_row):
|
||
for j in range(i + 1, min(i + 4, len(new_row))):
|
||
next_val = new_row[j].strip()
|
||
if next_val and next_val not in ["$", ")", "%", ""]:
|
||
new_row[i] = cell + next_val
|
||
for k in range(i + 1, j + 1):
|
||
new_row[k] = ""
|
||
break
|
||
if next_val in [")", "%"]:
|
||
break
|
||
i += 1
|
||
|
||
# Pass 2: Merge split negatives like "(2,257" + ")(b)" or "(2,257" + ")"
|
||
# Also handle "(2,257" + close paren in any form
|
||
# And handle "$(171" + ")" patterns (currency + open paren)
|
||
i = 0
|
||
while i < len(new_row):
|
||
cell = new_row[i].strip()
|
||
# Check if cell has an open paren that isn't closed
|
||
# Handles both "(2,257" and "$(171" patterns
|
||
has_open_paren = "(" in cell and not cell.endswith(")")
|
||
if has_open_paren and i + 1 < len(new_row):
|
||
# Look for closing paren in following cells
|
||
for j in range(i + 1, min(i + 3, len(new_row))):
|
||
next_val = new_row[j].strip()
|
||
if next_val.startswith(")"):
|
||
# Merge: "(2,257" + ")(b)" → "(2,257)(b)"
|
||
new_row[i] = cell + next_val
|
||
new_row[j] = ""
|
||
break
|
||
if next_val == "":
|
||
continue
|
||
break
|
||
i += 1
|
||
|
||
# Pass 3: Merge value with trailing ), %, pts., footnote markers (*, **)
|
||
# SEC tables often split suffixes into narrow separate columns
|
||
i = 0
|
||
while i < len(new_row):
|
||
cell = new_row[i].strip()
|
||
if cell and i + 1 < len(new_row):
|
||
for j in range(i + 1, min(i + 4, len(new_row))):
|
||
next_val = new_row[j].strip()
|
||
# Match suffix patterns: ), %, )%, %*, )pts., pts., *, **, etc.
|
||
if next_val and re.match(r"^[)%]*(?:pts\.?)?[*]*$", next_val):
|
||
new_row[i] = cell + next_val
|
||
for k in range(i + 1, j + 1):
|
||
new_row[k] = ""
|
||
break
|
||
|
||
if next_val and next_val not in ["", " "]:
|
||
break
|
||
|
||
i += 1
|
||
|
||
# Pass 4: Merge numeric value with following note like "(a)", "(b)"
|
||
# Pattern: "7" + "(a)" → "7 (a)" or "5,754" + "(a)" → "5,754 (a)"
|
||
i = 0
|
||
|
||
while i < len(new_row):
|
||
cell = new_row[i].strip()
|
||
# Check if cell is a numeric value
|
||
if (
|
||
cell
|
||
and re.match(r"^[\d,.$()-]+$", cell.replace(" ", ""))
|
||
and i + 1 < len(new_row)
|
||
):
|
||
next_val = new_row[i + 1].strip()
|
||
# Check if next cell is a note reference like "(a)", "(b)"
|
||
if next_val and re.match(r"^\([a-z]\)$", next_val):
|
||
new_row[i] = cell + " " + next_val
|
||
new_row[i + 1] = ""
|
||
i += 1
|
||
|
||
new_row = [c.strip() for c in new_row]
|
||
cleaned.append(new_row)
|
||
|
||
return cleaned
|
||
|
||
|
||
def merge_split_rows(rows, rows_with_colspan):
|
||
"""Merge consecutive rows that are continuations of split text."""
|
||
merged_data = []
|
||
merged_colspan = []
|
||
i = 0
|
||
while i < len(rows):
|
||
row = rows[i]
|
||
row_cs = rows_with_colspan[i] if i < len(rows_with_colspan) else []
|
||
row_text = " ".join(c.strip() for c in row if c.strip())
|
||
|
||
# ── New: label-continuation merge ──────────────────────────────────
|
||
# Detect rows whose label cell (col 0) has text but all other cells
|
||
# are empty, AND the next row's label starts with a lowercase word
|
||
# (i.e. it is a sentence continuation, e.g.
|
||
# Row A: "Level 3 assets for which we do not"
|
||
# Row B: "bear economic exposure (7)" | (14,437) | …
|
||
# → Merge to: "Level 3 assets for which we do not bear economic exposure (7)"
|
||
# and take the data values from Row B.
|
||
non_empty_cols = [j for j, c in enumerate(row) if c.strip()]
|
||
if len(non_empty_cols) == 1 and non_empty_cols[0] == 0 and i + 1 < len(rows):
|
||
next_row = rows[i + 1]
|
||
next_label = next_row[0].strip() if next_row else ""
|
||
# Continuation: next label starts with a lowercase letter
|
||
if next_label and next_label[0].islower():
|
||
label_a = row[0].strip()
|
||
merged_label = label_a + " " + next_label
|
||
# Build merged row: combined label, then data columns from next row
|
||
new_row = [merged_label] + list(next_row[1:])
|
||
next_cs = (
|
||
rows_with_colspan[i + 1] if i + 1 < len(rows_with_colspan) else []
|
||
)
|
||
merged_data.append(new_row)
|
||
merged_colspan.append(next_cs)
|
||
i += 2
|
||
continue
|
||
# ───────────────────────────────────────────────────────────────────
|
||
|
||
# Check if this row starts an incomplete parenthetical
|
||
# Pattern: starts with ( but doesn't end with )
|
||
# BUT only if the first non-empty cell itself has an unbalanced paren.
|
||
# e.g., "(in millions, except per" is incomplete → merge with "share amounts)"
|
||
# but "(in millions)" is complete → do NOT merge even if other cells follow.
|
||
# Also "(Benefit from) provision for taxes" has balanced parens even
|
||
# though it doesn't end with ")" — still complete, don't merge.
|
||
if row_text.startswith("(") and not row_text.endswith(")"):
|
||
# Check if the first non-empty cell has balanced parentheses
|
||
first_cell = ""
|
||
for c in row:
|
||
if c.strip():
|
||
first_cell = c.strip()
|
||
break
|
||
first_cell_balanced = first_cell.count("(") == first_cell.count(")")
|
||
if first_cell_balanced:
|
||
# First cell has balanced parens like "(in millions)" or
|
||
# "(Benefit from) provision for income taxes" —
|
||
# don't try to merge with subsequent rows
|
||
merged_data.append(row)
|
||
merged_colspan.append(row_cs)
|
||
i += 1
|
||
continue
|
||
|
||
# If non-label columns have content, this is a header row
|
||
# where the descriptor sits at col 0 alongside date/year
|
||
# columns. Don't merge — the unbalanced paren is just the
|
||
# descriptor wrapping across the HTML row, and the data in
|
||
# other columns must be preserved.
|
||
non_label_content = any(c.strip() for c in row[1:])
|
||
if non_label_content:
|
||
merged_data.append(row)
|
||
merged_colspan.append(row_cs)
|
||
i += 1
|
||
continue
|
||
|
||
# Look ahead for continuation
|
||
merged_text = row_text
|
||
j = i + 1
|
||
while j < len(rows):
|
||
next_row = rows[j]
|
||
next_text = " ".join(c.strip() for c in next_row if c.strip())
|
||
# Check if next row is continuation (ends with ) or has amounts/share text)
|
||
if next_text and (
|
||
next_text.endswith(")")
|
||
or "amount" in next_text.lower()
|
||
or "share" in next_text.lower()
|
||
or next_text.startswith("and ")
|
||
):
|
||
merged_text += " " + next_text
|
||
j += 1
|
||
if next_text.endswith(")"):
|
||
break
|
||
else:
|
||
break
|
||
|
||
if j > i + 1:
|
||
# We merged rows - create single row with combined text
|
||
new_row = [merged_text] + [""] * (len(row) - 1)
|
||
merged_data.append(new_row)
|
||
# For colspan, just use first row's structure
|
||
merged_colspan.append(row_cs)
|
||
i = j
|
||
continue
|
||
|
||
merged_data.append(row)
|
||
merged_colspan.append(row_cs)
|
||
i += 1
|
||
|
||
return merged_data, merged_colspan
|
||
|
||
|
||
def merge_split_cells(rows):
|
||
"""Merge cells that contain parts of the same value.
|
||
|
||
IMPORTANT: When merging, we add an empty placeholder to maintain column
|
||
alignment across all rows. Otherwise, rows with merged cells would have
|
||
fewer columns than rows without merges, causing misalignment.
|
||
"""
|
||
result = []
|
||
for row in rows:
|
||
merged_row = []
|
||
i = 0
|
||
while i < len(row):
|
||
cell = row[i]
|
||
cell_stripped = cell.strip()
|
||
|
||
# Look ahead to see if next cell should be merged
|
||
if i + 1 < len(row):
|
||
next_cell = row[i + 1].strip()
|
||
|
||
# Case 1: Cell ends with "(" and next cell is just ")"
|
||
# e.g., "(306" + ")" → "(306)"
|
||
if cell_stripped.endswith("(") and next_cell == ")":
|
||
merged_row.append(cell_stripped + ")")
|
||
merged_row.append("") # Placeholder to maintain column count
|
||
i += 2
|
||
continue
|
||
|
||
# Case 2: Cell is a number and next cell is just a closing paren
|
||
# e.g., "(306" in cell, ")" in next cell for alignment
|
||
# This handles negative numbers where open paren is with number
|
||
if re.match(r"^\([\d,\.]+$", cell_stripped) and next_cell == ")":
|
||
merged_row.append(cell_stripped + ")")
|
||
merged_row.append("") # Placeholder to maintain column count
|
||
i += 2
|
||
continue
|
||
|
||
# Case 3: Cell is a number and next cell is a footnote marker
|
||
# e.g., "2,264" + "(1)" → "2,264 (1)"
|
||
# Only merge footnotes with 1-2 digits; 3+ digits like
|
||
# "(193)" are negative financial values, not footnotes.
|
||
if re.match(r"^[\$]?[\d,\.]+$", cell_stripped) and re.match(
|
||
r"^\(\d{1,2}\)$", next_cell
|
||
):
|
||
merged_row.append(cell_stripped + " " + next_cell)
|
||
merged_row.append("") # Placeholder to maintain column count
|
||
i += 2
|
||
continue
|
||
|
||
# Case 4: Cell is a number and a nearby cell is "%" or "pts"
|
||
# SEC tables split "22%" across cells: "22"[cs=2] + "%"[cs=1]
|
||
# After colspan expansion this becomes: "22", "", "%"
|
||
# Also handles: "(1.2)" + "" + "pts" → "(1.2)pts"
|
||
if re.match(r"^[\(\)]?[\d,\.]+[\)]?$", cell_stripped):
|
||
# Look for "%" or "pts" up to 2 cells ahead (skipping empties)
|
||
suffix_offset = None
|
||
for look in range(1, min(3, len(row) - i)):
|
||
look_cell = row[i + look].strip()
|
||
if look_cell in ("%", "pts"):
|
||
suffix_offset = look
|
||
break
|
||
if look_cell != "":
|
||
break # Non-empty, non-suffix cell - stop
|
||
|
||
if suffix_offset is not None:
|
||
suffix = row[i + suffix_offset].strip()
|
||
merged_row.append(cell_stripped + suffix)
|
||
# Add empty placeholders for all consumed cells
|
||
for _ in range(suffix_offset):
|
||
merged_row.append("")
|
||
i += suffix_offset + 1
|
||
continue
|
||
|
||
merged_row.append(cell)
|
||
i += 1
|
||
|
||
result.append(merged_row)
|
||
return result
|
||
|
||
|
||
def collapse_repeated_headers(rows):
|
||
"""Collapse adjacent duplicate header cells from colspan expansion.
|
||
|
||
When header cells have colspan>1, they get expanded to multiple cells
|
||
with the same text. Data cells in those spans typically only fill one
|
||
position. After remove_empty_columns, we may have:
|
||
Header: ["Name", "Fiscal Year", "", "Salary", "", "Bonus", ...]
|
||
Data: ["Ellison", "", "2025", "", "950,000", "", ...]
|
||
|
||
This function collapses repeated adjacent headers and shifts data to align:
|
||
Header: ["Name", "Fiscal Year", "Salary", "Bonus", ...]
|
||
Data: ["Ellison", "2025", "950,000", ...]
|
||
"""
|
||
if not rows or len(rows) < 2:
|
||
return rows
|
||
|
||
header = rows[0]
|
||
if len(header) < 2:
|
||
return rows
|
||
|
||
# Identify positions to collapse: where header[i] == header[i-1] and both non-empty
|
||
# OR where header[i] is empty but header[i-1] is not (empty trailing from colspan)
|
||
cols_to_remove = set()
|
||
for i in range(1, len(header)):
|
||
prev = header[i - 1].strip() if header[i - 1] else ""
|
||
curr = header[i].strip() if header[i] else ""
|
||
|
||
# If current is duplicate of previous OR empty with non-empty prev
|
||
# AND the data rows mostly have content in current (not prev)
|
||
if prev and (curr == prev or not curr):
|
||
# Check if data rows have content in THIS column vs PREV column
|
||
# If data is in current column, keep current and remove previous
|
||
# If data is in previous column, remove current
|
||
prev_has_data = any(
|
||
i - 1 < len(row) and row[i - 1].strip() for row in rows[1:]
|
||
)
|
||
curr_has_data = any(i < len(row) and row[i].strip() for row in rows[1:])
|
||
|
||
if curr_has_data and not prev_has_data:
|
||
# Data is in current column, remove the previous header duplicate
|
||
# and copy header text to current
|
||
header[i] = prev # Copy header to data position
|
||
cols_to_remove.add(i - 1)
|
||
elif not curr_has_data and prev_has_data:
|
||
# Data is in previous column, just remove current empty
|
||
cols_to_remove.add(i)
|
||
elif not curr_has_data and not prev_has_data:
|
||
# Neither has data, remove the empty trailing
|
||
if not curr:
|
||
cols_to_remove.add(i)
|
||
elif curr_has_data and prev_has_data and not curr:
|
||
# Both have data, but header is only in prev (curr is empty).
|
||
# Check if data is complementary: no row has data in BOTH columns.
|
||
# This happens with $ prefix columns in SEC tables where dollar rows
|
||
# put merged $+value at a different position than non-dollar rows.
|
||
is_complementary = True
|
||
for row in rows[1:]:
|
||
p = row[i - 1].strip() if i - 1 < len(row) else ""
|
||
c = row[i].strip() if i < len(row) else ""
|
||
if p and c:
|
||
is_complementary = False
|
||
break
|
||
if is_complementary:
|
||
# Merge: move curr's data into prev's empty slots, remove curr
|
||
for row in rows[1:]:
|
||
c = row[i].strip() if i < len(row) else ""
|
||
p = row[i - 1].strip() if i - 1 < len(row) else ""
|
||
if c and not p:
|
||
row[i - 1] = row[i]
|
||
cols_to_remove.add(i)
|
||
|
||
if not cols_to_remove:
|
||
# Still check for trailing empty columns
|
||
pass
|
||
|
||
# Second pass: detect complementary adjacent columns caused by
|
||
# $ prefix merging offset. When $ is in a separate cell, the merged
|
||
# $+value lands at a different expanded position than non-dollar values.
|
||
# Result: adjacent columns with complementary data (never both non-empty
|
||
# in the same row). Merge the smaller into the larger regardless of
|
||
# whether rows[0] has header text.
|
||
for i in range(1, len(header)):
|
||
if i in cols_to_remove or (i - 1) in cols_to_remove:
|
||
continue # Already handled
|
||
|
||
# Check if columns i-1 and i are strictly complementary across ALL rows
|
||
is_complementary = True
|
||
col_prev_count = 0
|
||
col_curr_count = 0
|
||
for row in rows:
|
||
p = row[i - 1].strip() if i - 1 < len(row) else ""
|
||
c = row[i].strip() if i < len(row) else ""
|
||
if p and c:
|
||
is_complementary = False
|
||
break
|
||
if p:
|
||
col_prev_count += 1
|
||
if c:
|
||
col_curr_count += 1
|
||
|
||
if not is_complementary:
|
||
continue
|
||
if col_curr_count == 0:
|
||
continue # col i is fully empty, remove_empty_columns handles it
|
||
|
||
# Merge smaller into larger (by count of non-empty rows)
|
||
if col_prev_count >= col_curr_count:
|
||
# Merge col i data into col i-1
|
||
for row in rows:
|
||
c = row[i].strip() if i < len(row) else ""
|
||
p = row[i - 1].strip() if i - 1 < len(row) else ""
|
||
if c and not p:
|
||
row[i - 1] = row[i]
|
||
cols_to_remove.add(i)
|
||
else:
|
||
# Merge col i-1 data into col i
|
||
for row in rows:
|
||
p = row[i - 1].strip() if i - 1 < len(row) else ""
|
||
c = row[i].strip() if i < len(row) else ""
|
||
if p and not c:
|
||
row[i] = row[i - 1]
|
||
cols_to_remove.add(i - 1)
|
||
|
||
# Also remove trailing empty columns (both header and all data empty)
|
||
num_cols = len(header)
|
||
for col_idx in range(num_cols - 1, -1, -1):
|
||
header_empty = (not header[col_idx].strip()) if col_idx < len(header) else True
|
||
data_empty = all(
|
||
(not row[col_idx].strip() if col_idx < len(row) else True)
|
||
for row in rows[1:]
|
||
)
|
||
if header_empty and data_empty:
|
||
cols_to_remove.add(col_idx)
|
||
else:
|
||
break # Stop at first non-empty column from the right
|
||
|
||
if not cols_to_remove:
|
||
return rows
|
||
|
||
# Rebuild all rows without the collapsed columns
|
||
cols_to_keep = [i for i in range(len(header)) if i not in cols_to_remove]
|
||
result = []
|
||
for row in rows:
|
||
new_row = [row[i] if i < len(row) else "" for i in cols_to_keep]
|
||
result.append(new_row)
|
||
|
||
return result
|
||
|
||
|
||
def parse_row_semantic(row, num_expected_cols=0):
|
||
"""Parse a row into (label, values) like non_xbrl_parser does."""
|
||
label = None
|
||
values = []
|
||
additional_text = [] # Text that might be values if no numeric values found
|
||
|
||
for cell in row:
|
||
c = cell.strip()
|
||
|
||
if not c or c == "$":
|
||
continue
|
||
|
||
# Em dashes represent "not applicable" - they're values
|
||
# Handle plain dashes AND dollar+dash like "$—" or "$ —" (spaced)
|
||
if c in ("—", "–", "-") and len(c) == 1 or re.match(r"^\$\s*[—–\-]$", c):
|
||
values.append(c)
|
||
# Check if this is a number (with optional $ prefix, commas, parens for negative)
|
||
|
||
elif re.match(
|
||
r"^[+\-]?[\$]?\s*\(?[\$]?\s*\d[\d,]*\.?\d*\s*\)?\s*%?(?:pts\.?)?\s*\*{0,2}\s*(\(\d+\)|\([a-z]\))?$",
|
||
c.replace(",", ""),
|
||
):
|
||
# It's a number (possibly with footnote) - it's a value
|
||
values.append(c)
|
||
elif label is None:
|
||
label = c
|
||
else:
|
||
# Additional text - might be a text value (like credit ratings)
|
||
# Keep it separate so we can decide later
|
||
additional_text.append(c)
|
||
|
||
# If we found no numeric values but have additional text, and we expect columns,
|
||
# treat the additional text as values (e.g., credit ratings like "P-1", "A1")
|
||
if not values and additional_text and num_expected_cols > 0:
|
||
# Use additional text as values (up to expected count)
|
||
values = additional_text[:num_expected_cols]
|
||
elif not values and additional_text:
|
||
# No expected columns info - append to label as before
|
||
label = (label or "") + " " + " ".join(additional_text)
|
||
elif (
|
||
values
|
||
and additional_text
|
||
and len(values) < num_expected_cols
|
||
and num_expected_cols > 0
|
||
):
|
||
# We have some numeric values but fewer than expected. Fill remaining
|
||
# slots with extra text items — e.g., a "Valuation Technique" text
|
||
# column that appears alongside a numeric "Fair Value" column.
|
||
needed = num_expected_cols - len(values)
|
||
values.extend(additional_text[:needed])
|
||
|
||
# If we have values but no label, it's likely a total row
|
||
# Use "Total" as the label if values look like currency totals
|
||
if values and not label:
|
||
# Check if values contain dollar amounts (start with $ or have commas)
|
||
has_currency = any(
|
||
v.startswith("$") or re.match(r"^\(?\d{1,3}(,\d{3})+", v) for v in values
|
||
)
|
||
if has_currency:
|
||
label = "Total"
|
||
|
||
return label, values
|
||
|
||
|
||
def detect_and_merge_multiindex_headers(data_rows):
|
||
"""Detect and merge multi-index column headers from data rows.
|
||
|
||
Returns (header_rows, data_start_index, num_value_cols) where:
|
||
header_rows: list of COMPRESSED header rows [label_col, val1, val2, ...]
|
||
data_start_index: index into data_rows where actual data begins
|
||
num_value_cols: number of value columns (for data extraction)
|
||
"""
|
||
if not data_rows or len(data_rows) < 3:
|
||
return None, 0, 0
|
||
|
||
def is_year(t):
|
||
# Strip trailing footnote markers like *, **, †, ‡, §
|
||
t_clean = re.sub(r"[*†‡§+]+$", "", t.strip())
|
||
return bool(re.match(r"^(19|20)\d{2}$", t_clean))
|
||
|
||
def is_all_caps_word(t):
|
||
t = t.strip()
|
||
return bool(re.match(r"^[A-Z]{2,}$", t)) and len(t) > 1
|
||
|
||
def is_numeric(t):
|
||
t = t.strip()
|
||
return bool(re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t)) and any(
|
||
c.isdigit() for c in t
|
||
)
|
||
|
||
def row_has_years(row):
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
year_count = sum(1 for c in non_empty if is_year(c))
|
||
return year_count >= 3 # At least 3 years
|
||
|
||
def row_is_all_caps_categories(row):
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
if len(non_empty) < 2:
|
||
return False
|
||
caps_count = sum(1 for c in non_empty if is_all_caps_word(c))
|
||
return caps_count >= 2 and caps_count >= len(non_empty) * 0.5
|
||
|
||
def row_has_data(row):
|
||
return any(is_numeric(c) for c in row)
|
||
|
||
def row_is_title(row):
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
|
||
if not non_empty:
|
||
return True
|
||
|
||
row_text = " ".join(non_empty)
|
||
|
||
return bool(
|
||
re.search(
|
||
r"(years?\s+ended|months?\s+ended|weeks?\s+ended|unaudited|as\s+of|income\s+statement|balance\s+sheet|statement\s+of)",
|
||
row_text,
|
||
re.I,
|
||
)
|
||
)
|
||
|
||
# Scan for header structure
|
||
category_rows_data = [] # List of (row_idx, [cat_texts])
|
||
year_row_idx = None
|
||
years_list = [] # Actual year values in order
|
||
data_start = 0
|
||
|
||
for i, row in enumerate(data_rows):
|
||
# Check for year row FIRST (before data check, since years look numeric)
|
||
if row_has_years(row):
|
||
year_row_idx = i
|
||
years_list = [c.strip() for c in row if c.strip() and is_year(c.strip())]
|
||
data_start = i + 1
|
||
continue # Keep scanning to find where data actually starts
|
||
|
||
if row_has_data(row):
|
||
data_start = i
|
||
break
|
||
|
||
if row_is_title(row):
|
||
continue
|
||
|
||
if row_is_all_caps_categories(row):
|
||
# Extract category texts
|
||
cats = [c.strip() for c in row if c.strip() and is_all_caps_word(c.strip())]
|
||
category_rows_data.append((i, cats))
|
||
|
||
if year_row_idx is None or not years_list:
|
||
return None, 0, 0
|
||
|
||
num_years = len(years_list)
|
||
|
||
# Merge category rows vertically
|
||
# EQUIPMENT + OPERATIONS = "EQUIPMENT OPERATIONS"
|
||
# FINANCIAL + SERVICES = "FINANCIAL SERVICES"
|
||
# Build categories by position (first row gives base, subsequent rows add)
|
||
if category_rows_data:
|
||
# Use max categories from any row
|
||
max_cats = max(len(cats) for _, cats in category_rows_data)
|
||
merged_categories = [""] * max_cats
|
||
|
||
for _, cats in category_rows_data:
|
||
for j, cat in enumerate(cats):
|
||
if j < len(merged_categories):
|
||
if merged_categories[j]:
|
||
merged_categories[j] += " " + cat
|
||
else:
|
||
merged_categories[j] = cat
|
||
else: # pragma: no cover - unreachable: merged_categories is initialized to length max_cats and only grows via this append; since j indexes a single row's cats whose length never exceeds max_cats, j < len(merged_categories) always holds
|
||
merged_categories.append(cat)
|
||
|
||
num_categories = len(merged_categories)
|
||
else:
|
||
# No ALL CAPS categories found - return None to use fallback
|
||
# which can detect Title Case categories like "Pensions", "OPEB"
|
||
# via colspan analysis in extract_periods_from_rows
|
||
return None, 0, 0
|
||
|
||
# Calculate years per category
|
||
years_per_cat = num_years // num_categories
|
||
if years_per_cat == 0:
|
||
years_per_cat = 1
|
||
|
||
# Build COMPRESSED headers
|
||
# Format: ["", "CAT1", "", "", "CAT2", "", "", ...] (category in first cell of span)
|
||
# Format: ["", "2025", "2024", "2023", "2025", "2024", "2023", ...]
|
||
|
||
cat_row = [""] # Label column
|
||
year_row = [""] # Label column
|
||
|
||
year_idx = 0
|
||
for cat in merged_categories:
|
||
# Category name in first cell
|
||
cat_row.append(cat)
|
||
# Empty cells for remaining years under this category
|
||
for _ in range(years_per_cat - 1):
|
||
cat_row.append("")
|
||
|
||
# Years for this category
|
||
for _ in range(years_per_cat):
|
||
if year_idx < num_years:
|
||
year_row.append(years_list[year_idx])
|
||
year_idx += 1
|
||
else:
|
||
year_row.append("")
|
||
|
||
header_rows = [cat_row, year_row]
|
||
num_value_cols = num_categories * years_per_cat
|
||
|
||
# Find actual data start
|
||
for i in range(data_start, len(data_rows)):
|
||
row = data_rows[i]
|
||
if row_has_data(row):
|
||
data_start = i
|
||
break
|
||
if (
|
||
not row_is_title(row)
|
||
and not row_is_all_caps_categories(row)
|
||
and not row_has_years(row)
|
||
):
|
||
data_start = i
|
||
break
|
||
|
||
return header_rows, data_start, num_value_cols
|
||
|
||
|
||
def is_equity_statement_table(rows):
|
||
"""Detect if this is an equity statement with stacked column headers."""
|
||
if len(rows) < 5:
|
||
return False
|
||
|
||
equity_keywords = {
|
||
"stockholders",
|
||
"common",
|
||
"treasury",
|
||
"retained",
|
||
"accumulated",
|
||
"comprehensive",
|
||
"noncontrolling",
|
||
"redeemable",
|
||
"equity",
|
||
}
|
||
keyword_count = 0
|
||
for row in rows[:10]:
|
||
row_text = " ".join(c.strip().lower() for c in row if c.strip())
|
||
for kw in equity_keywords:
|
||
if kw in row_text:
|
||
keyword_count += 1
|
||
return keyword_count >= 4 # Need multiple equity-related headers
|
||
|
||
|
||
def process_equity_statement_table(rows, rows_with_colspan):
|
||
"""Process equity statement table with stacked vertical headers.
|
||
|
||
Returns (processed_rows, header_count) or (None, 0) if not applicable.
|
||
"""
|
||
# Step 1: Find first data row (row with actual numeric values)
|
||
data_start_idx = 0
|
||
|
||
for i, row in enumerate(rows):
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
if not non_empty:
|
||
continue
|
||
|
||
row_text_lower = " ".join(c.lower() for c in non_empty)
|
||
|
||
# Check for $ followed by number pattern (indicates data row)
|
||
# Look for actual balance/value data with $ prefix
|
||
has_balance_data = False
|
||
for j, cell in enumerate(row):
|
||
c = cell.strip()
|
||
next_c = row[j + 1].strip() if j + 1 < len(row) else ""
|
||
|
||
# Pattern 1: Combined dollar amounts like "$21,789" or "$5,303"
|
||
if (
|
||
(c.startswith("$") and len(c) > 1 and any(ch.isdigit() for ch in c))
|
||
or c == "$"
|
||
and next_c
|
||
and any(ch.isdigit() for ch in next_c)
|
||
) and "balance" in row_text_lower:
|
||
has_balance_data = True
|
||
break
|
||
|
||
if has_balance_data:
|
||
data_start_idx = i
|
||
break
|
||
|
||
if data_start_idx == 0 or data_start_idx >= len(rows):
|
||
return None, 0
|
||
|
||
# Step 2: Analyze a data row to find the column structure
|
||
# Each logical value column has: possibly spacer + $ cell + number cell
|
||
# Find all columns that directly contain $ + number format
|
||
sample_row = rows[data_start_idx]
|
||
value_positions = [] # (col_index, value_with_$)
|
||
|
||
for j, cell in enumerate(sample_row):
|
||
c = cell.strip()
|
||
# Match "$21,789" or "$(24,094)" or "$5,165" patterns
|
||
if c and re.match(r"^[\$][\(\)]?[\d,]+[\)]?$", c):
|
||
value_positions.append(j)
|
||
|
||
# If $ and value are in separate cells, find pairs
|
||
if not value_positions:
|
||
for j, cell in enumerate(sample_row[:-1]):
|
||
c = cell.strip()
|
||
next_c = sample_row[j + 1].strip() if j + 1 < len(sample_row) else ""
|
||
# $ in current cell, number in next
|
||
if c == "$" and next_c and re.match(r"^[\(\)]?[\d,]+[\)]?$", next_c):
|
||
value_positions.append(j) # Use the $ cell position as anchor
|
||
elif c in ["$(", "($"] and next_c and re.match(r"^[\d,]+\)?$", next_c):
|
||
value_positions.append(j)
|
||
|
||
num_value_cols = len(value_positions)
|
||
if num_value_cols < 2:
|
||
return None, 0
|
||
|
||
# Step 3: Build headers from header rows (rows before data_start_idx)
|
||
# For each value position, find the corresponding header by looking at
|
||
# which header text appears above or near that column
|
||
|
||
def is_title_row(row_idx):
|
||
"""Identify if a row is a title/descriptor row that should be skipped from headers."""
|
||
if row_idx >= len(rows):
|
||
return False # pragma: no cover - unreachable: is_title_row is only called over range(data_start_idx) and data_start_idx < len(rows) is guaranteed above, so row_idx is always a valid index
|
||
row = rows[row_idx]
|
||
non_empty = [c.strip() for c in row if c.strip()]
|
||
|
||
if not non_empty:
|
||
return True # Empty row - skip it
|
||
|
||
if len(non_empty) == 1 and row_idx < len(rows_with_colspan):
|
||
# Single non-empty cell - check if it spans most of the row
|
||
# Also check rows_with_colspan for actual colspan info
|
||
cs_row = rows_with_colspan[row_idx]
|
||
cs_non_empty = [(t, cs) for t, cs in cs_row if t.strip()]
|
||
|
||
if len(cs_non_empty) == 1:
|
||
_, colspan = cs_non_empty[0]
|
||
total_span = sum(cs for _, cs in cs_row)
|
||
|
||
if colspan >= total_span * 0.4: # Title spans 40%+ of table
|
||
return True
|
||
|
||
return False
|
||
|
||
# Filter header rows to exclude title rows
|
||
header_rows = [i for i in range(data_start_idx) if not is_title_row(i)]
|
||
# Build column boundaries: each value column "owns" cells from its position
|
||
# back to the previous value's position (or start of row)
|
||
col_boundaries = []
|
||
|
||
for idx, v_pos in enumerate(value_positions):
|
||
start = (
|
||
0 if idx == 0 else value_positions[idx - 1] + 2
|
||
) # Skip to after previous value
|
||
col_boundaries.append((start, v_pos + 1)) # inclusive range
|
||
|
||
# First, find all unique header column positions from header rows
|
||
header_positions: dict = {} # col_idx -> [text1, text2, ...]
|
||
|
||
for row_idx in header_rows:
|
||
row = rows[row_idx]
|
||
for col_idx, cell in enumerate(row):
|
||
text = cell.strip()
|
||
if text and not re.match(r"^[\$\d,\.\(\)]+$", text):
|
||
if col_idx not in header_positions:
|
||
header_positions[col_idx] = []
|
||
if text not in header_positions[col_idx]:
|
||
header_positions[col_idx].append(text)
|
||
|
||
# Get unique header columns in sorted order (by position)
|
||
sorted_header_cols = sorted(header_positions.keys())
|
||
# Build headers from the stacked text at each position
|
||
stacked_headers = []
|
||
|
||
for h_pos in sorted_header_cols:
|
||
parts = header_positions[h_pos]
|
||
header_text = " ".join(parts)
|
||
stacked_headers.append(header_text)
|
||
|
||
# Match headers to values by sequence
|
||
# If we have N values and M headers, match them by index
|
||
final_headers = []
|
||
|
||
for idx, v_pos in enumerate(value_positions):
|
||
if idx < len(stacked_headers):
|
||
final_headers.append(stacked_headers[idx])
|
||
else:
|
||
final_headers.append(f"Column {idx + 1}")
|
||
|
||
# Step 4: Build output table
|
||
result = []
|
||
|
||
# Add header row (empty label + column headers)
|
||
result.append([""] + final_headers)
|
||
|
||
# Process each data row
|
||
for row in rows[data_start_idx:]:
|
||
# Extract label (first cell, or first non-$ non-empty cell)
|
||
label = ""
|
||
for cell in row[:3]: # Label is usually in first few cells
|
||
c = cell.strip()
|
||
if (
|
||
c
|
||
and not c.startswith("$")
|
||
and not re.match(r"^\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", c)
|
||
) and c not in [
|
||
"$",
|
||
"(",
|
||
")",
|
||
"%",
|
||
"—",
|
||
"–",
|
||
"-",
|
||
]:
|
||
label = c
|
||
break
|
||
|
||
# Extract values from value positions
|
||
values = []
|
||
for v_pos in value_positions:
|
||
val = ""
|
||
if v_pos < len(row):
|
||
c = row[v_pos].strip()
|
||
# Check if this cell has the complete value ($ + number)
|
||
if c and (c.startswith("$") or re.match(r"^[\(\)]?[\d,]+", c)):
|
||
val = c
|
||
# If it's just $, look at next cell
|
||
if c in ["$", "$(", "($"] and v_pos + 1 < len(row):
|
||
next_c = row[v_pos + 1].strip()
|
||
if next_c:
|
||
val = c + next_c
|
||
elif v_pos + 1 < len(row):
|
||
# Value might be in next cell
|
||
next_c = row[v_pos + 1].strip()
|
||
if next_c and re.match(r"^[\(\)]?[\d,]+", next_c):
|
||
val = next_c
|
||
values.append(val)
|
||
|
||
# Skip rows with no label and no values
|
||
if not label and not any(v.strip() for v in values):
|
||
continue
|
||
|
||
result.append([label] + values)
|
||
|
||
return result, 1 # 1 header row
|
||
|
||
|
||
def build_column_headers_from_colspan(rows_with_colspan, _year_pos_shift):
|
||
"""Build proper multi-index column headers.
|
||
|
||
For tables with stacked category headers like:
|
||
Row 0: [Title spanning all columns - SKIP]
|
||
Row 1: "" | "EQUIPMENT" (colspan=8) | "FINANCIAL" (colspan=8) | ...
|
||
Row 2: "" | "OPERATIONS" (colspan=8) | "SERVICES" (colspan=8) | "ELIMINATIONS" | "CONSOLIDATED"
|
||
Row 3: "" | "2025" | "2024" | "2023" | "2025" | "2024" | "2023" | ...
|
||
|
||
Output multi-index format:
|
||
Row 1: ["", "EQUIPMENT OPERATIONS", "", "", "FINANCIAL SERVICES", "", "", ...]
|
||
Row 2: ["", "2025", "2024", "2023", "2025", "2024", "2023", ...]
|
||
|
||
POSITION-BASED vertical merge: if row N and row N+1 both have text at same
|
||
column position with large colspan, merge them into one category name.
|
||
Category name appears ONLY in the first cell of its span, not repeated.
|
||
|
||
Returns (header_layers, header_row_count)
|
||
|
||
``_year_pos_shift`` is a one-element list used as a mutable output
|
||
parameter so the caller can read back the computed shift value. When
|
||
called outside of ``convert_table`` (e.g. in tests) it may be ``None``; in
|
||
that case a local list is used so the function still works correctly.
|
||
|
||
"""
|
||
if not rows_with_colspan:
|
||
return None, 0
|
||
|
||
# Extract colspan structure and text for each row
|
||
# Structure: list of (text, colspan, start_col) tuples
|
||
parsed_rows = []
|
||
for row in rows_with_colspan:
|
||
parsed = []
|
||
col_pos = 0
|
||
for text, colspan in row:
|
||
stripped_text = text.strip()
|
||
parsed.append((stripped_text, colspan, col_pos))
|
||
col_pos += colspan
|
||
parsed_rows.append(parsed)
|
||
|
||
if not parsed_rows: # pragma: no cover - unreachable: parsed_rows is built 1:1 from rows_with_colspan in the loop above, and rows_with_colspan is guaranteed non-empty by the guard at the top of the function, so parsed_rows is never empty
|
||
return None, 0
|
||
|
||
def is_year(t):
|
||
# Strip trailing footnote markers like *, **, †, ‡, §
|
||
t_clean = re.sub(r"[*†‡§+]+$", "", t.strip())
|
||
return bool(re.match(r"^(19|20)\d{2}$", t_clean))
|
||
|
||
def is_category_text(t):
|
||
"""Check if text looks like a category header (not a year, not data)."""
|
||
if not t or is_year(t):
|
||
return False
|
||
# Remove <br> tags before checking patterns (multi-line headers)
|
||
t_clean = re.sub(r"<[Bb][Rr]\s*/?>", " ", t).strip()
|
||
t_clean = re.sub(r"\s+", " ", t_clean) # Collapse whitespace
|
||
# Remove trailing footnote markers like *, **, *+, **+
|
||
t_clean = re.sub(r"\s*[\*+]+$", "", t_clean)
|
||
# Remove trailing superscript footnote numbers like "1,2,3" or "4"
|
||
# These come from <sup> tags concatenated with the text
|
||
t_clean = re.sub(r"\s+\d+(?:,\d+)*$", "", t_clean)
|
||
# Also strip footnote digits directly attached to words (no whitespace)
|
||
# e.g., "Net Interest2" → "Net Interest", "All Other3" → "All Other"
|
||
t_clean = re.sub(r"(?<=[A-Za-z])\d+$", "", t_clean)
|
||
if re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t_clean):
|
||
return False # It's data
|
||
# Title Case: "Equipment Operations", "Operating Income/(Loss)"
|
||
# Strip /()\- before checking to avoid catastrophic backtracking
|
||
t_stripped = re.sub(r"[/()\-]", " ", t_clean)
|
||
t_stripped = re.sub(r"\s+", " ", t_stripped).strip()
|
||
# Also prepare a variant with leading symbol prefixes removed
|
||
# so that headers like "% Average rate" or "# of Shares" are
|
||
# recognized after stripping the leading punctuation.
|
||
t_no_prefix = re.sub(r"^[%#$&*~!@^]+\s*", "", t_stripped)
|
||
if (
|
||
re.match(r"^[A-Z][A-Za-z]*(\s+[A-Za-z&]+)*$", t_stripped)
|
||
and len(t_clean) > 2
|
||
):
|
||
return True
|
||
if (
|
||
t_no_prefix != t_stripped
|
||
and re.match(r"^[A-Z][A-Za-z]*(\s+[A-Za-z&]+)*$", t_no_prefix)
|
||
and len(t_no_prefix) > 2
|
||
):
|
||
return True
|
||
# ALL CAPS: "EQUIPMENT", "FINANCIAL SERVICES", "LONG-LIVED ASSETS"
|
||
if re.match(r"^[A-Z]{2,}(-[A-Z]+)?(\s+[A-Z]+(-[A-Z]+)?)*$", t_clean):
|
||
return True
|
||
# ALL CAPS with periods/abbreviations: "WTD. AVG. EXERCISE PRICE", "NO. OF SHARES"
|
||
# Pattern: uppercase word (optionally with period) followed by more words
|
||
if (
|
||
re.match(r"^[A-Z]{2,}\.?(\s+[A-Z]{2,}\.?)*(\s+[A-Z]+)*$", t_clean)
|
||
and len(t_clean) > 3
|
||
):
|
||
return True
|
||
# Abbreviated ALL-CAPS with dots and dashes: "YR.-TO-YR."
|
||
if re.match(r"^[A-Z]{2,}\.(-[A-Z]{2,}\.?)+$", t_clean):
|
||
return True
|
||
# Abbreviations like "U.S.", "NON-U.S.", "U.K." - single letters with periods
|
||
# Pattern: optional prefix (NON-), then letter-period pairs
|
||
if re.match(r"^([A-Z]+-)?[A-Z]\.[A-Z]\.?$", t_clean):
|
||
return True
|
||
# Abbreviations followed by words: "U.S. PLANS", "NON-U.S. PLANS"
|
||
if re.match(r"^([A-Z]+-)?[A-Z]\.[A-Z]\.?(\s+[A-Z]+)+$", t_clean):
|
||
return True
|
||
# Hyphenated categories: "NON-GAAP", "PRE-TAX"
|
||
if re.match(r"^[A-Z]+-[A-Z]+$", t_clean):
|
||
return True # pragma: no cover - unreachable: hyphens were converted to spaces upstream, so a "WORD-WORD" token already matched the title-case pattern earlier and returned; t_clean here never contains a hyphen for this regex to match
|
||
# Hyphenated words followed by more words: "PPP-QUALIFIED PORTION"
|
||
return bool(re.match(r"^[A-Z]+-[A-Z]+(\s+[A-Z]+)+$", t_clean))
|
||
|
||
def is_header_row(parsed):
|
||
"""Check if this row is a header row (years or categories)."""
|
||
|
||
# Filter truly empty cells and cells with only whitespace/invisible chars
|
||
# Zero-width spaces (\u200b) and similar should be skipped
|
||
def has_visible_content(t):
|
||
# Remove zero-width spaces, zero-width non-joiners, etc.
|
||
cleaned = (
|
||
t.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
return bool(cleaned)
|
||
|
||
non_empty = [(t, c, s) for t, c, s in parsed if t and has_visible_content(t)]
|
||
if not non_empty:
|
||
return False, False # empty, not header
|
||
|
||
# Check if title row (one text spans >=80%)
|
||
total_span = sum(c for t, c, s in parsed)
|
||
max_span = max((c for t, c, s in non_empty), default=0)
|
||
if max_span >= total_span * 0.8:
|
||
# Don't classify date/year super-headers as title rows — even if a
|
||
# date like "As of November 2007" spans ≥80% of the table width it
|
||
# should still be treated as a year super-header row, not skipped.
|
||
_spanning_text = next((t for t, c, s in non_empty if c == max_span), None)
|
||
_is_date_span = _spanning_text and bool(
|
||
re.search(
|
||
rf"(?:As\s+of\s+)?(?:{MONTHS_PATTERN}\s+)?(?:\d{{1,2}},?\s*)?(19|20)\d{{2}}\b",
|
||
_spanning_text,
|
||
re.I,
|
||
)
|
||
)
|
||
if not _is_date_span:
|
||
return False, True # title row - skip but count
|
||
|
||
# Check for period description rows like "For the Three Months Ended..."
|
||
# These are single-cell descriptions that should be treated as title rows
|
||
if len(non_empty) == 1:
|
||
text, colspan, start = non_empty[0]
|
||
# Period descriptions in first column with phrases like "For the X Months/Weeks/Years Ended"
|
||
if start == 0 and re.search(
|
||
r"for\s+the\s+(\w+\s+)?(months?|weeks?|quarters?|years?|period)\s+ended",
|
||
text,
|
||
re.I,
|
||
):
|
||
return False, True # title row - skip but count
|
||
|
||
# A single non-empty cell at position 0 that does NOT
|
||
# reference any year or date is a table title / section
|
||
# label (e.g. "Financial performance of JPMorganChase"),
|
||
# NOT a category header. Category headers appear at
|
||
# start > 0 spanning data columns, typically with multiple
|
||
# cells per row. Mis-classifying titles as categories
|
||
# breaks the header scan — it causes the scanner to stop
|
||
# at the very next non-header row, never reaching the
|
||
# actual year/period row further down.
|
||
if (
|
||
start == 0
|
||
and not re.search(r"\b(19|20)\d{2}\b", text)
|
||
and not re.search(
|
||
rf"(months?\s+ended|year\s+ended|weeks?\s+ended"
|
||
rf"|{MONTHS_PATTERN}\s+\d{{1,2}})",
|
||
text,
|
||
re.I,
|
||
)
|
||
):
|
||
return False, True # title row - skip but count
|
||
|
||
has_year = False
|
||
has_category = False
|
||
has_data = False
|
||
has_label = False # Line item label (text in col 0/1 with small colspan)
|
||
|
||
for t, colspan, start in non_empty:
|
||
if t.startswith("(") and ("million" in t.lower() or "except" in t.lower()):
|
||
continue
|
||
|
||
# Check for year before checking for numeric data
|
||
if is_year(t):
|
||
has_year = True
|
||
elif re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t):
|
||
# At position 0 (label column), a bare small number
|
||
# (1-3 digits with no financial formatting) may be a
|
||
# rendering artifact rather than a data value.
|
||
if start == 0 and re.match(r"^\d{1,3}$", t):
|
||
continue
|
||
has_data = True
|
||
break
|
||
elif start > 0 and re.search(
|
||
rf"(months?\s+ended|year\s+ended|weeks?\s+ended|quarters?\s+ended"
|
||
rf"|ended\s+{MONTHS_PATTERN}|{MONTHS_PATTERN}\s+\d{{1,2}}\s*,?(?:\s*(?:19|20)\d{{2}})?"
|
||
rf"|(?:As\s+of\s+)?{MONTHS_PATTERN}\s+(19|20)\d{{2}}\b)",
|
||
t,
|
||
re.I,
|
||
):
|
||
# Catches "Three Months Ended May 31" / "January 26, 2025" / "Ended November"
|
||
# / "November 2007" / "As of November 2007" / "October 31," (bare date fragment)
|
||
# but NOT standalone unit labels like "Months", "Years", "10 Years"
|
||
has_year = True
|
||
elif is_category_text(t) and colspan >= 2:
|
||
# Category headers can have colspan >= 2
|
||
has_category = True
|
||
elif is_category_text(t) and colspan == 1:
|
||
# Category text with colspan=1 can also be a header
|
||
# (e.g., "NUMBER OF SHARES UNDER OPTION" at start > 1)
|
||
if start > 1:
|
||
has_category = True
|
||
elif start <= 1 and colspan == 1 and len(t) > 3:
|
||
# Text in first column with small colspan = line item label (data row)
|
||
# Unless it's a very short text that could be a spacer
|
||
has_label = True
|
||
elif t.endswith(":"):
|
||
# Section labels like "Assets:", "Liabilities:", "Revenues:" indicate data rows
|
||
has_label = True
|
||
|
||
# Data rows always stop header scanning
|
||
if has_data:
|
||
return False, False
|
||
|
||
# If has_year or has_category, the label is just a row dimension header (e.g., "Risk Categories")
|
||
if has_label and not (has_year or has_category):
|
||
return False, False
|
||
|
||
# Staircase top-row: all non-empty cells in data columns (start > 0) with
|
||
# uniform small colspan and no financial data. Catches maturity-range
|
||
# labels like "0 - 6", "6 - 12", "10 Years" that fail every single-cell
|
||
# test but are clearly split column headers when they appear together with
|
||
# equal colspan (e.g., all colspan=2 spanning gutter+value pairs).
|
||
if not has_year and not has_category and not has_label:
|
||
data_col_cells = [(t, c, s) for t, c, s in non_empty if s > 0]
|
||
if (
|
||
len(data_col_cells) >= 2
|
||
and len({c for _, c, _ in data_col_cells}) == 1 # uniform colspan
|
||
and data_col_cells[0][1] >= 2 # not colspan-1 label cells
|
||
):
|
||
has_category = True
|
||
|
||
return (has_year or has_category), False
|
||
|
||
# First pass: identify all header rows and classify them
|
||
header_info: list = [] # (row_idx, parsed, is_year_row, is_category_row)
|
||
header_row_count = 0
|
||
|
||
def has_visible_text(t):
|
||
cleaned = (
|
||
t.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
return bool(cleaned)
|
||
|
||
title_period_texts = [] # Track period titles for super-header use
|
||
|
||
for row_idx, parsed in enumerate(parsed_rows):
|
||
is_header, is_title = is_header_row(parsed)
|
||
# Check if this row is completely empty (all cells empty after stripping)
|
||
non_empty_cells = [(t, c, s) for t, c, s in parsed if has_visible_text(t)]
|
||
|
||
if not non_empty_cells:
|
||
# Empty row - skip it but count it toward header_row_count
|
||
header_row_count = row_idx + 1
|
||
continue
|
||
|
||
if is_title:
|
||
# Save period/date titles for potential use as super-headers
|
||
for _tt, _tc, _ts in parsed:
|
||
if (
|
||
_tt
|
||
and has_visible_text(_tt)
|
||
and re.search(
|
||
rf"(months?|quarters?|years?|weeks?|period)\s+ended|ended\s+{MONTHS_PATTERN}|{MONTHS_PATTERN}\s+\d{{1,2}}",
|
||
_tt,
|
||
re.I,
|
||
)
|
||
):
|
||
title_period_texts.append(_tt)
|
||
header_row_count = row_idx + 1
|
||
continue
|
||
|
||
if not is_header:
|
||
# Before breaking, check if this is a pre-header title row.
|
||
# SEC filings often have company names and statement titles
|
||
# in single cells at position 0 without large colspan.
|
||
# These should be skipped before any real headers are found.
|
||
if not header_info: # No headers found yet
|
||
non_empty_check = [
|
||
(t, c, s) for t, c, s in parsed if has_visible_text(t)
|
||
]
|
||
if len(non_empty_check) <= 1 and (
|
||
not non_empty_check
|
||
or non_empty_check[0][2] == 0
|
||
or non_empty_check[0][1]
|
||
> 2 # Large colspan = title at any position
|
||
):
|
||
# Single text at position 0 (or empty) or with large colspan before headers → title row
|
||
header_row_count = row_idx + 1
|
||
continue
|
||
break # Hit actual data row
|
||
|
||
# After year rows have been found, a single-cell row at position 0
|
||
# is a section header (e.g., "Assets", "Liabilities"), not a column
|
||
# category. Stop scanning for headers.
|
||
if header_info:
|
||
has_year_already = any(is_yr for _, _, is_yr, _ in header_info)
|
||
if (
|
||
has_year_already
|
||
and len(non_empty_cells) == 1
|
||
and non_empty_cells[0][2] == 0
|
||
):
|
||
break
|
||
|
||
# Classify: year row or category row?
|
||
non_empty = [(t, c, s) for t, c, s in parsed if has_visible_text(t)]
|
||
|
||
# Check if this row has large-colspan period phrases (super-headers)
|
||
# E.g., "Three Months Ended May"[cs=7] is a category spanning year columns
|
||
has_large_colspan_period = any(
|
||
colspan > 2
|
||
and re.search(
|
||
rf"(months?|quarters?|years?|weeks?)\s+ended|ended\s+{MONTHS_PATTERN}",
|
||
t,
|
||
re.I,
|
||
)
|
||
for t, colspan, s in non_empty
|
||
)
|
||
|
||
has_year = any(
|
||
is_year(t)
|
||
or (
|
||
re.search(rf"(ended|{MONTHS_PATTERN})", t, re.I)
|
||
and colspan <= 2
|
||
and s > 0 # Skip first column (row labels like "AT DECEMBER 31:")
|
||
)
|
||
or (
|
||
# Full dates like "January 26, 2025", "September 26,2025",
|
||
# or "September 26,<br>2025" (with <br> tag) are year indicators
|
||
re.search(
|
||
rf"({MONTHS_PATTERN})\s+\d{{1,2}},?\s*(19|20)\d{{2}}",
|
||
re.sub(r"<[Bb][Rr]\s*/?>", " ", t),
|
||
re.I,
|
||
)
|
||
and s > 0
|
||
)
|
||
or (
|
||
# "Month YYYY" (no day) or "As of Month YYYY" — period super-headers
|
||
# with any colspan (e.g. "As of November 2007" cs=22 spans whole table)
|
||
re.search(
|
||
rf"(?:As\s+of\s+)?{MONTHS_PATTERN}\s+(19|20)\d{{2}}\b",
|
||
t,
|
||
re.I,
|
||
)
|
||
and s > 0
|
||
)
|
||
for t, colspan, s in non_empty
|
||
)
|
||
has_category = any(
|
||
is_category_text(t) and not is_year(t) for t, c, s in non_empty
|
||
)
|
||
|
||
# Large-colspan period phrases are treated as categories
|
||
if has_large_colspan_period:
|
||
period_phrases_have_year = any(
|
||
colspan > 2
|
||
and re.search(
|
||
rf"(months?|quarters?|years?|weeks?)\s+ended|ended\s+{MONTHS_PATTERN}",
|
||
t,
|
||
re.I,
|
||
)
|
||
and re.search(
|
||
r"\b(19|20)\d{2}\b",
|
||
re.sub(r"<[Bb][Rr]\s*/?>", " ", t),
|
||
)
|
||
for t, colspan, s in non_empty
|
||
)
|
||
if not period_phrases_have_year:
|
||
has_year = False
|
||
has_category = True
|
||
|
||
header_info.append((row_idx, parsed, has_year, has_category))
|
||
header_row_count = row_idx + 1
|
||
|
||
if not header_info:
|
||
return None, 0
|
||
|
||
# A valid financial table must have at least one data row after
|
||
# the header region, so bail out and let the caller fall back to
|
||
# simple non-financial table processing.
|
||
if header_row_count >= len(parsed_rows):
|
||
return None, 0
|
||
|
||
category_rows = [
|
||
(idx, p)
|
||
for idx, p, is_yr, is_cat in header_info
|
||
if (is_cat and not is_yr)
|
||
or (
|
||
not is_yr
|
||
and not is_cat
|
||
and any(has_visible_text(t) and s > 0 for t, _c, s in p)
|
||
)
|
||
]
|
||
year_rows = [(idx, p) for idx, p, is_yr, is_cat in header_info if is_yr]
|
||
|
||
# Extract column headers from year rows
|
||
column_headers_list = []
|
||
column_headers_positions = []
|
||
|
||
for row_idx, parsed in year_rows:
|
||
for text, colspan, start in parsed:
|
||
if not text:
|
||
continue
|
||
|
||
if start == 0 and not is_year(text):
|
||
continue
|
||
|
||
# Skip notes/descriptors in parentheses
|
||
if text.startswith("(") and (
|
||
"million" in text.lower() or "except" in text.lower()
|
||
):
|
||
continue
|
||
|
||
column_headers_list.append(text)
|
||
column_headers_positions.append(start)
|
||
|
||
# When the first year header is at position 0 (occupying the label
|
||
# column), it means the year sub-header row lacks a separate label
|
||
# cell — years start from the very first cell. Data rows DO have
|
||
# a label at position 0, so data values start at a later position.
|
||
# Shift all year header positions right by the label column width
|
||
# (= first category position) to align them with data columns.
|
||
# ``_year_pos_shift`` may be None when the function is called outside
|
||
# of convert_table (e.g. in tests); in that case use a local list.
|
||
if _year_pos_shift is None:
|
||
_year_pos_shift = [0]
|
||
_year_pos_shift[0] = 0
|
||
if column_headers_positions and column_headers_positions[0] == 0 and category_rows:
|
||
# Use first category's start position as the label width
|
||
for _, cat_parsed in category_rows:
|
||
for _ct, _cc, _cs in cat_parsed:
|
||
if _cs > 0:
|
||
_year_pos_shift[0] = _cs
|
||
break
|
||
break
|
||
if _year_pos_shift[0] > 0:
|
||
column_headers_positions = [
|
||
p + _year_pos_shift[0] for p in column_headers_positions
|
||
]
|
||
|
||
# Check for stacked date + year header pattern:
|
||
# Row A: "January 26," "October 27," "January 28," (dates with months)
|
||
# Row B: "2025" "2024" "2024" (years)
|
||
# Should combine to: "January 26, 2025", "October 27, 2024", etc.
|
||
# Also handles extra non-month/non-year items at different positions:
|
||
# Row A: "January 26" "January 28" "%"
|
||
# Row B: "2025" "2024" "Change"
|
||
# -> "January 26, 2025" "January 28, 2024" "% Change"
|
||
if len(year_rows) >= 2:
|
||
|
||
def _row_headers(parsed_row):
|
||
result = []
|
||
for text, _, start in parsed_row:
|
||
t = text.strip()
|
||
if not t:
|
||
continue
|
||
# Skip first-column labels but allow years at position 0
|
||
if start == 0 and not is_year(t):
|
||
continue
|
||
if t.startswith("(") and (
|
||
"million" in t.lower() or "except" in t.lower()
|
||
):
|
||
continue
|
||
result.append((t, start))
|
||
return result
|
||
|
||
def _try_position_merge(hdrs_a, hdrs_b):
|
||
"""Try to merge two header rows by matching positions.
|
||
Returns (merged_texts, merged_positions) sorted by position,
|
||
or (None, None) if no month+year merges found.
|
||
"""
|
||
# Build position maps
|
||
pos_a = {s: t for t, s in hdrs_a}
|
||
pos_b = {s: t for t, s in hdrs_b}
|
||
all_positions = sorted(set(pos_a.keys()) | set(pos_b.keys()))
|
||
|
||
merged = []
|
||
merged_pos = []
|
||
month_year_merges = 0
|
||
for pos in all_positions:
|
||
text_a = pos_a.get(pos)
|
||
text_b = pos_b.get(pos)
|
||
if text_a and text_b:
|
||
a_is_month = bool(re.search(MONTHS_PATTERN, text_a, re.I))
|
||
b_is_year = is_year(text_b)
|
||
a_is_year = is_year(text_a)
|
||
b_is_month = bool(re.search(MONTHS_PATTERN, text_b, re.I))
|
||
if a_is_month and b_is_year:
|
||
merged.append(f"{text_a.rstrip(',').strip()}, {text_b}")
|
||
month_year_merges += 1
|
||
elif a_is_year and b_is_month:
|
||
merged.append(f"{text_b.rstrip(',').strip()}, {text_a}")
|
||
month_year_merges += 1
|
||
else:
|
||
# Generic vertical merge (e.g., "%" + "Change" -> "% Change")
|
||
merged.append(f"{text_a} {text_b}".strip())
|
||
elif text_a:
|
||
merged.append(text_a)
|
||
elif text_b:
|
||
merged.append(text_b)
|
||
merged_pos.append(pos)
|
||
|
||
if month_year_merges > 0:
|
||
return merged, merged_pos
|
||
return None, None
|
||
|
||
for i in range(len(year_rows)):
|
||
for j in range(i + 1, len(year_rows)):
|
||
_, row_a = year_rows[i]
|
||
_, row_b = year_rows[j]
|
||
hdrs_a = _row_headers(row_a)
|
||
hdrs_b = _row_headers(row_b)
|
||
|
||
if len(hdrs_a) == 0 or len(hdrs_b) == 0:
|
||
continue # pragma: no cover - unreachable: a row only qualifies as a year_row when _row_headers keeps at least one header cell (years and dated cells are always retained), so hdrs_a/hdrs_b are never empty here
|
||
|
||
# Check that at least one row has months and at least one has years
|
||
a_has_months = any(
|
||
re.search(MONTHS_PATTERN, h, re.I) for h, _ in hdrs_a
|
||
)
|
||
b_has_months = any(
|
||
re.search(MONTHS_PATTERN, h, re.I) for h, _ in hdrs_b
|
||
)
|
||
a_has_years = any(is_year(h) for h, _ in hdrs_a)
|
||
b_has_years = any(is_year(h) for h, _ in hdrs_b)
|
||
|
||
if (a_has_months and b_has_years) or (a_has_years and b_has_months):
|
||
result, result_positions = _try_position_merge(hdrs_a, hdrs_b)
|
||
if result:
|
||
column_headers_list = result
|
||
column_headers_positions = result_positions
|
||
break
|
||
else:
|
||
continue
|
||
break
|
||
|
||
if column_headers_list and category_rows:
|
||
max_year_pos = max(column_headers_positions)
|
||
covered = set(column_headers_positions)
|
||
for _, cat_parsed in category_rows:
|
||
for text, _colspan, start in cat_parsed:
|
||
t = (text or "").strip()
|
||
if not t or start <= max_year_pos or start in covered:
|
||
continue
|
||
if t.startswith("(") and (
|
||
"million" in t.lower() or "except" in t.lower()
|
||
):
|
||
continue
|
||
covered.add(start)
|
||
column_headers_list.append(t)
|
||
column_headers_positions.append(start)
|
||
if len(column_headers_positions) > 1:
|
||
_order = sorted(
|
||
range(len(column_headers_positions)),
|
||
key=lambda k: column_headers_positions[k],
|
||
)
|
||
column_headers_list = [column_headers_list[k] for k in _order]
|
||
column_headers_positions = [column_headers_positions[k] for k in _order]
|
||
|
||
if not column_headers_list:
|
||
# No year row headers found
|
||
# Check for date-super-header pattern: one category row is a single
|
||
# large-colspan date/period phrase spanning everything, and remaining
|
||
# category rows have vertically-stacked text sub-column headers.
|
||
# Example:
|
||
# Row 0: "Three Months Ended January 26, 2025" cs=11 (super-header)
|
||
# Row 1: "Retail Notes" cs=2, "Revolving" cs=2
|
||
# Row 2: "& Financing" cs=2, "Charge" cs=2, "Wholesale" cs=2
|
||
# Row 3: "Leases" cs=2, "Accounts" cs=2, "Receivables" cs=2, "Total" cs=2
|
||
# → merge sub-columns: "Retail Notes & Financing Leases", etc.
|
||
if len(category_rows) >= 2:
|
||
_, first_parsed = category_rows[0]
|
||
first_ne = [(t, c, s) for t, c, s in first_parsed if has_visible_text(t)]
|
||
is_date_super = False
|
||
super_text = ""
|
||
if len(first_ne) == 1:
|
||
super_text, super_cs, _ = first_ne[0]
|
||
if super_cs > 2 and re.search(
|
||
rf"(months?|quarters?|years?|weeks?|period)\s+ended|ended\s+{MONTHS_PATTERN}|{MONTHS_PATTERN}\s+\d{{1,2}}",
|
||
super_text,
|
||
re.I,
|
||
):
|
||
is_date_super = True
|
||
|
||
if is_date_super:
|
||
# Vertically merge remaining category rows by position
|
||
pos_texts: dict = {} # col_pos -> [text1, text2, ...]
|
||
for cat_idx in range(1, len(category_rows)):
|
||
_, sub_parsed = category_rows[cat_idx]
|
||
for sub_text, _, sub_start in sub_parsed:
|
||
if (
|
||
not sub_text
|
||
or not has_visible_text(sub_text)
|
||
or sub_start == 0
|
||
):
|
||
continue
|
||
if sub_start not in pos_texts:
|
||
pos_texts[sub_start] = []
|
||
pos_texts[sub_start].append(sub_text)
|
||
|
||
if pos_texts:
|
||
sorted_pos = sorted(pos_texts.keys())
|
||
merged_sub_headers = [" ".join(pos_texts[p]) for p in sorted_pos]
|
||
n_cols = len(merged_sub_headers)
|
||
# Build 2-layer header
|
||
super_row = ["", super_text]
|
||
for _ in range(n_cols - 1):
|
||
super_row.append("")
|
||
sub_row = [""] + merged_sub_headers
|
||
header_layers = [super_row, sub_row]
|
||
return header_layers, header_row_count
|
||
|
||
# Fallback: check if category rows can serve as column headers (flat header table)
|
||
# Merge by POSITION across all category rows. When two rows
|
||
# have text at the same position, the WIDER row (more total
|
||
# columns) wins — it placed its header at an explicit position
|
||
# in a full-width row. The displaced text from the shorter
|
||
# row is appended at the end (first uncovered position).
|
||
# Example (MS 10-Q):
|
||
# R2 (18 cols): Net Interest2@9, All Other3@12
|
||
# R3 (12 cols): Trading@3, Fees1@6, Total@9
|
||
# → pos 9 conflict: R2 wins (wider), "Total" displaced to end
|
||
# → result: Trading | Fees1 | Net Interest2 | All Other3 | Total
|
||
if category_rows:
|
||
first_col_label = ""
|
||
for _, cat_parsed in category_rows:
|
||
for text, colspan, start in cat_parsed:
|
||
if start == 0 and text and not first_col_label:
|
||
first_col_label = text
|
||
|
||
# Compute each row's total column span
|
||
row_widths = []
|
||
for _, cat_parsed in category_rows:
|
||
total = sum(cs for _, cs, _ in cat_parsed)
|
||
row_widths.append(total)
|
||
|
||
# Build position -> (text, row_width) map.
|
||
# Wider row wins at conflicts.
|
||
pos_map = {} # pos -> (text, row_width)
|
||
displaced = [] # texts displaced from conflicts
|
||
for cr_idx, (_, cat_parsed) in enumerate(category_rows):
|
||
w = row_widths[cr_idx]
|
||
for text, colspan, start in cat_parsed:
|
||
if start == 0 or not text:
|
||
continue
|
||
if start not in pos_map:
|
||
pos_map[start] = (text, w)
|
||
else:
|
||
existing_text, existing_w = pos_map[start]
|
||
if w > existing_w:
|
||
# New row is wider, it wins
|
||
displaced.append(existing_text)
|
||
pos_map[start] = (text, w)
|
||
elif w < existing_w:
|
||
# Existing row is wider, it keeps the position
|
||
displaced.append(text)
|
||
else:
|
||
# Same width — later row overrides (more specific)
|
||
displaced.append(existing_text)
|
||
pos_map[start] = (text, w)
|
||
|
||
if pos_map:
|
||
# Collect headers in position order
|
||
final_headers = [pos_map[p][0] for p in sorted(pos_map.keys())]
|
||
# Append displaced texts at the end
|
||
final_headers.extend(displaced)
|
||
|
||
n_cols = len(final_headers)
|
||
header_layers = []
|
||
|
||
if title_period_texts:
|
||
super_row = ["", title_period_texts[-1]] + [""] * (n_cols - 1)
|
||
header_layers.append(super_row)
|
||
|
||
header_row = [first_col_label] + final_headers
|
||
header_layers.append(header_row)
|
||
return header_layers, header_row_count
|
||
|
||
if not column_headers_list: # pragma: no cover - unreachable: reaching the enclosing `if not column_headers_list` (line 2176) means no year-row headers, so header_info holds only category rows; any non-title category row has a cell at start>0 that populates pos_map and returns at line 2293 before this inner no-year staircase branch can run
|
||
# No year rows. If ≥ 2 category rows form a staircase (first row has
|
||
# all cells in data columns with uniform colspan, like "0 - 6" / "6 - 12"
|
||
# split from "Months" / "Months" below), merge them into one header row.
|
||
if len(category_rows) >= 2:
|
||
first_cat_parsed = category_rows[0][1]
|
||
first_ne = [(t, c, s) for t, c, s in first_cat_parsed if t.strip()]
|
||
first_is_staircase_top = (
|
||
first_ne
|
||
and all(s > 0 for _, _, s in first_ne)
|
||
and len({c for _, c, _ in first_ne}) == 1 # uniform colspan
|
||
and first_ne[0][1] >= 2 # colspan >= 2, not single-col labels
|
||
)
|
||
if first_is_staircase_top:
|
||
_sm_rows = []
|
||
for _, cat_parsed in category_rows:
|
||
sm: dict = {}
|
||
for text, colspan, start in cat_parsed:
|
||
t = (
|
||
text.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if t:
|
||
sm[start] = (t, start + colspan - 1)
|
||
if sm:
|
||
_sm_rows.append(sm)
|
||
if _sm_rows:
|
||
_leaf_pos = sorted(
|
||
p
|
||
for p in set().union(*[set(sm.keys()) for sm in _sm_rows])
|
||
if p > 0
|
||
)
|
||
if len(_leaf_pos) >= 1:
|
||
merged_cols = []
|
||
for lp in _leaf_pos:
|
||
parts: list = []
|
||
last_part: str | None = None
|
||
for sm in _sm_rows:
|
||
for s, (txt, end) in sm.items():
|
||
if s <= lp <= end:
|
||
if txt != last_part:
|
||
parts.append(txt)
|
||
last_part = txt
|
||
break
|
||
merged_cols.append(" ".join(parts) if parts else "")
|
||
# Label column: staircase-merge texts at position 0
|
||
first_col_parts: list = []
|
||
last_fc: str | None = None
|
||
for sm in _sm_rows:
|
||
if 0 in sm:
|
||
txt, _ = sm[0]
|
||
if txt != last_fc:
|
||
first_col_parts.append(txt)
|
||
last_fc = txt
|
||
first_col = " ".join(first_col_parts)
|
||
return [[first_col] + merged_cols], header_row_count
|
||
# Still no headers — fall back
|
||
return None, 0
|
||
|
||
num_headers = len(column_headers_list)
|
||
|
||
# Check for "year-as-super-header" pattern:
|
||
# Row 0: years with LARGE colspan (e.g., "2003" cs=5, "2002" cs=5)
|
||
# OR full dates with LARGE colspan (e.g., "January 26, 2025" cs=5)
|
||
# Row 1: sub-headers with smaller colspan (e.g., "BENEFIT OBLIGATION" cs=2)
|
||
# Result: merge year/date + sub-header -> "2003 BENEFIT OBLIGATION", etc.
|
||
year_super_headers = [] # [(year_text, start_col, end_col), ...]
|
||
for row_idx, parsed in year_rows:
|
||
for text, colspan, start in parsed:
|
||
if not text:
|
||
continue
|
||
# Skip first-column labels, but allow years at position 0
|
||
if start == 0 and not is_year(text):
|
||
continue
|
||
_text_clean = re.sub(r"<[Bb][Rr]\s*/?>", " ", text)
|
||
# Bare year (e.g. "2007") OR full date (e.g. "November 26, 2007")
|
||
_is_strong_date = is_year(text) or bool(
|
||
re.search(
|
||
rf"({MONTHS_PATTERN})\s+\d{{1,2}},?\s*(19|20)\d{{2}}",
|
||
_text_clean,
|
||
re.I,
|
||
)
|
||
)
|
||
# "Month YYYY" (no day) or "As of Month YYYY" — also a date
|
||
_is_month_year = bool(
|
||
re.search(
|
||
rf"(As\s+of\s+)?({MONTHS_PATTERN})\s+(19|20)\d{{2}}\b",
|
||
_text_clean,
|
||
re.I,
|
||
)
|
||
)
|
||
# Require colspan > 2 for bare dates; >= 2 is enough when the
|
||
# match is explicit (month+year or full date) since gutter tables
|
||
# pack one data column into cs=2.
|
||
if (_is_strong_date and colspan > 2) or (
|
||
(_is_strong_date or _is_month_year) and colspan >= 2
|
||
):
|
||
year_super_headers.append((text, start, start + colspan))
|
||
|
||
# Guard: if category rows have LARGER colspans than the year
|
||
# super-headers, then years are children of the categories, not
|
||
# super-headers. E.g. "Three Months Ended May" cs=7 contains
|
||
# "1999" cs=3 and "1998" cs=3. The standard category-distribution
|
||
# path handles this correctly.
|
||
# Check ALL non-empty cells in category rows (not just those passing
|
||
# is_category_text), since period phrases like "Three Months Ended
|
||
# October 31," are valid category headers but may fail the strict
|
||
# is_category_text check due to trailing numbers/punctuation.
|
||
if year_super_headers and category_rows:
|
||
max_year_cs = max((end - start) for _, start, end in year_super_headers)
|
||
max_cat_cs = 0
|
||
for _, cat_parsed in category_rows:
|
||
for _t, _c, _s in cat_parsed:
|
||
if _t and _s > 0 and _c > max_cat_cs:
|
||
max_cat_cs = _c
|
||
if max_cat_cs > max_year_cs:
|
||
year_super_headers = [] # Not really super-headers
|
||
|
||
# Allow the path even when header_info has only the year row itself
|
||
# (no other rows classified as headers) — the lookahead block below will
|
||
# discover staircase rows that failed is_header_row (e.g. "Assets / 0-6 / 6-12"
|
||
# which has a label at col 0 and non-category column text).
|
||
if year_super_headers and len(header_info) >= 1:
|
||
year_row_indices = {idx for idx, _ in year_rows}
|
||
|
||
# Pattern for words that are just date-label fragments (not real sub-headers).
|
||
# A row made entirely of these is a continuation of a year label split
|
||
# across two HTML rows (e.g., "As of" in row N, "November 2007" in row N+1).
|
||
_date_frag_re = re.compile(
|
||
rf"^(as(\s+of)?|of|through|ended|and|or)$"
|
||
rf"|^{MONTHS_PATTERN}$"
|
||
rf"|^(19|20)\d{{2}}$",
|
||
re.I,
|
||
)
|
||
|
||
def _is_year_label_frag_row(parsed_row):
|
||
"""Return True if every non-empty text cell is a bare date word fragment."""
|
||
text_vals = [
|
||
txt.strip()
|
||
for txt, _cs, _st in parsed_row
|
||
if txt and txt.strip() and re.search(r"[A-Za-z]", txt)
|
||
]
|
||
if not text_vals:
|
||
return False
|
||
return all(_date_frag_re.match(v) for v in text_vals)
|
||
|
||
# Collect all non-year header rows found during the initial scan,
|
||
# also excluding rows that are purely year-label fragment rows.
|
||
sub_rows = [
|
||
(idx, parsed)
|
||
for idx, parsed, is_yr, is_cat in header_info
|
||
if idx not in year_row_indices and not _is_year_label_frag_row(parsed)
|
||
]
|
||
|
||
# Look ahead past header_row_count for additional staircase rows that
|
||
# is_category_text() missed (e.g. "Owned, at", "Purchased, at" contain
|
||
# commas which break the category-text regex). Stop at first data row.
|
||
lookahead_count = header_row_count
|
||
for extra_idx in range(
|
||
header_row_count, min(header_row_count + 10, len(parsed_rows))
|
||
):
|
||
extra_parsed = parsed_rows[extra_idx]
|
||
extra_ne = [(t, c, s) for t, c, s in extra_parsed if t and t.strip()]
|
||
if not extra_ne:
|
||
continue # blank spacer row — keep looking
|
||
# Any purely numeric cell means we have hit a data row
|
||
has_numeric = any(
|
||
re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t)
|
||
for t, c, s in extra_ne
|
||
)
|
||
if has_numeric:
|
||
break
|
||
# Credit-rating / status values at data columns (start > 0) also
|
||
# signal a data row. These are short alphanumeric codes like F1,
|
||
# A+, AA-, P-1, Stable, N/A that appear in ratings tables but are
|
||
# NOT recognized by the numeric pattern above.
|
||
_rating_re = re.compile(
|
||
r"^(?:"
|
||
r"[A-Z]{1,3}[+-]" # A+, AA-, BBB+ (S&P/Fitch style)
|
||
r"|[A-Z]{1,2}-\d" # P-1, F-2 (Moody's/Fitch CP)
|
||
r"|F\d[+-]?" # F1, F1+ (Fitch specific)
|
||
r"|[A-Z][a-z]{1,3}\d" # Aa1, Baa2 (Moody's long-term)
|
||
r"|Prime-\d" # Prime-1
|
||
r")$"
|
||
)
|
||
_status_words = {
|
||
"stable",
|
||
"positive",
|
||
"negative",
|
||
"watch",
|
||
"developing",
|
||
"n/a",
|
||
"nm",
|
||
"nr",
|
||
"wd",
|
||
}
|
||
has_rating_data = any(
|
||
(
|
||
_rating_re.match(t)
|
||
or t.upper() in ("N/A", "NM", "NR", "WD")
|
||
or t.lower() in _status_words
|
||
)
|
||
for t, c, s in extra_ne
|
||
if s > 0 # only data columns, not the label column
|
||
)
|
||
if has_rating_data:
|
||
break
|
||
# Skip year-label fragment rows in the lookahead too
|
||
if _is_year_label_frag_row(extra_parsed):
|
||
continue
|
||
# Must contain at least some letter-bearing cells (not just blanks/punct)
|
||
text_cells = [
|
||
(t, c, s) for t, c, s in extra_ne if re.search(r"[A-Za-z]", t)
|
||
]
|
||
if text_cells:
|
||
sub_rows.append((extra_idx, extra_parsed))
|
||
lookahead_count = extra_idx + 1
|
||
|
||
if sub_rows:
|
||
# Build per-row start-maps: col_start -> (text, inclusive_end)
|
||
row_start_maps: list = []
|
||
for _, sub_parsed_row in sub_rows:
|
||
sm = {}
|
||
for text, colspan, start in sub_parsed_row:
|
||
t = (
|
||
text.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if t and re.search(r"[A-Za-z]", t):
|
||
sm[start] = (t, start + colspan - 1)
|
||
elif t and not re.match(
|
||
r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t
|
||
):
|
||
# Also include non-alpha cells that are NOT plain financial
|
||
# data — e.g. maturity-range labels like "0 - 6", "1 - 5".
|
||
sm[start] = (t, start + colspan - 1)
|
||
if sm:
|
||
row_start_maps.append(sm)
|
||
|
||
if row_start_maps:
|
||
# Leaf positions = union of all non-empty cell starts across
|
||
# every staircase row — ensures cells that only appear in one
|
||
# row (e.g. "Total" in the bottom leaf row) are not missed.
|
||
# Always exclude position 0: it is the label/row-header column.
|
||
leaf_positions_sub: list = sorted(
|
||
p
|
||
for p in set().union(*[set(sm.keys()) for sm in row_start_maps])
|
||
if p > 0
|
||
)
|
||
|
||
if len(leaf_positions_sub) >= 2:
|
||
# For each leaf position, walk every staircase row top-to-
|
||
# bottom collecting the cell that covers that position.
|
||
# Consecutive duplicates are suppressed so parent "2007"
|
||
# spanning both sub-columns doesn't repeat within a column.
|
||
# Date-fragment words (bare month names, "As of", "of" etc.)
|
||
# are skipped — they are split pieces of the year super-header
|
||
# label and must not contaminate sub-column names.
|
||
merged_subs: list = []
|
||
for leaf_pos in leaf_positions_sub:
|
||
parts = []
|
||
last_text = None
|
||
for sm in row_start_maps:
|
||
for start, (text, end) in sm.items():
|
||
if start <= leaf_pos <= end:
|
||
if (
|
||
not _date_frag_re.match(text)
|
||
and text != last_text
|
||
):
|
||
parts.append(text)
|
||
last_text = text
|
||
break
|
||
merged_subs.append(" ".join(parts) if parts else "")
|
||
|
||
# Determine whether position 0 is a label column.
|
||
# Merge staircase texts at position 0 the same way
|
||
# data columns do — e.g. "Assets" / "Contract Type"
|
||
# across two rows → "Assets Contract Type".
|
||
first_col_parts = []
|
||
last_first_col: str | None = None
|
||
for sm in row_start_maps:
|
||
if 0 in sm:
|
||
_fc_text, _fc_end = sm[0]
|
||
if (
|
||
not _date_frag_re.match(_fc_text)
|
||
and _fc_text != last_first_col
|
||
):
|
||
first_col_parts.append(_fc_text)
|
||
last_first_col = _fc_text
|
||
first_col_label = (
|
||
" ".join(first_col_parts) if first_col_parts else ""
|
||
)
|
||
|
||
# Build year layer using position-based assignment:
|
||
# assign the year label to the FIRST leaf under each span,
|
||
# and "" for subsequent leaves under the same span.
|
||
# This correctly handles uneven splits like 4+1 rather
|
||
# than assuming equal distribution.
|
||
|
||
# Check whether this is a one-to-one mapping: every year
|
||
# span covers exactly ONE leaf position. In that case the
|
||
# year cell and the sub-header cell at the same position
|
||
# together form ONE column name (e.g. "As of" + "November
|
||
# 2007" → "As of November 2007"). Producing a two-layer
|
||
# structure would split them inappropriately.
|
||
_leaves_per_span = {
|
||
(ys, ye): sum(1 for lp in leaf_positions_sub if ys <= lp < ye)
|
||
for _yt, ys, ye in year_super_headers
|
||
}
|
||
_all_one_to_one = all(v == 1 for v in _leaves_per_span.values())
|
||
|
||
if _all_one_to_one:
|
||
# Single-layer mode: merge ALL staircase rows (sub-rows
|
||
# AND the year row) vertically per column position,
|
||
# without filtering date-fragment words — they are
|
||
# legitimate parts of the column label here.
|
||
_all_staircase = sorted(
|
||
sub_rows + list(year_rows),
|
||
key=lambda x: x[0],
|
||
)
|
||
flat_maps: list = []
|
||
for _, _row_parsed in _all_staircase:
|
||
_sm: dict = {}
|
||
for _txt, _cs, _st in _row_parsed:
|
||
_t = (
|
||
_txt.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if _t and (
|
||
re.search(r"[A-Za-z0-9]", _t)
|
||
and not re.match(
|
||
r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$",
|
||
_t,
|
||
)
|
||
):
|
||
_sm[_st] = (_t, _st + _cs - 1)
|
||
if _sm:
|
||
flat_maps.append(_sm)
|
||
|
||
flat_subs: list = []
|
||
for leaf_pos in leaf_positions_sub:
|
||
_parts: list = []
|
||
_last: str | None = None
|
||
for _sm in flat_maps:
|
||
for _s, (_t, _end) in _sm.items():
|
||
if _s <= leaf_pos <= _end:
|
||
if _t != _last:
|
||
_parts.append(_t)
|
||
_last = _t
|
||
break
|
||
flat_subs.append(" ".join(_parts) if _parts else "")
|
||
|
||
# Label column: merge position-0 texts from all rows
|
||
_fc_parts: list = []
|
||
_last_fc: str | None = None
|
||
for _sm in flat_maps:
|
||
if 0 in _sm:
|
||
_t, _ = _sm[0]
|
||
if _t != _last_fc:
|
||
_fc_parts.append(_t)
|
||
_last_fc = _t
|
||
_first_col = " ".join(_fc_parts)
|
||
|
||
return (
|
||
[[_first_col] + flat_subs],
|
||
max(header_row_count, lookahead_count),
|
||
)
|
||
|
||
year_row = [first_col_label]
|
||
seen_years: set = set()
|
||
for leaf_pos in leaf_positions_sub:
|
||
assigned_year = ""
|
||
for year_text, year_start, year_end in year_super_headers:
|
||
if year_start <= leaf_pos < year_end:
|
||
if year_text not in seen_years:
|
||
assigned_year = year_text
|
||
seen_years.add(year_text)
|
||
break
|
||
year_row.append(assigned_year)
|
||
|
||
sub_header_row_data = [first_col_label] + merged_subs
|
||
header_layers = [year_row, sub_header_row_data]
|
||
return header_layers, max(header_row_count, lookahead_count)
|
||
|
||
# Extract categories with their positions, then merge vertically
|
||
# Categories at same position merge: "EQUIPMENT" + "OPERATIONS" -> "EQUIPMENT OPERATIONS"
|
||
# Only include cells with LARGE colspan (spanning multiple year columns)
|
||
position_texts: dict = {} # col_pos -> [text1, text2, ...]
|
||
position_colspans: dict = {} # col_pos -> colspan
|
||
|
||
for row_idx, parsed in category_rows:
|
||
for text, colspan, start in parsed:
|
||
# Category headers must have large colspan (> 2) to span header columns
|
||
if (
|
||
text
|
||
and colspan > 2
|
||
and start > 0
|
||
and (
|
||
is_category_text(text)
|
||
or re.search(
|
||
rf"(months?|quarters?|years?|weeks?)\s+ended|ended\s+{MONTHS_PATTERN}",
|
||
text,
|
||
re.I,
|
||
)
|
||
)
|
||
):
|
||
# Clean <br> tags from category text
|
||
clean_text = re.sub(r"<[Bb][Rr]\s*/?>", " ", text).strip()
|
||
clean_text = re.sub(r"\s+", " ", clean_text)
|
||
if start not in position_texts:
|
||
position_texts[start] = []
|
||
position_colspans[start] = colspan
|
||
else:
|
||
# Using the large parent colspan would incorrectly let the
|
||
# first child gobble up headers belonging to siblings.
|
||
position_colspans[start] = min(position_colspans[start], colspan)
|
||
position_texts[start].append(clean_text)
|
||
|
||
# Build merged categories in position order
|
||
sorted_positions = sorted(position_texts.keys())
|
||
categories = [] # [(merged_name, num_years_under), ...]
|
||
|
||
for pos in sorted_positions:
|
||
merged_name = " ".join(position_texts[pos])
|
||
# Estimate headers under this category by colspan ratio
|
||
# If all categories have same colspan, headers are evenly distributed
|
||
categories.append(merged_name)
|
||
|
||
# Collect "orphan" header cells from category rows — cells at
|
||
# start > 0 that are NOT categories (failed is_category_text) and
|
||
# NOT inside any recognized category's column span. These are
|
||
# independent column headers (e.g., "Balance Dec. 31, 2024")
|
||
# that should appear as their own columns in the output.
|
||
orphan_headers: list[tuple[str, int]] = [] # (text, start_pos)
|
||
for _ri, parsed in category_rows:
|
||
for text, colspan, start in parsed:
|
||
if not text or start == 0:
|
||
continue
|
||
# Skip cells already captured as categories
|
||
if start in position_texts:
|
||
continue
|
||
# Skip cells inside a category span
|
||
inside_cat = False
|
||
for cat_pos in sorted_positions:
|
||
cat_end = cat_pos + position_colspans[cat_pos]
|
||
if cat_pos <= start < cat_end:
|
||
inside_cat = True
|
||
break
|
||
if inside_cat:
|
||
continue
|
||
clean_text = re.sub(r"<[Bb][Rr]\s*/?>", " ", text).strip()
|
||
clean_text = re.sub(r"\s+", " ", clean_text)
|
||
if clean_text:
|
||
orphan_headers.append((clean_text, start))
|
||
|
||
num_categories = len(categories)
|
||
if num_categories == 0 and not orphan_headers:
|
||
# No categories - just column headers
|
||
header_layers = [[""] + column_headers_list]
|
||
return header_layers, header_row_count
|
||
|
||
# When there are orphan headers alongside categories, build a
|
||
# combined position-ordered structure: orphan headers appear as
|
||
# independent leaf columns (1 column each) and categories expand
|
||
# to hold their sub-headers from year_rows.
|
||
if orphan_headers and num_categories > 0:
|
||
# Build a unified position list: each entry is either
|
||
# ("orphan", text, start) or ("cat", name, start, colspan)
|
||
unified: list = []
|
||
for text, pos in orphan_headers:
|
||
unified.append(("orphan", text, pos, 0))
|
||
for cat_idx, cat_name in enumerate(categories):
|
||
cat_pos = sorted_positions[cat_idx]
|
||
cat_cs = position_colspans[cat_pos]
|
||
unified.append(("cat", cat_name, cat_pos, cat_cs))
|
||
unified.sort(key=lambda x: x[2]) # sort by start position
|
||
|
||
cat_row = [""]
|
||
header_row = [""]
|
||
header_idx = 0
|
||
|
||
for entry in unified:
|
||
kind = entry[0]
|
||
if kind == "orphan":
|
||
orphan_text = entry[1]
|
||
# Orphan headers are independent leaf columns —
|
||
# they appear directly in the header row with empty
|
||
# category text above.
|
||
cat_row.append("")
|
||
header_row.append(orphan_text)
|
||
else:
|
||
cat_name = entry[1]
|
||
cat_pos = entry[2]
|
||
cat_cs = entry[3]
|
||
cat_end = cat_pos + cat_cs
|
||
|
||
# Collect year headers whose positions fall within
|
||
# this category's column range.
|
||
sub_hdrs = []
|
||
for hi in range(num_headers):
|
||
if column_headers_positions:
|
||
hdr_pos = column_headers_positions[hi]
|
||
else: # pragma: no cover - unreachable: column_headers_positions is appended in lockstep with column_headers_list, so reaching this orphan+category block (which requires a non-empty headers list) guarantees positions is non-empty
|
||
break
|
||
if cat_pos <= hdr_pos < cat_end:
|
||
sub_hdrs.append(column_headers_list[hi])
|
||
|
||
if not sub_hdrs:
|
||
cat_row.append(cat_name)
|
||
header_row.append("")
|
||
else:
|
||
cat_row.append(cat_name)
|
||
for _ in range(len(sub_hdrs) - 1):
|
||
cat_row.append("")
|
||
for sh in sub_hdrs:
|
||
header_row.append(sh)
|
||
|
||
header_layers = [cat_row, header_row]
|
||
|
||
return header_layers, header_row_count
|
||
|
||
# Distribute column headers across categories
|
||
# Use position-based matching when position info is available,
|
||
# otherwise fall back to even distribution
|
||
cat_row = [""] # Start with label column
|
||
header_row = [""] # Start with label column
|
||
|
||
if column_headers_positions and len(column_headers_positions) == num_headers:
|
||
# Position-based distribution: match headers to categories
|
||
# by checking which headers fall within each category's column range
|
||
header_idx = 0
|
||
|
||
# These are independent data columns (like unit-of-measure)
|
||
# that don't belong under any period category.
|
||
first_cat_pos = sorted_positions[0] if sorted_positions else 0
|
||
while (
|
||
header_idx < num_headers
|
||
and column_headers_positions[header_idx] < first_cat_pos
|
||
):
|
||
cat_row.append("")
|
||
header_row.append(column_headers_list[header_idx])
|
||
header_idx += 1
|
||
|
||
for cat_idx, cat_name in enumerate(categories):
|
||
cat_pos = sorted_positions[cat_idx]
|
||
cat_cs = position_colspans[cat_pos]
|
||
cat_end = cat_pos + cat_cs
|
||
|
||
# Consume any gap/orphan headers that fall between the
|
||
# previous category's end and this category's start.
|
||
while (
|
||
header_idx < num_headers
|
||
and column_headers_positions[header_idx] < cat_pos
|
||
):
|
||
cat_row.append("")
|
||
header_row.append(column_headers_list[header_idx])
|
||
header_idx += 1
|
||
|
||
# Collect headers whose positions fall within [cat_pos, cat_end)
|
||
start_idx = header_idx
|
||
while header_idx < num_headers:
|
||
hdr_pos = column_headers_positions[header_idx]
|
||
if cat_pos <= hdr_pos < cat_end:
|
||
header_idx += 1
|
||
else:
|
||
break
|
||
|
||
count = header_idx - start_idx
|
||
if count == 0:
|
||
# Category has no sub-headers; add a single empty slot
|
||
cat_row.append(cat_name)
|
||
header_row.append("")
|
||
else:
|
||
cat_row.append(cat_name)
|
||
for _ in range(count - 1):
|
||
cat_row.append("")
|
||
|
||
for i in range(count):
|
||
idx = start_idx + i
|
||
if idx < len(column_headers_list):
|
||
header_row.append(column_headers_list[idx])
|
||
else: # pragma: no cover - unreachable: idx = start_idx + i with i < count = header_idx - start_idx, so idx is always < header_idx <= num_headers = len(column_headers_list)
|
||
header_row.append("")
|
||
|
||
# Append any remaining unmatched headers
|
||
while header_idx < num_headers:
|
||
cat_row.append("")
|
||
header_row.append(column_headers_list[header_idx])
|
||
header_idx += 1
|
||
else: # pragma: no cover - unreachable: column_headers_positions is appended one-per-header alongside column_headers_list, so its length always equals num_headers and the position-based branch above is always taken
|
||
# Fallback: even distribution when positions not available
|
||
headers_per_category = (
|
||
num_headers // num_categories if num_categories else num_headers
|
||
)
|
||
if headers_per_category == 0:
|
||
headers_per_category = 1
|
||
|
||
header_idx = 0
|
||
for cat_name in categories:
|
||
cat_row.append(cat_name)
|
||
for _ in range(headers_per_category - 1):
|
||
cat_row.append("")
|
||
for i in range(headers_per_category):
|
||
if header_idx < len(column_headers_list):
|
||
header_row.append(column_headers_list[header_idx])
|
||
header_idx += 1
|
||
else:
|
||
header_row.append("")
|
||
|
||
header_layers = [cat_row, header_row]
|
||
|
||
return header_layers, header_row_count
|
||
|
||
|
||
def extract_periods_from_rows(
|
||
rows_with_cs, row_has_th_flags=None, _year_pos_shift=None
|
||
):
|
||
"""Extract column headers from header rows.
|
||
|
||
Args:
|
||
rows_with_cs: List of rows, each row is list of (text, colspan) tuples
|
||
row_has_th_flags: Optional list of booleans indicating if each row has <th> elements
|
||
|
||
Returns (header_layers, header_row_count) where:
|
||
header_layers: list of rows (each row is list of strings for each column)
|
||
header_row_count: number of source rows consumed
|
||
|
||
For multi-level headers (e.g., "Equipment Operations" spanning 2025/2024/2023),
|
||
returns multiple header rows like:
|
||
[["", "Equipment Operations", "Equipment Operations", ..., "Financial Services", ...],
|
||
["", "2025", "2024", "2023", "2025", ...]]
|
||
"""
|
||
if not rows_with_cs:
|
||
return [], 0
|
||
|
||
# Calculate total columns from first row
|
||
total_cols = sum(cs for _, cs in rows_with_cs[0]) if rows_with_cs else 0
|
||
|
||
# Helper function to detect and merge vertical multi-row headers
|
||
def merge_vertical_headers(rows_with_cs, total_cols, row_has_th_flags=None):
|
||
"""Merge consecutive header rows that have text at same column positions.
|
||
|
||
For tables like:
|
||
Row 1: "Retail Notes"[cs=2] | "Revolving"[cs=2]
|
||
Row 2: "& Financing"[cs=2] | "Charge"[cs=2] | "Wholesale"[cs=2]
|
||
Row 3: "Leases"[cs=2] | "Accounts"[cs=2] | "Receivables"[cs=2] | "Total"[cs=2]
|
||
|
||
Merges to: ["Retail Notes & Financing Leases", "Revolving Charge Accounts",
|
||
"Wholesale Receivables", "Total"]
|
||
|
||
Args:
|
||
rows_with_cs: List of rows with colspan info
|
||
total_cols: Total number of columns in table
|
||
row_has_th_flags: Optional list of booleans - if row has only <td> elements (no <th>),
|
||
it's definitely a data row, not a header row
|
||
"""
|
||
if not rows_with_cs:
|
||
return (
|
||
None,
|
||
0,
|
||
) # pragma: no cover - unreachable: caller returns ([], 0) at the empty-rows guard before this nested fn is invoked, so rows_with_cs is always non-empty here
|
||
|
||
# Helper to check if text looks like a header (not data, not a year)
|
||
def is_header_text(t):
|
||
if not t:
|
||
return False
|
||
# Remove zero-width chars
|
||
t = (
|
||
t.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if not t:
|
||
return False
|
||
# Years are headers
|
||
if re.match(r"^(19|20)\d{2}$", t):
|
||
return True
|
||
# Year-range labels like "2009 -", "2011 –", "2013-" are headers
|
||
# (the trailing dash signals the start of a date range spanning the
|
||
# child row, e.g. "2009 - 2010").
|
||
if re.match(r"^(19|20)\d{2}\s*[\-\u2013\u2014]", t):
|
||
return True
|
||
# Data patterns - not headers
|
||
if re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t):
|
||
return False
|
||
# Single punctuation - not a header
|
||
if len(t) <= 1:
|
||
return False
|
||
# Numeric range labels like "0 - 6", "6 - 12", "1 - 5" are maturity
|
||
# bucket column headers — they contain no letters but are clearly headers.
|
||
if re.match(r"^\d+\s*[-\u2013\u2014]\s*\d+$", t):
|
||
return True
|
||
# Text with letters is likely a header
|
||
return bool(re.search(r"[A-Za-z]", t))
|
||
|
||
# Helper to check if row is a data row (has numeric values OR rating-like values)
|
||
def is_data_row(row):
|
||
col_pos = 0
|
||
for text, cs in row:
|
||
t = (
|
||
text.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if (
|
||
t
|
||
and re.match(r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$", t)
|
||
and not re.match(r"^(19|20)\d{2}$", t)
|
||
):
|
||
# At position 0 (label column), a bare small number
|
||
# (1-3 digits with no financial formatting) may be a
|
||
# rendering artifact rather than a data value.
|
||
if col_pos == 0 and re.match(r"^\d{1,3}$", t):
|
||
col_pos += cs
|
||
continue
|
||
# Check if it's a year (years in header rows are ok)
|
||
return True
|
||
# Also check for rating-like values (A+, A1, Prime-1, F1, Stable, etc.)
|
||
# These are alphanumeric but short and look like data, not headers
|
||
if t and len(t) <= 10:
|
||
# Short alphanumeric codes that are likely ratings/data
|
||
# Examples: A+, A1, Aa1, BBB+, Prime-1, F1, Stable, Positive, Negative
|
||
# IMPORTANT: Must end with a modifier (+/-/digit) to distinguish from words like "Tax"
|
||
if re.match(r"^[A-Z][a-z]{0,2}[+-][0-9]?$", t): # A+, Aa+, Baa+
|
||
return True
|
||
if re.match(r"^[A-Z][a-z]{0,2}[0-9]$", t): # A1, Aa1, Baa1
|
||
return True
|
||
if re.match(r"^[A-Z]{1,3}[+-]$", t): # AA+, BBB-
|
||
return True
|
||
if re.match(r"^Prime-\d$", t): # Prime-1
|
||
return True
|
||
if re.match(
|
||
r"^[A-Z]\d$", t
|
||
): # F1 # pragma: no cover - unreachable: every [A-Z]\d string (e.g. "F1") also matches the broader ^[A-Z][a-z]{0,2}[0-9]$ check above, which returns first
|
||
return True
|
||
# Common outlook/status values
|
||
if t.lower() in (
|
||
"stable",
|
||
"positive",
|
||
"negative",
|
||
"watch",
|
||
"developing",
|
||
):
|
||
return True
|
||
col_pos += cs
|
||
return False
|
||
|
||
# Helper to check if row is a title row (single cell spanning most of table)
|
||
def is_title_row(row, total_cols):
|
||
"""Check if this row is a title/description spanning most of the table."""
|
||
non_empty_cells = []
|
||
total_span = 0
|
||
for text, colspan in row:
|
||
t = (
|
||
text.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
total_span += colspan
|
||
if t:
|
||
non_empty_cells.append((t, colspan))
|
||
|
||
# If single text cell spans majority of the table width, it's a title row
|
||
# This catches company names, statement titles, period descriptions, etc.
|
||
if len(non_empty_cells) == 1:
|
||
text, colspan = non_empty_cells[0]
|
||
# Single cell spanning >= 40% of columns is a title/category row
|
||
# Lower threshold to catch super-headers like "Total Stockholders' Equity"
|
||
if colspan >= total_cols * 0.4:
|
||
return True
|
||
return False
|
||
|
||
# Find consecutive header rows (non-empty, non-data rows)
|
||
header_rows: list = []
|
||
|
||
# Only use <th>/<td> as a hard "data row" signal when the table mixes
|
||
# them. Old SEC HTML often uses <td> for every cell — including headers
|
||
# — so when every row is all-<td> we must rely on is_data_row() alone.
|
||
_some_th_rows = bool(row_has_th_flags and any(row_has_th_flags))
|
||
|
||
for row_idx, row in enumerate(rows_with_cs):
|
||
# In tables that mix <th> and <td>, a row with only <td> cells is
|
||
# definitely a data row — stop collecting headers here.
|
||
if (
|
||
_some_th_rows
|
||
and row_has_th_flags is not None
|
||
and row_idx < len(row_has_th_flags)
|
||
and not row_has_th_flags[row_idx]
|
||
):
|
||
# Row has only <td> elements in a table that mixes th/td - data row
|
||
break
|
||
|
||
# Check for empty row
|
||
has_content = any(is_header_text(text) for text, _ in row)
|
||
if not has_content:
|
||
if header_rows:
|
||
continue # Skip empty rows between header rows
|
||
continue
|
||
|
||
# Check if this is a data row
|
||
if is_data_row(row):
|
||
break
|
||
|
||
# Skip title/description rows that span most of the table
|
||
if is_title_row(row, total_cols):
|
||
continue
|
||
|
||
# Check if this is a section label in column 0 (like "Assets:" or "Liabilities:")
|
||
# Section labels can have colspan > 1 for formatting but are still a single label
|
||
non_empty_cells = [
|
||
(text.replace("\u200b", "").strip(), cs, idx)
|
||
for idx, (text, cs) in enumerate(row)
|
||
if text.replace("\u200b", "").strip()
|
||
]
|
||
if len(non_empty_cells) == 1:
|
||
text, cs, cell_idx = non_empty_cells[0]
|
||
# Section label detection:
|
||
# 1. Single non-empty cell at the start of the row (first few positions)
|
||
# 2. Text ends with ":" (like "Assets:", "Liabilities and Equity:")
|
||
# 3. OR small colspan (1-3) in first position - generic label
|
||
# BUT: Don't treat years as section labels - they're data markers
|
||
is_year_label = bool(re.match(r"^(19|20)\d{2}$", text.strip()))
|
||
is_section_label = False
|
||
if not is_year_label and (
|
||
cell_idx == 0 or cs >= 2
|
||
): # First cell or spanning cell
|
||
if text.endswith(":"):
|
||
# Explicit section label like "Assets:" or "Liabilities:"
|
||
is_section_label = True
|
||
elif cs <= 3 and cell_idx == 0:
|
||
# Small colspan single text at start - likely a label
|
||
is_section_label = True
|
||
|
||
if is_section_label:
|
||
if header_rows:
|
||
break
|
||
continue
|
||
|
||
header_rows.append((row_idx, row))
|
||
|
||
# Need at least 2 header rows for vertical merging to be useful
|
||
if len(header_rows) < 2:
|
||
return None, 0
|
||
|
||
# Build per-row cell maps: start_pos -> (text, end_pos_inclusive)
|
||
#
|
||
# We intentionally store only the START position of each cell (not all
|
||
# positions within its span). Parent cells like "2007" (colspan=6) and
|
||
# child cells like "Instruments" (colspan=2) therefore get distinct
|
||
# entries at their respective starting positions. The "covers" check
|
||
# below (start <= leaf_pos <= end) is then used to find the cell that
|
||
# covers any given leaf column position.
|
||
row_cell_start_maps: list[dict] = []
|
||
for _row_idx, row in header_rows:
|
||
start_map: dict = {}
|
||
col_pos = 0
|
||
for text, colspan in row:
|
||
t = (
|
||
text.replace("\u200b", "")
|
||
.replace("\u200c", "")
|
||
.replace("\u200d", "")
|
||
.replace("\ufeff", "")
|
||
.strip()
|
||
)
|
||
if t and is_header_text(t):
|
||
# (text, inclusive_end_position)
|
||
start_map[col_pos] = (t, col_pos + colspan - 1)
|
||
col_pos += colspan
|
||
if start_map:
|
||
row_cell_start_maps.append(start_map)
|
||
|
||
if not row_cell_start_maps:
|
||
return (
|
||
None,
|
||
0,
|
||
) # pragma: no cover - unreachable: every header row with a non-empty cell yields a start_map entry, so this list is always populated when we reach here
|
||
|
||
# Leaf column positions = the UNION of all cell starting positions across
|
||
# every header row. Using the union (rather than just the row with the
|
||
# most cells) ensures we capture columns that only appear in some rows.
|
||
# Example – Commitments table:
|
||
# Row 0: "2008"(col2) "2009 -"(col4) "2011 -"(col6) "2013 -"(col8) → 4 cells
|
||
# Row 1: "2010" (col4) "2012" (col6) "Thereafter"(col8) "Total"(col10) → 4 cells
|
||
# Union positions: [2, 4, 6, 8, 10] → "Total" at col 10 is included.
|
||
# Example – GS 2008 Financial Instruments staircase:
|
||
# Row 2: "Financial"(col6) "Financial"(col14) → 2 cells
|
||
# Row 3: "Financial"(col2) "Instruments"(col6) ... → 4 cells
|
||
# Union positions: [2, 6, 10, 14] → same as max-cells approach.
|
||
leaf_positions: list = sorted(set().union(*row_cell_start_maps))
|
||
|
||
if not leaf_positions or len(leaf_positions) < 2:
|
||
return None, 0
|
||
|
||
# For each leaf position, walk all header rows top-to-bottom and collect
|
||
# the text of the cell that COVERS that position
|
||
# (i.e. cell_start <= leaf_pos <= cell_end). Consecutive identical
|
||
# texts are deduplicated so a parent "2007" that covers multiple leaf
|
||
# columns doesn't repeat within the merged string for a single column.
|
||
merged_headers: list = []
|
||
for leaf_pos in leaf_positions:
|
||
parts: list = []
|
||
last_text = None
|
||
for sm in row_cell_start_maps:
|
||
for start, (text, end) in sm.items():
|
||
if start <= leaf_pos <= end:
|
||
if text != last_text:
|
||
parts.append(text)
|
||
last_text = text
|
||
break # only one cell can cover a position per row
|
||
if parts:
|
||
merged_headers.append(" ".join(parts))
|
||
|
||
if not merged_headers:
|
||
return (
|
||
None,
|
||
0,
|
||
) # pragma: no cover - unreachable: leaf_positions is non-empty here, and each leaf position contributes a (possibly empty-string) merged header, so this list is always populated
|
||
|
||
# If position 0 is not a leaf column it is the empty label column.
|
||
is_label_col = 0 not in leaf_positions
|
||
|
||
# Return as a single merged header layer.
|
||
header_row_count = max(row_idx for row_idx, _ in header_rows) + 1
|
||
prefix: list = [""] if is_label_col else []
|
||
return [prefix + merged_headers], header_row_count
|
||
|
||
# Try multi-level header extraction first
|
||
header_layers, header_row_count = build_column_headers_from_colspan(
|
||
rows_with_cs, _year_pos_shift
|
||
)
|
||
# Only attempt vertical merging when build_column_headers_from_colspan found
|
||
# nothing. If it already returned a proper multi-layer result (e.g. 2007/2006
|
||
# parent rows spanning child sub-headers) we must NOT flatten it into a single
|
||
# merged row — that destroys the multi-index structure the caller depends on.
|
||
vertical_headers, vertical_row_count = (None, 0)
|
||
_colspan_has_year_range_fragments = (
|
||
header_layers is not None
|
||
and len(header_layers) == 1
|
||
and any(
|
||
# Match incomplete year-range fragments like "2009 -" that need
|
||
# merging with a row below (e.g. "2010"), but NOT complete ranges
|
||
# like "2027-2028" or "2029 - 2030" which are valid headers.
|
||
re.match(r"^(19|20)\d{2}\s*[-\u2013\u2014]", h.strip())
|
||
and not re.match(
|
||
r"^(19|20)\d{2}\s*[-\u2013\u2014]\s*(19|20)\d{2}$", h.strip()
|
||
)
|
||
for h in header_layers[0]
|
||
if h
|
||
)
|
||
)
|
||
if header_row_count == 0 or _colspan_has_year_range_fragments:
|
||
# Also try vertical header merging for two cases:
|
||
# 1. Colspan extraction found nothing (header_row_count == 0).
|
||
# Example: "2009 -" / "2010" stacked → merged to "2009 - 2010"
|
||
# 2. Colspan returned a flat single-layer with incomplete year-range fragments
|
||
# like "2009 -" (companion row wasn't merged by the month-year logic).
|
||
# Vertical merging will produce the correct e.g. "2009 - 2010" strings.
|
||
if _colspan_has_year_range_fragments: # pragma: no cover - unreachable: build_column_headers_from_colspan never emits a single-layer "YYYY -" fragment (verified by a 10,845-shape sweep), so this flag is always False; the outer guard is reached only via header_row_count == 0
|
||
# Discard the useless flat result so the vertical output wins below
|
||
header_layers = None
|
||
header_row_count = 0
|
||
vertical_headers, vertical_row_count = merge_vertical_headers(
|
||
rows_with_cs, total_cols, row_has_th_flags
|
||
)
|
||
# Decide which approach produced better headers
|
||
# Prefer vertical merge if it created multi-word headers (indicating successful merge)
|
||
use_vertical = False
|
||
|
||
if vertical_headers and vertical_row_count > 0:
|
||
# Check if vertical merge created multi-word headers
|
||
vertical_multi_word = any(
|
||
len(h.split()) >= 2
|
||
for layer in vertical_headers
|
||
for h in layer
|
||
if h
|
||
and not re.match(r"^[\(\)]*$", h)
|
||
and not re.match(r"^(19|20)\d{2}$", h)
|
||
)
|
||
|
||
# Check if build_column_headers_from_colspan produced multi-word headers
|
||
colspan_multi_word = False
|
||
if header_layers:
|
||
colspan_multi_word = any( # pragma: no cover - unreachable: the vertical merge only runs when header_row_count == 0, and build_column never returns truthy layers with a 0 count (verified by a 14,478-shape sweep), so header_layers is always falsy here
|
||
len(h.split()) >= 2
|
||
for layer in header_layers
|
||
for h in layer
|
||
if h
|
||
and not re.match(r"^[\(\)]*$", h)
|
||
and not re.match(r"^(19|20)\d{2}$", h)
|
||
)
|
||
|
||
# Prefer vertical if it merged headers but colspan approach didn't
|
||
if vertical_multi_word and not colspan_multi_word:
|
||
use_vertical = True
|
||
# Also prefer vertical if it has more non-year headers (better merging)
|
||
elif vertical_multi_word: # pragma: no cover - unreachable: colspan_multi_word is always False (its only assignment is in the dead branch above), so the preceding `if vertical_multi_word and not colspan_multi_word` is equivalent to `if vertical_multi_word` and always claims this case first
|
||
vertical_non_year = sum(
|
||
1
|
||
for layer in vertical_headers
|
||
for h in layer
|
||
if h and not re.match(r"^(19|20)\d{2}$", h.strip())
|
||
)
|
||
colspan_non_year = (
|
||
sum(
|
||
1
|
||
for layer in header_layers
|
||
for h in layer
|
||
if h and not re.match(r"^(19|20)\d{2}$", h.strip())
|
||
)
|
||
if header_layers
|
||
else 0
|
||
)
|
||
if vertical_non_year >= colspan_non_year:
|
||
use_vertical = True
|
||
|
||
if use_vertical:
|
||
# Validate: check for years/periods or financial terms
|
||
all_header_text = " ".join(h for layer in vertical_headers for h in layer) # ty: ignore[not-iterable]
|
||
has_year_in_headers = bool(re.search(r"\b(19|20)\d{2}\b", all_header_text))
|
||
has_period_in_headers = bool(
|
||
re.search(
|
||
r"(months?\s+ended|year\s+ended|weeks?\s+ended|quarter|period|fiscal)",
|
||
all_header_text,
|
||
re.I,
|
||
)
|
||
)
|
||
has_year_in_rows = any(
|
||
re.search(r"\b(19|20)\d{2}\b", text)
|
||
for row in rows_with_cs[:vertical_row_count]
|
||
for text, _ in row
|
||
)
|
||
has_year_after_headers = (
|
||
any(
|
||
re.search(r"\b(19|20)\d{2}\b", text)
|
||
for row in rows_with_cs[vertical_row_count : vertical_row_count + 2]
|
||
for text, _ in row
|
||
)
|
||
if vertical_row_count < len(rows_with_cs)
|
||
else False
|
||
)
|
||
|
||
# Check for financial terms
|
||
financial_terms = [
|
||
r"\bshares?\b",
|
||
r"\bprice\b",
|
||
r"\bvalue\b",
|
||
r"\bterm\b",
|
||
r"\bexercise\b",
|
||
r"\bgranted?\b",
|
||
r"\bvested\b",
|
||
r"\bforfeited?\b",
|
||
r"\b(?:thousands?|millions?|billions?)\b",
|
||
r"\bper\s+share\b",
|
||
r"\bweighted\b",
|
||
r"\baverage\b",
|
||
r"\baggregate\b",
|
||
r"\bintrinsic\b",
|
||
r"\bcontractual\b",
|
||
r"\bremaining\b",
|
||
r"\bexercisable\b",
|
||
r"\bAmount\b",
|
||
r"\bCredit\b",
|
||
r"\bDebit\b",
|
||
r"\bBalance\b",
|
||
r"\bReceivables?\b",
|
||
r"\bLeases?\b",
|
||
r"\bNotes?\b",
|
||
r"\bAccounts?\b",
|
||
r"\bWholesale\b",
|
||
r"\bRetail\b",
|
||
r"\bFinancing\b",
|
||
r"\bTotal\b",
|
||
r"\bAllowance\b",
|
||
r"\bProvision\b",
|
||
r"\bRevolving\b",
|
||
r"\bTax\b",
|
||
r"\bExpense\b",
|
||
r"\bIncome\b",
|
||
r"\bLoss\b",
|
||
r"\bGain\b",
|
||
]
|
||
has_financial_terms = any(
|
||
re.search(term, all_header_text, re.I) for term in financial_terms
|
||
)
|
||
|
||
merged_multi_word = any(
|
||
len(h.split()) >= 2
|
||
for layer in vertical_headers # ty: ignore[not-iterable]
|
||
for h in layer
|
||
if h and not re.match(r"^[\(\)]*$", h)
|
||
)
|
||
|
||
if (
|
||
has_year_in_headers
|
||
or has_period_in_headers
|
||
or has_year_in_rows
|
||
or has_year_after_headers
|
||
):
|
||
return vertical_headers, vertical_row_count
|
||
|
||
if merged_multi_word and has_financial_terms:
|
||
return vertical_headers, vertical_row_count
|
||
|
||
# Fall back to colspan-based headers if available
|
||
if header_layers and header_row_count > 0:
|
||
# Check if we have meaningful multi-level headers
|
||
# Look for years in any layer
|
||
has_years = False
|
||
|
||
for layer in header_layers:
|
||
for h in layer:
|
||
if re.search(r"\b(19|20)\d{2}\b", h):
|
||
has_years = True
|
||
break
|
||
|
||
if has_years:
|
||
break
|
||
|
||
if has_years and len(header_layers) >= 1:
|
||
return header_layers, header_row_count
|
||
|
||
# Fall back to original single-row logic
|
||
period_prefixes = []
|
||
years: list = []
|
||
generic_headers: list = []
|
||
full_date_headers = []
|
||
sub_headers = []
|
||
period_parts = []
|
||
ended_parts = []
|
||
as_of_prefix = ""
|
||
|
||
# Only look at first 10 rows for headers (not data rows at the bottom)
|
||
for row_idx, row in enumerate(rows_with_cs[:10]):
|
||
texts = [t.strip() for t, cs in row if t.strip()]
|
||
if not texts:
|
||
continue
|
||
|
||
row_text = " ".join(texts)
|
||
|
||
# Look for period descriptions (e.g., "Three Months Ended May")
|
||
if re.search(
|
||
r"(months?\s+ended|year\s+ended|quarter\s+ended|weeks?\s+ended)",
|
||
row_text,
|
||
re.I,
|
||
):
|
||
prefixes = [
|
||
t
|
||
for t in texts
|
||
if re.search(r"(months?|year|quarter|weeks?)\s+ended", t, re.I)
|
||
]
|
||
if prefixes:
|
||
period_prefixes = prefixes
|
||
|
||
# Check individual cells, not the joined row_text
|
||
period_cell_matches = [
|
||
t
|
||
for t in texts
|
||
if re.match(
|
||
r"^(Three|Six|Nine|Twelve|[1-9]|[1-4]\d|5[0-3])\s+(Months?|Weeks?|Week Ended)$",
|
||
t,
|
||
re.I,
|
||
)
|
||
or re.match(r"^(Fiscal\s+)?(Year|Quarter)$", t, re.I)
|
||
]
|
||
|
||
if period_cell_matches:
|
||
period_parts = period_cell_matches
|
||
|
||
# Look for "Ended May" or "Ended" continuation row
|
||
ended_cell_matches = [t for t in texts if re.match(r"^Ended\b", t, re.I)]
|
||
|
||
if ended_cell_matches and period_parts:
|
||
ended_parts = ended_cell_matches
|
||
# Combine with period_parts to make full prefixes
|
||
if len(ended_parts) == len(period_parts):
|
||
period_prefixes = [
|
||
f"{p} {e}" for p, e in zip(period_parts, ended_parts)
|
||
]
|
||
elif len(ended_parts) == 1:
|
||
# Same "Ended X" for all periods
|
||
period_prefixes = [f"{p} {ended_parts[0]}" for p in period_parts]
|
||
|
||
# Look for "As of" prefix row (for balance sheets)
|
||
if re.match(r"^As\s+of$", row_text, re.I):
|
||
as_of_prefix = "As of"
|
||
|
||
# These are Month + Year in a single cell (possibly with "As of" prefix)
|
||
date_pattern = rf"^(As\s+of\s+)?({MONTHS_PATTERN})\s+((?:19|20)\d{{2}})$"
|
||
date_matches = [t for t in texts if re.match(date_pattern, t, re.I)]
|
||
if len(date_matches) >= 2:
|
||
full_date_headers = date_matches
|
||
|
||
# Look for sub-headers like "Assets | Liabilities" that should combine with parent headers
|
||
# These typically appear in rows with repeating short column labels
|
||
sub_header_pattern = r"^(Assets|Liabilities|Actual|Pro\s*Forma|Adjustments)$"
|
||
sub_matches = [t for t in texts if re.match(sub_header_pattern, t, re.I)]
|
||
if len(sub_matches) >= 2:
|
||
sub_headers = sub_matches
|
||
|
||
# Look for years - but only standalone years, not years in parentheses (effective dates)
|
||
# or years embedded in long descriptive text
|
||
year_cells = []
|
||
for t in texts:
|
||
# Skip cells that are too long to be column headers
|
||
if len(t) > 50:
|
||
continue
|
||
# Skip cells with years in parentheses like "(2020)" - these are effective dates
|
||
if re.search(r"\(\d{4}\)", t):
|
||
continue
|
||
# Skip cells where year is part of a longer descriptive header
|
||
# like "Executive Contributions in FY 2025 ($)" or "Aggregate Balance at FY 2025-end"
|
||
# These are descriptive column headers, not year column headers
|
||
if len(t) > 20 and re.search(
|
||
r"(contributions?|balance|earnings|aggregate|withdrawals?|distributions?)",
|
||
t,
|
||
re.I,
|
||
):
|
||
continue
|
||
# Skip cells that are full dates (e.g., "April 7, 2025")
|
||
# These are data values, not year column headers
|
||
if re.match(
|
||
rf"({MONTHS_PATTERN})\s+\d{{1,2}},?\s*(19|20)\d{{2}}",
|
||
t,
|
||
re.I,
|
||
):
|
||
continue
|
||
# Look for standalone years or years in simple header formats
|
||
match = re.search(r"\b((?:19|20)\d{2})\b", t)
|
||
if match:
|
||
year_cells.append(match.group(1))
|
||
if len(year_cells) >= 2 and not years:
|
||
years = year_cells
|
||
|
||
# Look for generic column headers ONLY in first few rows
|
||
# Must be: non-numeric text, multiple items, short length, row has no data
|
||
if row_idx < 5 and not generic_headers:
|
||
# Skip descriptor rows like "(in millions...)"
|
||
if row_text.startswith("("):
|
||
continue
|
||
# Skip rows with numbers that look like data (commas in numbers or decimals)
|
||
if re.search(r"\d{1,3}(,\d{3})+|\d+\.\d{2}", row_text):
|
||
continue
|
||
# Candidates: short text items that are not numbers
|
||
candidates = [
|
||
t
|
||
for t in texts
|
||
if 2 <= len(t) <= 30
|
||
and not re.match(r"^[\d\.,x\-]+$", t)
|
||
and not t.startswith("(")
|
||
]
|
||
if len(candidates) >= 2:
|
||
# Check if these look like headers (mostly alphabetic)
|
||
alpha_count = sum(1 for c in candidates if re.match(r"^[A-Za-z]", c))
|
||
if alpha_count >= 2:
|
||
generic_headers = candidates
|
||
|
||
# Priority 1: Full date headers with sub-headers (e.g., "As of May 1999" + "Assets/Liabilities")
|
||
if full_date_headers and sub_headers:
|
||
# Combine parent headers with sub-headers
|
||
combined = []
|
||
subs_per_parent = len(sub_headers) // len(full_date_headers)
|
||
|
||
if subs_per_parent > 0:
|
||
for i, parent in enumerate(full_date_headers):
|
||
for j in range(subs_per_parent):
|
||
sub_idx = i * subs_per_parent + j
|
||
|
||
if sub_idx < len(sub_headers):
|
||
combined.append(f"{parent} {sub_headers[sub_idx]}")
|
||
|
||
if combined:
|
||
# Return as two rows: parent row and sub row
|
||
parent_row = [""]
|
||
sub_row = [""]
|
||
|
||
for i, parent in enumerate(full_date_headers):
|
||
for j in range(subs_per_parent):
|
||
parent_row.append(parent)
|
||
sub_idx = i * subs_per_parent + j
|
||
|
||
if sub_idx < len(sub_headers):
|
||
sub_row.append(sub_headers[sub_idx])
|
||
|
||
return [parent_row, sub_row], 2
|
||
|
||
# Priority 2: Full date headers without sub-headers
|
||
if full_date_headers:
|
||
headers = (
|
||
[f"{as_of_prefix} {d}" for d in full_date_headers]
|
||
if as_of_prefix
|
||
else full_date_headers
|
||
)
|
||
return [[""] + headers], 1
|
||
|
||
# Priority 3: Combine prefixes with years
|
||
if period_prefixes and years:
|
||
# Check if prefixes already contain years (e.g., "For the Three Months Ended January 26, 2025")
|
||
# Count how many years are mentioned in the prefixes
|
||
prefix_year_counts = []
|
||
|
||
for p in period_prefixes:
|
||
year_matches = re.findall(r"\b(19|20)\d{2}\b", p)
|
||
prefix_year_counts.append(len(year_matches))
|
||
|
||
# If any prefix contains multiple years (describes multiple periods),
|
||
# skip using prefixes and just use the years
|
||
if any(count > 1 for count in prefix_year_counts):
|
||
return [[""] + years], 1
|
||
|
||
# If each prefix has exactly one year and matches the number of years,
|
||
# use prefixes directly (they're full period descriptions)
|
||
if all(count == 1 for count in prefix_year_counts) and len(
|
||
period_prefixes
|
||
) == len(years):
|
||
return [[""] + period_prefixes], 1
|
||
|
||
periods = []
|
||
prefix_idx = 0
|
||
|
||
for i, year in enumerate(years):
|
||
if prefix_idx < len(period_prefixes):
|
||
prefix = period_prefixes[prefix_idx]
|
||
# Only append year if prefix doesn't already have it
|
||
if not re.search(r"\b" + year + r"\b", prefix):
|
||
periods.append(f"{prefix} {year}")
|
||
else:
|
||
periods.append(prefix)
|
||
# Advance prefix index every N years based on ratio
|
||
years_per_prefix = len(years) // len(period_prefixes)
|
||
if years_per_prefix > 0 and (i + 1) % years_per_prefix == 0:
|
||
prefix_idx += 1
|
||
else:
|
||
periods.append(year)
|
||
return [[""] + periods], 1
|
||
|
||
if years:
|
||
return [[""] + years], 1
|
||
|
||
if generic_headers:
|
||
# No financial periods, use generic headers
|
||
return [[""] + generic_headers], 1
|
||
|
||
return [], 0
|
||
|
||
|
||
# ── Chart-legend helper ─────────────────────────────────────────────
|
||
_LEGEND_BG_RE = re.compile(r"background-color:\s*(#[0-9a-fA-F]{6})")
|
||
|
||
|
||
def _extract_chart_legend(table) -> str | None:
|
||
"""Detect a chart-legend table and return an inline legend string.
|
||
|
||
SEC filings embed bar/pie charts as ``<img>`` tags followed by
|
||
small HTML tables that use tiny coloured ``<td>`` cells as colour
|
||
swatches paired with label text (typically font-family 'Gotham
|
||
Narrow Book', font-size ~5 pt, rows of height ~3 pt).
|
||
|
||
The standard table converter turns these into single-column
|
||
markdown tables like::
|
||
|
||
| Affiliates |
|
||
|---|
|
||
| Europe |
|
||
|
||
This helper detects the pattern and emits a more useful format
|
||
that preserves the colour-to-label mapping so the chart can be
|
||
interpreted::
|
||
|
||
**Legend:** ■ (#009dd9) United States · ■ (#0b2d71) Other Americas · …
|
||
|
||
Returns ``None`` if the table is *not* a chart legend.
|
||
"""
|
||
rows = table.find_all("tr")
|
||
if not rows or len(rows) > 30:
|
||
return None
|
||
|
||
# Quick pre-check: reject tables that look like financial data.
|
||
# Real chart legends never contain numeric data, dollar signs,
|
||
# parenthesised negatives, or percentage values in their cells.
|
||
_data_cell_re = re.compile(
|
||
r"(?:^\s*[-—]?\s*\$|\d[\d,]+\.\d|^\s*\(\s*\d|\d\s*%\s*$" + r"|^\s*\d{4}\s*$)"
|
||
)
|
||
data_cell_count = 0
|
||
for row in rows:
|
||
for td in row.find_all("td"):
|
||
cell_text = td.get_text(strip=True)
|
||
if cell_text and _data_cell_re.search(cell_text):
|
||
data_cell_count += 1
|
||
if data_cell_count >= 2:
|
||
return None
|
||
|
||
# Chart-legend tables use tiny coloured cells as colour swatches.
|
||
# A "swatch cell" MUST be a cell whose ONLY purpose is to show
|
||
# a background colour — it must have NO text content (or at most
|
||
# a single non-breaking space). Cells that combine colour with
|
||
# text are header/data cells, NOT swatches.
|
||
#
|
||
# Additionally, swatch cells should be physically small (height
|
||
# ≤ 8px or width ≤ 30px via inline style).
|
||
_height_re = re.compile(r"height:\s*(\d+(?:\.\d+)?)\s*(?:px|pt)", re.I)
|
||
_width_re = re.compile(r"width:\s*(\d+(?:\.\d+)?)\s*(?:px|pt)", re.I)
|
||
swatch_count = 0
|
||
all_labels: list[str] = []
|
||
all_colors: list[str] = []
|
||
# Track how many rows have non-empty text in > 2 columns — a sign
|
||
# this is a real data table, not a legend.
|
||
multi_col_text_rows = 0
|
||
|
||
for row in rows:
|
||
tds = row.find_all("td")
|
||
row_color: str | None = None
|
||
row_label: str | None = None
|
||
text_cell_count = 0
|
||
for td in tds:
|
||
style = td.get("style", "") or ""
|
||
bg = _LEGEND_BG_RE.search(style)
|
||
cell_text = td.get_text(strip=True)
|
||
|
||
# A swatch cell: has background-color, is NOT white,
|
||
# and has NO meaningful text content.
|
||
if bg and bg.group(1).lower() not in ("#ffffff", "#fff") and not cell_text:
|
||
# Check for small dimensions (strong swatch signal)
|
||
h_m = _height_re.search(style)
|
||
w_m = _width_re.search(style)
|
||
is_tiny = (h_m and float(h_m.group(1)) <= 8) or (
|
||
w_m and float(w_m.group(1)) <= 30
|
||
)
|
||
if is_tiny:
|
||
row_color = bg.group(1)
|
||
swatch_count += 1
|
||
else:
|
||
# Empty cell with colour but not tiny — could
|
||
# still be a swatch if no dimension is specified
|
||
# (some legends omit explicit sizes). Count it
|
||
# but don't bump swatch_count (only truly tiny
|
||
# cells are confident swatch detections).
|
||
row_color = bg.group(1)
|
||
# Cell has BOTH colour and text → NOT a swatch.
|
||
# This is typically a header or data cell with shading.
|
||
|
||
if cell_text and len(cell_text) < 80:
|
||
row_label = cell_text
|
||
text_cell_count += 1
|
||
|
||
if text_cell_count > 2:
|
||
multi_col_text_rows += 1
|
||
if row_color and not row_label:
|
||
all_colors.append(row_color)
|
||
elif row_label and not row_color:
|
||
all_labels.append(row_label)
|
||
elif row_color and row_label:
|
||
# Both in one row — paired directly
|
||
all_colors.append(row_color)
|
||
all_labels.append(row_label)
|
||
|
||
# If many rows have text in 3+ columns, this is a data table.
|
||
if multi_col_text_rows >= 2:
|
||
return None
|
||
|
||
# Require enough CONFIDENT swatch detections (tiny empty colour
|
||
# cells) and at least two labels.
|
||
if swatch_count < 2 or len(all_labels) < 2:
|
||
return None
|
||
|
||
# Labels should be category names, NOT numbers/years/percentages.
|
||
_numeric_label_re = re.compile(r"^\s*[\d,.%$€£()\-—]+\s*$")
|
||
non_numeric_labels = [lab for lab in all_labels if not _numeric_label_re.match(lab)]
|
||
if len(non_numeric_labels) < 2:
|
||
return None
|
||
|
||
# Final sanity: the raw text of the whole table should be short
|
||
# (legends are just a handful of category names).
|
||
full_text = table.get_text(strip=True)
|
||
if len(full_text) > 500:
|
||
return None
|
||
|
||
# Pair colours and labels. The HTML structure typically puts the
|
||
# label row immediately before its colour swatch row, so
|
||
# all_labels[i] corresponds to all_colors[i].
|
||
# Only include labels that have a paired colour swatch.
|
||
# Unpaired labels (more labels than colours) are typically chart
|
||
# titles/descriptions embedded in the legend table — omit them.
|
||
n_pairs = min(len(non_numeric_labels), len(all_colors))
|
||
if n_pairs < 2:
|
||
return None
|
||
|
||
# Build an HTML legend with actual CSS-styled colour swatches.
|
||
# Using background-color on inline-block spans is the most reliable
|
||
# way to render coloured boxes across markdown renderers.
|
||
swatch = (
|
||
'<span style="display:inline-block;width:12px;height:12px;'
|
||
"background:{color};vertical-align:middle;border-radius:2px"
|
||
'"></span>'
|
||
)
|
||
items: list[str] = []
|
||
for idx in range(n_pairs):
|
||
box = swatch.format(color=all_colors[idx])
|
||
items.append(f"{box} {non_numeric_labels[idx]}")
|
||
|
||
legend_body = " · ".join(items)
|
||
return (
|
||
f'<div style="margin:4px 0;font-size:0.9em"><b>Legend:</b> {legend_body}</div>'
|
||
)
|
||
|
||
|
||
# ── Table-title pattern for composite-table splitting ──────────────
|
||
_TABLE_TITLE_RE = re.compile(r"^TABLE\s+\d+", re.IGNORECASE)
|
||
|
||
|
||
def _split_composite_table(table) -> list:
|
||
"""Split a single <table> that contains multiple sub-tables.
|
||
|
||
Certent CDM and similar absolute-position SEC filings often group
|
||
several logical tables (e.g. TABLE 5, TABLE 6, TABLE 7) plus
|
||
connecting body-text paragraphs into **one** horizontal-rule zone,
|
||
so ``_build_table_from_zone`` emits a single ``<table>`` for the
|
||
whole lot.
|
||
|
||
This function detects "TABLE X:" header rows and splits the
|
||
original table element into a list of ``(rows, is_body_text)``
|
||
tuples. Each ``rows`` list is a contiguous slice of ``<tr>``
|
||
elements. ``is_body_text`` is True when the section between two
|
||
TABLE headers consists entirely of single-cell (full-width) text
|
||
rows that should be rendered as paragraphs rather than a table.
|
||
|
||
Returns a list of BS4 ``<table>`` elements (and plain-text string
|
||
fragments for body-text sections) ready for independent conversion.
|
||
"""
|
||
all_rows = table.find_all("tr")
|
||
if len(all_rows) < 4:
|
||
return [table]
|
||
|
||
# Find rows whose first cell matches "TABLE X: …"
|
||
split_indices: list[int] = []
|
||
for idx, row in enumerate(all_rows):
|
||
cells = row.find_all(["td", "th"])
|
||
if not cells:
|
||
continue
|
||
first_text = cells[0].get_text(strip=True)
|
||
if _TABLE_TITLE_RE.match(first_text):
|
||
split_indices.append(idx)
|
||
|
||
# Need at least 2 sub-table headers to justify splitting
|
||
if len(split_indices) < 2:
|
||
return [table]
|
||
|
||
# Helper: detect if a row-set is purely body text
|
||
# (all data in column 0, rest empty)
|
||
def _is_body_text_section(rows):
|
||
if not rows:
|
||
return True # pragma: no cover - only caller (line 3904) passes sub_rows[ri:] with ri>=1 < len(sub_rows), so rows is never empty
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
# Check if any cell beyond the first has content
|
||
if any(c.get_text(strip=True) for c in cells[1:]):
|
||
return False
|
||
return True
|
||
|
||
# Build segments: each segment is a slice of rows
|
||
# Segments between TABLE headers that are all single-cell → body text
|
||
# TABLE header row + data rows after it → sub-table
|
||
parts: list = []
|
||
|
||
# Rows before the first TABLE header (belong to whatever table was at top)
|
||
if split_indices[0] > 0:
|
||
pre_rows = all_rows[: split_indices[0]]
|
||
# Check if there's a "TABLE X:" in the very first rows
|
||
# If so, this is the main table data; otherwise check for body text
|
||
sub = _make_sub_table(pre_rows, table)
|
||
parts.append(sub)
|
||
|
||
for si in range(len(split_indices)):
|
||
start = split_indices[si]
|
||
end = split_indices[si + 1] if si + 1 < len(split_indices) else len(all_rows)
|
||
|
||
# Find where body text starts between this and next TABLE header
|
||
# The sub-table continues until rows become single-cell body text
|
||
sub_rows = all_rows[start:end]
|
||
|
||
# Within this slice, find where the data rows end and body text begins
|
||
# Body text: every cell after col 0 is empty AND text is long (>60 chars),
|
||
# OR an all-caps heading followed by body text paragraphs.
|
||
data_end = len(sub_rows)
|
||
for ri in range(1, len(sub_rows)):
|
||
row = sub_rows[ri]
|
||
cells = row.find_all(["td", "th"])
|
||
first = cells[0].get_text(strip=True) if cells else ""
|
||
rest_empty = not any(c.get_text(strip=True) for c in cells[1:])
|
||
if not rest_empty or not first:
|
||
continue
|
||
if _TABLE_TITLE_RE.match(first):
|
||
continue # pragma: no cover - unreachable: every "TABLE N" row is captured as a split boundary, so each segment (all_rows[start:end], end = next boundary) contains no further TABLE-title row at scan index ri>=1
|
||
|
||
# Long body text paragraph
|
||
is_body_start = len(first) > 60
|
||
# Or: all-caps section heading followed by body text
|
||
if not is_body_start and first.isupper() and len(first) > 5:
|
||
# Check if the NEXT row is long body text
|
||
ni = ri + 1
|
||
if ni < len(sub_rows):
|
||
ncells = sub_rows[ni].find_all(["td", "th"])
|
||
nfirst = ncells[0].get_text(strip=True) if ncells else ""
|
||
nrest = not any(c.get_text(strip=True) for c in ncells[1:])
|
||
if nrest and len(nfirst) > 60:
|
||
is_body_start = True
|
||
|
||
if is_body_start:
|
||
remaining = sub_rows[ri:]
|
||
if _is_body_text_section(remaining):
|
||
data_end = ri
|
||
break
|
||
|
||
# Table data part
|
||
table_rows = sub_rows[:data_end]
|
||
if table_rows:
|
||
parts.append(_make_sub_table(table_rows, table))
|
||
|
||
# Body text part
|
||
if data_end < len(sub_rows):
|
||
body_rows = sub_rows[data_end:]
|
||
body_text = "\n".join(
|
||
row.find_all(["td", "th"])[0].get_text(strip=True)
|
||
for row in body_rows
|
||
if row.find_all(["td", "th"])
|
||
and row.find_all(["td", "th"])[0].get_text(strip=True)
|
||
)
|
||
if body_text.strip():
|
||
parts.append(body_text)
|
||
|
||
return parts
|
||
|
||
|
||
def _make_sub_table(rows, original_table):
|
||
"""Create a new BS4 <table> element from a subset of rows."""
|
||
import copy as _copy
|
||
|
||
soup = BeautifulSoup("<table></table>", "html.parser")
|
||
new_table = soup.new_tag("table")
|
||
# Copy attributes from original
|
||
for attr, val in original_table.attrs.items():
|
||
new_table[attr] = val
|
||
for row in rows:
|
||
new_table.append(_copy.copy(row))
|
||
return new_table
|
||
|
||
|
||
def convert_table(table, base_url: str = "") -> str:
|
||
"""Convert HTML table to markdown table or text.
|
||
|
||
Uses a single classification to determine table type:
|
||
- BULLET: Tables with bullet chars in first column → bullet list
|
||
- FOOTNOTE: Tables with (1), (a), * markers → "marker text" format
|
||
- HEADER: Single-cell tables with section titles → markdown header
|
||
- LAYOUT: Tables with multi-line content cells → section headers + bullet lists
|
||
- DATA: Everything else → markdown table (the default)
|
||
"""
|
||
# ── Chart-legend detection ──────────────────────────────────────
|
||
# SEC filings embed bar / pie charts as <img> tags with adjacent
|
||
# HTML tables that use tiny coloured cells as colour swatches
|
||
# paired with label text (font-family: 'Gotham Narrow Book' /
|
||
# similar, font-size ~5pt, rows of height 3pt). The standard
|
||
# table converter turns these into useless single-column markdown
|
||
# tables. Detect them early and emit a compact inline legend.
|
||
legend = _extract_chart_legend(table)
|
||
if legend is not None:
|
||
return legend
|
||
|
||
# Classify the table
|
||
table_type = _classify_table(table)
|
||
|
||
if table_type == "BULLET":
|
||
result = _extract_bullet_list(table)
|
||
if result:
|
||
return result
|
||
|
||
elif table_type == "FOOTNOTE":
|
||
result = _extract_footnote_text(table)
|
||
if result:
|
||
return result
|
||
|
||
elif table_type == "HEADER":
|
||
result = _extract_header_text(table)
|
||
if result:
|
||
return result
|
||
|
||
# For LAYOUT-classified tables (or unhandled types), try layout conversion.
|
||
# Skip for DATA tables — they should always go through the data-table path.
|
||
if table_type != "DATA":
|
||
layout_result = _convert_layout_table(table, base_url)
|
||
if layout_result:
|
||
return layout_result
|
||
|
||
# Default: process as data table (markdown table format)
|
||
rows = table.find_all("tr")
|
||
if not rows:
|
||
return ""
|
||
|
||
# Shared state: when year sub-headers start at position 0 (label
|
||
# column), build_column_headers_from_colspan computes the offset
|
||
# needed to align year positions with data-column positions. The
|
||
# semantic extraction code reads this to apply the same shift.
|
||
_year_pos_shift = [0] # mutable container so nested functions can write
|
||
|
||
# First pass: Extract all rows WITHOUT merge/shift
|
||
# We need to identify $ positions across all rows first
|
||
raw_extracted_rows = []
|
||
raw_extracted_colspans = []
|
||
raw_row_has_th = [] # Track whether each row has <th> elements
|
||
|
||
# Track grid positions occupied by cells with rowspan > 1 from
|
||
# earlier rows. Maps grid_col -> remaining row count.
|
||
_rowspan_grid: dict[int, int] = {}
|
||
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
row_data: list[str] = []
|
||
row_with_colspan: list[tuple[str, int]] = [] # (text, colspan) pairs
|
||
has_th = any(cell.name == "th" for cell in cells)
|
||
|
||
grid_col = 0
|
||
cell_idx = 0
|
||
|
||
while cell_idx < len(cells):
|
||
# Insert empty placeholders for positions occupied by
|
||
# rowspan from earlier rows before placing the current cell.
|
||
while grid_col in _rowspan_grid:
|
||
row_data.append("")
|
||
row_with_colspan.append(("", 1))
|
||
grid_col += 1
|
||
|
||
cell = cells[cell_idx]
|
||
|
||
# Check for id attribute on cell - emit anchor if present
|
||
cell_id = cell.get("id")
|
||
anchor_prefix = ""
|
||
if cell_id:
|
||
anchor_prefix = f'<a id="{cell_id}"></a>'
|
||
|
||
# For table cells, extract text while preserving links
|
||
# but without the full recursive processing that introduces newlines
|
||
text = _extract_cell_text(cell, base_url)
|
||
|
||
# Clean up text - remove zero-width spaces and normalize whitespace
|
||
text = text.replace("\u200b", "")
|
||
text = text.replace("\xa0", " ") # Non-breaking space
|
||
text = re.sub(r"\s+", " ", text)
|
||
text = text.strip()
|
||
text = text.replace("|", "\\|") # Escape pipes
|
||
# Prepend anchor if cell had an id
|
||
if anchor_prefix:
|
||
text = anchor_prefix + text
|
||
|
||
# Handle colspan and rowspan
|
||
colspan = int(cell.get("colspan", 1) or 1)
|
||
rowspan = int(cell.get("rowspan", 1) or 1)
|
||
|
||
row_data.append(text)
|
||
row_with_colspan.append((text, colspan))
|
||
|
||
# Register this cell's grid positions for future rows
|
||
if rowspan > 1:
|
||
for _c in range(colspan):
|
||
_rowspan_grid[grid_col + _c] = rowspan
|
||
|
||
for _ in range(colspan - 1):
|
||
row_data.append("")
|
||
|
||
grid_col += colspan
|
||
cell_idx += 1
|
||
|
||
# Fill any trailing positions still occupied by rowspan
|
||
while grid_col in _rowspan_grid:
|
||
row_data.append("")
|
||
row_with_colspan.append(("", 1))
|
||
grid_col += 1
|
||
|
||
raw_extracted_rows.append(row_data)
|
||
raw_extracted_colspans.append(row_with_colspan)
|
||
raw_row_has_th.append(has_th)
|
||
|
||
# Decrement rowspan counts; drop positions that have expired.
|
||
_rowspan_grid = {pos: rem - 1 for pos, rem in _rowspan_grid.items() if rem > 1}
|
||
|
||
# Identify positions that have currency prefixes ($, €, £) in ANY row.
|
||
# These are the only positions where "empty + numeric" shift should apply.
|
||
_CURRENCY_PREFIXES = {
|
||
"$",
|
||
"$(",
|
||
"($",
|
||
"$-",
|
||
"€",
|
||
"€(",
|
||
"(€",
|
||
"€-",
|
||
"£",
|
||
"£(",
|
||
"(£",
|
||
"£-",
|
||
}
|
||
dollar_positions = set()
|
||
for row_data in raw_extracted_rows:
|
||
for i, cell in enumerate(row_data):
|
||
cell_stripped = strip_all(cell)
|
||
if cell_stripped in _CURRENCY_PREFIXES:
|
||
dollar_positions.add(i)
|
||
|
||
# Extract all rows, preserving colspan info for headers
|
||
data = []
|
||
raw_rows_with_colspan = [] # For header detection - stores (text, colspan) pairs
|
||
row_has_th_flags = [] # Track which rows have <th> elements
|
||
for row_idx, (row_data, row_with_colspan, has_th) in enumerate(
|
||
zip(raw_extracted_rows, raw_extracted_colspans, raw_row_has_th)
|
||
):
|
||
# Merge currency prefix cells with their following value cells.
|
||
# SEC tables often have $ in one cell and the number in the next.
|
||
# 20-F filings frequently use € with colspan=3, placing the number
|
||
# up to 3 positions away after empty expansion cells.
|
||
merged_row = []
|
||
merged_row_with_colspan = []
|
||
i = 0
|
||
while i < len(row_data):
|
||
cell = strip_all(row_data[i])
|
||
# Check if this is a currency prefix cell ($, €, £, etc.)
|
||
if cell in _CURRENCY_PREFIXES and i + 1 < len(row_data):
|
||
# Look ahead up to 3 positions for the number
|
||
# (currency cell may have colspan>1 producing empty gaps)
|
||
_num_re = re.compile(r"^\(?\s*[\d,]+\.?\d*\s*\)?%?$")
|
||
_found_offset = None
|
||
for _look in range(1, min(4, len(row_data) - i)):
|
||
_ahead = strip_all(row_data[i + _look])
|
||
if _ahead and _num_re.match(_ahead):
|
||
_found_offset = _look
|
||
break
|
||
if _ahead: # non-empty non-numeric → stop
|
||
break
|
||
if _found_offset is not None:
|
||
merged_val = cell + strip_all(row_data[i + _found_offset])
|
||
merged_row.append(merged_val)
|
||
# Add empty placeholders for all consumed cells
|
||
for _ in range(_found_offset):
|
||
merged_row.append("")
|
||
# Preserve colspan tracking for all consumed cells
|
||
for j in range(_found_offset + 1):
|
||
if i + j < len(row_with_colspan):
|
||
merged_row_with_colspan.append(row_with_colspan[i + j])
|
||
i += _found_offset + 1
|
||
continue
|
||
# Handle rows WITHOUT currency prefix: empty cell followed by
|
||
# a numeric value. Secondary data rows may omit the currency
|
||
# symbol shown on the first row. Shift the value to the
|
||
# currency position to keep alignment. Also use look-ahead
|
||
# (up to 3 cells) for €-style tables with colspan>1 gaps.
|
||
elif cell == "" and i + 1 < len(row_data) and i in dollar_positions:
|
||
_num_re2 = re.compile(r"^\(?\s*[\d,]+\.?\d*\s*\)?%?$")
|
||
_found_offset2 = None
|
||
for _look2 in range(1, min(4, len(row_data) - i)):
|
||
_ahead2 = strip_all(row_data[i + _look2])
|
||
if _ahead2 and _num_re2.match(_ahead2):
|
||
_found_offset2 = _look2
|
||
break
|
||
if _ahead2:
|
||
break
|
||
if _found_offset2 is not None:
|
||
merged_row.append(strip_all(row_data[i + _found_offset2]))
|
||
for _ in range(_found_offset2):
|
||
merged_row.append("")
|
||
for j in range(_found_offset2 + 1):
|
||
if i + j < len(row_with_colspan):
|
||
merged_row_with_colspan.append(row_with_colspan[i + j])
|
||
i += _found_offset2 + 1
|
||
continue
|
||
merged_row.append(row_data[i])
|
||
if i < len(row_with_colspan):
|
||
merged_row_with_colspan.append(row_with_colspan[i])
|
||
i += 1
|
||
|
||
row_data = merged_row # noqa
|
||
row_with_colspan = merged_row_with_colspan # noqa
|
||
|
||
# Merge footnote cells with the ROW LABEL (first non-empty cell)
|
||
# SEC tables have footnote markers like <sup>12</sup> in their own cells
|
||
# These belong on the label, not on data values
|
||
footnote_merged_row = []
|
||
collected_footnotes = []
|
||
label_index = -1 # Track which cell is the row label
|
||
|
||
for i, cell_text in enumerate(row_data):
|
||
# Check if this cell is ONLY a superscript footnote
|
||
if re.match(r"^\s*<sup>\d{1,3}</sup>\s*$", cell_text):
|
||
# Collect footnotes to append to label later
|
||
collected_footnotes.append(cell_text)
|
||
else:
|
||
footnote_merged_row.append(cell_text)
|
||
# First non-empty cell is the label
|
||
if label_index == -1 and cell_text.strip():
|
||
label_index = len(footnote_merged_row) - 1
|
||
|
||
# Append all collected footnotes to the row label
|
||
if collected_footnotes and label_index >= 0:
|
||
footnote_merged_row[label_index] = footnote_merged_row[
|
||
label_index
|
||
] + "".join(collected_footnotes)
|
||
|
||
row_data = footnote_merged_row # noqa
|
||
|
||
if any(row_data): # Skip empty rows
|
||
data.append(row_data)
|
||
raw_rows_with_colspan.append(row_with_colspan)
|
||
row_has_th_flags.append(has_th)
|
||
|
||
if not data:
|
||
return ""
|
||
|
||
# Filter out page footer tables
|
||
if len(data) <= 2:
|
||
non_empty = [c for c in data[0] if c.strip()]
|
||
if len(non_empty) == 2:
|
||
first_cell = non_empty[0].strip()
|
||
second_cell = non_empty[1].strip()
|
||
first_lower = first_cell.lower()
|
||
second_lower = second_cell.lower()
|
||
form_keywords = ["form 10-", "10-k", "10-q", "annual report"]
|
||
if (
|
||
first_cell.isdigit() and any(kw in second_lower for kw in form_keywords)
|
||
) or (
|
||
second_cell.isdigit() and any(kw in first_lower for kw in form_keywords)
|
||
):
|
||
return "" # Skip page footer
|
||
|
||
# Check if this is a section header table (single cell with TOC anchor)
|
||
# Header like: <td id="toc..."><div style="font-weight:bold">Section Title</div></td>
|
||
if len(data) == 1 and len([c for c in data[0] if c.strip()]) == 1:
|
||
cell_text = [c for c in data[0] if c.strip()][0]
|
||
# Check if it has a TOC anchor
|
||
if '<a id="toc' in cell_text or '<a id="TOC' in cell_text:
|
||
# Extract the text after the anchor
|
||
text_match = re.search(r"</a>(.*)", cell_text)
|
||
anchor_match = re.search(r'(<a id="[^"]+"></a>)', cell_text)
|
||
if text_match and anchor_match:
|
||
anchor = anchor_match.group(1)
|
||
header_text = text_match.group(1).strip()
|
||
if header_text:
|
||
return f"\n\n{anchor}\n\n### {header_text}\n\n"
|
||
|
||
# Check if this is a bullet-list layout table
|
||
# Must have actual bullet characters, not just empty cells
|
||
is_bullet_list = False
|
||
bullet_items = []
|
||
for row in data:
|
||
non_empty = [c for c in row if c.strip()]
|
||
if len(non_empty) == 2 and non_empty[0] in BULLET_CHARS:
|
||
# Actual bullet + text pattern
|
||
bullet_items.append(non_empty[1])
|
||
is_bullet_list = True
|
||
elif len(non_empty) == 1 and is_bullet_list:
|
||
# Continuation of bullet list (single items after we've seen bullets)
|
||
bullet_items.append(non_empty[0])
|
||
elif len(non_empty) >= 1 and not is_bullet_list:
|
||
# Single row with content but not a bullet pattern
|
||
# Join all non-empty cells as text (preserves both image and text)
|
||
if len(data) == 1:
|
||
return " ".join(non_empty)
|
||
break
|
||
else:
|
||
is_bullet_list = False
|
||
break
|
||
|
||
if is_bullet_list and bullet_items:
|
||
return "\n".join(f"- {item}" for item in bullet_items)
|
||
|
||
# Normalize column count
|
||
max_cols = max(len(row) for row in data)
|
||
for row in data:
|
||
while len(row) < max_cols:
|
||
row.append("")
|
||
|
||
# Clean up financial table formatting
|
||
|
||
data = clean_table_cells(data)
|
||
|
||
# Normalize financial values to compact format.
|
||
# SEC filings use spacer elements between $ signs, parentheses, and
|
||
# numbers, producing inconsistent formats like "$ 1,787" vs "$1,787"
|
||
# or "( 135 )" vs "(135)". Normalize everything to compact form.
|
||
data = _normalize_financial_rows(data)
|
||
|
||
# Merge consecutive rows that contain split text (e.g., "(in millions, except per" + "share amounts)")
|
||
# This happens when HTML has multi-line text split across separate TR elements
|
||
|
||
data, raw_rows_with_colspan = merge_split_rows(data, raw_rows_with_colspan)
|
||
|
||
# Merge cells that contain split values within each row
|
||
# Old SEC HTML often splits negative numbers like "(306" and ")" across cells
|
||
# and puts footnote markers like "(1)" in separate cells
|
||
|
||
data = merge_split_cells(data)
|
||
|
||
# Normalize column count after cleaning
|
||
if data:
|
||
max_cols = max(len(row) for row in data)
|
||
for row in data:
|
||
while len(row) < max_cols:
|
||
row.append(
|
||
""
|
||
) # pragma: no cover - unreachable: data was rectangularized by the earlier column-count normalization pass (and merge_split_cells preserves width via placeholders), so every row already has max_cols entries here
|
||
|
||
# Remove columns that are completely empty
|
||
|
||
# Step 1: Extract period headers from header rows
|
||
|
||
# Step 2: Parse each row semantically - first text is label, numbers are values
|
||
|
||
# Detect multi-level headers directly from DATA (not raw_rows_with_colspan)
|
||
# This handles SEC tables where categories are stacked vertically:
|
||
# Row: EQUIPMENT | FINANCIAL | | | ...
|
||
# Row: OPERATIONS | SERVICES | ELIMINATIONS | CONSOLIDATED | ...
|
||
# Row: 2025 | 2024 | 2023 | 2025 | 2024 | 2023 | ...
|
||
|
||
# Try to detect multi-index headers from data
|
||
multiindex_result = detect_and_merge_multiindex_headers(data)
|
||
|
||
if multiindex_result[0] and len(multiindex_result[0]) >= 1:
|
||
header_layers, data_start_idx, num_periods = multiindex_result
|
||
is_financial_periods = True
|
||
header_row_count = data_start_idx
|
||
else:
|
||
# Fall back to original approach
|
||
header_layers, extracted_header_count = extract_periods_from_rows(
|
||
raw_rows_with_colspan, row_has_th_flags, _year_pos_shift
|
||
)
|
||
|
||
num_periods = 0
|
||
|
||
if header_layers:
|
||
# Count columns that have content in ANY header layer, but
|
||
# collapse adjacent positions with identical composite text
|
||
# (from colspan expansion). E.g., "2025" at positions 3,4,5
|
||
# from a cs=3 header counts as ONE period, not three.
|
||
max_cols = max(len(layer) for layer in header_layers)
|
||
num_periods = 0
|
||
prev_key = None
|
||
for col_idx in range(1, max_cols): # Skip first col (label)
|
||
key_parts = []
|
||
has_content = False
|
||
for layer in header_layers:
|
||
text = layer[col_idx].strip() if col_idx < len(layer) else ""
|
||
key_parts.append(text)
|
||
if text:
|
||
has_content = True
|
||
if has_content:
|
||
key = "|".join(key_parts)
|
||
if key != prev_key:
|
||
num_periods += 1
|
||
prev_key = key
|
||
else:
|
||
prev_key = None # pragma: no cover - unreachable: extract_periods_from_rows compacts each header layer to period positions (empties appear only in rendered data cells, never across all layers at one column), so has_content is never False for an interior column here
|
||
|
||
# When all header layers have the same size they are already
|
||
# compact (from the flat-header / multi-category-row path).
|
||
# Empty trailing columns still represent real data columns
|
||
# (e.g., a total column with no header text), so trust the
|
||
# layer size as the minimum column count.
|
||
layer_sizes = set(len(hl) for hl in header_layers)
|
||
|
||
if len(layer_sizes) == 1:
|
||
layers_cols = layer_sizes.pop() - 1 # subtract label col
|
||
num_periods = max(num_periods, layers_cols)
|
||
|
||
# Check if headers are financial periods (years, dates, "months ended",
|
||
# or maturity-bucket labels like "0 - 6 Months" / "1 - 5 Years")
|
||
is_financial_periods = False
|
||
|
||
if header_layers:
|
||
for layer in header_layers:
|
||
for h in layer:
|
||
if re.search(r"\b(19|20)\d{2}\b", h): # Has a year
|
||
is_financial_periods = True
|
||
break
|
||
if re.search(
|
||
r"(months?\s+ended|year\s+ended|quarter|as\s+of)", h, re.I
|
||
):
|
||
is_financial_periods = True
|
||
break
|
||
# Maturity-bucket labels: "0 - 6 Months", "1 - 5 Years",
|
||
# "10 Years or Greater", "Less than 1 Year", etc.
|
||
if re.search(
|
||
r"\d+\s*[-\u2013\u2014]\s*\d+\s*(months?|years?)"
|
||
r"|\d+\s+(months?|years?)(\s+or\s+(greater|more|less))?"
|
||
r"|less\s+than\s+\d+\s+(months?|years?)",
|
||
h,
|
||
re.I,
|
||
):
|
||
is_financial_periods = True
|
||
break
|
||
|
||
if is_financial_periods:
|
||
break
|
||
|
||
# Use extracted_header_count if available
|
||
header_row_count = extracted_header_count
|
||
|
||
# These need special handling to merge headers and align columns
|
||
|
||
# Try equity statement processing first
|
||
if is_equity_statement_table(data):
|
||
equity_result, equity_header_count = process_equity_statement_table(
|
||
data, raw_rows_with_colspan
|
||
)
|
||
if equity_result:
|
||
data = equity_result
|
||
num_header_rows = equity_header_count
|
||
# Skip the usual processing - go directly to markdown output
|
||
if data and data[0]:
|
||
max_cols = max(len(row) for row in data) if data else 0
|
||
lines = []
|
||
for i, row in enumerate(data):
|
||
while len(row) < max_cols:
|
||
row.append(
|
||
""
|
||
) # pragma: no cover - unreachable: process_equity_statement_table returns rows of uniform width (header is [""]+final_headers, each data row is [label]+values, both length 1+num_value_cols), so every row already has max_cols entries
|
||
clean_row = []
|
||
for cell in row:
|
||
clean_cell = re.sub(r"[\r\n]+", " ", cell)
|
||
# Remove <br>/<BR> tags from header cells (first few rows)
|
||
# num_header_rows is equity_header_count here
|
||
if i < max(num_header_rows, 2):
|
||
clean_cell = re.sub(r"<[Bb][Rr]\s*/?>", " ", clean_cell)
|
||
clean_cell = re.sub(r" +", " ", clean_cell).strip()
|
||
clean_row.append(clean_cell)
|
||
line = "| " + " | ".join(clean_row) + " |"
|
||
lines.append(line)
|
||
# Separator after FIRST row only (markdown requirement)
|
||
if i == 0:
|
||
lines.append("|" + "|".join(["---"] * max_cols) + "|")
|
||
return "\n".join(lines)
|
||
|
||
# Build output table
|
||
new_data = []
|
||
|
||
# Helper to check if a row from data array is a header/title row (should be skipped)
|
||
has_meaningful_headers = False
|
||
has_financial_header_terms = False
|
||
if header_layers:
|
||
all_header_text = " ".join(h for layer in header_layers for h in layer)
|
||
|
||
for layer in header_layers:
|
||
# Check for multi-word headers that indicate merged vertical labels
|
||
for _h in layer:
|
||
h = _h.strip()
|
||
# Multi-word headers (e.g., "Retail Notes & Financing Leases")
|
||
if h and len(h.split()) >= 2 and not re.match(r"^(19|20)\d{2}$", h):
|
||
has_meaningful_headers = True
|
||
break
|
||
if has_meaningful_headers:
|
||
break
|
||
|
||
# Check for financial terms in headers that indicate structured financial table
|
||
# These terms indicate the table should be parsed semantically even without years
|
||
financial_header_terms = [
|
||
r"\bshares?\b",
|
||
r"\bprice\b",
|
||
r"\bvalue\b",
|
||
r"\bterm\b",
|
||
r"\bexercise\b",
|
||
r"\bgranted?\b",
|
||
r"\bvested\b",
|
||
r"\bforfeited?\b",
|
||
r"\b(?:thousands?|millions?|billions?)\b",
|
||
r"\bper\s+share\b",
|
||
r"\baggregate\b",
|
||
r"\bintrinsic\b",
|
||
r"\bcontractual\b",
|
||
r"\bremaining\b",
|
||
r"\bweighted[-\s]?average\b",
|
||
r"\bexercisable\b",
|
||
r"\bAmount\b",
|
||
r"\bCredit\b",
|
||
r"\bDebit\b",
|
||
r"\bBalance\b",
|
||
r"\bReceivables?\b",
|
||
r"\bLeases?\b",
|
||
r"\bFinancing\b",
|
||
r"\bWholesale\b",
|
||
# Maturity / time-duration bucket labels
|
||
r"\bmonths?\b",
|
||
r"\byears?\b",
|
||
r"\bor\s+greater\b",
|
||
r"\bmaturity\b",
|
||
]
|
||
has_financial_header_terms = any(
|
||
re.search(term, all_header_text, re.I) for term in financial_header_terms
|
||
)
|
||
|
||
has_mixed_headers = False
|
||
|
||
if header_layers and len(header_layers) == 1:
|
||
# Single header row - check if it mixes years with non-year columns.
|
||
# "Mixed" means the non-year columns OUTNUMBER the year/year-range
|
||
# columns, implying the headers are different dimensions (e.g.,
|
||
# "2024 | 2023 | % Change | Useful Lives") rather than a year-period
|
||
# table with a summary column ("2008 | 2009-2010 | ... | Total").
|
||
last_layer = header_layers[-1]
|
||
year_cols: int = 0
|
||
non_year_cols: int = 0
|
||
|
||
for _h in last_layer[1:]: # Skip label column
|
||
h = _h.strip()
|
||
if not h:
|
||
continue # pragma: no cover - unreachable: this single-layer branch's header_layers come from extract_periods_from_rows, which compacts the layer (no interior empty cell survives in last_layer), so h is never empty here
|
||
if re.search(r"\b(19|20)\d{2}\b", h):
|
||
year_cols += 1
|
||
else:
|
||
non_year_cols += 1
|
||
|
||
# Mixed only when non-year columns are the majority, or there are NO
|
||
# year columns at all AND the headers don't form a cohesive maturity-
|
||
# bucket set (e.g. "0 - 6 Months", "1 - 5 Years", "Total").
|
||
# Maturity-bucket tables look like year-range tables but use time
|
||
# durations instead of calendar years — they are NOT truly mixed.
|
||
_maturity_bucket_re = re.compile(
|
||
r"^\s*("
|
||
r"\d+\s*[-\u2013\u2014]\s*(\d+|\w+)\s*(months?|years?)"
|
||
r"|\d+\s+(months?|years?)(\s+or\s+(greater|more|less))?"
|
||
r"|less\s+than\s+\d+"
|
||
r"|or\s+(greater|more|less)"
|
||
r"|n/?a"
|
||
r"|total"
|
||
r"|thereafter"
|
||
r")\s*$",
|
||
re.I,
|
||
)
|
||
if year_cols == 0:
|
||
# All non-empty, non-label headers are maturity buckets → not mixed
|
||
_all_maturity = all(
|
||
_maturity_bucket_re.match(h) for h in last_layer[1:] if h.strip()
|
||
)
|
||
if not _all_maturity:
|
||
has_mixed_headers = True
|
||
elif non_year_cols > year_cols:
|
||
has_mixed_headers = True
|
||
|
||
use_semantic_parsing = (
|
||
is_financial_periods and header_layers and not has_mixed_headers
|
||
)
|
||
|
||
if (
|
||
not use_semantic_parsing
|
||
and header_layers
|
||
and has_meaningful_headers
|
||
and has_financial_header_terms
|
||
and not has_mixed_headers
|
||
):
|
||
# Treat merged vertical headers with financial terms as financial tables
|
||
use_semantic_parsing = True # pragma: no cover - unreachable: this guard needs a non-year (is_financial_periods False) header that is not has_mixed_headers, yet any single-layer non-year financial header sets has_mixed_headers True and 2-layer non-year headers collapse to single-layer; the only not-mixed non-year headers are all-maturity sets, which either carry no financial-term match or set is_financial_periods True (making use_semantic_parsing already True at line 4534)
|
||
|
||
# Guard: verify num_periods matches the actual data width.
|
||
# Complex multi-index headers (e.g., "Economic value sensitivity" spanning
|
||
# date sub-headers spanning country sub-sub-headers) can cause the header
|
||
# detection to undercount periods. When data rows consistently have more
|
||
# non-empty values than num_periods, the header structure is wrong and we
|
||
# must fall back to positional (non-semantic) rendering.
|
||
if use_semantic_parsing and num_periods > 0 and header_row_count < len(data):
|
||
_val_re = re.compile(
|
||
r"^[+\-]?[\$\u20ac]?\s*\(?[\$\u20ac]?\s*[\d,]+\.?\d*\s*\)?\s*[*%]*(pts)?$"
|
||
)
|
||
_dash_vals = {
|
||
"\u2014",
|
||
"\u2013",
|
||
"-",
|
||
"$\u2014",
|
||
"$\u2013",
|
||
"$-",
|
||
"\u20ac\u2014",
|
||
"\u20ac\u2013",
|
||
"\u20ac-",
|
||
"N/A",
|
||
"n/a",
|
||
"NM",
|
||
"nm",
|
||
}
|
||
for _dr in data[header_row_count:]:
|
||
_n = sum(
|
||
1
|
||
for c in _dr[1:]
|
||
if c.strip() and (_val_re.match(c.strip()) or c.strip() in _dash_vals)
|
||
)
|
||
if _n > num_periods * 2:
|
||
use_semantic_parsing = False
|
||
break
|
||
|
||
if use_semantic_parsing:
|
||
# For multi-level headers, derive column positions from year cells
|
||
# in the expanded data array. This enables position-aware extraction
|
||
# for sparse data rows (where values have gaps between columns).
|
||
header_col_positions = None
|
||
|
||
if header_layers and len(header_layers) >= 2:
|
||
year_positions = []
|
||
for row_idx in range(min(header_row_count, len(data))):
|
||
for col_idx, cell in enumerate(data[row_idx]):
|
||
cell_clean = cell.strip().strip("\u200b").strip()
|
||
# Strip footnote markers
|
||
cell_stripped = re.sub(r"[*†‡§+]+$", "", cell_clean)
|
||
|
||
if (
|
||
cell_stripped
|
||
and re.match(r"^(19|20)\d{2}$", cell_stripped)
|
||
and col_idx not in year_positions
|
||
):
|
||
year_positions.append(col_idx)
|
||
|
||
year_positions.sort()
|
||
|
||
if len(year_positions) == num_periods and num_periods > 0:
|
||
# When year headers start at column 0 (label column),
|
||
# they are offset from data values. Apply the same shift
|
||
# that was computed during header extraction so positions
|
||
# align with actual data columns.
|
||
if year_positions[0] == 0 and _year_pos_shift[0] > 0:
|
||
year_positions = [p + _year_pos_shift[0] for p in year_positions]
|
||
header_col_positions = year_positions
|
||
|
||
# When year_positions alone don't cover all periods (e.g.,
|
||
# tables with "% Change" or "Basis Point Change" columns
|
||
# alongside date columns), augment with positions of other
|
||
# non-empty header texts from the header data rows. Exclude
|
||
# category texts (from Layer 0) and financial data values.
|
||
if (
|
||
not header_col_positions
|
||
and year_positions
|
||
and len(year_positions) < num_periods
|
||
and num_periods > 0
|
||
):
|
||
category_texts_set: set[str] = set()
|
||
if header_layers:
|
||
for _h in header_layers[0]:
|
||
_ht = _h.strip()
|
||
if _ht:
|
||
category_texts_set.add(_ht)
|
||
|
||
all_leaf_positions: set[int] = set(year_positions)
|
||
for row_idx in range(min(header_row_count, len(data))):
|
||
for col_idx, cell in enumerate(data[row_idx]):
|
||
if col_idx == 0 or col_idx in all_leaf_positions:
|
||
continue
|
||
cell_clean = cell.strip().strip("\u200b").strip()
|
||
cell_stripped = re.sub(r"[*†‡§+]+$", "", cell_clean)
|
||
if not cell_stripped:
|
||
continue
|
||
# Skip years (already collected)
|
||
if re.match(r"^(19|20)\d{2}$", cell_stripped):
|
||
continue # pragma: no cover - unreachable: year positions are pre-collected into year_positions over the same header rows and seeded into all_leaf_positions, so any year cell is already skipped by the `col_idx in all_leaf_positions` guard above before reaching this year re-check
|
||
# Skip financial data values
|
||
if re.match(
|
||
r"^[\$]?\s*[\(\)]?\s*[\d,]+\.?\d*\s*[\)\%]?$",
|
||
cell_stripped,
|
||
):
|
||
continue # pragma: no cover - unreachable: the multi-index header detector classifies any row containing a bare numeric/financial value as a data row, so such a value never appears within the header-row range (range(min(header_row_count, len(data)))) scanned by this augment loop
|
||
# Skip category header texts (from colspan expansion)
|
||
if cell_stripped in category_texts_set:
|
||
continue
|
||
# Skip descriptor text in parentheses
|
||
if cell_stripped.startswith("(") and re.search(
|
||
r"(million|except|thousand|billion|percent)",
|
||
cell_stripped,
|
||
re.I,
|
||
):
|
||
continue
|
||
all_leaf_positions.add(col_idx)
|
||
|
||
sorted_all = sorted(all_leaf_positions)
|
||
if len(sorted_all) == num_periods:
|
||
header_col_positions = sorted_all
|
||
|
||
# For tables with text sub-headers (no year values),
|
||
# dollar signs mark the start of each data column
|
||
if not header_col_positions and dollar_positions:
|
||
sorted_dollar = sorted(dollar_positions)
|
||
|
||
if len(sorted_dollar) == num_periods and num_periods > 0:
|
||
header_col_positions = sorted_dollar # pragma: no cover - unreachable: reaching this fallback (header_col_positions still unset) means years under-cover the periods, yet a 2-layer header requires year anchors to survive multi-index detection; that precludes every period being a lone-$ column, so len(dollar_positions) is always < num_periods here
|
||
|
||
if not header_col_positions and header_layers:
|
||
# Build a list of (col_idx_in_layer, text, layer_idx) for all
|
||
# non-empty header positions, then deduplicate by column index.
|
||
all_header_entries = [] # (col_pos, text, layer_idx)
|
||
|
||
for li, layer in enumerate(header_layers):
|
||
for col_pos in range(1, len(layer)):
|
||
h = layer[col_pos].strip()
|
||
|
||
if h:
|
||
all_header_entries.append((col_pos, h, li))
|
||
|
||
# Deduplicate
|
||
col_to_text: dict = {}
|
||
|
||
for col_pos, h, li in all_header_entries:
|
||
if col_pos not in col_to_text or li > col_to_text[col_pos][1]:
|
||
col_to_text[col_pos] = (h, li)
|
||
|
||
sub_texts = [col_to_text[cp][0] for cp in sorted(col_to_text.keys())]
|
||
|
||
if len(sub_texts) == num_periods and num_periods > 0:
|
||
found_positions: list[int] = []
|
||
used_cols: set[int] = set()
|
||
|
||
for target in sub_texts:
|
||
matched = False
|
||
for row_idx in range(min(header_row_count, len(data))):
|
||
for col_idx, cell in enumerate(data[row_idx]):
|
||
if col_idx in used_cols:
|
||
continue
|
||
|
||
if cell.strip() == target:
|
||
found_positions.append(col_idx)
|
||
used_cols.add(col_idx)
|
||
matched = True
|
||
break
|
||
|
||
if matched:
|
||
break
|
||
|
||
if len(found_positions) == num_periods:
|
||
header_col_positions = sorted(found_positions)
|
||
|
||
# Single-layer header fallback: find where each unique header text
|
||
# first appears in the raw (colspan-expanded) data header rows.
|
||
# E.g., header_layers=[['', '2025', '2024', '% Change']] and the
|
||
# raw data row is ['', '', '2025', '2025', '', '2024', '2024', '',
|
||
# '% Change', '']. We need header_col_positions=[2, 5, 8] so the
|
||
# position-aware extraction maps data cells to correct columns.
|
||
if not header_col_positions and header_layers and len(header_layers) == 1:
|
||
unique_headers = [h for h in header_layers[0][1:] if h.strip()]
|
||
if len(unique_headers) == num_periods and num_periods > 0:
|
||
found_positions = []
|
||
used_cols = set()
|
||
for target in unique_headers:
|
||
for row_idx in range(min(header_row_count, len(data))):
|
||
matched = False
|
||
for col_idx, cell in enumerate(data[row_idx]):
|
||
if col_idx in used_cols:
|
||
continue
|
||
if cell.strip() == target:
|
||
found_positions.append(col_idx)
|
||
used_cols.add(col_idx)
|
||
matched = True
|
||
break
|
||
if matched:
|
||
break
|
||
if len(found_positions) == num_periods:
|
||
header_col_positions = found_positions
|
||
|
||
if header_layers:
|
||
for layer in header_layers:
|
||
if len(layer) == num_periods + 1:
|
||
new_data.append(layer[:])
|
||
continue
|
||
|
||
# Pad on the right to match the expected column count rather than collapsing.
|
||
if (
|
||
len(layer) <= num_periods + 1
|
||
): # pragma: no cover - unreachable: every header-layer producer (build_column_headers_from_colspan, detect_and_merge_multiindex_headers, extract_periods_from_rows) emits layers of length num_periods+1, so line 4748's equal-length branch always continues before this pad/collapse fallback is reached
|
||
padded = layer[:] + [""] * (num_periods + 1 - len(layer))
|
||
new_data.append(padded[: num_periods + 1])
|
||
continue
|
||
|
||
# Collapse adjacent identical texts from colspan expansion.
|
||
# E.g., ['', '', '', '2025', '2025', '2025', '', '', '',
|
||
# '2024', '2024', '2024', ...]
|
||
# becomes ['', '2025', '2024', '2023']
|
||
collapsed = [] # pragma: no cover - unreachable: dead collapse fallback (see line 4753 - layers are always length num_periods+1)
|
||
prev_text = None # pragma: no cover - unreachable: dead collapse fallback (see line 4753)
|
||
for idx, text in enumerate(
|
||
layer
|
||
): # pragma: no cover - unreachable: dead collapse fallback (see line 4753)
|
||
t = text.strip()
|
||
|
||
if idx == 0:
|
||
# Always keep the label column as-is
|
||
collapsed.append(text)
|
||
prev_text = None
|
||
continue
|
||
|
||
if t:
|
||
if t != prev_text:
|
||
collapsed.append(text)
|
||
prev_text = t
|
||
else:
|
||
prev_text = None # Reset on empty
|
||
|
||
# Ensure it has the right number of columns
|
||
while (
|
||
len(collapsed) < num_periods + 1
|
||
): # pragma: no cover - unreachable: dead collapse fallback (see line 4753)
|
||
collapsed.append("")
|
||
new_data.append(
|
||
collapsed[: num_periods + 1]
|
||
) # pragma: no cover - unreachable: dead collapse fallback (see line 4753)
|
||
|
||
# Process data rows - use header_row_count to skip header rows
|
||
# Use a two-pass approach: first extract values into *groups*
|
||
# per header range, then flatten. This correctly interleaves
|
||
# sub-column values (e.g. absolute change + percentage under a
|
||
# single "2025 vs. 2024" header) instead of appending overflows
|
||
# to the end.
|
||
_extracted_rows: list[tuple] = []
|
||
_has_sub_columns = False
|
||
_num_header_entries = len(new_data) # header rows already added
|
||
|
||
for i, row in enumerate(data):
|
||
# Skip rows before data starts (headers already extracted)
|
||
if i < header_row_count:
|
||
continue
|
||
|
||
# Also skip any remaining header-like rows by content
|
||
if is_data_row_header(row):
|
||
continue
|
||
|
||
if header_col_positions:
|
||
# Position-aware extraction: map data cells to headers
|
||
# by checking which header column range each cell falls in
|
||
ranges = []
|
||
|
||
for hi, pos in enumerate(header_col_positions):
|
||
end = (
|
||
header_col_positions[hi + 1]
|
||
if hi + 1 < len(header_col_positions)
|
||
else len(row)
|
||
)
|
||
ranges.append((pos, end))
|
||
|
||
label_parts = []
|
||
value_groups: list[list[str]] = [[] for _ in range(num_periods)]
|
||
|
||
for col_idx, cell_text in enumerate(row):
|
||
cell_clean = cell_text.strip().strip("\u200b").strip()
|
||
|
||
if not cell_clean or cell_clean in ("$", "\u20ac"):
|
||
continue
|
||
# Before first header position = label column
|
||
if col_idx < header_col_positions[0]:
|
||
label_parts.append(cell_clean)
|
||
continue
|
||
|
||
# Find which header range this cell falls into
|
||
for hi, (start, end) in enumerate(ranges):
|
||
if start <= col_idx < end:
|
||
# Numeric/dash values go as data
|
||
if re.match(
|
||
r"^[+\-]?[\$\u20ac]?\s*\(?[\$\u20ac]?\s*[\d,]+\.?\d*\s*\)?\s*[*%]*(pts)?$",
|
||
cell_clean,
|
||
) or cell_clean in (
|
||
"\u2014",
|
||
"\u2013",
|
||
"-",
|
||
"$\u2014",
|
||
"$\u2013",
|
||
"$-",
|
||
"$ \u2014",
|
||
"$ \u2013",
|
||
"$ -",
|
||
"\u20ac\u2014",
|
||
"\u20ac\u2013",
|
||
"\u20ac-",
|
||
"\u2014%",
|
||
"\u2013%",
|
||
"-%",
|
||
"\u2014 %",
|
||
"\u2013 %",
|
||
"- %",
|
||
"N/A",
|
||
"n/a",
|
||
"NM",
|
||
"nm",
|
||
):
|
||
value_groups[hi].append(cell_clean)
|
||
if len(value_groups[hi]) > 1:
|
||
_has_sub_columns = True
|
||
elif (
|
||
re.match(
|
||
r"^(bps?|pts?|pps?|x)$",
|
||
cell_clean,
|
||
re.I,
|
||
)
|
||
and value_groups[hi]
|
||
):
|
||
# Unit suffix (e.g. "bps", "pts") that
|
||
# belongs to the preceding numeric value
|
||
# in the same header range — merge rather
|
||
# than creating a spurious extra column.
|
||
value_groups[hi][-1] = (
|
||
f"{value_groups[hi][-1]} {cell_clean}"
|
||
)
|
||
elif not label_parts:
|
||
# Non-numeric before any data = part of label
|
||
label_parts.append(cell_clean)
|
||
else:
|
||
value_groups[hi].append(cell_clean)
|
||
if len(value_groups[hi]) > 1:
|
||
_has_sub_columns = True
|
||
|
||
break
|
||
|
||
label = " ".join(label_parts) if label_parts else None
|
||
_extracted_rows.append((label, value_groups))
|
||
else:
|
||
label, values = parse_row_semantic(row, num_periods)
|
||
# Wrap each value as a single-element group
|
||
_extracted_rows.append((label, [[v] if v else [] for v in values]))
|
||
|
||
# Determine max sub-column count per period across all rows.
|
||
_max_group_sizes = [1] * num_periods
|
||
if _has_sub_columns:
|
||
for _, groups in _extracted_rows:
|
||
for hi in range(min(num_periods, len(groups))):
|
||
_max_group_sizes[hi] = max(_max_group_sizes[hi], len(groups[hi]))
|
||
|
||
_actual_periods = sum(_max_group_sizes)
|
||
|
||
# Flatten each row's value groups into a flat value list.
|
||
for label, groups in _extracted_rows:
|
||
if label is None and all(not g for g in groups):
|
||
continue
|
||
|
||
flat_values: list[str] = []
|
||
for hi in range(num_periods):
|
||
g = list(groups[hi]) if hi < len(groups) else []
|
||
while len(g) < _max_group_sizes[hi]:
|
||
g.append("")
|
||
flat_values.extend(g)
|
||
|
||
while len(flat_values) < _actual_periods:
|
||
flat_values.append(
|
||
""
|
||
) # pragma: no cover - unreachable: flat_values extends num_periods groups each padded to _max_group_sizes[hi]; the sizes sum to exactly _actual_periods, so len(flat_values) == _actual_periods already
|
||
|
||
new_data.append([label or ""] + flat_values)
|
||
|
||
# Expand header layers if sub-columns were detected.
|
||
if _has_sub_columns and _actual_periods > num_periods:
|
||
for li in range(_num_header_entries):
|
||
old_hdr = new_data[li]
|
||
expanded = [old_hdr[0]] # label column
|
||
for hi in range(num_periods):
|
||
h = old_hdr[hi + 1] if hi + 1 < len(old_hdr) else ""
|
||
expanded.append(h)
|
||
for _ in range(_max_group_sizes[hi] - 1):
|
||
expanded.append("")
|
||
while len(expanded) < _actual_periods + 1:
|
||
expanded.append(
|
||
""
|
||
) # pragma: no cover - unreachable: expanded is [label] plus num_periods groups each padded to _max_group_sizes[hi] (summing to _actual_periods), so its length is always _actual_periods+1
|
||
new_data[li] = expanded
|
||
num_periods = _actual_periods
|
||
|
||
data = new_data
|
||
num_header_rows = len(header_layers) if header_layers else 1
|
||
|
||
# Re-align single-layer period headers to match actual data column
|
||
# positions. When a year header like "September 2025" (cs=6) spans
|
||
# multiple physical data columns (e.g. amount + percentage), the
|
||
# collapsed header row places it at col 1 while "December 2024" lands
|
||
# at col 2 — even though the December data starts at col 3.
|
||
# Fix: scan the first data row for dollar-prefixed values ($xxx) and
|
||
# move each period name to the column where its period's $ amount sits.
|
||
if (
|
||
header_layers
|
||
and len(header_layers) == 1
|
||
and num_periods >= 2
|
||
and len(header_layers[0]) == num_periods + 1
|
||
and len(data) > num_header_rows
|
||
):
|
||
_period_names = [h for h in header_layers[0][1:] if h.strip()]
|
||
if len(_period_names) == num_periods:
|
||
_dollar_cols: list = []
|
||
for _drow in data[num_header_rows:]:
|
||
_positions = [
|
||
ci for ci, c in enumerate(_drow) if c and c.startswith("$")
|
||
]
|
||
if len(_positions) == num_periods:
|
||
_dollar_cols = _positions
|
||
break
|
||
if _dollar_cols and max(_dollar_cols) >= num_periods:
|
||
# Only realign if dollar cols are NOT already sequential
|
||
# from 1..num_periods (which would mean no change needed)
|
||
_expected = list(range(1, num_periods + 1))
|
||
if _dollar_cols != _expected:
|
||
_max_dc = max(
|
||
len(r) for r in data
|
||
) # pragma: no cover - unreachable: when realign runs, position-aware extraction has already placed each period's $ at its sequential column (1..num_periods), so _dollar_cols == _expected always holds at line 4968
|
||
_new_hdr = (
|
||
[""] * _max_dc
|
||
) # pragma: no cover - unreachable: dead realign body (see line 4968)
|
||
for _pi, _pos in enumerate(
|
||
_dollar_cols
|
||
): # pragma: no cover - unreachable: dead realign body (see line 4968)
|
||
if _pos < _max_dc:
|
||
_new_hdr[_pos] = _period_names[_pi]
|
||
data[0] = (
|
||
_new_hdr # pragma: no cover - unreachable: dead realign body (see line 4968)
|
||
)
|
||
|
||
elif has_mixed_headers and header_layers:
|
||
# Mixed-header table (has non-year columns like "Useful Lives")
|
||
header_part = data[:header_row_count]
|
||
if is_financial_periods:
|
||
filtered_rows = [
|
||
row for row in data[header_row_count:] if not is_data_row_header(row)
|
||
]
|
||
else:
|
||
filtered_rows = list(data[header_row_count:])
|
||
data = header_part + filtered_rows
|
||
|
||
# Check if this is a simple flat table
|
||
if header_row_count == 1:
|
||
data = remove_empty_columns(data)
|
||
data = collapse_repeated_headers(data)
|
||
num_header_rows = 1
|
||
else:
|
||
# Multi-row header structure with mixed headers.
|
||
header_part = data[:header_row_count]
|
||
data_rows_only = data[header_row_count:]
|
||
|
||
actual_header_rows = []
|
||
for row in header_part:
|
||
non_empty = [c for c in row if c.strip()]
|
||
|
||
if len(non_empty) >= 2:
|
||
# Multiple cells with content → column-defining header
|
||
actual_header_rows.append(row)
|
||
|
||
if not actual_header_rows:
|
||
# Fallback: use the last header row
|
||
actual_header_rows = [
|
||
header_part[-1]
|
||
] # pragma: no cover - unreachable: this branch needs has_mixed_headers True, which requires a single-layer header with >=2 comparable columns, i.e. a header row with >=2 non-empty cells - but such a row is appended to actual_header_rows above, so actual_header_rows is never empty here
|
||
|
||
combined = actual_header_rows + data_rows_only
|
||
combined = remove_empty_columns(combined)
|
||
combined = collapse_repeated_headers(combined)
|
||
|
||
data = combined
|
||
num_header_rows = len(actual_header_rows)
|
||
else:
|
||
# Non-financial table - filter out header-like rows from data portion
|
||
if header_row_count > 0:
|
||
header_part = data[:header_row_count]
|
||
filtered_rows = [
|
||
row for row in data[header_row_count:] if not is_data_row_header(row)
|
||
]
|
||
data = header_part + filtered_rows
|
||
data = remove_empty_columns(data)
|
||
# This handles tables where header has colspan=2 but data only fills one position
|
||
data = collapse_repeated_headers(data)
|
||
num_header_rows = 0
|
||
|
||
if not data or not data[0]:
|
||
return "" # pragma: no cover - unreachable: an empty table already returned at the earlier `if not data` guard; here data[0] is non-empty and remove_empty_columns keeps any column that has content in some row, so data[0] cannot become empty
|
||
|
||
max_cols = max(len(row) for row in data) if data else 0
|
||
lines = []
|
||
|
||
for i, row in enumerate(data):
|
||
while len(row) < max_cols:
|
||
row.append(
|
||
""
|
||
) # pragma: no cover - unreachable: new_data rows are [label]+flat_values of uniform length and remove_empty_columns/collapse_repeated_headers preserve rectangularity, so every row already has max_cols entries here
|
||
# Ensure no cell contains newlines
|
||
clean_row = []
|
||
|
||
for cell in row:
|
||
# Replace any newlines with space
|
||
clean_cell = re.sub(r"[\r\n]+", " ", cell)
|
||
# Remove <br>/<BR> tags from header cells (first few rows)
|
||
# This handles multi-row headers where row 1 also has column names
|
||
if i < max(num_header_rows, 2):
|
||
clean_cell = re.sub(r"<[Bb][Rr]\s*/?>", " ", clean_cell)
|
||
# Collapse multiple spaces
|
||
clean_cell = re.sub(r" +", " ", clean_cell).strip()
|
||
clean_row.append(clean_cell)
|
||
|
||
line = "| " + " | ".join(clean_row) + " |"
|
||
lines.append(line)
|
||
# Add separator after the first header row only — this is standard markdown.
|
||
# Additional header layers (Layer 1, Layer 2...) render as normal rows below
|
||
# the separator, acting as visual sub-headers.
|
||
if i == 0:
|
||
lines.append("|" + "|".join(["---"] * max_cols) + "|")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def get_text_content(
|
||
element, preserve_links_in_text: bool = False, base_url: str = ""
|
||
) -> str:
|
||
"""Get text from element, handling XBRL wrappers and preserving word boundaries.
|
||
|
||
If preserve_links_in_text is True, converts <a> tags to markdown [text](href) format.
|
||
"""
|
||
if isinstance(element, NavigableString):
|
||
return str(element)
|
||
|
||
texts = []
|
||
for child in element.children:
|
||
if isinstance(child, NavigableString):
|
||
texts.append(str(child))
|
||
elif child.name == "br":
|
||
texts.append(" ") # Treat line breaks as spaces
|
||
elif child.name == "a" and preserve_links_in_text:
|
||
# Convert link to markdown format
|
||
href = child.get("href", "")
|
||
link_text = child.get_text(strip=True)
|
||
if href and link_text:
|
||
# Keep anchor links as-is, resolve relative links
|
||
if (
|
||
not href.startswith("#")
|
||
and base_url
|
||
and not href.startswith(("http://", "https://"))
|
||
):
|
||
href = urljoin(base_url, href)
|
||
texts.append(f"[{link_text}]({href})")
|
||
else:
|
||
texts.append(link_text or "")
|
||
elif child.name and child.name.startswith("ix:"):
|
||
# XBRL wrapper - get its text content
|
||
texts.append(child.get_text(separator=" "))
|
||
else:
|
||
texts.append(get_text_content(child, preserve_links_in_text, base_url))
|
||
|
||
result_parts: list = []
|
||
had_whitespace_separator = False
|
||
|
||
for _t in texts:
|
||
t = _t.replace("\xa0", " ") # Normalize non-breaking spaces first
|
||
|
||
if not t.strip():
|
||
# This prevents split words like <b>INCOM</b><b>E</b> → "INCOM E"
|
||
# while preserving spaces where <b>WORD1</b><b> </b><b>WORD2</b>.
|
||
if result_parts:
|
||
had_whitespace_separator = True
|
||
continue
|
||
|
||
if result_parts:
|
||
last_char = result_parts[-1][-1] if result_parts[-1] else ""
|
||
first_char = t[0] if t else ""
|
||
|
||
if had_whitespace_separator:
|
||
# Explicit whitespace between parts — add space unless
|
||
# punctuation rules say not to
|
||
if (
|
||
last_char not in NO_SPACE_AFTER
|
||
and first_char not in NO_SPACE_BEFORE
|
||
):
|
||
result_parts.append(" ")
|
||
# Adjacent elements of the same type with no separator
|
||
# (e.g. <b>INCOM</b><b>E</b>) are parts of one word.
|
||
elif not (last_char.isalpha() and first_char.isalpha()) and (
|
||
last_char not in NO_SPACE_AFTER and first_char not in NO_SPACE_BEFORE
|
||
):
|
||
result_parts.append(" ")
|
||
|
||
had_whitespace_separator = False
|
||
|
||
result_parts.append(t)
|
||
|
||
result = "".join(result_parts)
|
||
# Collapse any remaining multiple spaces
|
||
result = re.sub(r" +", " ", result)
|
||
return result
|
||
|
||
|
||
# ============================================================================
|
||
# ABSOLUTE-POSITIONED LAYOUT REFLOW
|
||
# ============================================================================
|
||
# Some SEC filings (notably Canadian bank 40-F exhibits like TD) are
|
||
# generated from PDF-to-HTML converters that render every text fragment
|
||
# as an absolutely-positioned <div> with pixel left/top coordinates.
|
||
# These documents contain ZERO <table> tags and thousands of positioned
|
||
# divs. The regular html_to_markdown converter cannot reconstruct
|
||
# paragraphs or tables from these.
|
||
#
|
||
# _reflow_absolute_layout detects such documents and rewrites the HTML
|
||
# into flowing <p> / <table> elements so the rest of the pipeline works.
|
||
# ============================================================================
|
||
|
||
# HTML entity map for quick decoding in the reflow function.
|
||
_REFLOW_ENTITIES: dict[str, str] = {
|
||
" ": " ",
|
||
"’": "\u2019",
|
||
"‘": "\u2018",
|
||
"“": "\u201c",
|
||
"”": "\u201d",
|
||
"–": "\u2013",
|
||
"—": "\u2014",
|
||
"•": "\u2022",
|
||
"●": "\u25cf",
|
||
"…": "\u2026",
|
||
"™": "\u2122",
|
||
"®": "\u00ae",
|
||
"©": "\u00a9",
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
""": '"',
|
||
"'": "'",
|
||
"'": "'",
|
||
" ": " ",
|
||
}
|
||
|
||
# Regex to detect the "Page N" footer divs.
|
||
_PAGE_FOOTER_RE = re.compile(
|
||
r"(?:Page\s+\d+|TD\s+BANK\s+GROUP)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _reflow_absolute_layout(html_content: str) -> str | None:
|
||
"""Rewrite position:absolute HTML into flowing HTML.
|
||
|
||
Uses a *rule-based* approach: the Certent CDM (and similar PDF-to-HTML
|
||
generators) encode table row separators as thin (≤ 2 px high), full-width
|
||
(≥ 500 px) ``position:absolute`` divs. These horizontal rules provide
|
||
deterministic table detection — text fragments between consecutive rules
|
||
belong to the same table, and their horizontal positions map to columns.
|
||
|
||
Non-table text (paragraphs, headings, chart annotations) is rendered
|
||
outside the rule-delimited zones. When the page contains a side-by-side
|
||
layout (body text on the left, chart on the right), the fragments are
|
||
split into two columns so chart content doesn't pollute paragraph text.
|
||
|
||
Returns the rewritten HTML string, or ``None`` if the document does
|
||
not use an absolute-positioned layout and should be processed normally.
|
||
"""
|
||
# Quick heuristic: is this an abs-positioned document?
|
||
# Must have MANY absolute-positioned elements, very few <table> tags,
|
||
# AND the characteristic Certent CDM id="aNN" text-fragment pattern.
|
||
_sample = html_content[:50_000]
|
||
_abs_count = len(re.findall(r"position:\s*absolute", _sample, re.I))
|
||
_table_count = len(re.findall(r"<table\b", html_content[:200_000], re.I))
|
||
if _abs_count < 30 or _table_count > 2:
|
||
return None
|
||
|
||
_abs_total = len(re.findall(r"position:\s*absolute", html_content, re.I))
|
||
_div_total = len(re.findall(r"<div\b", html_content, re.I))
|
||
if _div_total == 0 or _abs_total / _div_total < 0.4:
|
||
return None
|
||
|
||
# Require the Certent CDM text-fragment pattern: divs with
|
||
# id="aNN" (numeric IDs) that carry the actual text content.
|
||
# This is the hallmark of PDF-to-HTML absolute-positioned layouts.
|
||
# Without this, normal filings with many absolute-positioned logos
|
||
# or headers would be incorrectly rewritten.
|
||
_text_frag_count = len(re.findall(r'<div[^>]+id="a\d+"', _sample, re.I))
|
||
if _text_frag_count < 15:
|
||
return None
|
||
|
||
# ---- Parse page boundaries ----
|
||
_page_re = re.compile(r'id="Page(\d+)"')
|
||
page_boundaries = list(_page_re.finditer(html_content))
|
||
if not page_boundaries:
|
||
return None
|
||
|
||
page_map: dict[int, int] = {int(m.group(1)): m.start() for m in page_boundaries}
|
||
total_pages = max(page_map.keys())
|
||
|
||
# ---- Regex toolbox ----
|
||
_tag_re = re.compile(r"<[^>]+>")
|
||
_bold_re = re.compile(r"font-weight:\s*bold", re.I)
|
||
_fsize_re = re.compile(r"font-size:\s*([\d.]+)px", re.I)
|
||
_content_re = re.compile(
|
||
r'(.*?)(?=<div\s+id="a\d+"|<div\s+style="[^"]*position:\s*absolute)',
|
||
re.S,
|
||
)
|
||
_content_end_re = re.compile(r"(.*?)</div>", re.S)
|
||
|
||
def _decode_entities(text: str) -> str:
|
||
for ent, ch in _REFLOW_ENTITIES.items():
|
||
text = text.replace(ent, ch)
|
||
text = re.sub(
|
||
r"&#(\d+);",
|
||
lambda m: chr(int(m.group(1))) if int(m.group(1)) < 0x10000 else "",
|
||
text,
|
||
)
|
||
return text
|
||
|
||
def _strip_tags(raw: str) -> str:
|
||
return _decode_entities(_tag_re.sub("", raw)).strip()
|
||
|
||
# ---- Per-page parser ----
|
||
def _parse_page(
|
||
page_num: int,
|
||
) -> tuple[list[float], list[tuple[float, float, str, bool, float]]]:
|
||
"""Extract horizontal rules and text fragments from a page.
|
||
|
||
Returns ``(hrules, text_frags)`` where *hrules* is a sorted list
|
||
of ``top`` positions of full-width rules and *text_frags* is a
|
||
list of ``(top, left, text, bold, font_size)`` tuples.
|
||
"""
|
||
p_start = page_map[page_num]
|
||
p_end = page_map.get(page_num + 1, len(html_content))
|
||
page_html = html_content[p_start:p_end]
|
||
|
||
hrules: list[float] = []
|
||
text_frags: list[tuple[float, float, str, bool, float]] = []
|
||
|
||
for m in re.finditer(
|
||
r'<div\s[^>]*?style="([^"]*position:\s*absolute[^"]*)"[^>]*>',
|
||
page_html,
|
||
re.I,
|
||
):
|
||
style = m.group(1)
|
||
|
||
left_m = re.search(r"left:([\d.]+)px", style)
|
||
top_m = re.search(r"top:([\d.]+)px", style)
|
||
if not (left_m and top_m):
|
||
continue
|
||
left = float(left_m.group(1))
|
||
top = float(top_m.group(1))
|
||
|
||
width_m = re.search(r"width:([\d.]+)px", style)
|
||
height_m = re.search(r"height:([\d.]+)px", style)
|
||
width = float(width_m.group(1)) if width_m else 0
|
||
height = float(height_m.group(1)) if height_m else 0
|
||
|
||
# Horizontal rule: thin + wide-enough + explicit dimensions.
|
||
# Full-width rules (≥ 500 px) delimit major tables.
|
||
# Medium rules (200–499 px) delimit smaller tables such as
|
||
# "Fiscal Year 2026 Targets" boxes or split-page layouts.
|
||
if height_m and width_m and height <= 2 and width >= 200:
|
||
hrules.append(top)
|
||
continue
|
||
|
||
# Skip vertical rules (explicit narrow width, e.g. 1px bars)
|
||
if width_m and width <= 2:
|
||
continue
|
||
# Skip coloured rectangles (chart bars) that have no text id
|
||
if width_m and height_m and width > 5 and height > 5:
|
||
div_tag = page_html[m.start() : m.end()]
|
||
if not re.search(r'id="a\d+"', div_tag):
|
||
continue
|
||
|
||
# Text fragment — must have id="aNN"
|
||
div_tag = page_html[m.start() : m.end()]
|
||
id_m = re.search(r'id="(a\d+)"', div_tag)
|
||
if not id_m:
|
||
continue
|
||
|
||
rest = page_html[m.end() : m.end() + 2000]
|
||
cm = _content_re.match(rest)
|
||
if not cm:
|
||
cm = _content_end_re.match(rest)
|
||
if not cm:
|
||
continue
|
||
|
||
text = _strip_tags(cm.group(1))
|
||
if not text:
|
||
continue
|
||
|
||
if _PAGE_FOOTER_RE.search(text) and top > 950:
|
||
continue
|
||
|
||
bold = bool(_bold_re.search(style))
|
||
fs_m = _fsize_re.search(style)
|
||
font_size = float(fs_m.group(1)) if fs_m else 10.0
|
||
|
||
text_frags.append((top, left, text, bold, font_size))
|
||
|
||
hrules = sorted(set(round(r, 1) for r in hrules))
|
||
text_frags.sort()
|
||
return hrules, text_frags
|
||
|
||
# ---- Table-zone detection ----
|
||
def _identify_table_zones(
|
||
hrules: list[float],
|
||
) -> list[tuple[float, float]]:
|
||
"""Group consecutive full-width rules into table zones.
|
||
|
||
Rules separated by < 300 px are considered part of the same table.
|
||
Returns a list of ``(zone_top, zone_bottom)`` pairs.
|
||
"""
|
||
if len(hrules) < 2:
|
||
return []
|
||
zones: list[tuple[float, float]] = []
|
||
zone_start = hrules[0]
|
||
zone_end = hrules[0]
|
||
for i in range(1, len(hrules)):
|
||
if hrules[i] - hrules[i - 1] < 300:
|
||
zone_end = hrules[i]
|
||
else:
|
||
if zone_end > zone_start:
|
||
zones.append((zone_start, zone_end))
|
||
zone_start = hrules[i]
|
||
zone_end = hrules[i]
|
||
if zone_end > zone_start:
|
||
zones.append((zone_start, zone_end))
|
||
return zones
|
||
|
||
# ---- Dedup consecutive identical rows ----
|
||
def _dedup_rows(
|
||
rows: list[list[str]],
|
||
) -> list[list[str]]:
|
||
"""Remove consecutive duplicate rows (same text ignoring bold)."""
|
||
if not rows:
|
||
return rows # pragma: no cover - unreachable: sole caller passes non-empty raw_rows
|
||
_bold_tag = re.compile(r"</?b>")
|
||
result = [rows[0]]
|
||
for row in rows[1:]:
|
||
prev_text = [_bold_tag.sub("", c) for c in result[-1]]
|
||
cur_text = [_bold_tag.sub("", c) for c in row]
|
||
if cur_text != prev_text:
|
||
result.append(row)
|
||
else:
|
||
prev_bold = sum(1 for c in result[-1] if "<b>" in c)
|
||
cur_bold = sum(1 for c in row if "<b>" in c)
|
||
if cur_bold > prev_bold:
|
||
result[-1] = row
|
||
return result
|
||
|
||
# ---- Build table from a rule-delimited zone ----
|
||
def _build_table_from_zone(
|
||
frags: list[tuple[float, float, str, bool, float]],
|
||
rules: list[float],
|
||
) -> str:
|
||
if not frags or len(rules) < 2:
|
||
return "" # pragma: no cover - unreachable: caller guards non-empty frags; zones bound >=2 rules
|
||
|
||
rules = sorted(rules)
|
||
|
||
# Build row bands: text between consecutive rules
|
||
row_bands: list[list[tuple[float, float, str, bool, float]]] = []
|
||
for i in range(len(rules) - 1):
|
||
band_top = rules[i]
|
||
band_bot = rules[i + 1]
|
||
band_frags = [f for f in frags if band_top - 2 <= f[0] <= band_bot + 2]
|
||
if band_frags:
|
||
row_bands.append(band_frags)
|
||
|
||
if not row_bands:
|
||
return ""
|
||
|
||
# Determine column positions (cluster left coordinates ±20 px)
|
||
all_lefts: list[float] = []
|
||
for band in row_bands:
|
||
for _, lf, _, _, fs in band:
|
||
if fs >= 7:
|
||
all_lefts.append(lf)
|
||
if not all_lefts:
|
||
return ""
|
||
|
||
all_lefts.sort()
|
||
cols: list[float] = []
|
||
col_n: list[int] = []
|
||
for lf in all_lefts:
|
||
merged = False
|
||
for ci in range(len(cols)):
|
||
if abs(lf - cols[ci]) <= 20:
|
||
cols[ci] = (cols[ci] * col_n[ci] + lf) / (col_n[ci] + 1)
|
||
col_n[ci] += 1
|
||
merged = True
|
||
break
|
||
if not merged:
|
||
cols.append(lf)
|
||
col_n.append(1)
|
||
cols.sort()
|
||
ncols = len(cols)
|
||
|
||
# Build raw row data
|
||
raw_rows: list[list[str]] = []
|
||
for band in row_bands:
|
||
band.sort()
|
||
lines: list[list[tuple[float, str, bool, float]]] = []
|
||
cur_top = band[0][0]
|
||
cur: list[tuple[float, str, bool, float]] = []
|
||
for top, lf, text, bold, fs in band:
|
||
if abs(top - cur_top) <= 3:
|
||
cur.append((lf, text, bold, fs))
|
||
else:
|
||
if cur:
|
||
lines.append(cur)
|
||
cur_top = top
|
||
cur = [(lf, text, bold, fs)]
|
||
if cur:
|
||
lines.append(cur)
|
||
|
||
for line in lines:
|
||
# Skip superscript footnote markers
|
||
main = [
|
||
(lv, t, b, f)
|
||
for lv, t, b, f in line
|
||
if not (f < 7 and re.match(r"^\d[\d,]*$", t.strip()))
|
||
]
|
||
if not main:
|
||
continue
|
||
|
||
cells = [""] * ncols
|
||
bolds = [False] * ncols
|
||
for lf, text, bold, _fs in main:
|
||
best = 0
|
||
best_dist = abs(lf - cols[0])
|
||
for ci in range(1, ncols):
|
||
d = abs(lf - cols[ci])
|
||
if d < best_dist:
|
||
best_dist = d
|
||
best = ci
|
||
if cells[best]:
|
||
cells[best] += " " + text
|
||
else:
|
||
cells[best] = text
|
||
if bold:
|
||
bolds[best] = True
|
||
|
||
formatted: list[str] = []
|
||
for ci in range(ncols):
|
||
c = _html_escape(cells[ci])
|
||
if bolds[ci] and c:
|
||
c = f"<b>{c}</b>"
|
||
formatted.append(c)
|
||
raw_rows.append(formatted)
|
||
|
||
raw_rows = _dedup_rows(raw_rows)
|
||
if not raw_rows:
|
||
return "" # pragma: no cover - unreachable: an fs>=7 frag always yields >=1 raw row
|
||
|
||
# ---- Pre-merge currency-symbol cells (per-row) ----
|
||
# Absolute-positioned layouts produce "$" / "€" / "£" as
|
||
# separate fragments in their own column. For each row,
|
||
# merge a lone currency cell with the nearest numeric cell
|
||
# to its right so that convert_table() receives clean data
|
||
# (e.g. "$4,602" instead of "$" + "" + "4,602" in three
|
||
# separate cells).
|
||
_CURR_PLAIN = {"$", "\u20ac", "\u00a3"} # $, €, £
|
||
_re_btag = re.compile(r"</?b>")
|
||
_re_numval = re.compile(r"^\(?\s*[\d,]+\.?\d*\s*\)?\s*%?$")
|
||
|
||
for row in raw_rows:
|
||
ci = 0
|
||
while ci < len(row):
|
||
plain = _re_btag.sub("", row[ci]).strip()
|
||
if plain not in _CURR_PLAIN or not plain:
|
||
ci += 1
|
||
continue
|
||
# Look right: skip empties, merge into first numeric
|
||
merged = False
|
||
for cj in range(ci + 1, len(row)):
|
||
tgt_plain = _re_btag.sub("", row[cj]).strip()
|
||
if not tgt_plain:
|
||
continue # skip empty cells
|
||
if _re_numval.match(tgt_plain):
|
||
sym_bold = "<b>" in row[ci]
|
||
tgt_bold = "<b>" in row[cj]
|
||
combo = plain + tgt_plain
|
||
if sym_bold or tgt_bold:
|
||
combo = f"<b>{combo}</b>"
|
||
row[cj] = combo
|
||
row[ci] = ""
|
||
merged = True
|
||
break # stop on first non-empty (merge or not)
|
||
ci += 1
|
||
|
||
# Remove columns that are entirely empty
|
||
non_empty = [
|
||
ci
|
||
for ci in range(ncols)
|
||
if any(row[ci].strip() for row in raw_rows if ci < len(row))
|
||
]
|
||
|
||
parts: list[str] = ["<table>\n"]
|
||
for row in raw_rows:
|
||
parts.append("<tr>")
|
||
for ci in non_empty:
|
||
parts.append(f"<td>{row[ci]}</td>")
|
||
parts.append("</tr>\n")
|
||
parts.append("</table>\n")
|
||
return "".join(parts)
|
||
|
||
# ---- Fragment classification ----
|
||
def _classify_fragments(
|
||
frags: list[tuple[float, float, str, bool, float]],
|
||
) -> tuple[
|
||
list[tuple[float, float, str, bool, float]],
|
||
list[tuple[float, float, str, bool, float]],
|
||
list[tuple[float, float, str, bool, float]],
|
||
]:
|
||
"""Classify free fragments as body text, chart content, or footnotes.
|
||
|
||
Uses **per-page body-font detection** so the same logic works on
|
||
pages where body text is set in 10 px *and* pages where it is set
|
||
in 8 px (common in the business-segment detail pages).
|
||
|
||
1. Detect the page's body font size — the most frequent font size
|
||
among left-margin (left < 75 px) fragments.
|
||
2. *Body text*: fragment whose font size is within +/-0.5 px of
|
||
the detected body font and sits at the left margin, **or**
|
||
within +/-0.3 px at any position (right-column text / unruled
|
||
tables). Large headings (font > 18 px) and bold subheadings
|
||
at the left margin are also body text.
|
||
3. *Footnotes*: fragments near the page bottom (top > 950 px).
|
||
4. *Chart content*: everything else — axis ticks, legend labels,
|
||
chart titles whose font deviates from the body font.
|
||
|
||
Returns ``(body_frags, chart_frags, footnote_frags)``.
|
||
"""
|
||
if not frags:
|
||
return [], [], []
|
||
|
||
# ---- Detect per-page body font size ----
|
||
# Use a tight left margin (< 55 px) so chart axis labels that
|
||
# sit at left ≈ 62–82 px don't bias the detection.
|
||
body_margin_sizes = [
|
||
round(fs, 1)
|
||
for top, left, _, _, fs in frags
|
||
if left < 55 and top < 950 and 5.0 < fs < 18.0
|
||
]
|
||
body_fs = (
|
||
Counter(body_margin_sizes).most_common(1)[0][0]
|
||
if len(body_margin_sizes) >= 3
|
||
else 10.0
|
||
)
|
||
|
||
# ---- Classify each fragment ----
|
||
# Short numeric/currency tokens that look like chart axis ticks
|
||
# are excluded from body text even when their font matches.
|
||
_chart_val = re.compile(r"^-?[$]\d[\d,.]*$|^-?\d+[%]$|^\d{4}$")
|
||
|
||
body: list[tuple[float, float, str, bool, float]] = []
|
||
chart: list[tuple[float, float, str, bool, float]] = []
|
||
footnotes: list[tuple[float, float, str, bool, float]] = []
|
||
|
||
for frag in frags:
|
||
top, left, _text, _bold, font_size = frag
|
||
stripped = _text.strip()
|
||
is_axis = len(stripped) < 10 and bool(_chart_val.match(stripped))
|
||
|
||
if top > 950:
|
||
footnotes.append(frag)
|
||
elif font_size > 18:
|
||
# Large section heading (e.g. "Net Income" at 22.7 px)
|
||
body.append(frag)
|
||
elif left < 75 and abs(font_size - body_fs) <= 0.5 and not is_axis:
|
||
# Body-font fragment at the left margin
|
||
body.append(frag)
|
||
elif abs(font_size - body_fs) <= 0.3 and not is_axis:
|
||
# Body-font fragment anywhere (right column, unruled table)
|
||
body.append(frag)
|
||
elif left < 75 and _bold and len(stripped) > 8:
|
||
# Bold subheading at the left margin
|
||
body.append(frag)
|
||
else:
|
||
chart.append(frag)
|
||
|
||
return body, chart, footnotes
|
||
|
||
# ---- Free-content (paragraph / heading) builder ----
|
||
def _build_free_content(
|
||
frags: list[tuple[float, float, str, bool, float]],
|
||
) -> str:
|
||
"""Build body text as flowing HTML with lists, headings, paragraphs.
|
||
|
||
Handles bullet lists (●/•), section headings (detected by gap),
|
||
first-line-indent paragraph breaks, and preserves per-fragment
|
||
bold formatting.
|
||
"""
|
||
if not frags:
|
||
return "" # pragma: no cover - unreachable: callers pass non-empty body/footnote groups
|
||
|
||
frags = sorted(frags)
|
||
|
||
# ---- Build lines: group fragments by top (± 2 px) ----
|
||
# Each line: (top, min_left, fragments)
|
||
lines: list[tuple[float, float, list[tuple[float, str, bool, float]]]] = []
|
||
cur_top = frags[0][0]
|
||
cur: list[tuple[float, str, bool, float]] = []
|
||
for top, left, text, bold, fs in frags:
|
||
if abs(top - cur_top) <= 2:
|
||
cur.append((left, text, bold, fs))
|
||
else:
|
||
if cur:
|
||
cur.sort()
|
||
lines.append((cur_top, cur[0][0], cur))
|
||
cur_top = top
|
||
cur = [(left, text, bold, fs)]
|
||
if cur:
|
||
cur.sort()
|
||
lines.append((cur_top, cur[0][0], cur))
|
||
|
||
# ---- Split ALL-CAPS heading prefixes from mixed-case text ----
|
||
# Certent CDM layouts place section headers like
|
||
# "BUSINESS SEGMENT ANALYSIS" and subsection names like
|
||
# "Business Focus" at the same top coordinate, forming a
|
||
# multi-fragment line. Split the ALL-CAPS bold prefix into
|
||
# its own line so the single-bold H2 check can fire for each.
|
||
_ALL_CAPS_RE = re.compile(r"^[A-Z][A-Z &,\-/\u2019\u00a0']+$")
|
||
split_lines: list[
|
||
tuple[float, float, list[tuple[float, str, bool, float]]]
|
||
] = []
|
||
for line_top, line_left, line_frags in lines:
|
||
if len(line_frags) <= 1:
|
||
split_lines.append((line_top, line_left, line_frags))
|
||
continue
|
||
|
||
# Find the boundary: consecutive bold ALL-CAPS fragments
|
||
# at the start, followed by non-ALL-CAPS or non-bold text.
|
||
caps_end = 0
|
||
for idx, (_, ftext, fbold, _) in enumerate(line_frags):
|
||
t = ftext.strip()
|
||
if fbold and t and _ALL_CAPS_RE.match(t):
|
||
caps_end = idx + 1
|
||
else:
|
||
break
|
||
|
||
if caps_end > 0 and caps_end < len(line_frags):
|
||
# Verify the remaining text starts mixed-case
|
||
rest_text = " ".join(
|
||
t.strip() for _, t, _, _ in line_frags[caps_end:]
|
||
).strip()
|
||
if rest_text and not _ALL_CAPS_RE.match(rest_text):
|
||
caps_frags = line_frags[:caps_end]
|
||
rest_frags = line_frags[caps_end:]
|
||
split_lines.append((line_top, caps_frags[0][0], caps_frags))
|
||
split_lines.append((line_top + 0.1, rest_frags[0][0], rest_frags))
|
||
continue
|
||
|
||
split_lines.append((line_top, line_left, line_frags))
|
||
|
||
lines = split_lines
|
||
|
||
# ---- Helpers ----
|
||
_BULLET = {"\u25cf", "\u2022"}
|
||
|
||
def _has_bullet(
|
||
lf: list[tuple[float, str, bool, float]],
|
||
) -> bool:
|
||
return any(t.strip() in _BULLET for _, t, _, _ in lf)
|
||
|
||
def _strip_bullet(
|
||
lf: list[tuple[float, str, bool, float]],
|
||
) -> list[tuple[float, str, bool, float]]:
|
||
return [
|
||
(left_, t, b, f) for left_, t, b, f in lf if t.strip() not in _BULLET
|
||
]
|
||
|
||
def _rich(
|
||
lf: list[tuple[float, str, bool, float]],
|
||
) -> str:
|
||
"""Combine fragments preserving per-fragment bold."""
|
||
parts: list[str] = []
|
||
for _, text, bold, _ in lf:
|
||
esc = _html_escape(text)
|
||
if bold and esc.strip():
|
||
parts.append(f"<b>{esc}</b>")
|
||
else:
|
||
parts.append(esc)
|
||
return " ".join(parts)
|
||
|
||
# Detect common body-left margin for indent paragraph detection
|
||
left_vals = [lf[0][0] for _, _, lf in lines if lf]
|
||
if left_vals:
|
||
_left_counts: dict[int, int] = {}
|
||
for _lv in left_vals:
|
||
_k = round(_lv)
|
||
_left_counts[_k] = _left_counts.get(_k, 0) + 1
|
||
body_left = max(_left_counts, key=lambda _k: _left_counts[_k])
|
||
else:
|
||
body_left = 48 # pragma: no cover - unreachable: non-empty frags always yield >=1 line with left_vals
|
||
|
||
# Regex for detecting sentence verbs — a strong signal that
|
||
# the text is body-paragraph content, not a heading title.
|
||
_SENTENCE_VERB_RE = re.compile(
|
||
r"\b(?:is|are|was|were|has|have|had|"
|
||
r"offers?|provides?|includes?|presents?|enables?|allows?"
|
||
r"|consists?|describes?|involves?|ensures?|continues?"
|
||
r"|represents?|reflects?|operates?|serves?|manages?"
|
||
r"|oversees?|supports?|covers?|conform[s]?"
|
||
r"|should|shall|will|would|could)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# ---- Main loop ----
|
||
out: list[str] = []
|
||
in_list = False
|
||
i = 0
|
||
|
||
while i < len(lines):
|
||
line_top, line_left, line_frags = lines[i]
|
||
prev_top = lines[i - 1][0] if i > 0 else None
|
||
gap = (line_top - prev_top) if prev_top is not None else 999
|
||
|
||
# ---- H2: single bold fragment ----
|
||
# In absolute-positioned SEC layouts a standalone bold
|
||
# line is always a heading — no gap threshold needed.
|
||
# However, bold body-text paragraphs can also appear as
|
||
# single fragments per line; exclude those based on
|
||
# length, casing, and verb-based sentence detection.
|
||
if len(line_frags) == 1:
|
||
_, text, bold, fs = line_frags[0]
|
||
t = text.strip()
|
||
if bold and fs >= 9.5 and len(t) > 3:
|
||
_is_heading = True
|
||
# Starts lowercase → mid-sentence fragment
|
||
if (
|
||
t[0].islower()
|
||
or len(t) > 120
|
||
and not t.isupper()
|
||
or len(t) > 60
|
||
and not t.isupper()
|
||
and _SENTENCE_VERB_RE.search(t)
|
||
or t.endswith(".")
|
||
and not re.search(
|
||
r"\b(?:INC|CORP|LTD|LLC|CO|JR|SR|DR|MR|MS"
|
||
+ r"|U\.S)\.\s*$",
|
||
t,
|
||
re.IGNORECASE,
|
||
)
|
||
):
|
||
_is_heading = False
|
||
|
||
if in_list:
|
||
out.append("</ul>\n")
|
||
in_list = False
|
||
if _is_heading:
|
||
out.append(f"<h2>{_html_escape(t)}</h2>\n")
|
||
else:
|
||
out.append(
|
||
f'<p data-body-text="1"><b>{_html_escape(t)}</b></p>\n'
|
||
)
|
||
i += 1
|
||
continue
|
||
|
||
# ---- H2: very large font ----
|
||
max_fs = max(fs for _, _, _, fs in line_frags)
|
||
if max_fs >= 18:
|
||
if in_list:
|
||
out.append("</ul>\n")
|
||
in_list = False
|
||
parts = [_html_escape(t) for _, t, _, _ in line_frags]
|
||
out.append(f"<h2>{' '.join(parts)}</h2>\n")
|
||
i += 1
|
||
continue
|
||
|
||
# ---- Section heading: large gap + short + followed by bullet ----
|
||
plain = " ".join(t for _, t, _, _ in line_frags).strip()
|
||
next_is_bullet = i + 1 < len(lines) and _has_bullet(lines[i + 1][2])
|
||
if (
|
||
gap > 22
|
||
and line_left <= 55
|
||
and len(plain) < 50
|
||
and not _has_bullet(line_frags)
|
||
and next_is_bullet
|
||
):
|
||
if in_list:
|
||
out.append("</ul>\n")
|
||
in_list = False
|
||
out.append(f"<h3>{_rich(line_frags)}</h3>\n")
|
||
i += 1
|
||
continue
|
||
|
||
# ---- Bullet line: absorb continuations ----
|
||
if _has_bullet(line_frags):
|
||
if not in_list:
|
||
out.append("<ul>\n")
|
||
in_list = True
|
||
clean = _strip_bullet(line_frags)
|
||
parts_list = [_rich(clean)]
|
||
j = i + 1
|
||
while j < len(lines):
|
||
ntop, _, nfrags = lines[j]
|
||
ngap = ntop - lines[j - 1][0]
|
||
if ngap <= 18 and not _has_bullet(nfrags):
|
||
nmax = max(fs for _, _, _, fs in nfrags)
|
||
if nmax >= 18:
|
||
break
|
||
if len(nfrags) == 1 and nfrags[0][2] and nfrags[0][3] >= 9.5:
|
||
break
|
||
parts_list.append(_rich(nfrags))
|
||
j += 1
|
||
else:
|
||
break
|
||
out.append(f"<li>{' '.join(parts_list)}</li>\n")
|
||
i = j
|
||
continue
|
||
|
||
# ---- Close list if we fell out of bullets ----
|
||
if in_list:
|
||
out.append("</ul>\n")
|
||
in_list = False
|
||
|
||
# ---- Paragraph: collect lines (gap ≤ 18 px) ----
|
||
para = [_rich(line_frags)]
|
||
j = i + 1
|
||
while j < len(lines):
|
||
ntop, _, nfrags = lines[j]
|
||
ngap = ntop - lines[j - 1][0]
|
||
if ngap <= 18 and not _has_bullet(nfrags):
|
||
nmax = max(fs for _, _, _, fs in nfrags)
|
||
if nmax >= 18:
|
||
break
|
||
if len(nfrags) == 1 and nfrags[0][2] and nfrags[0][3] >= 9.5:
|
||
break
|
||
# First-line indent → new paragraph
|
||
if nfrags[0][0] > body_left + 5:
|
||
break
|
||
para.append(_rich(nfrags))
|
||
j += 1
|
||
else:
|
||
break
|
||
|
||
out.append(f"<p>{' '.join(para)}</p>\n")
|
||
i = j
|
||
|
||
if in_list:
|
||
out.append("</ul>\n")
|
||
|
||
return "".join(out)
|
||
|
||
# ---- Chart-summary builder ----
|
||
def _build_chart_summary(
|
||
frags: list[tuple[float, float, str, bool, float]],
|
||
) -> str:
|
||
"""Build a chart placeholder rendered as a <div class="chart">.
|
||
|
||
Extracts bold titles and parenthesised descriptions. The
|
||
resulting ``<div>`` is preserved as raw HTML in the final
|
||
markdown output so downstream consumers can identify and
|
||
render chart blocks with their own styling.
|
||
"""
|
||
if not frags:
|
||
return "" # pragma: no cover - unreachable: only called inside `if chart_frags:`
|
||
|
||
titles: list[str] = []
|
||
descs: list[str] = []
|
||
seen_titles: set[str] = set()
|
||
seen_descs: set[str] = set()
|
||
|
||
for _top, _left, text, bold, fs in sorted(frags):
|
||
t = text.strip()
|
||
if not t:
|
||
continue # pragma: no cover - unreachable: parser drops empty/whitespace frags (5325)
|
||
if bold and fs > 9 and len(t) > 3 and t not in seen_titles:
|
||
titles.append(t)
|
||
seen_titles.add(t)
|
||
elif t.startswith("(") and len(t) > 10 and t not in seen_descs:
|
||
descs.append(t)
|
||
seen_descs.add(t)
|
||
|
||
if not titles:
|
||
return ""
|
||
|
||
label = " / ".join(titles)
|
||
# Build as a SINGLE line so post-processing cleanup steps
|
||
# (e.g. _remove_repeated_page_elements) cannot split the div
|
||
# into individual lines and strip them as short repeats.
|
||
inner = f"<span>{_html_escape(label)}</span>"
|
||
if descs:
|
||
inner += f'<span class="chart-desc">{_html_escape("; ".join(descs))}</span>'
|
||
return f'<div class="chart">{inner}</div>\n'
|
||
|
||
# ---- Per-page reflow orchestrator ----
|
||
def _reflow_page(page_num: int) -> str:
|
||
hrules, text_frags = _parse_page(page_num)
|
||
zones = _identify_table_zones(hrules)
|
||
|
||
# Classify fragments into table zones vs. free
|
||
table_frags: dict[int, list[tuple[float, float, str, bool, float]]] = {
|
||
zi: [] for zi in range(len(zones))
|
||
}
|
||
free_frags: list[tuple[float, float, str, bool, float]] = []
|
||
|
||
for frag in text_frags:
|
||
top = frag[0]
|
||
placed = False
|
||
for zi, (zt, zb) in enumerate(zones):
|
||
if zt - 5 <= top <= zb + 15:
|
||
table_frags[zi].append(frag)
|
||
placed = True
|
||
break
|
||
if not placed:
|
||
free_frags.append(frag)
|
||
|
||
# Classify free fragments into body text, chart, and footnotes
|
||
body_frags, chart_frags, footnote_frags = _classify_fragments(free_frags)
|
||
|
||
# Collect page segments in vertical order
|
||
segments: list[tuple[float, str]] = []
|
||
|
||
for zi, (zt, zb) in enumerate(zones):
|
||
if table_frags[zi]:
|
||
rules_in = [r for r in hrules if zt - 1 <= r <= zb + 1]
|
||
t_html = _build_table_from_zone(table_frags[zi], rules_in)
|
||
if t_html:
|
||
# Split composite tables (TABLE 5 + 6 + 7 in one
|
||
# zone) into independent <table> elements so each
|
||
# gets converted separately by convert_table().
|
||
_t_soup = BeautifulSoup(t_html, "html.parser")
|
||
_t_tag = _t_soup.find("table")
|
||
if _t_tag:
|
||
_parts = _split_composite_table(_t_tag)
|
||
if len(_parts) > 1:
|
||
_offset = 0.0
|
||
for _p in _parts:
|
||
if isinstance(_p, str):
|
||
segments.append((zt + _offset, f"<p>{_p}</p>"))
|
||
else:
|
||
segments.append((zt + _offset, str(_p)))
|
||
_offset += 0.01
|
||
else:
|
||
segments.append((zt, t_html))
|
||
else:
|
||
segments.append(
|
||
(zt, t_html)
|
||
) # pragma: no cover - unreachable: truthy t_html always contains <table>
|
||
|
||
# Body text → paragraphs / headings
|
||
if body_frags:
|
||
body_frags.sort()
|
||
groups: list[list[tuple[float, float, str, bool, float]]] = []
|
||
cur_group = [body_frags[0]]
|
||
for i in range(1, len(body_frags)):
|
||
gap = body_frags[i][0] - body_frags[i - 1][0]
|
||
crosses_zone = any(
|
||
body_frags[i - 1][0] < zt and body_frags[i][0] > zb
|
||
for zt, zb in zones
|
||
)
|
||
if crosses_zone or gap > 40:
|
||
groups.append(cur_group)
|
||
cur_group = [body_frags[i]]
|
||
else:
|
||
cur_group.append(body_frags[i])
|
||
groups.append(cur_group)
|
||
|
||
for g in groups:
|
||
content = _build_free_content(g)
|
||
if content.strip():
|
||
segments.append((g[0][0], content))
|
||
|
||
# Chart content → compact annotation
|
||
if chart_frags:
|
||
chart_html = _build_chart_summary(chart_frags)
|
||
if chart_html.strip():
|
||
avg_top = sum(f[0] for f in chart_frags) / len(chart_frags)
|
||
segments.append((avg_top, chart_html))
|
||
|
||
# Footnotes → rendered as body paragraphs at page bottom
|
||
if footnote_frags:
|
||
fn_html = _build_free_content(footnote_frags)
|
||
if fn_html.strip():
|
||
segments.append((footnote_frags[0][0], fn_html))
|
||
|
||
segments.sort(key=lambda s: s[0])
|
||
return "".join(s[1] for s in segments)
|
||
|
||
# ---- Main loop: process every page ----
|
||
out_parts: list[str] = ["<html><body>\n"]
|
||
|
||
for pg in range(1, total_pages + 1):
|
||
page_html = _reflow_page(pg)
|
||
if page_html:
|
||
out_parts.append(page_html)
|
||
|
||
out_parts.append("</body></html>")
|
||
return "".join(out_parts)
|
||
|
||
|
||
def _html_escape(text: str) -> str:
|
||
"""Minimal HTML escaping for reflowed text."""
|
||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||
|
||
|
||
def html_to_markdown(
|
||
html_content: str,
|
||
base_url: str = "",
|
||
keep_tables: bool | None = True,
|
||
) -> str:
|
||
"""Convert SEC HTML content to Markdown format.
|
||
|
||
Parameters
|
||
----------
|
||
html_content : str or bytes
|
||
HTML content to convert
|
||
base_url : str
|
||
Base URL for resolving relative links and images
|
||
keep_tables : bool
|
||
If True, convert tables to markdown tables. If False, skip tables entirely.
|
||
|
||
Returns
|
||
-------
|
||
str
|
||
Markdown-formatted text
|
||
"""
|
||
# Handle legacy parameter
|
||
if not keep_tables:
|
||
keep_tables = False
|
||
|
||
if not html_content:
|
||
return ""
|
||
|
||
if isinstance(html_content, bytes):
|
||
html_content = html_content.decode("utf-8", errors="ignore")
|
||
|
||
# Remove XML declaration
|
||
html_content = re.sub(r"<\?xml.*?\?>", "", html_content, flags=re.IGNORECASE)
|
||
|
||
for entity, char in WIN1252_MAP.items():
|
||
html_content = html_content.replace(entity, char)
|
||
|
||
# Detect and rewrite absolute-positioned layouts (e.g. TD 40-F
|
||
# exhibits generated by PDF-to-HTML converters) into flowing HTML
|
||
# before the main converter processes them.
|
||
_reflowed = _reflow_absolute_layout(html_content)
|
||
if _reflowed is not None:
|
||
html_content = _reflowed
|
||
|
||
soup = BeautifulSoup(html_content, "lxml")
|
||
|
||
# Remove hidden XBRL elements and junk
|
||
for tag in soup.find_all(["ix:header", "ix:hidden", "script", "style", "noscript"]):
|
||
tag.decompose()
|
||
|
||
for comment in soup.find_all(string=lambda text: isinstance(text, Comment)):
|
||
comment.extract()
|
||
|
||
for tag in soup.find_all(True):
|
||
if tag.attrs and "style" in tag.attrs:
|
||
style = str(tag.get("style", "")).lower().replace(" ", "")
|
||
if "display:none" in style or "visibility:hidden" in style:
|
||
# Do NOT decompose table cells (td/th) — their colspan
|
||
# is structural even when hidden. Removing them breaks
|
||
# the column grid and causes data columns to disappear.
|
||
# Instead, clear their content so they remain as empty
|
||
# placeholders in the grid.
|
||
if tag.name in ("td", "th"):
|
||
tag.string = ""
|
||
else:
|
||
tag.decompose()
|
||
|
||
_merge_continuation_tables(soup)
|
||
|
||
# Presentation slide decks (e.g. Spotify 6-K EX-99):
|
||
# Each slide is a <div class="slide"> containing an <img> (the
|
||
# actual slide) and a <div class="slideText"> with invisible
|
||
# (1pt / white) accessibility text. The text is an unstructured
|
||
# dump that doesn't convert to readable markdown. Convert the
|
||
# slideText content into HTML comments so it is preserved for
|
||
# search / AI consumption but not rendered visually.
|
||
_slide_divs = soup.find_all("div", class_="slide")
|
||
if len(_slide_divs) >= 3: # at least 3 slides → slide deck
|
||
for _sd in _slide_divs:
|
||
for _st in _sd.find_all("div", class_="slideText"):
|
||
_txt = _st.get_text(separator=" ", strip=True)
|
||
_txt = re.sub(r"\s+", " ", _txt).strip()
|
||
if _txt:
|
||
_comment = Comment(f" {_txt} ")
|
||
_st.replace_with(_comment)
|
||
else:
|
||
_st.decompose()
|
||
# Also remove the spacer divs between slides.
|
||
for _sp in soup.find_all("div", class_="spaceAfterSlideText"):
|
||
_sp.decompose()
|
||
|
||
# General invisible accessibility text (e.g. LOW 8-K infographics):
|
||
# Some filings embed tiny white text (<font style="font-size:1pt;
|
||
# color:white">) as accessibility descriptions for images. This
|
||
# text dumps as an unformatted wall of text in markdown. Convert
|
||
# it to HTML comments so it's preserved for search / AI but not
|
||
# rendered visually. This handles patterns not covered by the
|
||
# slide-deck detection above.
|
||
_invisible_re = re.compile(r"font-size\s*:\s*1(?:pt|px)", re.IGNORECASE)
|
||
_white_re = re.compile(r"color\s*:\s*white", re.IGNORECASE)
|
||
for _inv_tag in soup.find_all(["font", "span", "div", "p"]):
|
||
_style = cast("str", _inv_tag.get("style", ""))
|
||
if not _style:
|
||
continue
|
||
if _invisible_re.search(_style) and _white_re.search(_style):
|
||
_txt = _inv_tag.get_text(separator=" ", strip=True)
|
||
_txt = re.sub(r"\s+", " ", _txt).strip()
|
||
if _txt and len(_txt) > 20:
|
||
_comment = Comment(f" {_txt} ")
|
||
_inv_tag.replace_with(_comment)
|
||
elif not _txt:
|
||
_inv_tag.decompose()
|
||
# Short text (<= 20 chars) is left alone — unlikely to be
|
||
# meaningful accessibility description.
|
||
|
||
# Track whether we have already emitted a TOC / page-navigation table.
|
||
# Older SEC exhibits (e.g. IBM 2008 Annual Report) embed the same sidebar
|
||
# navigation table on every page — dozens of copies. We keep only the
|
||
# first one and suppress the rest.
|
||
_seen_toc_table = [False] # mutable so inner function can write
|
||
|
||
def process_element(element, depth=0) -> str:
|
||
"""Recursively process element to markdown."""
|
||
if isinstance(element, NavigableString):
|
||
# Preserve Comment nodes as HTML comments in markdown output.
|
||
if isinstance(element, Comment):
|
||
return f"<!--{element}-->\n\n"
|
||
text = str(element)
|
||
# Normalize whitespace: convert tabs/newlines to spaces, collapse multiple spaces
|
||
text = text.replace("\xa0", " ") # Non-breaking space
|
||
text = re.sub(r"[\t\n\r]+", " ", text)
|
||
text = re.sub(r" +", " ", text) # Collapse multiple spaces
|
||
return text
|
||
|
||
if element.name is None:
|
||
return "" # pragma: no cover - unreachable: bs4 yields name=None only for NavigableString subclasses (handled above)
|
||
|
||
if element.name in ["script", "style", "noscript"]:
|
||
return "" # pragma: no cover - unreachable: preprocessing decomposes all script/style/noscript before process_element runs
|
||
|
||
# Skip "Table of Contents" page-header elements.
|
||
# SEC filings repeat these at every page break. Two patterns:
|
||
# 1) A simple div/p whose *only* text is the TOC link.
|
||
# 2) A container div (often with min-height style) that holds
|
||
# a TOC link *plus* a running section name (e.g.
|
||
# "Notes to Consolidated Financial Statements — (continued)").
|
||
# Both are navigation artefacts, not document content.
|
||
if element.name in ["div", "p"]:
|
||
toc_link = element.find(
|
||
"a",
|
||
href=True,
|
||
string=re.compile(r"^\s*Table of Contents\s*$", re.I),
|
||
)
|
||
if toc_link:
|
||
full_text = element.get_text(separator=" ", strip=True)
|
||
if len(full_text) < 200:
|
||
return "" # Skip this navigation / page-header element
|
||
|
||
# Also skip h1-h6 navigation headings whose sole text is "Table of Contents".
|
||
# Some SEC filings embed these as page-break separators (e.g. <h5>Table of Contents</h5>).
|
||
if element.name in ["h1", "h2", "h3", "h4", "h5", "h6"]:
|
||
_h_text = element.get_text(separator=" ", strip=True)
|
||
if re.match(r"^\s*Table of Contents\s*$", _h_text, re.I):
|
||
return ""
|
||
|
||
# This handles modern HTML where anchors use id= on div/p/td/etc
|
||
element_id = element.get("id")
|
||
anchor_prefix = ""
|
||
|
||
if element_id and element.name != "a": # Don't double-process <a> tags
|
||
anchor_prefix = f'<a id="{element_id}"></a>'
|
||
|
||
# Handle XBRL inline elements (ix:continuation, ix:nonNumeric, etc.)
|
||
# These are containers that should preserve the structure of their children
|
||
if element.name.startswith("ix:"):
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
if isinstance(child, NavigableString):
|
||
text = str(child).strip()
|
||
if text:
|
||
parts.append(text)
|
||
else:
|
||
parts.append(process_element(child, depth + 1))
|
||
# Join with empty string to preserve paragraph breaks (\n\n) from children
|
||
return "".join(parts)
|
||
|
||
# Images - use helper to preserve sizes
|
||
if element.name == "img":
|
||
img_md = _convert_image_to_html(element, base_url)
|
||
|
||
if img_md:
|
||
return f"\n\n{img_md}\n\n"
|
||
|
||
return ""
|
||
|
||
# Tables
|
||
if element.name == "table":
|
||
if keep_tables:
|
||
# Detect TOC / page-navigation tables and suppress duplicates.
|
||
# Older SEC exhibits (e.g. IBM 2008 Annual Report) embed a
|
||
# sidebar navigation table on every page. These tables have
|
||
# section names with page numbers — NOT financial data.
|
||
# Key signal: a row whose first cell says "Management
|
||
# Discussion" (or similar TOC heading) combined with most
|
||
# rows ending in a bare page-number integer.
|
||
tbl_rows = element.find_all("tr")
|
||
if len(tbl_rows) >= 5:
|
||
_has_toc_heading = False
|
||
_page_num_rows = 0
|
||
for _tr in tbl_rows:
|
||
_cells = _tr.find_all(["td", "th"])
|
||
if not _cells:
|
||
continue
|
||
_first = _cells[0].get_text(strip=True).lower()
|
||
if _first in (
|
||
"management discussion",
|
||
"table of contents",
|
||
) or _first.startswith("management\n"):
|
||
_has_toc_heading = True
|
||
_last = _cells[-1].get_text(strip=True)
|
||
if _last.isdigit() and 0 < int(_last) < 300:
|
||
_page_num_rows += 1
|
||
if _has_toc_heading and _page_num_rows >= len(tbl_rows) * 0.5:
|
||
if _seen_toc_table[0]:
|
||
return "" # Already emitted one — skip
|
||
_seen_toc_table[0] = True
|
||
return "\n\n" + convert_table(element, base_url) + "\n\n"
|
||
return ""
|
||
|
||
# Headers
|
||
if is_header_element(element):
|
||
anchor_tag = element.find("a", attrs={"name": True})
|
||
anchor_html = anchor_prefix # Use element's id if present
|
||
|
||
if anchor_tag:
|
||
anchor_name = anchor_tag.get("name")
|
||
if anchor_name:
|
||
anchor_html = f'<a id="{anchor_name}"></a>\n\n'
|
||
elif anchor_prefix:
|
||
anchor_html = anchor_prefix + "\n\n"
|
||
|
||
text = get_text_content(element).strip()
|
||
text = re.sub(r"\s+", " ", text)
|
||
|
||
if text:
|
||
level = get_header_level(element)
|
||
return f"\n\n{anchor_html}{'#' * level} {text}\n\n"
|
||
|
||
return anchor_html if anchor_html else ""
|
||
|
||
# Lists
|
||
if element.name == "ul":
|
||
items = []
|
||
|
||
for li in element.find_all("li", recursive=False):
|
||
# Check for id on li element
|
||
li_id = li.get("id")
|
||
li_anchor = f'<a id="{li_id}"></a>' if li_id else ""
|
||
item_text = process_element(li, depth + 1).strip()
|
||
|
||
if item_text:
|
||
items.append(f"- {li_anchor}{item_text}")
|
||
|
||
result = "\n" + "\n".join(items) + "\n" if items else ""
|
||
|
||
return f"{anchor_prefix}{result}" if anchor_prefix else result
|
||
|
||
if element.name == "ol":
|
||
items = []
|
||
|
||
for i, li in enumerate(element.find_all("li", recursive=False), 1):
|
||
# Check for id on li element
|
||
li_id = li.get("id")
|
||
li_anchor = f'<a id="{li_id}"></a>' if li_id else ""
|
||
item_text = process_element(li, depth + 1).strip()
|
||
|
||
if item_text:
|
||
items.append(f"{i}. {li_anchor}{item_text}")
|
||
|
||
result = "\n" + "\n".join(items) + "\n" if items else ""
|
||
|
||
return f"{anchor_prefix}{result}" if anchor_prefix else result
|
||
|
||
# Paragraphs and divs
|
||
if element.name in ["p", "div"]:
|
||
# Chart divs produced by _build_chart_summary() should be
|
||
# preserved as raw HTML blocks in the markdown output.
|
||
if element.get("class") and "chart" in element.get("class", []):
|
||
return f"\n\n{str(element)}\n\n"
|
||
|
||
# Check if this is an inline element (display:inline in style)
|
||
# Be careful not to match "display:inline-block" which is block-level
|
||
style = element.get("style", "")
|
||
is_inline = bool(
|
||
re.search(
|
||
r"display:\s*inline(?:\s*;|\s*$|(?![a-z-]))", style, re.IGNORECASE
|
||
)
|
||
)
|
||
has_bold = re.search(
|
||
r"font-weight:\s*(bold|bolder|[6-9]00)", style, re.IGNORECASE
|
||
)
|
||
has_italic = re.search(r"font-style:\s*italic", style, re.IGNORECASE)
|
||
|
||
# Check if this is a CSS table-row with bullet (display: table-row)
|
||
# These are used as pseudo-lists in SEC filings
|
||
is_table_row = "display: table-row" in style or "display:table-row" in style
|
||
|
||
if is_table_row:
|
||
# Look for bullet in first table-cell, text in second
|
||
cells = element.find_all(
|
||
"div", style=re.compile(r"display:\s*table-cell", re.IGNORECASE)
|
||
)
|
||
if len(cells) >= 2:
|
||
first_cell_text = cells[0].get_text(strip=True)
|
||
|
||
if first_cell_text in BULLET_CHARS:
|
||
# This is a bullet item - extract text from remaining cells
|
||
text = " ".join(
|
||
c.get_text(separator=" ", strip=True) for c in cells[1:]
|
||
)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text:
|
||
return f"\n- {text}"
|
||
|
||
# These are section headers where an anchor tag splits bold text
|
||
children_list = list(element.children)
|
||
children_text = []
|
||
skip_until = -1
|
||
|
||
for ci, child in enumerate(children_list):
|
||
if ci <= skip_until:
|
||
continue
|
||
# Check for anchor with bold content followed by sibling bold
|
||
if (
|
||
isinstance(child, Tag)
|
||
and child.name == "a"
|
||
and (child.get("name") or child.get("id"))
|
||
):
|
||
inner_b = child.find(["b", "strong"])
|
||
|
||
if inner_b:
|
||
anchor_text = child.get_text()
|
||
|
||
if anchor_text.strip():
|
||
# Look for following <b>/<strong> siblings
|
||
combined = anchor_text
|
||
last_consumed = ci
|
||
found_bold_sib = False
|
||
|
||
for j in range(ci + 1, len(children_list)):
|
||
sib = children_list[j]
|
||
|
||
if (
|
||
isinstance(sib, NavigableString)
|
||
and sib.strip() == ""
|
||
):
|
||
last_consumed = j
|
||
continue
|
||
|
||
if isinstance(sib, Tag) and sib.name in [
|
||
"b",
|
||
"strong",
|
||
]:
|
||
combined += sib.get_text()
|
||
last_consumed = j
|
||
found_bold_sib = True
|
||
else:
|
||
break
|
||
|
||
if found_bold_sib:
|
||
anchor_id = child.get("name") or child.get("id")
|
||
anchor_html = f'<a id="{anchor_id}"></a>\n\n'
|
||
combined = re.sub(r"\s+", " ", combined).strip()
|
||
children_text.append(
|
||
f"\n\n{anchor_html}### {combined}\n\n"
|
||
)
|
||
skip_until = last_consumed
|
||
continue
|
||
|
||
children_text.append(process_element(child, depth + 1))
|
||
|
||
text = "".join(children_text).strip()
|
||
|
||
if text:
|
||
if is_inline:
|
||
# Apply bold/italic formatting for CSS-styled inline elements
|
||
if has_bold and has_italic:
|
||
text = f"***{text}***"
|
||
elif has_bold:
|
||
text = f"**{text}**"
|
||
elif has_italic:
|
||
text = f"*{text}*"
|
||
return f"{anchor_prefix}{text}" if anchor_prefix else text
|
||
|
||
# Convert to proper heading + paragraph
|
||
subheading_match = re.match(
|
||
r"^(\*{2,3})([^*]+)\1\s*\.\s*(.+)$", text, re.DOTALL
|
||
)
|
||
|
||
if subheading_match:
|
||
title = subheading_match.group(2).strip()
|
||
body = subheading_match.group(3).strip()
|
||
|
||
return f"\n\n{anchor_prefix}#### {title}\n\n{body}\n\n"
|
||
|
||
return f"\n\n{anchor_prefix}{text}\n\n"
|
||
|
||
return anchor_prefix if anchor_prefix else ""
|
||
|
||
# Line breaks
|
||
if element.name == "br":
|
||
return "\n"
|
||
|
||
# Horizontal rules
|
||
if element.name == "hr":
|
||
return "\n\n---\n\n"
|
||
|
||
# Anchor tags - handle both links and anchor targets
|
||
if element.name == "a":
|
||
name = element.get("name")
|
||
elem_id = element.get("id") # Modern HTML uses id instead of name
|
||
href = element.get("href")
|
||
text = get_text_content(element).strip()
|
||
|
||
# Anchor target (for TOC links to jump to) - support both name and id
|
||
if name or elem_id:
|
||
anchor_id = name or elem_id # Prefer name, fall back to id
|
||
anchor = f'<a id="{anchor_id}"></a>'
|
||
|
||
if text:
|
||
return f"{anchor}\n{text}"
|
||
|
||
return anchor
|
||
|
||
# Regular link
|
||
if href and text:
|
||
if (
|
||
not href.startswith("#")
|
||
and base_url
|
||
and not href.startswith(("http://", "https://"))
|
||
):
|
||
href = urljoin(base_url, href)
|
||
|
||
return f"[{text}]({href})"
|
||
|
||
return text or ""
|
||
|
||
# Bold - process children to preserve anchors inside
|
||
if element.name in ["b", "strong"]:
|
||
if element.find("img") and not element.get_text(strip=True):
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
parts.append(process_element(child, depth + 1))
|
||
|
||
return "".join(parts)
|
||
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
parts.append(process_element(child, depth + 1))
|
||
|
||
inner = "".join(parts).strip()
|
||
inner = re.sub(r"\s+", " ", inner)
|
||
|
||
if inner:
|
||
if '<a id="' in inner:
|
||
match = re.search(r'(<a id="[^"]+"></a>)(.*)', inner)
|
||
if match:
|
||
anchor = match.group(1)
|
||
text = match.group(2).strip()
|
||
if text:
|
||
return f"{anchor}**{text}** "
|
||
|
||
return anchor
|
||
# If inner is only asterisks (footnote markers like *, **),
|
||
# escape them to prevent collision with Markdown formatting
|
||
if re.match(r"^\*+$", inner):
|
||
return inner.replace("*", "\\*")
|
||
|
||
return f"**{inner}** "
|
||
|
||
return ""
|
||
|
||
# Italic - process children to preserve anchors
|
||
if element.name in ["i", "em"]:
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
parts.append(process_element(child, depth + 1))
|
||
|
||
inner = "".join(parts).strip()
|
||
|
||
if inner:
|
||
if '<a id="' in inner:
|
||
match = re.search(r'(<a id="[^"]+"></a>)(.*)', inner)
|
||
if match:
|
||
anchor = match.group(1)
|
||
text = match.group(2).strip()
|
||
|
||
if text:
|
||
return f"{anchor}*{text}*"
|
||
|
||
return anchor
|
||
# If inner is only asterisks (footnote markers like *, **),
|
||
# escape them to prevent collision with Markdown formatting
|
||
if re.match(r"^\*+$", inner):
|
||
return inner.replace("*", "\\*")
|
||
|
||
return f"*{inner}*"
|
||
|
||
return ""
|
||
|
||
# Underline - just process children
|
||
if element.name == "u":
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
parts.append(process_element(child, depth + 1))
|
||
|
||
return "".join(parts).strip()
|
||
|
||
# Superscript - ensure space separation from adjacent text
|
||
# SEC footnotes use <sup>1</sup>Text which concatenates as "1Text" without this
|
||
if element.name == "sup":
|
||
text = element.get_text().strip()
|
||
if text:
|
||
return f"{text} "
|
||
return ""
|
||
|
||
# Blockquote
|
||
if element.name == "blockquote":
|
||
parts = []
|
||
|
||
for child in element.children:
|
||
parts.append(process_element(child, depth + 1))
|
||
|
||
text = "".join(parts).strip()
|
||
|
||
if text:
|
||
lines = text.split("\n")
|
||
quoted = "\n".join(f"> {line}" for line in lines)
|
||
|
||
return f"\n\n{quoted}\n\n"
|
||
|
||
return ""
|
||
|
||
# Pre/code
|
||
if element.name == "pre":
|
||
text = element.get_text()
|
||
|
||
return f"\n\n```\n{text}\n```\n\n"
|
||
|
||
if element.name == "code":
|
||
text = element.get_text()
|
||
|
||
if "\n" not in text:
|
||
return f"`{text}`"
|
||
|
||
return f"\n```\n{text}\n```\n"
|
||
|
||
# Default: process children (including spans, sections, etc.)
|
||
result_list: list = []
|
||
|
||
for child in element.children:
|
||
result_list.append(process_element(child, depth + 1))
|
||
|
||
inner = "".join(result_list)
|
||
# Prepend anchor if element had an id attribute
|
||
if anchor_prefix and inner.strip():
|
||
return f"{anchor_prefix}{inner}"
|
||
|
||
return inner
|
||
|
||
# Process the body or root
|
||
body = soup.body or soup
|
||
markdown = process_element(body)
|
||
|
||
# Clean up
|
||
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
|
||
markdown = re.sub(r" {2,}", " ", markdown)
|
||
|
||
# Fix orphaned bullet characters
|
||
markdown = re.sub(
|
||
r"^([•●◦○▪■\-\*])\s*\n+([A-Z])", r"• \2", markdown, flags=re.MULTILINE
|
||
)
|
||
|
||
# Convert inline bullet characters to markdown bullets
|
||
markdown = re.sub(
|
||
r"(?:^|\n\n)([•●◦○▪■])([A-Z])",
|
||
r"\n\n- \2",
|
||
markdown,
|
||
)
|
||
|
||
# Detect section headers from standalone short lines
|
||
header_patterns = [
|
||
r"^(Introduction)$",
|
||
r"^(Executive Overview)$",
|
||
r"^(Financial Overview)$",
|
||
r"^(Business Environment)$",
|
||
r"^(Critical Accounting Policies)$",
|
||
r"^(Results of Operations)$",
|
||
r"^(Liquidity and Capital Resources)$",
|
||
r"^(Risk Management)$",
|
||
r"^(Balance Sheet Analysis)$",
|
||
r"^(Segment Results)$",
|
||
r"^(Recent Developments)$",
|
||
r"^(Forward[-\s]Looking Statements?)$",
|
||
r"^(Market Risk)$",
|
||
r"^(Credit Risk)$",
|
||
r"^(Operational Risk)$",
|
||
r"^(Legal Proceedings?)$",
|
||
r"^(Controls and Procedures)$",
|
||
r"^(Fair Value)$",
|
||
r"^(Allowance for Credit Losses)$",
|
||
r"^(Capital)$",
|
||
r"^(Funding)$",
|
||
]
|
||
for pattern in header_patterns:
|
||
markdown = re.sub(
|
||
pattern,
|
||
r"## \1",
|
||
markdown,
|
||
flags=re.MULTILINE | re.IGNORECASE,
|
||
)
|
||
|
||
# Strip anchor tags that are not referenced by any in-document link.
|
||
# XBRL cell IDs (Tc_*), GUID-like IDs, and other noise anchors clutter
|
||
# the output and break section header formatting.
|
||
_referenced_ids = set(re.findall(r"\(#([^)]+)\)", markdown))
|
||
|
||
def _filter_anchor(m):
|
||
anchor_id = m.group(1)
|
||
if anchor_id in _referenced_ids:
|
||
return m.group(0)
|
||
return ""
|
||
|
||
markdown = re.sub(r'<a id="([^"]+)"></a>', _filter_anchor, markdown)
|
||
# Clean up lines that are now empty after anchor removal
|
||
markdown = re.sub(r"\n[ \t]*\n", "\n\n", markdown)
|
||
|
||
# Merge consecutive bold sections on the same line.
|
||
# Use [ ]+ (not \s*) to avoid merging across paragraph boundaries,
|
||
# and skip merges when either side contains an image tag.
|
||
def _merge_bold(m):
|
||
a, b = m.group(1), m.group(2)
|
||
if "<img " in a or "<img " in b:
|
||
return m.group(
|
||
0
|
||
) # pragma: no cover - unreachable: literal "<img " only appears newline-isolated (standalone img -> \n\n<img/>\n\n; inline img -> ![]() markdown; multi-div cell -> \n-joined), never inside a **...** span adjacent to another, so the guard never fires
|
||
return f"**{a} {b}**"
|
||
|
||
markdown = re.sub(r"\*\*([^*]+)\*\*[ ]+\*\*([^*]+)\*\*", _merge_bold, markdown)
|
||
for _ in range(5):
|
||
new_md = re.sub(r"\*\*([^*]+)\*\*[ ]+\*\*([^*]+)\*\*", _merge_bold, markdown)
|
||
if new_md == markdown:
|
||
break
|
||
markdown = new_md
|
||
|
||
# Remove repeated "Table of Contents" headers.
|
||
# Some filings (e.g. IBM) produce lines like:
|
||
# ### Table of Contents Management Discussion – (continued)
|
||
# so match any heading that *starts with* the TOC prefix.
|
||
_toc_re = re.compile(r"^#{1,6}\s+Table of Contents", re.IGNORECASE)
|
||
lines = markdown.split("\n")
|
||
first_toc_kept = False
|
||
filtered_lines = []
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
if _toc_re.match(line.strip()):
|
||
# Keep the very first occurrence when it is followed by an
|
||
# actual TOC table (pipe-delimited rows).
|
||
j = i + 1
|
||
while j < len(lines) and lines[j].strip() == "":
|
||
j += 1
|
||
if (
|
||
j < len(lines)
|
||
and lines[j].strip().startswith("|")
|
||
and not first_toc_kept
|
||
):
|
||
filtered_lines.append(line)
|
||
first_toc_kept = True
|
||
i += 1
|
||
continue
|
||
i += 1
|
||
continue
|
||
filtered_lines.append(line)
|
||
i += 1
|
||
markdown = "\n".join(filtered_lines)
|
||
|
||
# Remove repeated [Table of Contents](#...) links (page header navigation)
|
||
# Anchor target varies by filing: #toc, GUIDs, etc.
|
||
markdown = re.sub(
|
||
r"\n+\[Table of Contents\]\(#[^)]+\)\n+", "\n\n", markdown, flags=re.IGNORECASE
|
||
)
|
||
|
||
# Remove page break horizontal rules (often followed/preceded by excessive whitespace)
|
||
# These are artifacts from page-break-before:always in the HTML
|
||
markdown = re.sub(r"\n{2,}---\n{2,}", "\n\n", markdown)
|
||
|
||
# Remove standalone page numbers
|
||
markdown = re.sub(
|
||
r"\n\n(?:S-)?(?:[ivxlcdm]+|[IVX]+|\d{1,3}|F-\d{1,2})\n\n",
|
||
"\n\n",
|
||
markdown,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
|
||
# Clean up double bullet/dash patterns (e.g., "- •" or "- –")
|
||
# These occur when the converter adds a markdown bullet and the source also had a Unicode bullet
|
||
markdown = re.sub(r"^- •\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(r"^- ●\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(r"^- –\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(r"^- —\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(r"^• •\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(r"^● ●\s*", "- ", markdown, flags=re.MULTILINE)
|
||
markdown = re.sub(
|
||
r"^•\s+", "- ", markdown, flags=re.MULTILINE
|
||
) # Convert standalone • to -
|
||
markdown = re.sub(
|
||
r"^●\s+", "- ", markdown, flags=re.MULTILINE
|
||
) # Convert standalone ● to -
|
||
|
||
# Remove specific page header/footer patterns with images (e.g., DEF14A proxy statements)
|
||
markdown = re.sub(
|
||
r"\n(?:\d{1,3}\s+)?<img[^>]+>\s*\d{4}\s+Annual Meeting of Stockholders\s*\n",
|
||
"\n",
|
||
markdown,
|
||
)
|
||
markdown = re.sub(
|
||
r"\n\d{4}\s+Annual Meeting of Stockholders\s*<img[^>]+>(?:\s*\d{1,3})?\s*\n",
|
||
"\n",
|
||
markdown,
|
||
)
|
||
|
||
# Remove repeated page footer/header patterns
|
||
# Find short lines that repeat many times (likely page footers/headers)
|
||
markdown = _remove_repeated_page_elements(markdown)
|
||
|
||
# Normalize bullet characters (•) into proper markdown bullet lines
|
||
markdown = _normalize_bullet_chars(markdown)
|
||
|
||
# Join paragraphs that were split by page breaks
|
||
markdown = _join_split_paragraphs(markdown)
|
||
|
||
# Convert inline subheadings to proper markdown headers
|
||
# Pattern: "Title . Body text" where Title starts the line and is followed by period-space
|
||
# These are common in SEC filings for subsection titles
|
||
markdown = _convert_inline_subheadings(markdown)
|
||
|
||
# Merge consecutive headers at the same level
|
||
# SEC filings often have split headers like:
|
||
# ### CONDENSED CONSOLIDATED STATEMENTS OF CHANGES IN
|
||
# ### STOCKHOLDERS EQUITY AND PARTNERS CAPITAL
|
||
# These should be merged into a single header
|
||
markdown = _merge_consecutive_headers(markdown)
|
||
|
||
# Separate footnote markers from text.
|
||
# SEC filings often have footnotes like "1Refer to Note 11..." or
|
||
# "2We calculated..." where the footnote number (from a <font> or
|
||
# <span> tag with small font-size, NOT a <sup> tag) is directly
|
||
# concatenated with the following text. Insert a space so it
|
||
# renders as "1 Refer to Note 11..." instead of "1Refer...".
|
||
# Only match 1-2 digit markers immediately followed by an uppercase
|
||
# letter at the start of a line (or after a double-newline paragraph
|
||
# break) to avoid splitting things like "3M" or "401k".
|
||
markdown = re.sub(
|
||
r"^(\d{1,2})([A-Z][a-z])",
|
||
r"\1 \2",
|
||
markdown,
|
||
flags=re.MULTILINE,
|
||
)
|
||
|
||
# Final whitespace cleanup - convert any remaining Windows-1252 characters
|
||
for win_char, unicode_char in WIN1252_MAP.items():
|
||
markdown = markdown.replace(win_char, unicode_char)
|
||
|
||
# Replace non-breaking spaces with regular spaces
|
||
markdown = markdown.replace("\xa0", " ")
|
||
# Collapse multiple consecutive spaces to single space
|
||
markdown = re.sub(r" {2,}", " ", markdown)
|
||
# Clean up trailing whitespace on lines
|
||
markdown = re.sub(r" +$", "", markdown, flags=re.MULTILINE)
|
||
# Clean up leading whitespace on lines (except for code blocks/lists)
|
||
# Only strip leading spaces that aren't part of markdown formatting
|
||
markdown = re.sub(r"^[ \t]+(?=[^\-\*\d\|#])", "", markdown, flags=re.MULTILINE)
|
||
# Collapse excessive blank lines (more than 2 newlines -> 2 newlines)
|
||
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
|
||
# Also collapse lines that are just whitespace
|
||
markdown = re.sub(r"\n[ \t]+\n", "\n\n", markdown)
|
||
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
|
||
markdown = markdown.strip()
|
||
|
||
return markdown
|
||
|
||
|
||
def _merge_consecutive_headers(markdown: str) -> str:
|
||
"""
|
||
Merge consecutive headers at the same level into a single header.
|
||
|
||
SEC filings often have titles split across multiple lines/elements:
|
||
### CONDENSED CONSOLIDATED STATEMENTS OF CHANGES IN
|
||
### STOCKHOLDERS EQUITY AND PARTNERS CAPITAL
|
||
**(UNAUDITED)**
|
||
|
||
This merges them into:
|
||
### CONDENSED CONSOLIDATED STATEMENTS OF CHANGES IN STOCKHOLDERS EQUITY AND PARTNERS CAPITAL
|
||
**(UNAUDITED)**
|
||
|
||
Only merges when the first header looks *incomplete* — i.e. its last
|
||
word is a preposition, conjunction, article, or determiner. Two
|
||
genuinely separate headers (e.g. a section title followed by a
|
||
subsection title) are left alone.
|
||
"""
|
||
# Words that signal the heading phrase is incomplete.
|
||
_CONTINUATION_ENDINGS = {
|
||
"in",
|
||
"of",
|
||
"and",
|
||
"the",
|
||
"for",
|
||
"to",
|
||
"with",
|
||
"on",
|
||
"a",
|
||
"an",
|
||
"by",
|
||
"at",
|
||
"as",
|
||
"or",
|
||
"nor",
|
||
"but",
|
||
"from",
|
||
"into",
|
||
"onto",
|
||
"upon",
|
||
"per",
|
||
"its",
|
||
"their",
|
||
"our",
|
||
"your",
|
||
"this",
|
||
"that",
|
||
"these",
|
||
"those",
|
||
"which",
|
||
"who",
|
||
"whom",
|
||
"whose",
|
||
}
|
||
|
||
def _looks_incomplete(text: str) -> bool:
|
||
"""Return True if *text* appears to be an incomplete title phrase."""
|
||
t = text.rstrip()
|
||
if not t:
|
||
return False # pragma: no cover - unreachable: callers always pass header_text from re.match(r"^(#{1,6})\s+(.+)$", stripped_line).group(2); the line is .strip()-ed so group(2) never ends in whitespace and t is never empty
|
||
# Ends with a continuation punctuation mark (comma, dash, colon)
|
||
if t[-1] in (",", "\u2013", "\u2014", "-"):
|
||
return True
|
||
last_word = t.split()[-1].lower().rstrip(".,;:")
|
||
return last_word in _CONTINUATION_ENDINGS
|
||
|
||
lines = markdown.split("\n")
|
||
result = []
|
||
i = 0
|
||
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
|
||
# Check if this is a header line
|
||
header_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
||
|
||
if header_match:
|
||
level = header_match.group(1) # e.g., "###"
|
||
header_text = header_match.group(2)
|
||
|
||
# Look ahead for consecutive headers at the same level
|
||
j = i + 1
|
||
while j < len(lines):
|
||
next_line = lines[j].strip()
|
||
|
||
# Skip empty lines between consecutive headers
|
||
if not next_line:
|
||
j += 1
|
||
continue
|
||
|
||
# Check if next non-empty line is same-level header
|
||
next_match = re.match(r"^(#{1,6})\s+(.+)$", next_line)
|
||
if (
|
||
next_match
|
||
and next_match.group(1) == level
|
||
and _looks_incomplete(header_text)
|
||
):
|
||
# Merge this header text — first header is an
|
||
# incomplete phrase that continues on the next line.
|
||
header_text = header_text.rstrip() + " " + next_match.group(2)
|
||
j += 1
|
||
elif (
|
||
next_match
|
||
and next_match.group(1) != level
|
||
and _looks_incomplete(header_text)
|
||
):
|
||
# Different-level header but first header is clearly
|
||
# incomplete — merge at the current (higher) level.
|
||
header_text = header_text.rstrip() + " " + next_match.group(2)
|
||
j += 1
|
||
else:
|
||
# Not a continuation — stop merging
|
||
break
|
||
|
||
# Output the merged header
|
||
result.append(f"{level} {header_text}")
|
||
i = j
|
||
else:
|
||
result.append(line)
|
||
i += 1
|
||
|
||
return "\n".join(result)
|
||
|
||
|
||
def _convert_inline_subheadings(markdown: str) -> str:
|
||
"""
|
||
Convert inline subheadings to proper markdown headers.
|
||
|
||
SEC filings often have subsection titles formatted as:
|
||
"Subsection Title . Body text continues here..."
|
||
|
||
This converts them to:
|
||
"#### Subsection Title
|
||
|
||
Body text continues here..."
|
||
"""
|
||
# Pattern matches: Start of line, capitalized title (2-8 words), period, space, body text
|
||
# Title must start with capital letter and contain 2-8 words
|
||
# Must have period followed by space and then more text (body)
|
||
pattern = (
|
||
r"^([A-Z][A-Za-z]+(?:\s+(?:and|of|on|the|for|in|to|a|an|with|as|or|by|at|from|"
|
||
r"under|into|through|after|before|between|during|without|within|about|against|"
|
||
r"among|around|behind|below|beyond|except|inside|outside|over|since|toward|until|"
|
||
r"upon|versus|via|[A-Z][A-Za-z\']+)){1,7})\s+\.\s+([A-Z].+)$"
|
||
)
|
||
|
||
def replace_subheading(match):
|
||
title = match.group(1).strip()
|
||
body = match.group(2).strip()
|
||
return f"#### {title}\n\n{body}"
|
||
|
||
# Process line by line since we need to match at start of line
|
||
lines = markdown.split("\n")
|
||
result = []
|
||
for line in lines:
|
||
# Only process non-table, non-header lines
|
||
stripped = line.strip()
|
||
if stripped and not stripped.startswith("|") and not stripped.startswith("#"):
|
||
new_line = re.sub(pattern, replace_subheading, line)
|
||
result.append(new_line)
|
||
else:
|
||
result.append(line)
|
||
|
||
return "\n".join(result)
|
||
|
||
|
||
# ============================================================================
|
||
# PAGE ELEMENT CLEANUP FUNCTIONS
|
||
# ============================================================================
|
||
|
||
|
||
def _normalize_bullet_chars(markdown: str) -> str:
|
||
"""Normalize Unicode bullet characters (•) into proper markdown bullet lines.
|
||
|
||
Position-based HTML layouts (Workiva) often produce lines like:
|
||
•item one; •item two; and •item three.
|
||
This splits them into separate markdown bullet lines:
|
||
- item one;
|
||
- item two; and
|
||
- item three.
|
||
|
||
Also handles lines that start with a single •.
|
||
"""
|
||
lines = markdown.split("\n")
|
||
result = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
# Skip lines without bullet chars
|
||
if "\u2022" not in stripped:
|
||
result.append(line)
|
||
continue
|
||
|
||
# Skip if inside a markdown table row
|
||
if stripped.startswith("|"):
|
||
result.append(line)
|
||
continue
|
||
|
||
# Split on • to get individual bullet items
|
||
# Handle leading text before first •
|
||
parts = re.split(r"\u2022", stripped)
|
||
|
||
# parts[0] is text before the first • (may be empty)
|
||
prefix_text = parts[0].strip()
|
||
if prefix_text:
|
||
result.append(prefix_text)
|
||
|
||
# Each subsequent part is a bullet item
|
||
for part in parts[1:]:
|
||
item_text = part.strip()
|
||
if item_text:
|
||
result.append(f"- {item_text}")
|
||
|
||
return "\n".join(result)
|
||
|
||
|
||
def _join_split_paragraphs(markdown: str) -> str:
|
||
"""
|
||
Join paragraphs that were split by page breaks in SEC filings.
|
||
|
||
Handles two patterns:
|
||
1. Hyphenated words split across pages: "pre-" ... "tax" → "pre-tax"
|
||
2. Sentences split at connecting words: "and" ... "acquisitions" → "and acquisitions"
|
||
|
||
Also removes page headers that interrupt paragraphs (e.g., company name, section titles).
|
||
"""
|
||
# First, remove common SEC filing page headers that interrupt text
|
||
# These are bold lines that repeat throughout the document
|
||
page_header_patterns = [
|
||
# Company name + section continuation markers
|
||
r"\*\*[A-Z][A-Z\s,\.&]+(?:INC\.|CORP\.|LLC|LP|L\.P\.)?\s+(?:and\s+)?(?:SUBSIDIARIES?)?\s*(?:NOTES?\s+TO\s+)?(?:CONDENSED\s+)?(?:CONSOLIDATED\s+)?(?:FINANCIAL\s+STATEMENTS?)?\s*(?:—|–|-)\s*\(Continued\)\s*(?:\(UNAUDITED\))?\*\*",
|
||
# Just "(UNAUDITED)" on its own line
|
||
r"^\*\*\(UNAUDITED\)\*\*$",
|
||
]
|
||
|
||
for pattern in page_header_patterns:
|
||
markdown = re.sub(pattern, "", markdown, flags=re.MULTILINE | re.IGNORECASE)
|
||
|
||
# Clean up any resulting excessive blank lines
|
||
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
|
||
|
||
lines = markdown.split("\n")
|
||
result = []
|
||
i = 0
|
||
|
||
# Patterns for structural lines that should never be joined
|
||
# Note: \d{1,2}\.\s matches numbered lists (1-99) but NOT years like "2024."
|
||
_STRUCTURAL_RE = re.compile(
|
||
r"^(?:#|\||- |\* |\d{1,2}\.\s|\(\d{1,2}\)|<img|<a\s|\[)"
|
||
)
|
||
# Connecting words that always indicate mid-sentence when at line end
|
||
_CONNECTING_RE = re.compile(
|
||
r"\b(and|or|the|a|an|of|to|in|for|with|by|from|at|on|as|that|which|but|"
|
||
r"is|are|was|were|be|been|being|have|has|had|not|its|their|our|this|"
|
||
r"these|those|into|upon|about|through|under|between|during|after|"
|
||
r"before|including|such)\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
def _is_joinable_line(s: str) -> bool:
|
||
"""Return True if stripped line *s* is eligible for joining."""
|
||
return (
|
||
bool(s)
|
||
and not _STRUCTURAL_RE.match(s)
|
||
and not _INLINE_HTML_RE.search(s)
|
||
and not _PAGE_HEADER_LINK_RE.match(s)
|
||
)
|
||
|
||
def _next_nonblank(start: int):
|
||
"""Return (index, stripped_text) of next non-blank line, or (start, None)."""
|
||
j = start
|
||
while j < len(lines) and not lines[j].strip():
|
||
j += 1
|
||
if j < len(lines):
|
||
return j, lines[j].strip()
|
||
return j, None
|
||
|
||
# Bullet prefixes that should participate in forward-joining
|
||
_BULLET_RE = re.compile(r"^(?:- |\* |\d+\.\s)")
|
||
# Non-bullet structural lines that should NEVER join
|
||
_HARD_STRUCTURAL_RE = re.compile(r"^(?:#|\||\<img|\<a\s|\<div\s|\*\*Legend:\*\*)")
|
||
# Lines containing inline HTML spans/divs are intentionally formatted;
|
||
# they should never be joined or consumed as continuation lines.
|
||
_INLINE_HTML_RE = re.compile(r"<(?:span|div)\s")
|
||
# Running page headers: "Section Title [Link](#anchor)" patterns
|
||
# that repeat on every page of the original filing.
|
||
_PAGE_HEADER_LINK_RE = re.compile(r"^[A-Z].*\[.*\]\(#[^)]+\)\s*$")
|
||
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
|
||
# Skip blank lines and hard-structural lines (headers, tables, images)
|
||
if (
|
||
not stripped
|
||
or _HARD_STRUCTURAL_RE.match(stripped)
|
||
or _INLINE_HTML_RE.search(stripped)
|
||
or _PAGE_HEADER_LINK_RE.match(stripped)
|
||
):
|
||
result.append(line)
|
||
i += 1
|
||
continue
|
||
|
||
# --- Iterative joining loop ---
|
||
is_bullet = bool(_BULLET_RE.match(stripped))
|
||
is_footnote = bool(re.match(r"^\(\d{1,2}\)", stripped))
|
||
joined_any = True
|
||
join_count = 0
|
||
|
||
while joined_any:
|
||
joined_any = False
|
||
|
||
# 1) Hyphenated word split: "pre-" ... "tax" → "pre-tax"
|
||
if line.rstrip().endswith("-") and not line.rstrip().endswith("--"):
|
||
j, nxt = _next_nonblank(i + 1)
|
||
|
||
if nxt and nxt[0].islower():
|
||
m = re.match(r"^(\S+)(.*)", nxt)
|
||
if m:
|
||
line = line.rstrip()[:-1] + m.group(1) + m.group(2)
|
||
i = j + 1
|
||
join_count += 1
|
||
joined_any = True
|
||
continue
|
||
|
||
cur_stripped = line.strip()
|
||
|
||
# 2) Line ends with a connecting word → ALWAYS continuation
|
||
# (regardless of next-line case)
|
||
if _CONNECTING_RE.search(line.rstrip()):
|
||
j, nxt = _next_nonblank(i + 1)
|
||
|
||
if nxt and _is_joinable_line(nxt):
|
||
line = line.rstrip() + " " + nxt
|
||
i = j + 1
|
||
join_count += 1
|
||
joined_any = True
|
||
continue
|
||
|
||
# 3) General mid-sentence: line does NOT end with sentence-terminal
|
||
# punctuation → join with next non-structural line.
|
||
# 3a) next starts lowercase → always join
|
||
# 3b) current line is >30 chars AND first join attempt → join
|
||
# (guards against runaway merging: titles/references that
|
||
# don't end with punctuation won't chain into the next
|
||
# paragraph after the first join)
|
||
# 3c) line ends with continuation punctuation (, ; &) → join
|
||
# Also handles quoted forms like ," or ;"
|
||
_end_stripped = cur_stripped.rstrip("\"\u201d\u2019\u2018'")
|
||
_has_cont_punct = _end_stripped.endswith((",", ";", "&"))
|
||
|
||
if (
|
||
not cur_stripped.endswith((".", "!", "?", ":", '"', "\u201d"))
|
||
or _has_cont_punct
|
||
):
|
||
j, nxt = _next_nonblank(i + 1)
|
||
|
||
if nxt and _is_joinable_line(nxt):
|
||
should_join = (
|
||
nxt[0].islower()
|
||
or (
|
||
len(cur_stripped) > 30
|
||
and join_count == 0
|
||
and not is_bullet
|
||
and not is_footnote
|
||
)
|
||
or _has_cont_punct
|
||
)
|
||
|
||
if should_join:
|
||
line = line.rstrip() + " " + nxt
|
||
i = j + 1
|
||
join_count += 1
|
||
joined_any = True
|
||
continue
|
||
|
||
# 4) Abbreviation / mid-sentence period: line ends with "." but
|
||
# next non-blank starts lowercase → definitely a continuation
|
||
# (handles "U.S." / "Inc." / "No." at visual line-wrap boundary)
|
||
if cur_stripped.endswith("."):
|
||
j, nxt = _next_nonblank(i + 1)
|
||
|
||
if nxt and _is_joinable_line(nxt) and nxt[0].islower():
|
||
line = line.rstrip() + " " + nxt
|
||
i = j + 1
|
||
join_count += 1
|
||
joined_any = True
|
||
continue
|
||
|
||
# 5) Unclosed quote: line contains an opening " (or ")
|
||
# that hasn't been closed yet → we're inside a quoted
|
||
# reference (e.g. refer to "Part II, Item 7.)
|
||
# The period/punctuation is NOT sentence-terminal.
|
||
_quote_chars = cur_stripped.count('"') + cur_stripped.count("\u201c")
|
||
_close_chars = cur_stripped.count("\u201d")
|
||
# For straight quotes, odd count means unclosed; for smart
|
||
# quotes, more openers than closers means unclosed.
|
||
_has_unclosed = (cur_stripped.count("\u201c") > _close_chars) or (
|
||
cur_stripped.count('"') % 2 == 1
|
||
and "\u201c" not in cur_stripped
|
||
and "\u201d" not in cur_stripped
|
||
)
|
||
|
||
if _has_unclosed:
|
||
j, nxt = _next_nonblank(i + 1)
|
||
|
||
if nxt and _is_joinable_line(nxt):
|
||
line = line.rstrip() + " " + nxt
|
||
i = j + 1
|
||
join_count += 1
|
||
joined_any = True
|
||
continue
|
||
|
||
result.append(line)
|
||
i += 1
|
||
|
||
return "\n".join(result)
|
||
|
||
|
||
def _remove_repeated_page_elements(markdown: str) -> str:
|
||
"""
|
||
Remove repeated page footer/header patterns from markdown.
|
||
|
||
Detects short text patterns that appear many times (5+) in the document,
|
||
which are likely page headers or footers that got repeated during conversion.
|
||
|
||
Examples of patterns this catches:
|
||
- "- 32 2025 Annual Meeting of Stockholders"
|
||
- "Company Name | Page 5"
|
||
- "CONFIDENTIAL"
|
||
"""
|
||
lines = markdown.split("\n")
|
||
|
||
# Find candidate repeated lines (short lines that appear multiple times)
|
||
# Normalize lines by stripping and replacing page numbers with placeholder
|
||
def normalize_line(line: str) -> str:
|
||
"""Normalize a line for comparison by replacing variable parts."""
|
||
normalized = line.strip()
|
||
# Replace page numbers (standalone digits) with placeholder
|
||
normalized = re.sub(r"\b\d{1,3}\b", "#", normalized)
|
||
# Replace markdown image URLs with placeholder, but keep the image marker
|
||
# so we can distinguish "text with image" from "standalone image"
|
||
normalized = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"![IMG]", normalized)
|
||
# Also replace HTML <img> tags with placeholder
|
||
normalized = re.sub(r"<img[^>]+>", "[IMG]", normalized)
|
||
return normalized
|
||
|
||
# Count normalized patterns
|
||
line_counts: Counter = Counter()
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
normalized = normalize_line(stripped)
|
||
# Only consider lines that normalize to short patterns (< 80 chars after normalization)
|
||
# that aren't empty, headers, table rows, or STANDALONE images
|
||
# Standalone images like  or <img ...> should NOT be removed
|
||
if (
|
||
normalized
|
||
and len(normalized) < 80
|
||
and not stripped.startswith("#") # Not a header
|
||
and not stripped.startswith("|") # Not a table row
|
||
and not stripped.startswith("[") # Not a link
|
||
and not re.match(r"^!\[", stripped) # Not a standalone markdown image
|
||
and not re.match(r"^<img\s", stripped) # Not a standalone HTML image
|
||
and not re.match(r"^<div[\s>]", stripped) # Not an HTML div block
|
||
and stripped != "---"
|
||
): # Not a horizontal rule
|
||
line_counts[normalized] += 1
|
||
|
||
# Find patterns that repeat 5+ times (likely page footers/headers)
|
||
repeated_patterns = {
|
||
pattern for pattern, count in line_counts.items() if count >= 5
|
||
}
|
||
|
||
if not repeated_patterns:
|
||
return markdown
|
||
|
||
# Filter out the repeated lines
|
||
filtered_lines = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
normalized = normalize_line(stripped)
|
||
if normalized in repeated_patterns:
|
||
# Skip this repeated page element
|
||
continue
|
||
filtered_lines.append(line)
|
||
|
||
return "\n".join(filtered_lines)
|
||
|
||
|
||
# ============================================================================
|
||
# TABLE HELPER FUNCTIONS
|
||
# ============================================================================
|
||
|
||
|
||
def _classify_table(table_elem) -> str:
|
||
"""
|
||
Unified table classifier. Returns one of:
|
||
- "BULLET": Table with bullet characters → extract as bullet list
|
||
- "FOOTNOTE": Table with footnote markers (*, (1), (a)) → extract as text
|
||
- "HEADER": Single-cell section header → extract as markdown header
|
||
- "DATA": Everything else → render as markdown table (DEFAULT)
|
||
|
||
The key principle: be CONSERVATIVE about extracting as text.
|
||
When in doubt, render as a table. Tables are data; don't lose structure.
|
||
"""
|
||
rows = table_elem.find_all("tr")
|
||
if not rows:
|
||
return "DATA"
|
||
|
||
# Collect info about the table structure
|
||
non_empty_rows = []
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
cell_texts = [c.get_text(strip=True) for c in cells]
|
||
non_empty_texts = [t for t in cell_texts if t]
|
||
if non_empty_texts:
|
||
non_empty_rows.append((cells, non_empty_texts))
|
||
|
||
if not non_empty_rows:
|
||
return "DATA"
|
||
|
||
# Check for bullet list (actual bullet characters, not asterisk which is a footnote marker)
|
||
bullet_rows = 0
|
||
for cells, texts in non_empty_rows:
|
||
for text in texts[:2]:
|
||
if text in BULLET_CHARS:
|
||
bullet_rows += 1
|
||
break
|
||
|
||
if bullet_rows >= 1 and bullet_rows >= len(non_empty_rows) * 0.5:
|
||
return "BULLET"
|
||
|
||
# ===== CHECK FOR FOOTNOTE TABLE =====
|
||
# Table where first non-empty cell is a footnote marker
|
||
# Markers: *, **, †, ‡, (1), (2), (a), (b), etc.
|
||
footnote_pattern = re.compile(r"^[\*†‡§¶]+$|^\(\d+\)$|^\([a-z]\)$|^\d+[\.\)]$")
|
||
|
||
# Count rows that look like footnotes
|
||
footnote_rows = 0
|
||
for cells, texts in non_empty_rows:
|
||
if len(texts) >= 2:
|
||
first_text = texts[0]
|
||
if footnote_pattern.match(first_text):
|
||
footnote_rows += 1
|
||
|
||
# If ALL multi-column rows are footnote-style, treat as footnote table.
|
||
# Single-column rows are neutral continuation text (e.g. "The following
|
||
# table sets forth the reconciliation...") and should not disqualify the
|
||
# table from FOOTNOTE classification.
|
||
candidate_rows = sum(1 for _, texts in non_empty_rows if len(texts) >= 2)
|
||
if candidate_rows >= 1 and footnote_rows == candidate_rows:
|
||
return "FOOTNOTE"
|
||
|
||
# ===== CHECK FOR SECTION HEADER =====
|
||
# Single row, single non-empty cell, typically bold or has id attribute
|
||
if len(non_empty_rows) == 1:
|
||
cells, texts = non_empty_rows[0]
|
||
if len(texts) == 1:
|
||
text = texts[0]
|
||
cell = None
|
||
for c in cells:
|
||
if c.get_text(strip=True) == text:
|
||
cell = c
|
||
break
|
||
|
||
if cell and len(text) < 100:
|
||
# Check for header indicators
|
||
has_toc_id = any(c.get("id", "").startswith("toc") for c in cells)
|
||
is_bold = cell.find(["b", "strong"]) is not None
|
||
# Also check for font-weight:bold in CSS
|
||
if not is_bold:
|
||
is_bold = (
|
||
cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
is not None
|
||
)
|
||
# If it looks like a section header, extract as header
|
||
if has_toc_id or (is_bold and len(text) < 60):
|
||
return "HEADER"
|
||
|
||
# ===== DEFAULT: DATA TABLE =====
|
||
return "DATA"
|
||
|
||
|
||
def _extract_bullet_list(table_elem) -> str | None:
|
||
"""Extract bullet list from table format.
|
||
|
||
Note: * is NOT included as it's typically a footnote marker, not a bullet.
|
||
|
||
Special handling: If the content cell has a bold header at the beginning,
|
||
format as a subsection header (#### Header) followed by the description.
|
||
"""
|
||
bullets = []
|
||
rows = table_elem.find_all("tr")
|
||
|
||
for row in rows:
|
||
cells = row.find_all("td")
|
||
if len(cells) >= 2:
|
||
bullet_col = -1
|
||
for idx, cell in enumerate(cells[:2]):
|
||
if cell.get_text(strip=True) in BULLET_CHARS:
|
||
bullet_col = idx
|
||
break
|
||
|
||
if bullet_col >= 0:
|
||
content_cell = (
|
||
cells[bullet_col + 1] if bullet_col + 1 < len(cells) else None
|
||
)
|
||
if content_cell:
|
||
# Check if this cell starts with bold text (subsection header pattern)
|
||
bold_elem = content_cell.find(["b", "strong"])
|
||
if not bold_elem:
|
||
# Check for font-weight:bold in nested divs
|
||
bold_elem = content_cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
|
||
if bold_elem:
|
||
# Use separator=" " to preserve spacing between inline elements
|
||
bold_text = bold_elem.get_text(separator=" ").strip()
|
||
bold_text = re.sub(
|
||
r"\s+", " ", bold_text
|
||
) # Normalize whitespace
|
||
full_text = content_cell.get_text(separator=" ", strip=True)
|
||
full_text = _clean_html_entities(full_text)
|
||
full_text = re.sub(r"\s+", " ", full_text).strip()
|
||
|
||
# If bold text is at the start and there's more content after
|
||
if (
|
||
full_text.startswith(bold_text)
|
||
and len(full_text) > len(bold_text) + 5
|
||
):
|
||
# This is a subsection header: "**Header**. Description..."
|
||
rest = full_text[len(bold_text) :].strip()
|
||
if rest.startswith("."):
|
||
rest = rest[1:].strip()
|
||
bullets.append(f"**{bold_text}**. {rest}")
|
||
else:
|
||
# Just bold text with no description
|
||
bullets.append(f"**{bold_text}**")
|
||
else:
|
||
# Regular bullet item
|
||
text = " ".join(
|
||
c.get_text(separator=" ", strip=True)
|
||
for c in cells[bullet_col + 1 :]
|
||
)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text and len(text) > 3:
|
||
bullets.append(f"- {text}")
|
||
elif len(cells) == 1:
|
||
text = cells[0].get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text and text[0] in BULLET_CHARS:
|
||
text = text[1:].strip()
|
||
if text:
|
||
bullets.append(f"- {text}")
|
||
|
||
return "\n\n".join(bullets) if bullets else None
|
||
|
||
|
||
def _extract_footnote_text(table_elem) -> str | None:
|
||
"""Extract footnote table as 'marker text' format."""
|
||
parts = []
|
||
rows = table_elem.find_all("tr")
|
||
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
texts = []
|
||
for cell in cells:
|
||
text = cell.get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text:
|
||
texts.append(text)
|
||
|
||
if len(texts) >= 2:
|
||
# Join marker with its text
|
||
parts.append(" ".join(texts))
|
||
elif len(texts) == 1:
|
||
parts.append(texts[0])
|
||
|
||
return "\n\n".join(parts) if parts else None
|
||
|
||
|
||
def _convert_layout_table(table_elem, base_url: str = "") -> str | None:
|
||
"""Convert layout tables with multi-line content cells to formatted sections.
|
||
|
||
Detects tables where cells contain multiple block elements (like bullet lists
|
||
of checkmark items) and converts them to section headers with bullet lists
|
||
instead of trying to force them into markdown table format.
|
||
|
||
Handles three patterns:
|
||
1. Single-column tables with cells containing multiple divs (checkmark lists)
|
||
2. Multi-column tables where one column has headers and another has bullet items (same row)
|
||
3. Tables with alternating header rows and bullet rows
|
||
"""
|
||
rows = table_elem.find_all("tr")
|
||
|
||
if not rows:
|
||
return None
|
||
|
||
# Check if this is a multi-column data table with a header row (not a layout table)
|
||
# Look for a row that has 3+ cells with bold text (indicating a header row)
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
bold_content_cells = 0
|
||
for cell in cells:
|
||
text = cell.get_text(strip=True)
|
||
if text and text not in ["\xa0", " ", ""]:
|
||
# Check if this cell has bold text
|
||
bold = cell.find(["b", "strong"]) or cell.find(
|
||
style=re.compile(
|
||
r"font-weight:\s*(?:bold|bolder|[6-9]00)",
|
||
re.IGNORECASE,
|
||
)
|
||
)
|
||
if bold:
|
||
bold_content_cells += 1
|
||
# If this row has 3+ bold content cells, it's a real data table header
|
||
if bold_content_cells >= 3:
|
||
return None # Let regular table processing handle this
|
||
|
||
# Helper to extract bullet items from a cell with embedded bullets
|
||
def extract_bullet_items(cell):
|
||
"""Extract bullet items from a cell containing divs with bullets at start."""
|
||
items = []
|
||
divs = cell.find_all("div", recursive=True)
|
||
for div in divs:
|
||
# Skip empty divs
|
||
div_text = div.get_text(strip=True)
|
||
if not div_text or div_text in ["\xa0", " ", ""]:
|
||
continue
|
||
# Skip divs that are purely containers of other divs
|
||
if div.find("div") and len(div.find_all(string=True, recursive=False)) == 0:
|
||
continue
|
||
|
||
# Check if this div starts with a bullet character
|
||
first_char = div_text[0] if div_text else ""
|
||
if first_char in BULLET_CHARS:
|
||
# Convert images in this div
|
||
for img in div.find_all("img"):
|
||
img.replace_with(_convert_image_to_html(img, base_url))
|
||
|
||
text = div.get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
# Remove leading bullet
|
||
if text and text[0] in BULLET_CHARS:
|
||
text = text[1:].strip()
|
||
if text:
|
||
items.append(text)
|
||
return items
|
||
|
||
# Helper to extract header text from a cell
|
||
def extract_header_text(cell):
|
||
"""Extract header text and anchor ID, handling multi-line name cells.
|
||
|
||
Returns tuple of (anchor_id, text) where anchor_id may be None.
|
||
"""
|
||
lines = []
|
||
anchor_id = cell.get("id")
|
||
|
||
# Also check nested divs for anchor ID
|
||
if not anchor_id:
|
||
for div in cell.find_all("div"):
|
||
div_id = div.get("id")
|
||
if div_id:
|
||
anchor_id = div_id
|
||
break
|
||
|
||
# First try to get text from bold elements
|
||
for bold in cell.find_all(["b", "strong"]):
|
||
text = bold.get_text(separator=" ", strip=True)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text and text not in ["\xa0", " "]:
|
||
lines.append(text)
|
||
|
||
# Also check for font-weight:bold in style
|
||
if not lines:
|
||
bold_styled = cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
if bold_styled:
|
||
text = bold_styled.get_text(separator=" ", strip=True)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text and text not in ["\xa0", " "]:
|
||
lines.append(text)
|
||
|
||
# If no bold elements, try divs
|
||
if not lines:
|
||
for div in cell.find_all("div", recursive=False):
|
||
text = div.get_text(separator=" ", strip=True)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text and text not in ["\xa0", " "]:
|
||
lines.append(text)
|
||
|
||
text = (
|
||
" ".join(lines)
|
||
if lines
|
||
else cell.get_text(separator=" ", strip=True).strip()
|
||
)
|
||
return (anchor_id, text)
|
||
|
||
# Helper to check if a row is a header-only row (bold text, no bullets)
|
||
def is_header_row(row):
|
||
"""Check if this row contains only header text (bold, no bullets)."""
|
||
cells = row.find_all(["td", "th"])
|
||
row_text = ""
|
||
has_bold = False
|
||
has_bullets = False
|
||
|
||
for cell in cells:
|
||
text = cell.get_text(strip=True)
|
||
if text and text not in ["\xa0", " "]:
|
||
row_text += text
|
||
# Check for bold
|
||
if cell.find(["b", "strong"]) or cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
):
|
||
has_bold = True
|
||
# Check for bullets
|
||
if extract_bullet_items(
|
||
cell
|
||
): # pragma: no cover - unreachable: this is_header_row helper is only called from the PATTERN 3 branch, which is reached only when extract_bullet_items already returned empty for these same cells, so the guard is always false here
|
||
has_bullets = True
|
||
|
||
# It's a header row if it has bold text, no bullets, and reasonable length
|
||
return has_bold and not has_bullets and row_text and len(row_text) < 150
|
||
|
||
# ===== PATTERN 0: Bio-style tables with rowspan paragraph cells =====
|
||
# Detect tables that have cells with rowspan containing paragraph content
|
||
# (e.g., director biographies in proxy statements)
|
||
bio_sections: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
for cell in cells:
|
||
rowspan = int(cell.get("rowspan", 1) or 1)
|
||
if rowspan > 1:
|
||
# This cell spans multiple rows - check if it has paragraph content
|
||
cell_text = cell.get_text(strip=True)
|
||
# Bio cells typically have 200+ characters of paragraph text
|
||
if len(cell_text) > 200:
|
||
# Found a potential bio cell - extract header from same or nearby rows
|
||
# Look for a bold name cell in an earlier row of this table
|
||
name_text = None
|
||
|
||
# Search backward through rows for header
|
||
row_idx = rows.index(row)
|
||
for prev_idx in range(row_idx - 1, -1, -1):
|
||
prev_row = rows[prev_idx]
|
||
prev_cells = prev_row.find_all(["td", "th"])
|
||
for prev_cell in prev_cells:
|
||
prev_text = prev_cell.get_text(strip=True)
|
||
if not prev_text or prev_text in ["\xa0", " "]:
|
||
continue
|
||
# Check for bold name
|
||
bold = prev_cell.find(["b", "strong"]) or prev_cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
# A name is typically short, bold, and all caps or title case
|
||
if bold and len(prev_text) < 50 and prev_text.isupper():
|
||
name_text = prev_text
|
||
break
|
||
if name_text:
|
||
break
|
||
|
||
# Also extract metadata from the same row (title, tenure, age, etc.)
|
||
metadata_items = []
|
||
seen_metadata = set()
|
||
for other_cell in cells:
|
||
if other_cell == cell:
|
||
continue
|
||
other_text = other_cell.get_text(strip=True)
|
||
if not other_text or other_text in ["\xa0", " "]:
|
||
continue
|
||
# Skip if too long (probably another bio)
|
||
if len(other_text) > 150:
|
||
continue
|
||
# Extract items from divs (direct children only)
|
||
for div in other_cell.find_all("div", recursive=False):
|
||
div_text = div.get_text(strip=True)
|
||
div_text = _clean_html_entities(div_text)
|
||
div_text = re.sub(r"\s+", " ", div_text).strip()
|
||
if (
|
||
div_text
|
||
and div_text not in ["\xa0", " ", ""]
|
||
and div_text not in seen_metadata
|
||
):
|
||
metadata_items.append(div_text)
|
||
seen_metadata.add(div_text)
|
||
|
||
# Extract bio paragraphs from the cell
|
||
# Only get top-level divs to avoid duplicates from nested divs
|
||
bio_paragraphs = []
|
||
seen_texts = set()
|
||
for div in cell.find_all("div", recursive=False):
|
||
div_text = div.get_text(strip=True)
|
||
div_text = _clean_html_entities(div_text)
|
||
div_text = re.sub(r"\s+", " ", div_text).strip()
|
||
if (
|
||
div_text
|
||
and div_text not in ["\xa0", " ", ""]
|
||
and len(div_text) > 20
|
||
and div_text not in seen_texts
|
||
):
|
||
bio_paragraphs.append(div_text)
|
||
seen_texts.add(div_text)
|
||
|
||
if bio_paragraphs:
|
||
bio_sections.append(
|
||
{
|
||
"name": name_text,
|
||
"metadata": metadata_items,
|
||
"paragraphs": bio_paragraphs,
|
||
}
|
||
)
|
||
|
||
# If we found bio sections, format them
|
||
if bio_sections:
|
||
result_parts = []
|
||
for section in bio_sections:
|
||
if section["name"]:
|
||
result_parts.append(f"\n**{section['name']}**\n")
|
||
for item in section.get("metadata", []):
|
||
result_parts.append(f"- {item}")
|
||
if section["metadata"]:
|
||
result_parts.append("") # Blank line after metadata
|
||
for para in section.get("paragraphs", []):
|
||
result_parts.append(para)
|
||
result_parts.append("") # Blank line after each paragraph
|
||
if result_parts:
|
||
return "\n".join(result_parts)
|
||
|
||
# ===== PATTERN 2: Multi-column table with headers and bullet content in SAME row =====
|
||
# Check if this is a table with header column + bullet column
|
||
result_sections = []
|
||
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
if not cells:
|
||
continue
|
||
|
||
# Find cells with content
|
||
header_cell = None
|
||
bullet_cell = None
|
||
|
||
for cell in cells:
|
||
cell_text = cell.get_text(strip=True)
|
||
if not cell_text or cell_text in ["\xa0", " "]:
|
||
continue
|
||
|
||
# Check if this cell contains bullet items
|
||
bullet_items = extract_bullet_items(cell)
|
||
if bullet_items:
|
||
bullet_cell = (cell, bullet_items)
|
||
else:
|
||
# Check if this looks like a header cell (bold, short text)
|
||
bold = cell.find(["b", "strong"]) or cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
if bold and len(cell_text) < 200:
|
||
header_cell = cell
|
||
|
||
if bullet_cell:
|
||
header_anchor = None
|
||
header_text = ""
|
||
if header_cell:
|
||
header_anchor, header_text = extract_header_text(header_cell)
|
||
|
||
result_sections.append((header_anchor, header_text, bullet_cell[1]))
|
||
|
||
# If we found header+bullet sections (same row), format them
|
||
# Only use this pattern if we found headers for at least some rows
|
||
if result_sections:
|
||
has_headers = any(h for _, h, _ in result_sections)
|
||
if has_headers:
|
||
result_parts = []
|
||
for header_anchor, header_text, bullet_items in result_sections:
|
||
if header_text:
|
||
if header_anchor:
|
||
result_parts.append(
|
||
f'\n<a id="{header_anchor}"></a>**{header_text}**\n'
|
||
)
|
||
else:
|
||
result_parts.append(f"\n**{header_text}**\n")
|
||
for item in bullet_items:
|
||
result_parts.append(f"- {item}")
|
||
result_parts.append("")
|
||
|
||
if result_parts:
|
||
return "\n".join(result_parts)
|
||
|
||
# ===== PATTERN 3: Alternating header rows and bullet rows =====
|
||
# Process rows in sequence, building sections
|
||
sections = []
|
||
current_header = ""
|
||
current_anchor = None
|
||
current_bullets = []
|
||
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
if not cells:
|
||
continue
|
||
|
||
row_text = "".join(c.get_text(strip=True) for c in cells)
|
||
if not row_text or row_text in ["\xa0", " "]:
|
||
continue
|
||
|
||
# Collect all bullet items from this row
|
||
row_bullets = []
|
||
for cell in cells:
|
||
row_bullets.extend(extract_bullet_items(cell))
|
||
|
||
if row_bullets:
|
||
# This is a bullet row
|
||
current_bullets.extend(row_bullets)
|
||
elif is_header_row(row):
|
||
# This is a header row
|
||
# Save previous section if exists
|
||
if current_bullets:
|
||
sections.append((current_anchor, current_header, current_bullets))
|
||
current_bullets = []
|
||
|
||
# Get header text and anchor from this row
|
||
header_parts = []
|
||
current_anchor = None
|
||
for cell in cells:
|
||
anchor_id, text = extract_header_text(cell)
|
||
if anchor_id and not current_anchor:
|
||
current_anchor = anchor_id
|
||
if text and text not in ["\xa0", " "]:
|
||
header_parts.append(text)
|
||
current_header = " ".join(header_parts)
|
||
|
||
# Don't forget the last section
|
||
if current_bullets:
|
||
sections.append((current_anchor, current_header, current_bullets))
|
||
|
||
# If we found alternating sections, format them
|
||
if sections and len(sections) >= 1:
|
||
# Verify this looks like a structured document (not just random bullets)
|
||
total_bullets = sum(len(s[2]) for s in sections)
|
||
if total_bullets >= 3:
|
||
result_parts = []
|
||
for header_anchor, header_text, bullet_items in sections:
|
||
if header_text:
|
||
if header_anchor:
|
||
result_parts.append(
|
||
f'\n<a id="{header_anchor}"></a>**{header_text}**\n'
|
||
)
|
||
else:
|
||
result_parts.append(f"\n**{header_text}**\n")
|
||
for item in bullet_items:
|
||
result_parts.append(f"- {item}")
|
||
result_parts.append("")
|
||
|
||
if result_parts:
|
||
return "\n".join(result_parts)
|
||
|
||
# ===== PATTERN 1: Single column with multi-div cells (checkmark lists) =====
|
||
# First, find the header row (if any) and content rows
|
||
header_cells: list = []
|
||
content_cells: list = []
|
||
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
|
||
# Check if this row looks like a header row
|
||
is_header = False
|
||
if row.find("th"):
|
||
is_header = True
|
||
else:
|
||
# Check if cells have bold text and minimal content (header-like)
|
||
for cell in cells:
|
||
text = cell.get_text(strip=True)
|
||
if text and len(text) < 80:
|
||
bold = cell.find(["b", "strong"]) or cell.find(
|
||
style=re.compile(r"font-weight:\s*bold", re.IGNORECASE)
|
||
)
|
||
if bold:
|
||
is_header = True
|
||
break
|
||
|
||
if is_header and not header_cells:
|
||
for cell in cells:
|
||
text = cell.get_text(strip=True)
|
||
if text and text not in ["\xa0", " "]:
|
||
header_cells.append(text)
|
||
else:
|
||
# This is a content row - check for multi-line cells
|
||
for cell in cells:
|
||
# Count meaningful divs in this cell
|
||
content_divs = []
|
||
for div in cell.find_all("div", recursive=False):
|
||
div_text = div.get_text(strip=True)
|
||
if div_text and div_text not in ["\xa0", " ", ""]:
|
||
content_divs.append(div)
|
||
|
||
if len(content_divs) >= 3:
|
||
# This cell has multiple items - extract them
|
||
items = []
|
||
for div in content_divs:
|
||
# Convert images in this div
|
||
for img in div.find_all("img"):
|
||
img.replace_with(_convert_image_to_html(img, base_url))
|
||
|
||
text = div.get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text:
|
||
items.append(text)
|
||
|
||
if items:
|
||
content_cells.append(items)
|
||
|
||
# If we didn't find any multi-line content cells, this isn't a layout table
|
||
if not content_cells:
|
||
return None
|
||
|
||
# Build the output: pair headers with their content lists
|
||
result_parts = []
|
||
|
||
for i, items in enumerate(content_cells):
|
||
if i < len(header_cells):
|
||
# Add header
|
||
result_parts.append(f"\n**{header_cells[i]}**\n")
|
||
|
||
# Add items as bullet list
|
||
for item in items:
|
||
result_parts.append(f"- {item}")
|
||
result_parts.append("") # Blank line after each section
|
||
|
||
if result_parts:
|
||
return "\n".join(result_parts)
|
||
|
||
return None # pragma: no cover - unreachable: reaching here requires content_cells non-empty (guarded above), and the loop above always appends at least the per-section blank line, so result_parts is always truthy and the function returns above
|
||
|
||
|
||
def _extract_header_text(table_elem) -> str | None:
|
||
"""Extract single-cell header table as markdown header, preserving anchors."""
|
||
rows = table_elem.find_all("tr")
|
||
for row in rows:
|
||
cells = row.find_all(["td", "th"])
|
||
for cell in cells:
|
||
text = cell.get_text(strip=True)
|
||
if text:
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
|
||
# Look for anchor id on the cell or nested divs
|
||
anchor_id = cell.get("id")
|
||
if not anchor_id:
|
||
# Check nested divs for id attribute
|
||
for div in cell.find_all("div"):
|
||
div_id = div.get("id")
|
||
if div_id:
|
||
anchor_id = div_id
|
||
break
|
||
|
||
# Return as bold header with anchor if present
|
||
if anchor_id:
|
||
return f'\n<a id="{anchor_id}"></a>**{text}**\n'
|
||
return f"\n**{text}**\n"
|
||
return None
|
||
|
||
|
||
def _extract_cell_text(
|
||
cell, base_url: str = "", preserve_line_breaks: bool = False
|
||
) -> str:
|
||
"""Extract text from a table cell, preserving links and images as markdown.
|
||
|
||
If preserve_line_breaks is True, detects cells with multiple block elements
|
||
(like divs with checkmark images) and preserves them as separate lines.
|
||
|
||
Always preserves <br> tags within cells as HTML line breaks.
|
||
"""
|
||
# Check if this cell contains ONLY a footnote reference (sup tag with a number)
|
||
# These appear in SEC tables as separate cells and should be merged with the label
|
||
sup_tags = cell.find_all("sup")
|
||
if sup_tags:
|
||
# Get all text content excluding sup tags and invisible content
|
||
non_sup_text = ""
|
||
for elem in cell.descendants:
|
||
if isinstance(elem, NavigableString):
|
||
parent = elem.parent
|
||
# Skip text inside sup tags
|
||
is_in_sup = False
|
||
while parent:
|
||
if parent.name == "sup":
|
||
is_in_sup = True
|
||
break
|
||
parent = parent.parent
|
||
if not is_in_sup:
|
||
# Not inside a sup tag - check if it's visible text
|
||
text = str(elem).strip()
|
||
# Skip hidden/invisible content (zero-width spaces, nbsp, etc)
|
||
if text and text not in ["\u200b", "\xa0", " ", ""]:
|
||
non_sup_text += text
|
||
|
||
non_sup_text = non_sup_text.strip()
|
||
if not non_sup_text:
|
||
# Cell only has sup content - check if it's a footnote reference (small number)
|
||
sup_text = " ".join(sup.get_text(strip=True) for sup in sup_tags)
|
||
# Footnote references are typically 1-3 digit numbers
|
||
if re.match(r"^\s*\d{1,3}\s*$", sup_text):
|
||
# Return as superscript HTML - will be merged with previous cell later
|
||
return f"<sup>{sup_text.strip()}</sup>"
|
||
|
||
# Work on a copy so we don't mutate the original
|
||
cell_copy = copy(cell)
|
||
|
||
# Convert BR tags to a space so they don't leak as literal "<br>" into markdown.
|
||
# In preserve_line_breaks mode keep a newline marker instead.
|
||
for br in cell_copy.find_all("br"):
|
||
br.replace_with(" " if not preserve_line_breaks else "\n")
|
||
|
||
# Check if this cell has multiple block-level divs with content (like bullet lists)
|
||
# This detects layout cells that should NOT be flattened
|
||
content_divs = []
|
||
for div in cell_copy.find_all("div", recursive=False):
|
||
div_text = div.get_text(strip=True)
|
||
# Skip empty divs and spacer divs (just nbsp)
|
||
if div_text and div_text not in ["\xa0", " ", ""]:
|
||
content_divs.append(div)
|
||
|
||
# If cell has 3+ content divs, treat as multi-line content
|
||
if preserve_line_breaks and len(content_divs) >= 3:
|
||
lines = []
|
||
for div in content_divs:
|
||
# Convert images in this div
|
||
for img in div.find_all("img"):
|
||
img.replace_with(_convert_image_to_html(img, base_url))
|
||
# Convert links in this div
|
||
for a in div.find_all("a"):
|
||
href = a.get("href", "")
|
||
link_text = a.get_text(strip=True)
|
||
if href and link_text:
|
||
if base_url and not href.startswith(("#", "http://", "https://")):
|
||
href = urljoin(base_url, href)
|
||
a.replace_with(f"[{link_text}]({href})")
|
||
elif link_text:
|
||
a.replace_with(link_text)
|
||
|
||
text = div.get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if text:
|
||
lines.append(text)
|
||
|
||
if lines:
|
||
# Return with special marker for multi-line cells
|
||
return "\n".join(lines)
|
||
|
||
# Standard single-line extraction
|
||
# Convert links to markdown format
|
||
for a in cell_copy.find_all("a"):
|
||
href = a.get("href", "")
|
||
link_text = a.get_text(strip=True)
|
||
if href and link_text:
|
||
if base_url and not href.startswith(("#", "http://", "https://")):
|
||
href = urljoin(base_url, href)
|
||
a.replace_with(f"[{link_text}]({href})")
|
||
elif link_text:
|
||
a.replace_with(link_text)
|
||
|
||
# Strip images from table cells — SEC filings use <img> for decorative
|
||
# elements (brackets, braces, spacers) that have no textual meaning.
|
||
# Use the alt attribute only if it carries actual content (not the generic
|
||
# "Image" placeholder that BeautifulSoup/browsers supply by default).
|
||
for img in cell_copy.find_all("img"):
|
||
alt = img.get("alt", "").strip()
|
||
img.replace_with(alt if alt and alt.lower() not in ("image", "img", "") else "")
|
||
|
||
# Get text with space separator
|
||
text = cell_copy.get_text(separator=" ", strip=True)
|
||
text = _clean_html_entities(text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
# Common word-fragment suffixes that indicate a broken word, not a compound
|
||
_WORD_SUFFIXES = r"MENTS|MENT|TIONS|ATED|TING|NESS|ABLE|IBLE"
|
||
# Rejoin words broken by line wrapping in narrow HTML columns
|
||
# Pattern 1: "ADJUST- MENTS" → "ADJUSTMENTS" (soft hyphen, no <br>)
|
||
text = re.sub(r"(\w)- (?!<br>)(\w)", r"\1\2", text)
|
||
# Pattern 2a: "ADJUST- <br> MENTS" → "ADJUSTMENTS" (word-break hyphen + br)
|
||
# When the continuation is a word fragment (suffix), remove the hyphen
|
||
text = re.sub(rf"(\w)- (?:<br> ?)+({_WORD_SUFFIXES})\b", r"\1\2", text)
|
||
# Pattern 2b: "PERCENTAGE- <br> POINT" → "PERCENTAGE-POINT" (compound + br)
|
||
# When the continuation is a full word, keep the hyphen
|
||
text = re.sub(r"(\w)- (?:<br> ?)+(\w)", r"\1-\2", text)
|
||
# Pattern 3: "ADJUST MENTS" → "ADJUSTMENTS" (space from word-wrap in source)
|
||
text = re.sub(
|
||
rf"\b([A-Z]{{2,}}) ({_WORD_SUFFIXES})\b",
|
||
r"\1\2",
|
||
text,
|
||
)
|
||
# Normalize <br> spacing: " <br> " → "<br>"
|
||
text = re.sub(r"\s*<br>\s*", "<br>", text)
|
||
return text
|
||
|
||
|
||
def _clean_html_entities(text: str) -> str:
|
||
"""Clean common HTML entities."""
|
||
replacements = {
|
||
" ": " ",
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
""": '"',
|
||
"'": "'",
|
||
"'": "'",
|
||
"•": "•",
|
||
" ": " ",
|
||
"\xa0": " ",
|
||
"\u200b": "",
|
||
"Ø": "•",
|
||
}
|
||
|
||
for entity, replacement in replacements.items():
|
||
text = text.replace(entity, replacement)
|
||
|
||
return text
|