diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/CustomOffsetsFromPositions.java b/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/CustomOffsetsFromPositions.java
new file mode 100644
index 00000000000..4e02ee28ad8
--- /dev/null
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/CustomOffsetsFromPositions.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.search.matchhighlight;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.tokenattributes.PositionIncrementAttribute;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.MatchesIterator;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.util.BytesRef;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Retrieve highlights from custom offset attribute for each position.
+ */
+public final class CustomOffsetsFromPositions implements OffsetsRetrievalStrategy {
+  private final String field;
+  private final Analyzer analyzer;
+
+  CustomOffsetsFromPositions(String field, Analyzer analyzer) {
+    this.field = field;
+    this.analyzer = analyzer;
+  }
+
+  @Override
+  public List<OffsetRange> get(MatchesIterator matchesIterator, MatchRegionRetriever.FieldValueProvider doc)
+      throws IOException {
+    ArrayList<OffsetRange> ranges = new ArrayList<>();
+
+    while (matchesIterator.next()) {
+      int from = matchesIterator.startPosition();
+      int to = matchesIterator.endPosition();
+      if (from < 0 || to < 0) {
+        throw new IOException("Matches API returned negative positions for field: " + field);
+      }
+      ranges.add(new OffsetRange(from, to));
+    }
+
+    // Convert from positions to offsets.
+    ranges = convertPositionsToOffsets(ranges, analyzer, field, doc.getValues(field));
+
+    return ranges;
+  }
+
+  @Override
+  public boolean requiresDocument() {
+    return true;
+  }
+
+  private static ArrayList<OffsetRange> convertPositionsToOffsets(
+      ArrayList<OffsetRange> ranges,
+      Analyzer analyzer,
+      String fieldName,
+      List<CharSequence> values)
+      throws IOException {
+
+    if (ranges.isEmpty()) {
+      return ranges;
+    }
+
+    class LeftRight {
+      int left = Integer.MAX_VALUE;
+      int right = Integer.MIN_VALUE;
+
+      @Override
+      public String toString() {
+        return "[" + "L: " + left + ", R: " + right + ']';
+      }
+    }
+
+    Map<Integer, LeftRight> requiredPositionSpans = new HashMap<>();
+    int minPosition = Integer.MAX_VALUE;
+    int maxPosition = Integer.MIN_VALUE;
+    for (OffsetRange range : ranges) {
+      requiredPositionSpans.computeIfAbsent(range.from, (key) -> new LeftRight());
+      requiredPositionSpans.computeIfAbsent(range.to, (key) -> new LeftRight());
+      minPosition = Math.min(minPosition, range.from);
+      maxPosition = Math.max(maxPosition, range.to);
+    }
+
+    int position = -1;
+    int valueOffset = 0;
+    for (int valueIndex = 0, max = values.size(); valueIndex < max; valueIndex++) {
+      final String value = values.get(valueIndex).toString();
+      final boolean lastValue = valueIndex + 1 == max;
+
+      TokenStream ts = analyzer.tokenStream(fieldName, value);
+      TestCustomTokenOffsets.CustomOffsetAttribute offsetAttr = ts.getAttribute(
+          TestCustomTokenOffsets.CustomOffsetAttribute.class);
+      PositionIncrementAttribute posAttr = ts.getAttribute(PositionIncrementAttribute.class);
+      CharTermAttribute charTerm = ts.getAttribute(CharTermAttribute.class);
+      ts.reset();
+      while (ts.incrementToken()) {
+        position += posAttr.getPositionIncrement();
+
+        if (position >= minPosition) {
+          LeftRight leftRight = requiredPositionSpans.get(position);
+           if (leftRight != null) {
+            int startOffset = valueOffset + offsetAttr.startOffset();
+            int endOffset = valueOffset + offsetAttr.endOffset();
+
+            leftRight.left = Math.min(leftRight.left, startOffset);
+            leftRight.right = Math.max(leftRight.right, endOffset);
+          }
+
+          // Only short-circuit if we're on the last value (which should be the common
+          // case since most fields would only have a single value anyway). We need
+          // to make sure of this because otherwise offsetAttr would have incorrect value.
+          if (position > maxPosition && lastValue) {
+            break;
+          }
+        }
+      }
+      ts.end();
+      position += posAttr.getPositionIncrement() + analyzer.getPositionIncrementGap(fieldName);
+      valueOffset += offsetAttr.endOffset() + analyzer.getOffsetGap(fieldName);
+      ts.close();
+    }
+
+    ArrayList<OffsetRange> converted = new ArrayList<>();
+    for (OffsetRange range : ranges) {
+      LeftRight left = requiredPositionSpans.get(range.from);
+      LeftRight right = requiredPositionSpans.get(range.to);
+      if (left == null
+          || right == null
+          || left.left == Integer.MAX_VALUE
+          || right.right == Integer.MIN_VALUE) {
+        throw new RuntimeException("Position not properly initialized for range: " + range);
+      }
+      converted.add(new OffsetRange(left.left, right.right));
+    }
+
+    return converted;
+  }
+}
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestCustomTokenOffsets.java b/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestCustomTokenOffsets.java
new file mode 100644
index 00000000000..32ad41fb10f
--- /dev/null
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestCustomTokenOffsets.java
@@ -0,0 +1,311 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.search.matchhighlight;
+
+import com.carrotsearch.randomizedtesting.RandomizedTest;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.Token;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
+import org.apache.lucene.analysis.tokenattributes.OffsetAttributeImpl;
+import org.apache.lucene.analysis.tokenattributes.PositionIncrementAttribute;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchesIterator;
+import org.apache.lucene.search.PhraseQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.Attribute;
+import org.apache.lucene.util.AttributeImpl;
+import org.apache.lucene.util.AttributeReflector;
+import org.apache.lucene.util.IOUtils;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.hamcrest.Matchers.*;
+
+public class TestCustomTokenOffsets extends LuceneTestCase {
+  private static final String FLD_ID = "field_id";
+  private static final String FLD_TEXT_POS = "field_text";
+
+  private Analyzer analyzer;
+
+  public interface CustomOffsetAttribute extends Attribute {
+    public int startOffset();
+    public void setOffset(int startOffset, int endOffset);
+    public int endOffset();
+  }
+
+  public static class CustomOffsetAttributeImpl
+      extends AttributeImpl implements CustomOffsetAttribute {
+    int start, end;
+
+    @Override
+    public int startOffset() {
+      return start;
+    }
+
+    @Override
+    public void setOffset(int startOffset, int endOffset) {
+      this.start = startOffset;
+      this.end = endOffset;
+    }
+
+    @Override
+    public int endOffset() {
+      return end;
+    }
+
+    @Override
+    public void clear() {
+      start = end = 0;
+    }
+
+    @Override
+    public void reflectWith(AttributeReflector reflector) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void copyTo(AttributeImpl target) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static class CustomTokenStream extends TokenStream {
+    private final Token[] tokens;
+    private int upto = 0;
+    private final CustomOffsetAttribute offsetAtt = addAttribute(CustomOffsetAttribute.class);
+    private final PositionIncrementAttribute posIncrAtt = addAttribute(PositionIncrementAttribute.class);
+    private final CharTermAttribute charTermAttribute = addAttribute(CharTermAttribute.class);
+    private final int finalOffset;
+    private final int finalPosInc;
+
+    public CustomTokenStream(Token... tokens) {
+      super(Token.TOKEN_ATTRIBUTE_FACTORY);
+      this.tokens = tokens;
+      this.finalOffset = 0;
+      this.finalPosInc = 0;
+    }
+
+    @Override
+    public void end() throws IOException {
+      super.end();
+      posIncrAtt.setPositionIncrement(finalPosInc);
+      offsetAtt.setOffset(finalOffset, finalOffset);
+    }
+
+    @Override
+    public void reset() throws IOException {
+      upto = 0;
+      super.reset();
+    }
+
+    @Override
+    public boolean incrementToken() {
+      if (upto < tokens.length) {
+        clearAttributes();
+        // NOTE: this looks weird, casting offsetAtt to Token, but because we are using the Token class's AttributeFactory, all attributes are
+        // in fact backed by the Token class, so we just copy the current token into our Token:
+        Token token = tokens[upto++];
+        offsetAtt.setOffset(token.startOffset(), token.endOffset());
+        posIncrAtt.setPositionIncrement(token.getPositionIncrement());
+        charTermAttribute.setLength(0);
+        charTermAttribute.append(token);
+        return true;
+      } else {
+        return false;
+      }
+    }
+  }
+
+  private static class CustomAnalyzer extends Analyzer {
+    @Override
+    protected TokenStreamComponents createComponents(String fieldName) {
+      // Always produces a constant token stream, just for the test.
+      Token[] tokens = {
+          new Token("organic", 1, 0, 7),
+          new Token("light", 1, 8, 13),
+          new Token("light-emitting-diode", 0, 8, 28),
+          new Token("emitting", 1, 14, 22),
+          new Token("diode", 1, 23, 28),
+          new Token("light-emitting-diode", 0, 8, 28),
+          new Token("glows", 1, 29, 34)
+      };
+      return new TokenStreamComponents((reader) -> {}, new CustomTokenStream(tokens));
+    }
+  }
+
+  @Before
+  public void setup() {
+    Map<String, Analyzer> fieldAnalyzers = new HashMap<>();
+    fieldAnalyzers.put(FLD_TEXT_POS, new CustomAnalyzer());
+    analyzer = new PerFieldAnalyzerWrapper(new MissingAnalyzer(), fieldAnalyzers);
+  }
+
+  @Test
+  public void testAnyOffsets() throws IOException {
+    String field = FLD_TEXT_POS;
+
+    withReader(
+        List.of(
+            Map.of(field, values("organic light emitting diode glows"))),
+        reader -> {
+          // Note that the match on 'light' highlights the largest offset
+          // range indexed over the match range's position. In this and the
+          // next case all the way up to 'light emitting diode'.
+          assertThat(highlights(fld -> new CustomOffsetsFromPositions(fld, analyzer),
+              reader, new TermQuery(new Term(field, "light"))),
+              containsInAnyOrder(
+                  fmt("0: (%s: 'organic >light emitting diode< glows')", field)));
+
+          assertThat(highlights(fld -> new CustomOffsetsFromPositions(fld, analyzer),
+              reader, new PhraseQuery(0, field, "diode", "glows")),
+              containsInAnyOrder(
+                  fmt("0: (%s: 'organic >light emitting diode glows<')", field)));
+
+          // Note 'double' highlight over light emitting diode as it actually
+          // matches on two positions with the same offsets.
+          assertThat(highlights(fld -> new CustomOffsetsFromPositions(fld, analyzer),
+              reader, new TermQuery(new Term(field, "light-emitting-diode"))),
+              containsInAnyOrder(
+                  fmt("0: (%s: 'organic >>light emitting diode<< glows')", field)));
+
+          // Phrase query over 'backward' offsets synonym at 'diode'.
+          assertThat(highlights(fld -> new CustomOffsetsFromPositions(fld, analyzer),
+              reader, new PhraseQuery(0, field, "light-emitting-diode", "glows")),
+              containsInAnyOrder(
+                  fmt("0: (%s: 'organic >light emitting diode glows<')", field)));
+        });
+  }
+
+  private List<String> highlights(IndexReader reader, Query query) throws IOException {
+    return highlights(MatchRegionRetriever.computeOffsetRetrievalStrategies(reader, analyzer),
+        reader, query);
+  }
+
+  private List<String> highlights(OffsetsRetrievalStrategySupplier offsetsStrategySupplier,
+                                  IndexReader reader, Query query) throws IOException {
+    IndexSearcher searcher = new IndexSearcher(reader);
+    int maxDocs = 1000;
+
+    Query rewrittenQuery = searcher.rewrite(query);
+    TopDocs topDocs = searcher.search(rewrittenQuery, maxDocs);
+
+    ArrayList<String> highlights = new ArrayList<>();
+
+    AsciiMatchRangeHighlighter formatter = new AsciiMatchRangeHighlighter(analyzer);
+
+    MatchRegionRetriever.MatchOffsetsConsumer highlightCollector =
+        (docId, leafReader, leafDocId, fieldHighlights) -> {
+          StringBuilder sb = new StringBuilder();
+
+          Document document = leafReader.document(leafDocId);
+          formatter
+              .apply(document, new TreeMap<>(fieldHighlights))
+              .forEach(
+                  (field, snippets) -> {
+                    sb.append(
+                        String.format(
+                            Locale.ROOT, "(%s: '%s')", field, String.join(" | ", snippets)));
+                  });
+
+          if (sb.length() > 0) {
+            sb.insert(0, document.get(FLD_ID) + ": ");
+            highlights.add(sb.toString());
+          }
+        };
+
+    MatchRegionRetriever highlighter = new MatchRegionRetriever(searcher, rewrittenQuery, analyzer,
+        offsetsStrategySupplier);
+    highlighter.highlightDocuments(topDocs, highlightCollector);
+
+    return highlights;
+  }
+
+  private String[] values(String... values) {
+    assertThat(values, not(emptyArray()));
+    return values;
+  }
+
+  private void withReader(
+      Collection<Map<String, String[]>> docs, IOUtils.IOConsumer<DirectoryReader> block)
+      throws IOException {
+    IndexWriterConfig config = new IndexWriterConfig(analyzer);
+
+    try (Directory directory = new ByteBuffersDirectory()) {
+      IndexWriter iw = new IndexWriter(directory, config);
+
+      int seq = 0;
+      for (Map<String, String[]> fields : docs) {
+        Document doc = new Document();
+        doc.add(new StringField(FLD_ID, Integer.toString(seq++), Field.Store.YES));
+        for (Map.Entry<String, String[]> field : fields.entrySet()) {
+          for (String value : field.getValue()) {
+            doc.add(toField(field.getKey(), value));
+          }
+        }
+        iw.addDocument(doc);
+        if (RandomizedTest.randomBoolean()) {
+          iw.commit();
+        }
+      }
+      iw.flush();
+
+      try (DirectoryReader reader = DirectoryReader.open(iw)) {
+        block.accept(reader);
+      }
+    }
+  }
+
+  private IndexableField toField(String name, String value) {
+    switch (name) {
+      case FLD_TEXT_POS:
+        return new TextField(name, value, Field.Store.YES);
+      default:
+        throw new AssertionError("Don't know how to handle this field: " + name);
+    }
+  }
+
+  private static String fmt(String string, Object... args) {
+    return String.format(Locale.ROOT, string, args);
+  }
+}
