← 输入框|github.com|刷新
Skip to content

fix(nlp): match single-letter bullets in the enumerated bullet pattern - #4636

Open
Eapp1e wants to merge 2 commits into
Unstructured-IO:mainfrom
Eapp1e:fix/enumerated-bullets-single-letter
Open

Eapp1e wants to merge 2 commits into
Unstructured-IO:mainfrom
Eapp1e:fix/enumerated-bullets-single-letter

Conversation

@Eapp1e

@Eapp1e Eapp1e commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

ENUMERATED_BULLETS_RE cannot match a single-letter bullet, so lettered lists come out as one ListItem instead of one per item.

unstructured/nlp/patterns.py:111 compiled

ENUMERATED_BULLETS_RE = re.compile(r"(?:(?:\d{1,3}|[a-z][A-Z])\.?){1,3}")

[a-z][A-Z] is two single-character classes placed next to each other, so it matches exactly two characters (aB) and never a single a or A. The two ordered-bullet helpers in this repo both accept a single letter as a marker:

  • unstructured/cleaners/core.py:53 clean_ordered_bullets — docstring says "accounting numeric and alphanumeric types", with the example a.b This is a very important point -> This is a very important point.
  • unstructured/cleaners/extract.py:104 extract_ordered_bullets — docstring shows a.1 This is a very important point -> ("a", "1", None).

The splitter regex used by the list path does not, so the marker stays in the text:

from unstructured.partition.common.common import layout_list_to_list_items

[e.text for e in layout_list_to_list_items("a. foo\nb. bar", None, None, None, None)]
# ['a. foo\nb. bar']      expected ['foo', 'bar']

[e.text for e in layout_list_to_list_items("1. foo\n2. bar", None, None, None, None)]
# ['foo', 'bar']          unchanged

It is user visible through partition_image and partition_pdf(strategy="ocr_only"): ocr_data_to_elements -> normalize_layout_element -> layout_list_to_list_items (unstructured/partition/common/common.py:137).

Change

Two files change; the numeric branch and every existing behaviour stay byte for byte.

unstructured/nlp/patterns.py gains a lettered branch next to the untouched numeric one:

ENUMERATED_BULLETS_RE = re.compile(
    r"(?:(?:\d{1,3})\.?){1,3}"
    rf"|(?:(?:(?<=\n)|\A){NON_NEWLINE_WHITESPACE}[a-zA-Z]\."
    rf"(?=[^\S\n\r]|$)(?![^\S\n\r]+[a-zA-Z]\.))",
)

Each restriction is load-bearing:

  • The line anchor: an unanchored [a-zA-Z] makes every letter a separator and "1. foo\n2. bar" splits into [].
  • The literal dot: \d{1,3}\.? keeps the dot optional for numbers, but "a" without a dot is a word, not a marker.
  • The lookahead (?=[^\S\n\r]|$) requires same-line whitespace or the end of the text after the dot.
  • The guard (?![^\S\n\r]+[a-zA-Z]\.) rejects a second letter and dot on the same line. Without it "e. g. spaced example" becomes ['g. spaced example'] and "i. e. the letter" becomes ['e. the letter']. It uses [^\S\n\r]+ rather than a single space because extracted text writes abbreviations with repeated spaces or tabs as often as with one space, and it must not reach across a line break.

unstructured/partition/common/common.py also has to split mixed blocks. A block can hold enumerated markers and bullet glyphs at the same time:

layout_list_to_list_items("a. heading\n\u2022 first\n\u2022 second", None, None, None, None)
# ['heading', 'first', 'second']

Before, the enumerated split produced two segments and the bullet glyphs stayed glued to the second one, because the glyph split only ran when the enumerated split found nothing. The list path now splits every remaining segment on the glyph pattern as well, so both orders of the two marker families produce one item per item, and blocks that use a single family are unaffected.

Behaviour on the existing inputs is unchanged: "1. foo\n2. bar", "1.1 ...", "1.1.1 Deep point", "Page 1 of 2", "1 foo 2 bar", "a cat sat on a mat.", "See section A. Appendix", "Fig. 3 shows the trend", "Note. This is a note.", "b) alt paren", "e. g. spaced example", "i. e. the letter" all behave exactly as before.

Test

test_unstructured/nlp/test_patterns.py is new and covers the regex, the converter, mixed marker families, and the abbreviation cases that must not split.

python -m pytest test_unstructured/nlp/test_patterns.py -q

pristine (main 910d9f41): 12 failed, 16 passed
patched:                  28 passed

ruff check reports All checks passed! and ruff format --check reports 3 files already formatted for the two source files and the new test.

Fixes #4635

@cragwolfe cragwolfe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing single-letter list markers. I found two output regressions in the inferred-list conversion path:

  1. Mixed marker styles lose item boundaries (unstructured/nlp/patterns.py:119, unstructured/partition/common/common.py:137-140). For a. heading\n• first\n• second, the base produces three items, but the new letter match prevents the Unicode fallback and produces one item containing both bullet lines. Please preserve both marker families when they occur in the same block and add cases in both orders.
  2. Repeated whitespace in abbreviations loses text (unstructured/nlp/patterns.py:119). The guard checks exactly one whitespace character, so e. g. spaced example matches and removes e., although the single-space form is preserved. Please handle a same-line whitespace run and cover repeated spaces and mixed spaces/tabs without crossing line boundaries.

The intended splitting also changes public element text, counts, and boundaries for lettered blocks; removing the old mixed-case delimiter changes output for those inputs too. A maintainer should decide the compatibility and release treatment for these changes. GPT Pro reviewed the full diff and relevant source; these observations were checked against the code. No local tests or reproductions were run.

(authored by codex)

@Eapp1e
Eapp1e force-pushed the fix/enumerated-bullets-single-letter branch from 2190912 to adfb18e Compare October 30, 2026 18:46
`[a-z][A-Z]` is two adjacent single-character classes, so
ENUMERATED_BULLETS_RE matched exactly two characters and never a single
`a` or `A`. Lettered lists therefore came out as one ListItem with the
markers still in the text, while clean_ordered_bullets and
extract_ordered_bullets both accept a single letter as a marker.

Add a line-anchored letter branch with a literal dot, a same-line
whitespace lookahead and an abbreviation guard that tolerates repeated
spaces and tabs. The numeric branch is unchanged.

A block can also mix enumerated markers with bullet glyphs. The glyph
split only ran when the enumerated split found nothing, so the glyph
items stayed glued to the last enumerated one. Split every remaining
segment on the glyph pattern too.

Signed-off-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
@Eapp1e
Eapp1e force-pushed the fix/enumerated-bullets-single-letter branch from adfb18e to 8cb3d31 Compare October 30, 2026 18:49
@Eapp1e

Eapp1e commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, and you are right on both points. Both are fixed in 8cb3d310.

Mixed marker families. The glyph split now also runs on every segment the enumerated split produced, so a block that mixes both families yields one item per item in either order:

layout_list_to_list_items("a. heading\n\u2022 first\n\u2022 second", None, None, None, None)
# before: ['a. heading\n\u2022 first\n\u2022 second']
# after:  ['heading', 'first', 'second']

layout_list_to_list_items("\u2022 first\na. heading\nb. other", None, None, None, None)
# after: ['first', 'heading', 'other']

Blocks that use a single family take the same path as before, because the extra split only re-runs over segments that already had no glyphs in them.

Whitespace between abbreviation letters. The lookahead and the guard now both use a run of same-line whitespace instead of a single space:

ENUMERATED_BULLETS_RE = re.compile(
    r"(?:(?:\d{1,3})\.?){1,3}"
    rf"|(?:(?:(?<=\n)|\A){NON_NEWLINE_WHITESPACE}[a-zA-Z]\."
    rf"(?=[^\S\n\r]|$)(?![^\S\n\r]+[a-zA-Z]\.))",
)

[^\S\n\r] is whitespace that is not a line break, so e. g. (two spaces), e. \tg. (space then tab) and e.\tg. (tab) are all kept as prose, and the guard still cannot reach across a line break, so a bullet on the next line is still a bullet. All four spellings are now parametrised in the test file alongside i. e. the letter.

test_unstructured/nlp/test_patterns.py grows to 28 cases and now covers both marker orders of a mixed block. On the current main (910d9f41) the same file reports 12 failed, 16 passed; with the patch it is 28 passed. ruff check and ruff format --check are clean on the three files.

On the third point: I agree the maintainer owns the compatibility call. The letter branch only turns on for a marker that starts a line, carries a literal dot, and is followed by same-line whitespace or the end of the block, so prose and the existing numeric cases are untouched. If you would rather land the compatibility note separately (release notes or a follow-up), say so and I will keep this PR to the pattern and the mixed-block split.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(nlp): match single-letter bullets in the enumerated bullet pattern

2 participants