Skip to content

Commit fe47a95

Browse files
committed
Keep top-of-column section headings from being dropped as running headers
The raw-line running-furniture filter treated the whole top ~25% band as header candidates and removed repeating lines before block building, so a "SECTION 1/2/4" heading at the top of a column on a recurring page layout (us_constitution p5/p7/p8/p13/p15) was deleted as a phantom running header -- leaving no element and no bounding box. Tighten the line-level header band to the top 10% (LineHeaderBottomRatio = 0.90); the broader element-level repeating-furniture detector still runs after classification as the real backstop. Adds a regression test asserting those section headings keep positive-area bounding boxes.
1 parent b302e90 commit fe47a95

2 files changed

Lines changed: 41 additions & 4 deletions

File tree

src/PdfStruct.Tests/UsConstitutionFixtureTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ public void AllArticlesAndAmendmentsAreExtracted()
8787
"First Article reference must appear before first Amendment reference in document order.");
8888
}
8989

90+
/// <summary>
91+
/// Section headings at the top of a page or column are content, not running
92+
/// furniture. They must survive with usable bounding boxes so JSON consumers
93+
/// can cite the structural boundary directly.
94+
/// </summary>
95+
[Fact]
96+
public void TopOfColumnSectionHeadingsKeepBoundingBoxes()
97+
{
98+
var path = FixturePath("us_constitution.pdf");
99+
var result = new PdfStructParser().Parse(path);
100+
101+
AssertHasBox(result.Document.Kids, page: 5, text: "SECTION. 1");
102+
AssertHasBox(result.Document.Kids, page: 7, text: "SECTION. 1");
103+
AssertHasBox(result.Document.Kids, page: 8, text: "SECTION. 1");
104+
AssertHasBox(result.Document.Kids, page: 13, text: "SECTION 2", minLeft: 300);
105+
AssertHasBox(result.Document.Kids, page: 15, text: "SECTION 4", minLeft: 300);
106+
}
107+
90108
/// <summary>Extracts the text content of any text-bearing element, or empty for non-text element types.</summary>
91109
private static string GetText(ContentElement element) => element switch
92110
{
@@ -96,6 +114,18 @@ public void AllArticlesAndAmendmentsAreExtracted()
96114
_ => string.Empty
97115
};
98116

117+
private static void AssertHasBox(IEnumerable<ContentElement> elements, int page, string text, double minLeft = 0)
118+
{
119+
var element = elements.FirstOrDefault(e =>
120+
e.PageNumber == page
121+
&& e.BoundingBox.Left >= minLeft
122+
&& string.Equals(GetText(e).Trim(), text, StringComparison.Ordinal));
123+
124+
Assert.NotNull(element);
125+
Assert.True(element.BoundingBox.Width > 0 && element.BoundingBox.Height > 0,
126+
$"{text} on page {page} must have a positive-area bounding box.");
127+
}
128+
99129
/// <summary>Parses a Roman numeral (I..MMMM) into its integer value. Throws on invalid input.</summary>
100130
private static int ParseRoman(string roman)
101131
{

src/PdfStruct/PdfStructParser.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ public sealed class PdfStructParser
9090
@"^\s*(자료|출처|source|data source|note|notes)\s*[::]|^\s*https?://|^\s*www\.|doi\.org|^\s*doi\s*:",
9191
RegexOptions.IgnoreCase | RegexOptions.Compiled);
9292

93+
/// <summary>
94+
/// Raw line filtering runs before paragraph grouping and heading
95+
/// classification, so header candidates must be close to the physical page
96+
/// edge. The broader element-level detector still runs after classification.
97+
/// </summary>
98+
private const double LineHeaderBottomRatio = 0.90;
99+
93100
/// <summary>Initializes with default options.</summary>
94101
public PdfStructParser() : this(new PdfStructOptions()) { }
95102

@@ -2029,9 +2036,9 @@ private static Dictionary<int, IReadOnlyList<TextLineBlock>> FilterRunningFurnit
20292036
/// <summary>
20302037
/// Classifies a line's bounding box as belonging to the page's header,
20312038
/// footer, or side-furniture band, or returns <c>null</c> when the box
2032-
/// sits in the body region. Header and footer bands are defined by Y
2033-
/// ratios; side bands require a narrow-and-tall box hugging the left or
2034-
/// right page edge.
2039+
/// sits in the body region. The line-level header band is deliberately
2040+
/// tighter than the post-classification element detector because this pass
2041+
/// cannot yet distinguish content headings from page furniture.
20352042
/// </summary>
20362043
private static RunningFurnitureBand? ClassifyRunningFurnitureBand(Models.BoundingBox bbox, PageGeometry pageGeometry)
20372044
{
@@ -2042,7 +2049,7 @@ private static Dictionary<int, IReadOnlyList<TextLineBlock>> FilterRunningFurnit
20422049
var topRatio = bbox.Top / pageGeometry.Height;
20432050
if (topRatio < RunningFurnitureDetector.FooterBandBottomRatio)
20442051
return RunningFurnitureBand.Footer;
2045-
if (bottomRatio > 1.0 - RunningFurnitureDetector.HeaderBandTopRatio)
2052+
if (bottomRatio > LineHeaderBottomRatio)
20462053
return RunningFurnitureBand.Header;
20472054

20482055
var nearLeftOrRightEdge = bbox.Right <= pageGeometry.Width * 0.12

0 commit comments

Comments
 (0)