Repository: freelawproject/eyecite

Issue:
Find name-only ReferenceCitations
Finding ReferenceCitations depend on preceding FullCaseCitations. Currently, we find them using either the defendant or the plaintiff as identified on the FullCaseCitation; and we require a pincite.
https://github.com/freelawproject/eyecite/blob/abfc7f76f5977b620f9143e502db93fdf2ea6065/eyecite/find.py#L157-L167

To find name-only ReferenceCitations, without pincites, we need to account for a different structure. I think this is easiest for HTML / XML opinions, since the intention of referencing is made explicit by `<i>` or `<em>` style tags. Other seemingly obvious structure is the `"In $name,"`. But, see the Halper references that do not conform to `in Halper`. And see the blue highlight for a case where the party names was not supposed to be a reference

![Image](https://github.com/user-attachments/assets/93189a72-df70-4a55-ab56-f9ecd0f487e4)

So, I think we should start looking for this kind of citations on HTML / XML documents only, and using the structured text itself, not a cleaned text. This second round of citation finding may benefit also from using the resolved objects metadata https://github.com/freelawproject/eyecite/issues/199

Hints from issue comments:
@grossir: @flooie we talked about this in Monday, I think you were going to implement a PR?

Solution patch:
diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py
index 352be91..f7e48f3 100644
--- a/benchmark/benchmark.py
+++ b/benchmark/benchmark.py
@@ -4,6 +4,7 @@ import csv
 import datetime
 import json
 import os
+import re
 import sys
 from io import StringIO
 from pathlib import Path
@@ -50,13 +51,24 @@ class Benchmark(object):
                 or row["html_columbia"]
                 or row["html_anon_2020"]
                 or row["html"]
-                or row["plain_text"]
             )
+            if text:
+                # Remove XML encodings from xml_harvard
+                text = re.sub(r"^<\?xml.*?\?>", "", text, count=1)
+                opinion_text_is_marked_up = True
+            else:
+                text = row["plain_text"]
+                opinion_text_is_marked_up = False
+
+            plain_text = clean_text(text, ["html", "inline_whitespace"])
             found_citations = get_citations(
-                clean_text(text, ["html", "inline_whitespace"])
+                plain_text,
+                markup_text=text if opinion_text_is_marked_up else "",
             )
+
             # Get the citation text string from the cite object
             cites = [cite.token.data for cite in found_citations if cite.token]
+
             count += len(cites)
             output = {
                 "id": row["id"],
diff --git a/eyecite/find.py b/eyecite/find.py
index 49aeafc..de322d6 100644
--- a/eyecite/find.py
+++ b/eyecite/find.py
@@ -1,6 +1,8 @@
 import re
-from typing import List, Type, cast
+from bisect import bisect_left, bisect_right
+from typing import List, Optional, Type, cast
 
+from eyecite.annotate import SpanUpdater
 from eyecite.helpers import (
     disambiguate_reporters,
     extract_pin_cite,
@@ -29,13 +31,14 @@ from eyecite.models import (
 )
 from eyecite.regexes import SHORT_CITE_ANTECEDENT_REGEX, SUPRA_ANTECEDENT_REGEX
 from eyecite.tokenizers import Tokenizer, default_tokenizer
-from eyecite.utils import DISALLOWED_NAMES
+from eyecite.utils import is_valid_name
 
 
 def get_citations(
     plain_text: str,
     remove_ambiguous: bool = False,
     tokenizer: Tokenizer = default_tokenizer,
+    markup_text: str = "",
 ) -> List[CitationBase]:
     """This is eyecite's main workhorse function. Given a string of text
     (e.g., a judicial opinion or other legal document), return a list of
@@ -51,6 +54,9 @@ def get_citations(
         tokenizer: An instance of a Tokenizer object. See `eyecite.tokenizers`
             for information about available tokenizers. Uses the
             `eyecite.tokenizers.AhocorasickTokenizer` by default.
+        markup_text: if the source text has markup (XML or HTML mostly), pass
+            it to extract ReferenceCitations that may be detectable via
+            markup style tags
 
     Returns:
         A list of `eyecite.models.CitationBase` objects
@@ -61,6 +67,12 @@ def get_citations(
     words, citation_tokens = tokenizer.tokenize(plain_text)
     citations: list[CitationBase] = []
 
+    if markup_text:
+        plain_to_markup = SpanUpdater(plain_text, markup_text)
+        markup_to_plain = SpanUpdater(markup_text, plain_text)
+    else:
+        plain_to_markup, markup_to_plain = None, None
+
     for i, token in citation_tokens:
         citation: CitationBase
         token_type = type(token)
@@ -80,7 +92,13 @@ def get_citations(
 
                 # Check for reference citations that follow a full citation
                 # Using the plaintiff or defendant
-                references = extract_reference_citations(citation, plain_text)
+                references = extract_reference_citations(
+                    citation,
+                    plain_text,
+                    markup_text,
+                    plain_to_markup,
+                    markup_to_plain,
+                )
                 citations.extend(references)
 
         # CASE 2: Token is an "Id." or "Ibid." reference.
@@ -128,35 +146,54 @@ def get_citations(
 def extract_reference_citations(
     citation: FullCitation,
     plain_text: str,
+    markup_text: str = "",
+    plain_to_markup: Optional[SpanUpdater] = None,
+    markup_to_plain: Optional[SpanUpdater] = None,
 ) -> List[ReferenceCitation]:
     """Extract reference citations that follow a full citation
 
     :param citation: the full case citation found
     :param plain_text: the text
-    :return: Pin cite reference citations
+    :param markup_text: optional argument for source text with XML style tags
+        that may help extracting name-only ReferenceCitations
+    :param plain_to_markup: a SpanUpdater from plain or clean text to
+        marked up text
+    :param markup_to_plain: a SpanUpdater from marked up text to plain text
+
+    :return: Reference citations
     """
     if len(plain_text) <= citation.span()[-1]:
         return []
     if not isinstance(citation, FullCaseCitation):
         return []
 
-    def is_valid_name(name: str) -> bool:
-        """Validate name isnt a regex issue
-
-        Excludes strings like Co., numbers or lower case strs
-
-        :param name: The name to check
-        :return: True if usable, false if not
-        """
-        return (
-            isinstance(name, str)
-            and len(name) > 2
-            and name[0].isupper()
-            and not name.endswith(".")
-            and not name.isdigit()
-            and name.lower() not in DISALLOWED_NAMES
+    reference_citations = extract_pincited_reference_citations(
+        citation, plain_text
+    )
+
+    if markup_text:
+        reference_citations.extend(
+            find_reference_citations_from_markup(
+                markup_text,
+                plain_text,
+                [citation],
+                plain_to_markup,
+                markup_to_plain,
+            )
         )
 
+    return reference_citations
+
+
+def extract_pincited_reference_citations(
+    citation: FullCaseCitation, plain_text: str
+) -> List[ReferenceCitation]:
+    """Extract reference citations with the name-pincite pattern
+
+    :param citation: the full case citation found
+    :param plain_text: the text
+    :return: a list of ReferenceCitations
+    """
     regexes = [
         rf"(?P<{key}>{re.escape(value)})"
         for key in ReferenceCitation.name_fields
@@ -186,6 +223,7 @@ def extract_reference_citations(
             metadata=match.groupdict(),
         )
         reference_citations.append(reference)
+
     return reference_citations
 
 
@@ -238,6 +276,7 @@ def _extract_shortform_citation(
 
     Shortform 1: Adarand, 515 U.S., at 241
     Shortform 2: 515 U.S., at 241
+    Shortform 3: Adarand at 241, 515 U.S.
     """
     # get antecedent word
     antecedent_guess = None
@@ -248,11 +287,13 @@ def _extract_shortform_citation(
         strings_only=True,
         forward=False,
     )
-    offset = 0
+
     if m:
         ante_start, ante_end = m.span()
-        offset = ante_end - ante_start
+        antecedent_length = ante_end - ante_start
         antecedent_guess = m["antecedent"].strip()
+    else:
+        antecedent_length = 0
 
     # Get pin_cite
     cite_token = cast(CitationToken, words[index])
@@ -268,7 +309,7 @@ def _extract_shortform_citation(
         exact_editions=cite_token.exact_editions,
         variation_editions=cite_token.variation_editions,
         span_end=span_end,
-        full_span_start=cite_token.start - offset,
+        full_span_start=cite_token.start - antecedent_length,
         full_span_end=max([span_end, cite_token.end]),
         metadata={
             "antecedent_guess": antecedent_guess,
@@ -309,11 +350,17 @@ def _extract_supra_citation(
     if m:
         antecedent_guess = m["antecedent"]
         volume = m["volume"]
+        antecedent_length = m.span()[1] - m.span()[0]
+    else:
+        antecedent_length = 0
 
+    supra_token = cast(SupraToken, words[index])
     # Return SupraCitation
     return SupraCitation(
-        cast(SupraToken, words[index]),
+        supra_token,
         index,
+        full_span_start=supra_token.start - antecedent_length,
+        full_span_end=span_end or supra_token.end,
         span_end=span_end,
         metadata={
             "antecedent_guess": antecedent_guess,
@@ -342,3 +389,104 @@ def _extract_id_citation(
             "parenthetical": parenthetical,
         },
     )
+
+
+def find_reference_citations_from_markup(
+    markup_text: str,
+    plain_text: str,
+    citations: list,
+    plain_to_markup: Optional[SpanUpdater] = None,
+    markup_to_plain: Optional[SpanUpdater] = None,
+) -> list[ReferenceCitation]:
+    """Use HTML/XML style tags and parties names to find ReferenceCitations
+
+    We will use SpanUpdaters to go back and forth between `markup_text` and
+    `plain_text` spaces. The ReferenceCitations found will be in the same
+    (plain_text) space as the citations got from `find.get_citations`
+
+    Depending on the input FullCaseCitations, the References may be repeated
+    so it's important to apply `eyecite.helpers.filter_citations` once
+
+    Creating the SpanUpdaters for each full citation will be too slow,
+    re-use them if possible
+
+    :param markup_text: HTML or XML source
+    :param plain_text: cleaned text
+    :param citations: list of citations found over plain text. The full cites
+        will be used to access parties names metadata
+    :param plain_to_markup: a SpanUpdater from plain or clean text to
+        marked up text
+    :param markup_to_plain: a SpanUpdater from marked up text to plain text
+
+    :return: a list of ReferenceCitations
+    """
+    if not markup_to_plain:
+        markup_to_plain = SpanUpdater(markup_text, plain_text)
+    if not plain_to_markup:
+        plain_to_markup = SpanUpdater(plain_text, markup_text)
+
+    references = []
+    tags = "|".join(["em", "i"])
+
+    for citation in citations:
+        if not isinstance(citation, FullCaseCitation):
+            continue
+
+        regexes = []
+        for key in ReferenceCitation.name_fields:
+            if not (value := getattr(citation.metadata, key, None)):
+                continue
+            if not is_valid_name(value):
+                continue
+            value = re.sub(r"\s+", re.escape(" "), re.escape(value.strip()))
+            regexes.append(
+                r"(?P<{}>{})".format(key, value.replace(" ", r"\s+"))
+            )
+        if not regexes:
+            continue
+
+        # Include punctuation and spaces surrounding the party name, in order
+        # to be contiguous to the style tags.
+        # See related test for real data example where this happens
+        # It may also be important to include those characters to preserve
+        # valid HTML when annotating HTML (ex: `html_with_citations`). See
+        # `utils.maybe_balance_style tags` for reference; it has some tolerance
+        # which may be enough for these citations
+        regex = rf"<(?:{tags})>\s*({'|'.join(regexes)})[:;.,\s]*</(?:{tags})>"
+        start_in_markup = plain_to_markup.update(
+            citation.span()[0], bisect_right
+        )
+        for match in re.finditer(regex, markup_text[start_in_markup:]):
+            full_start_in_plain = markup_to_plain.update(
+                start_in_markup + match.start(), bisect_left
+            )
+            full_end_in_plain = markup_to_plain.update(
+                start_in_markup + match.end(), bisect_right
+            )
+
+            # the first group [match.group(0)] is the whole match,
+            # with whitespace and punctuation. the second group, match.group(1)
+            # is the only capturing and named group
+            start_in_plain = markup_to_plain.update(
+                start_in_markup + match.start(1), bisect_left
+            )
+            end_in_plain = markup_to_plain.update(
+                start_in_markup + match.end(1), bisect_right
+            )
+
+            reference = ReferenceCitation(
+                token=CaseReferenceToken(
+                    data=plain_text[start_in_plain:end_in_plain],
+                    start=start_in_plain,
+                    end=end_in_plain,
+                ),
+                span_start=start_in_plain,
+                span_end=end_in_plain,
+                full_span_start=full_start_in_plain,
+                full_span_end=full_end_in_plain,
+                index=0,
+                metadata=match.groupdict(),
+            )
+            references.append(reference)
+
+    return references
diff --git a/eyecite/helpers.py b/eyecite/helpers.py
index 499b582..f0445e4 100644
--- a/eyecite/helpers.py
+++ b/eyecite/helpers.py
@@ -1,3 +1,4 @@
+import logging
 from datetime import date
 from typing import List, Optional, Tuple, cast
 
@@ -23,9 +24,12 @@ from eyecite.regexes import (
     POST_JOURNAL_CITATION_REGEX,
     POST_LAW_CITATION_REGEX,
     POST_SHORT_CITATION_REGEX,
+    PRE_FULL_CITATION_REGEX,
     YEAR_REGEX,
 )
 
+logger = logging.getLogger()
+
 BACKWARD_SEEK = 28  # Median case name length in the CL db is 28 (2016-02-26)
 
 # Maximum characters to scan using match_on_tokens.
@@ -160,6 +164,30 @@ def add_defendant(citation: CaseCitation, words: Tokens) -> None:
             citation.metadata.defendant = defendant
 
 
+def add_pre_citation(citation: FullCaseCitation, words: Tokens) -> None:
+    """Scan backwards to find a (PartyName - Pincite) component
+
+    Do not try if plaintiff or defendant has already been found
+    """
+    if citation.metadata.plaintiff or citation.metadata.defendant:
+        return
+
+    m = match_on_tokens(
+        words,
+        citation.index - 1,
+        PRE_FULL_CITATION_REGEX,
+        forward=False,
+        strings_only=True,
+    )
+    if not m:
+        return
+
+    citation.metadata.pin_cite = clean_pin_cite(m["pin_cite"]) or None
+    citation.metadata.antecedent_guess = m["antecedent"]
+    match_length = m.span()[1] - m.span()[0]
+    citation.full_span_start = citation.span()[0] - match_length
+
+
 def add_law_metadata(citation: FullLawCitation, words: Tokens) -> None:
     """Annotate FullLawCitation with pin_cite, publisher, etc."""
     m = match_on_tokens(
@@ -307,7 +335,10 @@ def match_on_tokens(
 
         # check for max length
         if len(text) >= MAX_MATCH_CHARS:
-            text = text[:MAX_MATCH_CHARS]
+            if forward:
+                text = text[:MAX_MATCH_CHARS]
+            else:
+                text = text[-MAX_MATCH_CHARS:]
             break
 
     m = re.search(regex, text, flags=flags)
@@ -349,30 +380,44 @@ def filter_citations(citations: List[CitationBase]) -> List[CitationBase]:
     :param citations: List of citations
     :return: Sorted and filtered citations
     """
+    if not citations:
+        return citations
+
     citations = list(
         {citation.span(): citation for citation in citations}.values()
     )
-    filtered_citations: List[CitationBase] = []
     sorted_citations = sorted(
         citations, key=lambda citation: citation.full_span()
     )
-    for citation in sorted_citations:
-        if filtered_citations:
-            last_citation = filtered_citations[-1]
-            is_overlapping = overlapping_citations(
-                citation.full_span(), last_citation.full_span()
-            )
-            if is_overlapping and isinstance(last_citation, ReferenceCitation):
-                # Remove the overlapping reference citation
+    filtered_citations: List[CitationBase] = [sorted_citations[0]]
+
+    for citation in sorted_citations[1:]:
+        last_citation = filtered_citations[-1]
+        is_overlapping = overlapping_citations(
+            citation.full_span(), last_citation.full_span()
+        )
+        if is_overlapping:
+            # In cases overlap, prefer anything to a reference citation
+            if isinstance(last_citation, ReferenceCitation):
                 filtered_citations.pop(-1)
                 filtered_citations.append(citation)
                 continue
-            if is_overlapping and isinstance(citation, ReferenceCitation):
-                # Skip overlapping reference citations
+            if isinstance(citation, ReferenceCitation):
                 continue
-            filtered_citations.append(citation)
-        else:
-            filtered_citations.append(citation)
+
+            # Known overlap case are parallel full citations
+            if not (
+                isinstance(citation, FullCaseCitation)
+                and isinstance(last_citation, FullCaseCitation)
+            ):
+                logger.error(
+                    "Unknown overlap case. Last cite: %s. Current: %s",
+                    last_citation,
+                    citation,
+                )
+
+        filtered_citations.append(citation)
+
     return filtered_citations
 
 
diff --git a/eyecite/models.py b/eyecite/models.py
index cf3db60..d455cd2 100644
--- a/eyecite/models.py
+++ b/eyecite/models.py
@@ -446,6 +446,7 @@ class FullCaseCitation(CaseCitation, FullCitation):
     Example:
     ```
     Adarand Constructors, Inc. v. Peña, 515 U.S. 200, 240
+    Peña at 222, 515 U.S. 200
     ```
     """
 
@@ -456,6 +457,7 @@ class FullCaseCitation(CaseCitation, FullCitation):
         plaintiff: Optional[str] = None
         defendant: Optional[str] = None
         extra: Optional[str] = None
+        antecedent_guess: Optional[str] = None
         # May be populated after citation resolution
         resolved_case_name_short: Optional[str] = None
         resolved_case_name: Optional[str] = None
@@ -463,10 +465,16 @@ class FullCaseCitation(CaseCitation, FullCitation):
     def add_metadata(self, words: "Tokens"):
         """Extract metadata from text before and after citation."""
         # pylint: disable=import-outside-toplevel
-        from eyecite.helpers import add_defendant, add_post_citation
+        from eyecite.helpers import (
+            add_defendant,
+            add_post_citation,
+            add_pre_citation,
+        )
 
         add_post_citation(self, words)
         add_defendant(self, words)
+        add_pre_citation(self, words)
+
         self.guess_court()
         super().add_metadata(words)
 
diff --git a/eyecite/regexes.py b/eyecite/regexes.py
index f5cfc0c..ffe5b5b 100644
--- a/eyecite/regexes.py
+++ b/eyecite/regexes.py
@@ -249,6 +249,20 @@ POST_FULL_CITATION_REGEX = rf"""
     )
 """
 
+# These full case citations usually appear after the same full case citation
+# has appeared at least once.
+# Example with pincite: "Nobelman at 332, 113 S.Ct. 2106"
+# Example without pincite: "Johnson, 515 U. S. 304"
+PRE_FULL_CITATION_REGEX = rf"""
+    # single word antecedent
+    (?P<antecedent>[A-Z][a-z\-.]+)\ ?,?
+    # optional pincite
+    {PIN_CITE_REGEX}?
+    # `PIN_CITE_REGEX` uses a positive lookahead for end characters, but we
+    # must also capture them to calculate spans
+    ,?\ ?
+"""
+
 
 # Post short-form citation regex:
 # Capture pin cite and parenthetical after a short, id, or supra citation.
diff --git a/eyecite/resolve.py b/eyecite/resolve.py
index a0274bc..9b068f6 100644
--- a/eyecite/resolve.py
+++ b/eyecite/resolve.py
@@ -91,18 +91,17 @@ def _filter_by_matching_plaintiff_or_defendant_or_resolved_names(
     """Filter out reference citations that point to more than 1 Resource"""
     matches: List[ResourceType] = []
 
-    match_count = 0
-    reference_values = []
+    reference_values = set()
     for key in ReferenceCitation.name_fields:
         reference_value = getattr(reference_citation.metadata, key)
         if reference_value:
-            reference_values.append(reference_value)
+            reference_values.add(reference_value)
+
     for citation, resource in resolved_full_cites:
-        full_cite_values = list(
-            [value for value in citation.metadata.__dict__.values() if value]
-        )
-        if set(full_cite_values) & set(reference_values):
-            match_count += 1
+        full_cite_values = {
+            value for value in citation.metadata.__dict__.values() if value
+        }
+        if full_cite_values & reference_values:
             matches.append(resource)
 
     matches = list(set(matches))
diff --git a/eyecite/utils.py b/eyecite/utils.py
index 0b920d3..c8d1dc5 100644
--- a/eyecite/utils.py
+++ b/eyecite/utils.py
@@ -187,10 +187,10 @@ def dump_citations(citations, text, context_chars=30):
       * ...
     """
     out = []
-    green_fmt = "\x1B[32m"
-    blue_fmt = "\x1B[94m"
-    bold_fmt = "\x1B[1m"
-    end_fmt = "\x1B[0m"
+    green_fmt = "\x1b[32m"
+    blue_fmt = "\x1b[94m"
+    bold_fmt = "\x1b[1m"
+    end_fmt = "\x1b[0m"
     for citation in citations:
         start, end = citation.span()
         context_before = text[max(0, start - context_chars) : start]
@@ -233,6 +233,24 @@ def hash_sha256(dictionary: dict) -> int:
     return int.from_bytes(hashlib.sha256(json_bytes).digest(), byteorder="big")
 
 
+def is_valid_name(name: str) -> bool:
+    """Validate name isnt a regex issue
+
+    Excludes strings like Co., numbers or lower case strs
+
+    :param name: The name to check
+    :return: True if usable, false if not
+    """
+    return (
+        isinstance(name, str)
+        and len(name) > 2
+        and name[0].isupper()
+        and not name.endswith(".")
+        and not name.isdigit()
+        and name.lower() not in DISALLOWED_NAMES
+    )
+
+
 def maybe_balance_style_tags(
     start: int, end: int, plain_text: str
 ) -> tuple[int, int, str]:


Test patch:
diff --git a/tests/test_AnnotateTest.py b/tests/test_AnnotateTest.py
index 7572b82..a9a17ce 100644
--- a/tests/test_AnnotateTest.py
+++ b/tests/test_AnnotateTest.py
@@ -115,18 +115,13 @@ class AnnotateTest(TestCase):
                 "<em>Nobelman </em>at 332, 113 S.Ct. 2106. Section 1123(b)(5) "
                 "codifies the <em>Nobelman </em>decision in individual debtor "
                 "chapter 11 cases.",
-                " partially secured by a debtor’s principal residence was not"
-                " con-firmable. <em>Nobelman v. Am. Sav. Bank, </em>"
-                "<a href='something'>508 U.S. 324</a>, <a href='something'>"
-                "113 S.Ct. 2106</a>, <a href='something'>124 L.Ed.2d 228</a>"
-                " (1993). That plan proposed to bifurcate the claim and..."
-                " pay the unsecured... only by a lien on the debtor’s"
-                " principal residence.” <a href='something'><em>Nobelman </em>"
-                "at 332</a>, <a href='something'>113 S.Ct. 2106</a>. Section"
-                " 1123(b)(5) codifies the <em>Nobelman </em>decision in"
-                " individual debtor chapter 11 cases.",
+                " partially secured by a debtor’s principal residence was not con-firmable. <em>Nobelman v. Am. Sav. Bank, </em><a href='something'>508 U.S. 324</a>, <a href='something'>113 S.Ct. 2106</a>, <a href='something'>124 L.Ed.2d 228</a> (1993). That plan proposed to bifurcate the claim and... pay the unsecured... only by a lien on the debtor’s principal residence.” <em>Nobelman </em>at 332, <a href='something'>113 S.Ct. 2106</a>. Section 1123(b)(5) codifies the <em><a href='something'>Nobelman</a> </em>decision in individual debtor chapter 11 cases.",
                 ["html", "all_whitespace"],
-                {"annotate_anchors": True, "unbalanced_tags": "skip"},
+                {
+                    "annotate_anchors": True,
+                    "unbalanced_tags": "skip",
+                    "use_markup": True,
+                },
             ),
             # solvable unbalanced <i> tag
             # from https://www.courtlistener.com/api/rest/v4/opinions/2841253/
@@ -144,7 +139,11 @@ class AnnotateTest(TestCase):
                 " (citing <a href='something'><i>Howsam</i> at 84</a>, <a"
                 " href='something'>123 S. Ct. at 592</a>)",
                 ["html", "all_whitespace"],
-                {"annotate_anchors": True, "unbalanced_tags": "skip"},
+                {
+                    "annotate_anchors": True,
+                    "unbalanced_tags": "skip",
+                    "use_markup": True,
+                },
             ),
             # The next 2 examples could be resolved if we increased the
             # character tolerance or admitted the full case name instead of
@@ -170,7 +169,11 @@ class AnnotateTest(TestCase):
                 " Tall Oaks, Inc. (In re Hatch), </em> at 748."
                 "</p>",
                 ["html", "all_whitespace"],
-                {"annotate_anchors": True, "unbalanced_tags": "skip"},
+                {
+                    "annotate_anchors": True,
+                    "unbalanced_tags": "skip",
+                    "use_markup": True,
+                },
             ),
             (
                 # https://www.courtlistener.com/api/rest/v4/opinions/1985850/
@@ -188,7 +191,11 @@ class AnnotateTest(TestCase):
                 " href='something'>135 <i>A.</i>2d 468</a>, <i>citing,"
                 " </i><i>Minnesota ex rel.</i>",
                 ["html", "all_whitespace"],
-                {"annotate_anchors": True, "unbalanced_tags": "skip"},
+                {
+                    "annotate_anchors": True,
+                    "unbalanced_tags": "skip",
+                    "use_markup": True,
+                },
             ),
         )
         for source_text, expected, clean_steps, *annotate_kwargs in test_pairs:
@@ -198,8 +205,12 @@ class AnnotateTest(TestCase):
                 clean_steps=clean_steps,
                 annotate_args=annotate_kwargs,
             ):
+                get_citations_args = {}
+                if annotate_kwargs.pop("use_markup", False):
+                    get_citations_args = {"markup_text": source_text}
+
                 plain_text = clean_text(source_text, clean_steps)
-                cites = get_citations(plain_text)
+                cites = get_citations(plain_text, **get_citations_args)
                 annotations = [
                     (c.span(), f"<{i}>", f"</{i}>")
                     for i, c in enumerate(cites)
diff --git a/tests/test_FindTest.py b/tests/test_FindTest.py
index b2d67f1..8459601 100644
--- a/tests/test_FindTest.py
+++ b/tests/test_FindTest.py
@@ -9,7 +9,11 @@ from eyecite.helpers import filter_citations
 
 # by default tests use a cache for speed
 # call tests with `EYECITE_CACHE_DIR= python ...` to disable cache
-from eyecite.models import FullCaseCitation, FullCitation, ResourceCitation
+from eyecite.models import (
+    FullCaseCitation,
+    ReferenceCitation,
+    ResourceCitation,
+)
 from eyecite.test_factories import (
     case_citation,
     id_citation,
@@ -743,7 +747,6 @@ class FindTest(TestCase):
         this shouldn't occur but if it did we would be able to filter these
         correcly
         """
-        ".... at Conley v. Gibson, 355 Mass. 41, 42 (1999) ..."
         citations = [
             case_citation(
                 volume="355",
@@ -757,7 +760,7 @@ class FindTest(TestCase):
                 metadata={"plaintiff": "Conley", "defendant": "Gibson"},
             ),
             reference_citation("Conley", span_start=8, span_end=14),
-            reference_citation("Conley", span_start=18, span_end=24),
+            reference_citation("Gibson", span_start=18, span_end=24),
         ]
         self.assertEqual(len(citations), 3)
         filtered_citations = filter_citations(citations)
@@ -901,7 +904,7 @@ class FindTest(TestCase):
                 extracted.full_span(), (start_idx, len(sentence)), error_msg
             )
 
-    def test_reference_extraction(self):
+    def test_reference_extraction_using_resolved_names(self):
         """Can we extract a reference citation using resolved metadata?"""
         texts = [
             # In this case the reference citation got with the
@@ -917,17 +920,82 @@ class FindTest(TestCase):
         for plain_text in texts:
             citations = get_citations(plain_text)
             found_cite = citations[0]
-            if isinstance(found_cite, FullCitation):
-                found_cite.metadata.resolved_case_name = "State v. Wingler"
-                references = extract_reference_citations(
-                    found_cite, plain_text
-                )
-                final_citations = filter_citations(citations + references)
-                self.assertEqual(
-                    len(final_citations), 2, "There should only be 2 citations"
-                )
-                self.assertEqual(
-                    len(references),
-                    1,
-                    "Only a reference citation should had been picked up",
+            found_cite.metadata.resolved_case_name = "State v. Wingler"
+            references = extract_reference_citations(found_cite, plain_text)
+            final_citations = filter_citations(citations + references)
+            self.assertEqual(
+                len(final_citations), 2, "There should only be 2 citations"
+            )
+            self.assertEqual(
+                len(references),
+                1,
+                "Only a reference citation should had been picked up",
+            )
+
+    def test_reference_extraction_from_markup(self):
+        """Can we extract references from markup text?"""
+        # https://www.courtlistener.com/api/rest/v4/opinions/1985850/
+        markup_text = """
+        <i>citing, </i><i>U.S. v. Halper,</i> 490 <i>U.S.</i> 435, 446, 109 <i>
+        S.Ct.</i> 1892, 1901, 104 <i>L.Ed.</i>2d 487 (1989).
+        ; and see, <i>Bae v. Shalala,</i> 44 <i>F.</i>3d 489 (7th Cir.1995).
+        <p>In <i>Bae,</i> the 7th Circuit Court further interpreted
+        the holding of <i>Halper.</i> In <i>Bae,</i> the court... by the
+        <i>ex post facto</i> clause of the U.S. Constitution...</p>
+        <p>In <i>Bae,</i> the circuit court rejected the defendant's
+        argument that since debarment served both remedial <i>and</i>
+        punitive goals it must be characterized as punishment. Bae's argument
+        evidently relied on the <i>Halper</i> court's use of the word \"solely\"
+        in the discussion leading to its holding. The circuit court's
+        interpretation was much more pragmatic: \"A civil sanction that can
+        fairly be said solely to serve remedial goals will not fail under
+        <i>ex post facto</i> scrutiny simply because it is consistent with
+        punitive goals as well.\" 44 <i>F.</i>3d at 493.</p>"""
+
+        plain_text = clean_text(markup_text, ["html", "all_whitespace"])
+        citations = get_citations(plain_text, markup_text=markup_text)
+        references = [c for c in citations if isinstance(c, ReferenceCitation)]
+        # Tests both for the order and exact counts. Note that there is one
+        # "Bae" in the text that should not be picked up: "Bae's argument"...
+        self.assertListEqual(
+            [ref.matched_text().strip(",.") for ref in references],
+            ["Bae", "Halper", "Bae", "Bae", "Halper"],
+        )
+
+    def test_reference_filtering(self):
+        """Can we filter out ReferenceCitation that overlap other citations?"""
+        texts = [
+            # https://www.courtlistener.com/api/rest/v4/opinions/9435339/
+            # Test no overlap with supra citations
+            """<em>Bell Atlantic Corp. </em>v. <em>Twombly, </em>550 U. S. 544 (2007),
+            which discussed... apellate court’s core competency.
+             <em>Twombly, </em>550 U. S., at 557. Evaluating...
+            In <em>Twombly</em>, supra, at 553-554, the Court found...
+            Another, in <em>Twombly, supra</em>, at 553-554, the Court found
+            """,
+            # From the previous source; test no overlap with single-name
+            # full case citation
+            """
+            <em>Johnson </em>v. <em>Jones, </em>515 U. S. 304, 309 (1995)
+             something... with,” <em>Swint </em>v. <em>Chambers County Comm’n,
+             </em>514 U. S. 35, 51 (1995), and “directly implicated by,”
+             <em>Hartman, supra, </em>at 257, n. 5, the qualified-immunity
+             defense.</p>\n<p id=\"b773-6\">Respondent counters that our
+             holding in <em>Johnson, </em>515 U. S. 304, confirms
+            """,
+            # https://www.courtlistener.com/opinion/8524158/in-re-cahill/
+            # Test no overlap with single-name-and-pincite full case citation
+            """ was not con-firmable. <em>Nobelman v. Am. Sav. Bank, </em>
+            508 U.S. 324, 113 S.Ct. 2106, 124 L.Ed.2d 228 (1993). That plan
+             residence.” <em>Nobelman </em>at 332, 113 S.Ct. 2106.
+             Section 1123(b)(5) codifies the
+            """,
+        ]
+        for markup_text in texts:
+            plain_text = clean_text(markup_text, ["html", "all_whitespace"])
+            citations = get_citations(plain_text, markup_text=markup_text)
+            self.assertFalse(
+                any(
+                    [isinstance(cite, ReferenceCitation) for cite in citations]
                 )
+            )