unstructured
804e8a10 - fix(html): keep text inside unrecognized elements (#4497)

Commit
13 days ago
fix(html): keep text inside unrecognized elements (#4497) ### Summary Fixes #4496. Resolves #3842 (#3708 was closed in its favor). The v1 HTML parser gives every tag it has no class for a `DefaultElement`, and `DefaultElement` emits only the element's tail: its own text and everything inside it are dropped without a trace. Before the parser rewrite in #3218 (0.15.0) the legacy parser listed `font` in its `TEXT_TAGS`, so for `<font>` this is also a regression. In practice this loses: - `<font>`, still common in email HTML (Outlook and Gmail reply headers). - Inline XBRL, required in SEC 10-K/10-Q filings: `<ix:nonFraction>` wraps the numbers inside sentences, and `<ix:nonNumeric>`/`<ix:continuation>` wrap whole notes, tables included. - Custom elements (`<my-card>`, the `<hauptteil_AB>` from #3708), `<ins>`, `<nobr>`, `<ruby>`, `<picture>`. Everything routed through `partition_html()` is affected: `.html`, `.md`, `.eml`/`.msg` HTML bodies, `.epub`, `.rst`, `.org`. Measured on the repo's fixtures. "Token coverage" is the share of word tokens from a reference text, built from each fixture's own HTML, that appear in the output; the reference approximates, but is not, what a browser displays (method in **Testing**). | fixture | elements, main → this PR | token coverage, main → this PR | |---|---|---| | `example-10k.html` (same for `-utf-16`) | 314 → 466 | 66.0% → 100.0% of 27,619 tokens | | `example-10k-1p.html` | 23 → 24 | 97.3% → 100.0% | | `example-steelJIS-datasheet.html` (same for `-utf-16`) | 12 → 19 | 75.0% → 88.7% | | `eml/email-no-utf8-2008-07-16.062410.eml`, HTML body | 2 → 22 | 31.5% → 93.8% | On `main` the 10-K loses its cover-page values (form type, fiscal year end, registrant name, shares outstanding) and all of Notes 1–14. The email keeps two quoted paragraphs; the replies and their `From:`/`Sent:` headers are inside `<font>`. The other 40 HTML-routed fixtures in `example-docs` produce identical output. ### Change `DefaultElement` drops its two overrides and inherits `Phrasing.iter_text_segments()` and `Flow.iter_elements()`, so an unrecognized element is transparent. On the block-vs-inline question in #3842, no guess is needed. The element keeps identifying as phrasing, which is how a browser renders an unknown element (`display: inline`), and the parser already turns each block nested inside phrasing into its own element, as for a `<p>` inside a `<span>`. So `<font>` around two words joins the sentence, and `<ix:nonNumeric>` around a note with headings and tables yields the same elements as the note without the wrapper. Checked on the real filing: `example-10k.html` with every `ix:` wrapper stripped (content kept) partitions to the same 466 elements; 448 are identical, and the other 18 are tables whose text differs only in whitespace from `TableBlock` code this PR doesn't touch. Text mixed with blocks (`<x>before<p>para</p>after</x>`) and emphasis and link annotations come out exactly as they do inside a `<span>`. Elements whose content is not rendered as text now have to be skipped explicitly. They are registered as `RemovedPhrasing`, which behaves exactly like the old `DefaultElement`, so their output does not change: - metadata: `head`, `title`, and legacy `xml` data islands (such as Office document properties); - fallback content: `object`, `iframe`, `audio`, `video`, `canvas`, `noembed`, `noframes`; - form-control internals: `select`, `datalist`, `textarea`; - `dialog`, which is hidden until opened and on static pages is mostly popup UI; - `svg`, `math`, and Inline XBRL's hidden `ix:header` (facts, contexts and units that filings put in a `display:none` `<div>`). Each entry has a test that leaks text if the entry is removed. Judgment calls: - `math` stays skipped so this doesn't pre-empt #4478, which adds a `Math` class. Whichever PR lands second deletes that line; ruff's F601 flags the duplicate key otherwise. - `dialog` is skipped whether or not it has `open`, which keeps today's output for both. An `open`-aware class would be a small follow-up if you want open dialogs included. - `embed`, `source` and `track` are left transparent: libxml2 nests the *following* content inside these void elements (`<p>See <embed src=x> for details.</p>` puts "for details." inside `<embed>`), so skipping them would keep losing that text. - Tags that are already registered (`button`, `label`, `details`, `dl`, `figure`, `form`, `nav`, `template`, …) are untouched. `Flow._page_number` now walks ancestors in a loop instead of recursing, with the same result. `DefaultElement` is a `Flow`, so a block inside N nested unknown elements now looks up N ancestors while the phrasing traversal is itself about 2N frames deep; without this, a `<p>` inside 250 nested unknown elements raised `RecursionError`. The v2 parser (`html_parser_version="v2"`) is not affected; it keeps unknown-tag text as `UncategorizedText`. ### Before / after ```python from unstructured.partition.html import partition_html for html in [ "<p><font face=Arial>Quarterly revenue</font> rose 12%.</p>", '<p>Revenue was $<ix:nonFraction name="us-gaap:Revenues">4,213</ix:nonFraction> million.</p>', "<my-card><h2>Pricing</h2><p>Plans start at $10.</p></my-card>", ]: print([str(e) for e in partition_html(text=html)]) ``` Before: ``` ['rose 12%.'] ['Revenue was $ million.'] [] ``` After: ``` ['Quarterly revenue rose 12%.'] ['Revenue was $4,213 million.'] ['Pricing', 'Plans start at $10.'] ``` ### Testing - `test_parser.py`: the three `DescribeDefaultElement` tests that pinned the old behavior now assert transparency. New tests cover blocks inside `<foobar>`, `<ix:nonNumeric>` and nested `<my-card><font>` (same elements and metadata as without the wrapper), text mixed with a block (same as inside `<span>`), annotations, each newly skipped tag (parametrized, including open and closed `<dialog>`, plus `head` via a body-less document), and the page number of a block nested in 250 unknown elements. - `test_partition.py`: end-to-end tests for `<font>` and for Inline XBRL (inline fact, block `ix:nonNumeric` note with a table, `ix:header` content that must not appear). - Against `main`'s code, all 36 new or changed parser tests and both end-to-end tests fail. - `test_unstructured/partition/html/`: 402 passed, 1 failed on this branch; 367 passed, 1 failed on `main`. The failure is the same test on both, from my Windows checkout's CRLF fixtures. - `ruff check .` and `ruff format --check` on the changed files pass. <details> <summary>Other suites compared on main and this branch</summary> Compared test-by-test; every test present on both has the same outcome. | suite | main | this branch | |---|---|---| | `test_md.py` / `test_email.py` | 39 / 72 passed | 39 / 72 passed | | `test_msg.py` | 49 passed, 1 failed | 49 passed, 1 failed | | `test_epub.py` + `test_rst.py` + `test_org.py` | 46 passed, 1 failed | 46 passed, 1 failed | | `chunking/test_title.py` | 46 passed, 7 skipped | 46 passed, 7 skipped | The failures are the same tests on both sides and come from my environment (missing image/PDF extras, CRLF fixtures). </details> <details> <summary>How token coverage was measured</summary> The reference is each fixture's HTML (for `.eml`, the HTML body `partition_email()` uses) parsed with lxml's HTML parser, with `<head>`, `<script>`, `<style>`, `<noscript>`, `<template>` and `hidden`/inline `display:none` elements removed, split into `\w+` tokens. Coverage is the share of reference tokens, counted as a multiset, found in the concatenated element text. It does not apply stylesheets, so it is an approximation of what a browser shows. No fixture gains output tokens that aren't in the reference, and I read every newly emitted element. Every token still missing on this branch was already missing on `main`: one-character page numbers (skipped by design), text in a few table cells, and an antivirus footer the email places after `</html>`. </details> ### Limitations and not verified - `ix:header` is matched by its conventional `ix` prefix, because libxml2's HTML parser has no namespaces. A filing that binds the Inline XBRL namespace to another prefix would have its hidden header emitted. The repo's filing fixture uses `ix`. - Other XML vocabularies embedded in HTML, such as Confluence `ac:` macros, now contribute their text like any unrecognized element. I have no such fixture. - As with `<span>`, adjacent unknown elements with no whitespace between them are joined: `<x-a>foo</x-a><x-b>bar</x-b>` gives `foobar`, which is also how a browser renders them. - The `azure/spring-weather.html` ingest fixture, whose source isn't in the repo; `test_auto.py` doesn't collect in my environment. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4497?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
Author
Parents
Loading