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)
3033from 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+
193249def _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 ,
0 commit comments