Skip to content

Commit d304353

Browse files
authored
Merge pull request #5 from jet52/claude/implement-margin-requirements-to4Pa
Claude/implement margin requirements to4 pa better handling of small caps to avoid triggering minimum font size rule
2 parents 5c2b5f1 + a7f3f56 commit d304353

5 files changed

Lines changed: 456 additions & 29 deletions

File tree

core/checks_mechanical.py

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
PAPER_TOLERANCE,
2727
PAPER_WIDTH,
2828
SECTION_PATTERNS,
29+
SMALL_CAPS_SIZE_RATIO_MAX,
30+
SMALL_CAPS_SIZE_RATIO_MIN,
31+
SMALL_CAPS_SUSPICIOUS_PAGE_PCT,
2932
)
3033
from core.models import BriefMetadata, BriefType, CheckResult, Severity
3134

@@ -162,13 +165,30 @@ def _check_fonts(metadata: BriefMetadata) -> list[CheckResult]:
162165
return results
163166

164167

165-
def _classify_font_span(font: dict, page_height_pts: float) -> str:
166-
"""Classify a font span as 'header_footer', 'superscript', or 'body'.
168+
def _is_all_uppercase(text: str) -> bool:
169+
"""True if every alphabetic character in *text* is uppercase.
167170
168-
- header_footer: origin_y in top or bottom 10% of the page
169-
- superscript: PyMuPDF superscript flag (bit 0) set, or <=4 chars of
170-
digit-only text at small size (catches footnote markers / ordinal suffixes)
171-
- body: everything else
171+
Returns False when there are no alphabetic characters at all (pure
172+
digits / punctuation cannot be small caps).
173+
"""
174+
alpha = [c for c in text if c.isalpha()]
175+
return bool(alpha) and all(c.isupper() for c in alpha)
176+
177+
178+
def _classify_font_span(
179+
font: dict,
180+
page_height_pts: float,
181+
predominant_size: float | None = None,
182+
) -> str:
183+
"""Classify a noncompliant font span.
184+
185+
Returns one of:
186+
- ``"header_footer"`` — origin_y in top or bottom 10 % of the page
187+
- ``"superscript"`` — PyMuPDF superscript flag set, or ≤ 4 chars at
188+
small size (footnote markers / ordinal suffixes)
189+
- ``"small_caps"`` — all-uppercase text at 55–85 % of the predominant
190+
body font size (conventional small-caps formatting)
191+
- ``"body"`` — everything else (genuine undersized body text)
172192
"""
173193
origin_y = font.get("origin_y", page_height_pts / 2)
174194
top_zone = page_height_pts * 0.10
@@ -187,12 +207,66 @@ def _classify_font_span(font: dict, page_height_pts: float) -> str:
187207
if chars <= 4 and font["size"] < MIN_FONT_SIZE_PT - FONT_SIZE_TOLERANCE:
188208
return "superscript"
189209

210+
# --- Layer 1: Small-caps heuristic ---
211+
# Small caps in a PDF are encoded as uppercase glyphs at a reduced point
212+
# size (typically 60-80 % of the full body font). Detect them by
213+
# checking (a) all alpha chars are uppercase and (b) the size ratio
214+
# falls within the expected small-caps band.
215+
if predominant_size and predominant_size > 0:
216+
text = font.get("text", "")
217+
if text and _is_all_uppercase(text):
218+
ratio = font["size"] / predominant_size
219+
if SMALL_CAPS_SIZE_RATIO_MIN <= ratio <= SMALL_CAPS_SIZE_RATIO_MAX:
220+
return "small_caps"
221+
190222
return "body"
191223

192224

225+
# Patterns that identify pages where small caps are conventionally expected.
226+
_CONVENTIONAL_SC_PATTERNS = [
227+
re.compile(r"(?i)respectfully\s+submitted"),
228+
re.compile(r"(?i)certificate\s+of\s+(service|compliance|mailing)"),
229+
re.compile(r"(?i)table\s+of\s+(contents|authorities)"),
230+
]
231+
232+
233+
def _is_conventional_small_caps_page(
234+
page: "PageInfo", page_idx: int,
235+
) -> bool:
236+
"""Return True if the page is one where small caps are conventionally used.
237+
238+
Conventional pages:
239+
- Cover page (page 0): court name, party designations
240+
- TOC / TOA pages
241+
- Certificate of service / compliance / signature blocks
242+
"""
243+
if page_idx == 0:
244+
return True
245+
text = page.text
246+
return any(pat.search(text) for pat in _CONVENTIONAL_SC_PATTERNS)
247+
248+
193249
def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
194-
"""FMT-006: Font size >= 12pt with per-page detail and categorisation."""
250+
"""FMT-006: Font size >= 12pt with per-page detail, categorisation, and
251+
small-caps awareness.
252+
253+
Three-layer approach:
254+
1. **Heuristic detection** — each sub-12pt span is classified as
255+
header/footer, superscript, small_caps, or body.
256+
2. **Location-aware weighting** — small caps on conventional pages
257+
(cover, TOC/TOA, certificate / signature blocks) are always treated
258+
as benign. On non-conventional pages, small caps exceeding a
259+
per-page percentage threshold are reclassified as body text
260+
(guards against whole paragraphs set in small caps to evade the
261+
font-size rule).
262+
3. **Graduated severity** —
263+
* all noncompliant chars are harmless (sc + sup + hf) → PASS
264+
with informational note
265+
* some ambiguous body chars but below threshold → NOTE
266+
* substantial body text violations → REJECT
267+
"""
195268
threshold = MIN_FONT_SIZE_PT - FONT_SIZE_TOLERANCE
269+
predominant = metadata.predominant_font_size
196270
page_issues: list[dict] = [] # one entry per page that has noncompliant chars
197271

198272
for p in metadata.pages:
@@ -204,25 +278,39 @@ def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
204278
nc_body = 0
205279
nc_hf = 0
206280
nc_super = 0
281+
nc_small_caps = 0
207282
min_size_on_page: float | None = None
208283

209284
for f in p.fonts:
210285
char_count = f.get("chars", 1)
211286
total_chars += char_count
212287

213288
if f["size"] < threshold:
214-
category = _classify_font_span(f, page_height_pts)
289+
category = _classify_font_span(f, page_height_pts, predominant)
215290
if category == "header_footer":
216291
nc_hf += char_count
217292
elif category == "superscript":
218293
nc_super += char_count
294+
elif category == "small_caps":
295+
nc_small_caps += char_count
219296
else:
220297
nc_body += char_count
221298

222299
if min_size_on_page is None or f["size"] < min_size_on_page:
223300
min_size_on_page = f["size"]
224301

225-
nc_total = nc_body + nc_hf + nc_super
302+
# --- Layer 2: location-aware weighting ---
303+
# On non-conventional pages, if small caps exceed the suspicious
304+
# threshold they are reclassified as body text.
305+
if nc_small_caps > 0:
306+
is_conventional = _is_conventional_small_caps_page(p, p.page_number)
307+
if not is_conventional and total_chars > 0:
308+
sc_pct = nc_small_caps / total_chars * 100
309+
if sc_pct > SMALL_CAPS_SUSPICIOUS_PAGE_PCT:
310+
nc_body += nc_small_caps
311+
nc_small_caps = 0
312+
313+
nc_total = nc_body + nc_hf + nc_super + nc_small_caps
226314
if nc_total > 0:
227315
page_issues.append({
228316
"page": p.page_number + 1,
@@ -231,6 +319,7 @@ def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
231319
"nc_body": nc_body,
232320
"nc_hf": nc_hf,
233321
"nc_super": nc_super,
322+
"nc_small_caps": nc_small_caps,
234323
"min_size": min_size_on_page,
235324
})
236325

@@ -241,13 +330,45 @@ def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
241330
message=f"Font size meets the {MIN_FONT_SIZE_PT}pt minimum.",
242331
)
243332

244-
# Determine severity: REJECT if any page has >= threshold count, else NOTE
245-
any_serious = any(pi["nc_total"] >= FONT_NONCOMPLIANT_THRESHOLD for pi in page_issues)
246-
severity = Severity.REJECT if any_serious else Severity.NOTE
333+
# --- Layer 3: graduated severity ---
334+
total_nc_body = sum(pi["nc_body"] for pi in page_issues)
335+
total_nc_sc = sum(pi["nc_small_caps"] for pi in page_issues)
247336

248337
global_min = min(pi["min_size"] for pi in page_issues)
249338
bad_page_nums = [pi["page"] for pi in page_issues]
250339

340+
# (a) All noncompliant chars are harmless → PASS with informational note
341+
if total_nc_body == 0:
342+
cat_parts = []
343+
if total_nc_sc:
344+
sc_pages = sorted({pi["page"] for pi in page_issues if pi["nc_small_caps"]})
345+
cat_parts.append(f"small caps on pages {_page_list(sc_pages)}")
346+
total_hf = sum(pi["nc_hf"] for pi in page_issues)
347+
if total_hf:
348+
hf_pages = sorted({pi["page"] for pi in page_issues if pi["nc_hf"]})
349+
cat_parts.append(f"headers/footers on pages {_page_list(hf_pages)}")
350+
total_sup = sum(pi["nc_super"] for pi in page_issues)
351+
if total_sup:
352+
sup_pages = sorted({pi["page"] for pi in page_issues if pi["nc_super"]})
353+
cat_parts.append(f"superscripts on pages {_page_list(sup_pages)}")
354+
detail = ("Sub-12pt characters detected; all appear consistent with "
355+
"conventional formatting (not undersized body text).")
356+
if cat_parts:
357+
detail += "\n" + "; ".join(cat_parts) + "."
358+
return CheckResult(
359+
check_id="FMT-006", name="Minimum Font Size", rule="32(a)(5)",
360+
passed=True, severity=Severity.REJECT,
361+
message=f"Font size meets the {MIN_FONT_SIZE_PT}pt minimum "
362+
f"(sub-12pt characters are small caps / superscripts / headers).",
363+
details=detail,
364+
)
365+
366+
# (b) / (c) Some body violations exist — determine severity
367+
any_serious = any(
368+
pi["nc_body"] >= FONT_NONCOMPLIANT_THRESHOLD for pi in page_issues
369+
)
370+
severity = Severity.REJECT if any_serious else Severity.NOTE
371+
251372
page_label = "page" if len(bad_page_nums) == 1 else "pages"
252373
message = (
253374
f"Font size {global_min:.1f}pt found on {page_label} "
@@ -266,6 +387,8 @@ def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
266387
parts = []
267388
if pi["nc_body"]:
268389
parts.append(f"{pi['nc_body']} body")
390+
if pi["nc_small_caps"]:
391+
parts.append(f"{pi['nc_small_caps']} small caps")
269392
if pi["nc_hf"]:
270393
parts.append(f"{pi['nc_hf']} header/footer")
271394
if pi["nc_super"]:
@@ -276,6 +399,13 @@ def _check_font_size_per_page(metadata: BriefMetadata) -> CheckResult:
276399
f"({pct:.1f}%) noncompliant — {breakdown}"
277400
)
278401

402+
if total_nc_sc > 0:
403+
lines.append("")
404+
lines.append(
405+
f"Note: {total_nc_sc} sub-12pt characters appear consistent with "
406+
f"small-caps formatting and are excluded from the violation count."
407+
)
408+
279409
return CheckResult(
280410
check_id="FMT-006", name="Minimum Font Size", rule="32(a)(5)",
281411
passed=False, severity=severity,

core/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
FONT_SIZE_TOLERANCE = 0.3 # pt tolerance for font size detection
2121
FONT_NONCOMPLIANT_THRESHOLD = 10 # chars per page: >= this count is REJECT, below is NOTE
2222

23+
# Small caps detection — Rule 32(a)(5) font size check should not penalize
24+
# conventional small-caps formatting (headings, citations, cover page, signatures).
25+
SMALL_CAPS_SIZE_RATIO_MIN = 0.55 # smallest ratio of span size to body font
26+
SMALL_CAPS_SIZE_RATIO_MAX = 0.85 # largest ratio (above this it's near full-size)
27+
SMALL_CAPS_SUSPICIOUS_PAGE_PCT = 15.0 # % of page chars; above this, small caps on
28+
# non-conventional pages are treated as body text
29+
2330
# --- Spacing ---
2431
# Double spacing is ~24pt between baselines for 12pt text.
2532
# We allow some tolerance: anything >= 20pt is "double-spaced."

core/pdf_extract.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def _extract_page(page: fitz.Page, page_idx: int) -> PageInfo:
107107
"flags": span["flags"], # bold/italic flags
108108
"chars": len(span["text"].strip()),
109109
"origin_y": span["origin"][1],
110+
"text": span["text"].strip(), # for small caps detection
110111
})
111112

112113
# Margins: find bounding box of all content

0 commit comments

Comments
 (0)