Skip to content

Commit b374a45

Browse files
diluculoclaude
andcommitted
Cluster heading levels on indent and alignment, preserve authoritative
Two extensions to AssignHeadingLevels. - Headings that arrive with HeadingLevel != 0 are treated as authoritative and left untouched. The classifier convention is now inverted: FontBasedElementClassifier emits HeadingLevel = 0 (label empty) so AssignHeadingLevels can cluster the heading among its style siblings, while a pattern-driven classifier such as RegexHeadingClassifier sets a specific HeadingLevel per matched pattern and that level survives the assignment pass. The hierarchy a regex classifier intentionally encodes (chapter = 2, section = 3, …) is no longer overwritten by a typography-only re-clustering. - Indent and alignment join the style key. The clustering used to be (font size, bold, font name); a document whose chapter, section, and sub-section headings share font and weight but sit at distinct left margins all collapsed into a single level. Add an indent bucket (rounded to 5pt) and a coarse alignment rank (centred vs left-aligned) so visually distinct layout roles cluster into distinct levels, while still letting font size dominate the ordering. Tests pass (78/78) and the playground heading-count distribution is unchanged on fixtures that were already clean — the indent+alignment extensions are activated only when style alone fails to discriminate levels, which the current playground does not exercise heavily; they are groundwork for fixtures with monolithic typography (legal-style hierarchies that ride on indent, primarily). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 244a6af commit b374a45

2 files changed

Lines changed: 86 additions & 17 deletions

File tree

src/PdfStruct/Analysis/FontBasedElementClassifier.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -581,14 +581,21 @@ private static bool IsBulleted(string text)
581581
+ "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
582582
+ "❶❷❸❹❺❻❼❽❾❿";
583583

584-
/// <summary>Creates a <see cref="HeadingElement"/> for the given block. Heading level is provisional (always 1) and is refined by a later pass that clusters headings by typographic style.</summary>
584+
/// <summary>
585+
/// Creates a <see cref="HeadingElement"/> for the given block. The heading
586+
/// level is left unassigned (<c>0</c>) and the label empty so the
587+
/// downstream <c>AssignHeadingLevels</c> pass can cluster the heading
588+
/// alongside its style siblings. A regex- or pattern-driven classifier
589+
/// that knows the heading's hierarchical level emits non-zero values
590+
/// directly; <c>AssignHeadingLevels</c> preserves those.
591+
/// </summary>
585592
private static HeadingElement CreateHeading(TextBlock block, int pageNumber, ref int id) => new()
586593
{
587594
Id = id++,
588595
PageNumber = pageNumber,
589596
BoundingBox = block.BoundingBox,
590-
HeadingLevel = 1,
591-
Level = "Doctitle",
597+
HeadingLevel = 0,
598+
Level = string.Empty,
592599
Text = ToTextProperties(block)
593600
};
594601

src/PdfStruct/PdfStructParser.cs

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ private PdfStructResult ParseInternal(UglyToad.PdfPig.PdfDocument pdf, string fi
266266

267267
TemplateClassConsistency.PromoteSharedTemplates(doc.Kids);
268268

269-
AssignHeadingLevels(doc.Kids);
269+
var pageWidths = pageGeometries.ToDictionary(pair => pair.Key, pair => pair.Value.Width);
270+
AssignHeadingLevels(doc.Kids, pageWidths);
270271

271272
if (_options.ExcludeHeadersFooters)
272273
{
@@ -287,27 +288,40 @@ private PdfStructResult ParseInternal(UglyToad.PdfPig.PdfDocument pdf, string fi
287288

288289
/// <summary>
289290
/// Assigns numeric heading levels 1..N to <see cref="Models.HeadingElement"/>
290-
/// instances by clustering them on typographic style (font size, font name,
291-
/// derived bold flag) and ordering style groups from largest/heaviest to
291+
/// instances by clustering them on typographic and layout style
292+
/// (font size, font name, derived bold flag, indent bucket, page
293+
/// alignment) and ordering the resulting groups from largest/heaviest to
292294
/// smallest/lightest. Levels are uncapped on the data model; the Markdown
293295
/// renderer clamps to H6 at output time.
294296
/// </summary>
295297
/// <remarks>
296298
/// Ports the OpenDataLoader-pdf <c>HeadingProcessor</c> level-assignment
297-
/// pass: a document's distinct heading styles form a hierarchy without
298-
/// the parser needing to reason about specific heading semantics. If
299-
/// every heading shares the same style, all become level 1, which is
300-
/// consistent if uninformative.
299+
/// pass with two extensions: indent and alignment join the style key
300+
/// (so a document whose chapter/section/sub-section headings share font
301+
/// and weight but sit at distinct left margins can still cluster into
302+
/// distinct levels), and headings that arrive with a non-zero
303+
/// <see cref="Models.HeadingElement.HeadingLevel"/> are treated as
304+
/// already-authoritative and left unchanged. The latter preserves the
305+
/// hierarchy a pattern-driven classifier (e.g.
306+
/// <see cref="Analysis.RegexHeadingClassifier"/>) intentionally
307+
/// assigned per pattern.
301308
/// </remarks>
302-
private static void AssignHeadingLevels(List<Models.ContentElement> kids)
309+
private static void AssignHeadingLevels(
310+
List<Models.ContentElement> kids,
311+
IReadOnlyDictionary<int, double> pageWidths)
303312
{
304-
var headings = kids.OfType<Models.HeadingElement>().ToList();
305-
if (headings.Count == 0) return;
313+
var unassigned = kids
314+
.OfType<Models.HeadingElement>()
315+
.Where(h => h.HeadingLevel == 0)
316+
.ToList();
317+
if (unassigned.Count == 0) return;
306318

307-
var styleGroups = headings
308-
.GroupBy(h => new TextStyleKey(h.Text.FontSize, IsBoldFontName(h.Text.Font), h.Text.Font))
319+
var styleGroups = unassigned
320+
.GroupBy(h => BuildStyleKey(h, pageWidths))
309321
.OrderByDescending(g => g.Key.FontSize)
310322
.ThenByDescending(g => g.Key.IsBold)
323+
.ThenBy(g => g.Key.AlignmentRank)
324+
.ThenBy(g => g.Key.IndentBucket)
311325
.ThenBy(g => g.Key.FontName, StringComparer.Ordinal)
312326
.ToList();
313327

@@ -323,8 +337,56 @@ private static void AssignHeadingLevels(List<Models.ContentElement> kids)
323337
}
324338
}
325339

326-
/// <summary>Composite typographic-style key used for grouping headings.</summary>
327-
private readonly record struct TextStyleKey(double FontSize, bool IsBold, string FontName);
340+
/// <summary>
341+
/// Builds the composite style key used to group headings in
342+
/// <see cref="AssignHeadingLevels"/>. Font size, bold, and font name
343+
/// remain the primary axes; indent (rounded to a 5pt bucket) and
344+
/// alignment (centered vs left-aligned) are added so headings that
345+
/// share typography but differ in layout role end up in distinct
346+
/// groups — a sub-section indented one column further than its
347+
/// parent chapter, for example, or a centred document title above
348+
/// left-aligned section headings of the same font size.
349+
/// </summary>
350+
private static TextStyleKey BuildStyleKey(Models.HeadingElement heading, IReadOnlyDictionary<int, double> pageWidths)
351+
{
352+
var indentBucket = (int)Math.Round(heading.BoundingBox.Left / 5.0);
353+
var alignmentRank = ClassifyAlignment(heading, pageWidths);
354+
return new TextStyleKey(
355+
FontSize: heading.Text.FontSize,
356+
IsBold: IsBoldFontName(heading.Text.Font),
357+
FontName: heading.Text.Font,
358+
IndentBucket: indentBucket,
359+
AlignmentRank: alignmentRank);
360+
}
361+
362+
/// <summary>
363+
/// Maps a heading's horizontal position on its page to a coarse rank:
364+
/// <c>0</c> for centred (both side margins substantial and roughly
365+
/// equal), <c>1</c> for left-aligned, <c>2</c> when page geometry is
366+
/// unknown. The rank doubles as the within-cluster sort order, so a
367+
/// centred title naturally precedes a left-aligned heading of the
368+
/// same font size when both groups need to be ranked.
369+
/// </summary>
370+
private static int ClassifyAlignment(
371+
Models.HeadingElement heading,
372+
IReadOnlyDictionary<int, double> pageWidths)
373+
{
374+
if (!pageWidths.TryGetValue(heading.PageNumber, out var pageWidth) || pageWidth <= 0)
375+
return 2;
376+
377+
var leftMargin = heading.BoundingBox.Left;
378+
var rightMargin = pageWidth - heading.BoundingBox.Right;
379+
if (leftMargin <= 0 || rightMargin <= 0) return 1;
380+
381+
var minMargin = pageWidth * 0.15;
382+
if (leftMargin < minMargin || rightMargin < minMargin) return 1;
383+
384+
var asymmetry = Math.Abs(leftMargin - rightMargin) / pageWidth;
385+
return asymmetry < 0.05 ? 0 : 1;
386+
}
387+
388+
/// <summary>Composite typographic-and-layout style key used for grouping headings.</summary>
389+
private readonly record struct TextStyleKey(double FontSize, bool IsBold, string FontName, int IndentBucket, int AlignmentRank);
328390

329391
/// <summary>
330392
/// Heuristic bold detection from a font name. Mirrors the

0 commit comments

Comments
 (0)