Repository: opendataloader-project/opendataloader-pdf (2026-04-03T09:55:38Z)

Issue:
Footer detection absorbs body text on pages with nearby notes
## Context
Parent issue: #354 — reported by @jsmount on v2.0.2 with CERAGEM BALANCE user manual (52 pages).

## Problem
Footer detection over-extends on pages where body text is spatially close to the footer region.

**Pages 19–20:** Footer bounding box extends to y=135 (h≈100) instead of the normal y=43 (h≈9). This absorbs the body note `※ 출수 중 출수 버튼을 터치하면 출수가 정지됩니다.` (at y=116) into the footer element.

**Page 21:** Same document, same footer pattern — footer is correctly detected with compact bbox (h=9.2).

**Pages 6, 20:** Footer text (`06 CERAGEM BALANCE USER MANUAL`, `20 CERAGEM BALANCE USER MANUAL`) leaks into Markdown output instead of being filtered.

## Data

| Page | Footer bbox height | Body text absorbed? | Footer text in MD? |
|------|--------------------|--------------------|--------------------|
| 18   | 9.2                | No                 | No                 |
| 19   | 100.4              | Yes (※ note)       | No                 |
| 20   | 100.4              | Yes (※ note)       | Yes                |
| 21   | 9.2                | No                 | No                 |

## Reproduction
```bash
pip install opendataloader-pdf==2.0.2
# PDF: CERAGEM_BALANCE_사용설명서.pdf (in odl-test-fixtures/inbox/354-text-ordering-footer-detection/)
opendataloader-pdf convert --format json --pages 19,20,21 CERAGEM_BALANCE_사용설명서.pdf
# Check footer bounding box heights in JSON output
```

## Labels
bug


Discussion:

Patch:
diff --git a/java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/processors/HeaderFooterProcessor.java b/java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/processors/HeaderFooterProcessor.java
index 1149b7e19..08ac9cc75 100644
--- a/java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/processors/HeaderFooterProcessor.java
+++ b/java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/processors/HeaderFooterProcessor.java
@@ -147,6 +147,14 @@ private static List<IObject> processHeaderOrFooterContent(List<IObject> contents
         return newContents;
     }
 
+    /**
+     * Maximum vertical gap (in points) allowed between consecutive footer/header elements.
+     * If a candidate element is farther than this from the previously accepted element,
+     * it is not included in the header/footer region. This prevents body text that happens
+     * to repeat across pages from being absorbed into the footer.
+     */
+    private static final double MAX_HEADER_FOOTER_GAP = 30.0;
+
     private static List<Integer> getNumberOfHeaderOrFooterContentsForEachPage(List<List<IObject>> sortedContents, boolean isHeaderDetection) {
         List<Integer> numberOfHeaderOrFooterContentsForEachPage = new ArrayList<>(sortedContents.size());
         for (int pageNumber = 0; pageNumber < sortedContents.size(); pageNumber++) {
@@ -163,7 +171,13 @@ private static List<Integer> getNumberOfHeaderOrFooterContentsForEachPage(List<L
                 List<IObject> pageContents = sortedContents.get(pageNumber);
                 int index = isHeaderDetection ? currentIndex : pageContents.size() - 1 - currentIndex;
                 if (index >= 0 && index < pageContents.size()) {
-                    contents.add(pageContents.get(index));
+                    IObject candidate = pageContents.get(index);
+                    if (currentIndex > 0 && !isAdjacentToExistingHeaderOrFooter(
+                            pageContents, currentIndex, isHeaderDetection, candidate)) {
+                        contents.add(null);
+                    } else {
+                        contents.add(candidate);
+                    }
                 } else {
                     contents.add(null);
                 }
@@ -180,6 +194,22 @@ private static List<Integer> getNumberOfHeaderOrFooterContentsForEachPage(List<L
         return numberOfHeaderOrFooterContentsForEachPage;
     }
 
+    private static boolean isAdjacentToExistingHeaderOrFooter(
+            List<IObject> pageContents, int currentIndex, boolean isHeaderDetection, IObject candidate) {
+        int previousIndex = isHeaderDetection ? currentIndex - 1 : pageContents.size() - currentIndex;
+        if (previousIndex < 0 || previousIndex >= pageContents.size()) {
+            return true;
+        }
+        IObject previousElement = pageContents.get(previousIndex);
+        double gap;
+        if (isHeaderDetection) {
+            gap = previousElement.getBottomY() - candidate.getTopY();
+        } else {
+            gap = candidate.getBottomY() - previousElement.getTopY();
+        }
+        return gap <= MAX_HEADER_FOOTER_GAP;
+    }
+
     private static Set<Integer> getIndexesOfHeaderOrFootersContents(List<IObject> contents) {
         Set<Integer> result = new HashSet<>(contents.size());
         for (int pageNumber = 0; pageNumber < contents.size() - 1; pageNumber++) {


Test patch:
diff --git a/java/opendataloader-pdf-core/src/test/java/org/opendataloader/pdf/processors/HeaderFooterProcessorTest.java b/java/opendataloader-pdf-core/src/test/java/org/opendataloader/pdf/processors/HeaderFooterProcessorTest.java
index 5d0f77f8d..afa494632 100644
--- a/java/opendataloader-pdf-core/src/test/java/org/opendataloader/pdf/processors/HeaderFooterProcessorTest.java
+++ b/java/opendataloader-pdf-core/src/test/java/org/opendataloader/pdf/processors/HeaderFooterProcessorTest.java
@@ -32,12 +32,16 @@
 
 public class HeaderFooterProcessorTest {
 
-    @Test
-    public void testProcessHeadersAndFooters() {
+    private void initContainers() {
         StaticContainers.setIsDataLoader(true);
         StaticContainers.setIsIgnoreCharactersWithoutUnicode(false);
         StaticResources.setDocument(null);
-        StaticLayoutContainers.setCurrentContentId(0);
+        StaticLayoutContainers.clearContainers();
+    }
+
+    @Test
+    public void testProcessHeadersAndFooters() {
+        initContainers();
         List<List<IObject>> contents = new ArrayList<>();
         List<IObject> page1Contents = new ArrayList<>();
         page1Contents.add(new TextLine(new TextChunk(new BoundingBox(0, 10.0, 30.0, 20.0, 40.0),
@@ -69,4 +73,129 @@ public void testProcessHeadersAndFooters() {
         Assertions.assertTrue(contents.get(1).get(2) instanceof SemanticHeaderOrFooter);
         Assertions.assertEquals(SemanticType.FOOTER, ((SemanticHeaderOrFooter) contents.get(1).get(2)).getSemanticType());
     }
+
+    /**
+     * Tests that body text repeated on adjacent pages is not absorbed into the footer.
+     * Reproduces #385: pages 19-20 of CERAGEM PDF have identical note text
+     * "※ 출수 중 출수 버튼을 터치하면 출수가 정지됩니다." at y=116 above the actual
+     * footer at y=34. The note was incorrectly classified as footer because it matched
+     * across pages. Page height is 595 (A4-like).
+     */
+    @Test
+    public void testRepeatedBodyTextNotAbsorbedIntoFooter() {
+        initContainers();
+        // Simulate 4 pages (17-20) with A4-like height (595pt)
+        // Page bounding box: [0, 0, 420, 595]
+        // Footer line at y=35 (bottom), body note at y=117 (well above footer)
+        double pageHeight = 595.0;
+        double footerY = 35.0;
+        double bodyNoteY = 117.0;
+
+        List<List<IObject>> contents = new ArrayList<>();
+        for (int page = 0; page < 4; page++) {
+            List<IObject> pageContents = new ArrayList<>();
+            // Body heading at top
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, pageHeight - 60, 300.0, pageHeight - 30),
+                "Section " + (page + 1), 12, pageHeight - 30)));
+            // Body paragraph in middle
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, pageHeight / 2, 300.0, pageHeight / 2 + 30),
+                "Body content page " + (page + 1), 10, pageHeight / 2 + 30)));
+
+            // Repeated body note — same text on pages 2 and 3 (simulating pages 19-20)
+            if (page == 2 || page == 3) {
+                pageContents.add(new TextLine(new TextChunk(
+                    new BoundingBox(page, 223.0, bodyNoteY, 360.0, bodyNoteY + 18),
+                    "※ Repeated note text", 6.5, bodyNoteY + 18)));
+            }
+
+            // Actual footer line (repeating pattern across all pages)
+            String footerText = (page % 2 == 0)
+                ? "CGM BALANCE " + (page + 17)
+                : (page + 17) + " CERAGEM BALANCE USER MANUAL";
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, footerY, 280.0, footerY + 9),
+                footerText, 7.5, footerY + 9)));
+
+            contents.add(pageContents);
+        }
+
+        HeaderFooterProcessor.processHeadersAndFooters(contents, false);
+
+        // Verify: each page should have footer detected
+        for (int page = 0; page < 4; page++) {
+            List<IObject> pageContent = contents.get(page);
+            IObject lastElement = pageContent.get(pageContent.size() - 1);
+            Assertions.assertTrue(lastElement instanceof SemanticHeaderOrFooter,
+                "Page " + page + ": last element should be footer");
+            SemanticHeaderOrFooter footer = (SemanticHeaderOrFooter) lastElement;
+            Assertions.assertEquals(SemanticType.FOOTER, footer.getSemanticType());
+
+            // Critical: footer should contain only 1 element (the actual footer line),
+            // NOT the repeated body note
+            Assertions.assertEquals(1, footer.getContents().size(),
+                "Page " + page + ": footer should contain only the footer line, " +
+                "not absorb the repeated body note. Got " + footer.getContents().size() + " elements.");
+        }
+
+        // Verify: the repeated note text on pages 2-3 should still be in body content
+        for (int page = 2; page <= 3; page++) {
+            List<IObject> pageContent = contents.get(page);
+            boolean foundNote = false;
+            for (IObject obj : pageContent) {
+                if (!(obj instanceof SemanticHeaderOrFooter) && obj instanceof TextLine) {
+                    TextLine line = (TextLine) obj;
+                    if (line.getValue().contains("Repeated note")) {
+                        foundNote = true;
+                        break;
+                    }
+                }
+            }
+            Assertions.assertTrue(foundNote,
+                "Page " + page + ": repeated note text should remain in body, not be absorbed into footer");
+        }
+    }
+
+    /**
+     * Positive control: two closely spaced footer lines (gap < 30pt) should be
+     * grouped into a single footer. Ensures the proximity check does not reject
+     * legitimate multi-line footers.
+     */
+    @Test
+    public void testCloseFooterLinesAreGrouped() {
+        initContainers();
+        List<List<IObject>> contents = new ArrayList<>();
+        for (int page = 0; page < 3; page++) {
+            List<IObject> pageContents = new ArrayList<>();
+            // Body text at top
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, 500.0, 300.0, 530.0),
+                "Body text page " + (page + 1), 10, 530.0)));
+
+            // Two footer lines close together (11pt gap between nearest edges)
+            // Line 1: y=[55, 67]  Line 2: y=[35, 44]  gap = 55-44 = 11pt
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, 55.0, 280.0, 67.0),
+                "Copyright 2026", 7.5, 67.0)));
+            pageContents.add(new TextLine(new TextChunk(
+                new BoundingBox(page, 37.0, 35.0, 280.0, 44.0),
+                "Company Footer", 7.5, 44.0)));
+
+            contents.add(pageContents);
+        }
+
+        HeaderFooterProcessor.processHeadersAndFooters(contents, false);
+
+        for (int page = 0; page < 3; page++) {
+            List<IObject> pageContent = contents.get(page);
+            IObject lastElement = pageContent.get(pageContent.size() - 1);
+            Assertions.assertTrue(lastElement instanceof SemanticHeaderOrFooter,
+                "Page " + page + ": last element should be footer");
+            SemanticHeaderOrFooter footer = (SemanticHeaderOrFooter) lastElement;
+            Assertions.assertEquals(SemanticType.FOOTER, footer.getSemanticType());
+            Assertions.assertEquals(2, footer.getContents().size(),
+                "Page " + page + ": footer should contain both close footer lines (gap=11pt < 30pt)");
+        }
+    }
 }