Page Limit & Font Enforcement
Federal funding agencies reject non-conforming proposals before a reviewer reads a single sentence of science. A narrative that runs one line past the limit, a caption set in an unapproved typeface, or a margin that drifts below the declared minimum is enough to trigger administrative withdrawal inside the submission portal — and the portal applies that screen identically at 4:59 PM on a deadline day as it does a week early. Manual verification does not scale across proposals that span dozens of subsections, embedded vector figures, multi-author biographical sketches, and supplementary data tables. This page covers how to enforce the typographic and pagination envelope programmatically, intercepting violations during document assembly rather than at the deadline. It is one of the enforcement concerns inside the Compliance Validation & Rule Engines layer, and it depends on the section boundaries produced upstream by Required Section Mapping so that only countable narrative pages are measured against the limit.
The core failure this addresses is the silent formatting violation: a document that looks correct to the human eye but carries font descriptors, subsetted glyph metrics, or scaled point sizes that fall outside the National Institutes of Health (NIH), National Science Foundation (NSF), or Department of Defense (DoD) rules. Enforcement here is deterministic — the same compiled PDF must always produce the same verdict — so the engine reads the machine-level typographic data rather than trusting the rendered appearance or the document’s metadata.
Prerequisites and environment setup
Enforcement runs against the final compiled PDF — the artifact that will actually be uploaded — not the author’s source DOCX or LaTeX, because font substitution and point-size scaling happen at PDF export. Target Python 3.10 or later for the structural-pattern-matching and union-type syntax used throughout the engine.
python -m venv .venv && source .venv/bin/activate
pip install "pdfplumber>=0.11" "PyMuPDF>=1.24" "python-docx>=1.1" "pydantic>=2.6" "pytest>=8.0"
Two libraries do the heavy lifting and expose different surfaces:
PyMuPDF(imported asfitz) reads span-level font descriptors — family name, point size, flags, and origin coordinates — which is what typographic checks require.pdfplumberreconstructs page geometry and per-character bounding boxes, which is what margin and pagination checks require. The same coordinate-aware extraction underpins PDF Text Extraction with pdfplumber upstream in the ingestion workflow.
Assume the input PDF carries an embedded, extractable text layer. Scanned or image-only submissions have no font metadata at all and must be routed to the optical character recognition (OCR) fallback described under error handling. Assume, too, that fonts are embedded and subsetted — Grants.gov and eRA Commons both require full font embedding — which means family names arrive prefixed with a six-character subset tag such as ABCDEF+Arial that the engine has to normalize before comparison.
Core mechanism — how enforcement works internally
Enforcement treats policy as data and evaluation as code. Rather than hardcoding “eleven-point Arial” or “twelve pages” into scripts, each agency rule is loaded as a structured record and a common engine evaluates any compiled PDF against it. That separation is what lets the same engine serve a new Notice of Funding Opportunity (NOFO) by swapping parameters instead of rewriting parsing logic.
A formatting rule set has four measurable dimensions, and every one of them is read from the machine-level PDF data, never from the /Info metadata dictionary (which authoring tools populate unreliably):
- Font family — the resolved base font of each text span, after stripping the subset prefix.
- Point size — the effective rendered size, which is the nominal size multiplied by any text-matrix scale factor.
- Countable page count — total pages minus the exempt sections (references, biosketches, data management plans) identified by section mapping.
- Margins — the minimum horizontal and vertical offset of any glyph’s bounding box from the page edge.
The rule set itself is modeled as a validated schema so that a malformed agency configuration fails loudly at load time rather than silently passing every document. The following Pydantic v2 model is the contract every agency profile must satisfy:
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class FormattingRule(BaseModel):
"""Declarative typographic and pagination envelope for one agency profile."""
agency: str
allowed_fonts: frozenset[str]
min_point_size: float = Field(gt=0)
min_margin_inches: float = Field(gt=0)
max_countable_pages: int = Field(gt=0)
# Tolerance absorbs PDF-export rounding; see Threshold Tuning for Compliance.
point_tolerance: float = Field(default=0.05, ge=0)
@field_validator("allowed_fonts")
@classmethod
def normalize_font_names(cls, value: frozenset[str]) -> frozenset[str]:
# Compare case-insensitively and without whitespace so that
# "Palatino Linotype", "palatinolinotype", and a subset tag all match.
return frozenset(name.replace(" ", "").lower() for name in value)
def size_floor(self) -> float:
"""Smallest point size that is not a violation, after tolerance."""
return self.min_point_size - self.point_tolerance
The point_tolerance field is deliberately part of the schema rather than buried in the engine: the width of that band is a calibration decision that belongs to Threshold Tuning for Compliance, which sets how much point-size and margin drift counts as a harmless rendering artifact versus a genuine violation.
Rule-aware implementation
With the schema in place, the engine walks every text span in the document, resolves its true family and effective size, and emits a structured violation record for anything that falls outside the rule. Structured records — not booleans — are what let the downstream report route each failure to the page and text where it occurs.
import fitz # PyMuPDF
from pydantic import BaseModel
class Violation(BaseModel):
page: int
kind: str # "font_family" | "font_size" | "margin" | "page_count"
detail: str
text_preview: str = ""
def _normalize(font_name: str) -> str:
# Strip the six-char subset prefix ("ABCDEF+Arial" -> "Arial"),
# drop style suffixes, and match the rule's normalization.
base = font_name.split("+", 1)[-1]
base = base.split("-", 1)[0]
return base.replace(" ", "").lower()
def audit_typography(pdf_path: str, rule: FormattingRule) -> list[Violation]:
"""Scan every span for disallowed fonts or undersized text."""
violations: list[Violation] = []
with fitz.open(pdf_path) as doc:
for page_index, page in enumerate(doc, start=1):
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
for span in line["spans"]:
family = _normalize(span.get("font", "Unknown"))
# Effective size = nominal size * text-matrix scale.
scale = abs(span.get("size", 0.0))
preview = span.get("text", "")[:60].strip()
if not preview:
continue # ignore whitespace-only spans
if family not in rule.allowed_fonts:
violations.append(Violation(
page=page_index, kind="font_family",
detail=span.get("font", "Unknown"),
text_preview=preview,
))
elif scale < rule.size_floor():
violations.append(Violation(
page=page_index, kind="font_size",
detail=f"{scale:.2f}pt < {rule.min_point_size}pt",
text_preview=preview,
))
return violations
Pagination is measured separately, because the total page count is meaningless until exempt sections are removed. The engine takes the exempt page ranges resolved by section mapping and counts only what remains:
def audit_page_count(
total_pages: int,
exempt_pages: set[int],
rule: FormattingRule,
) -> list[Violation]:
"""Compare countable narrative length against the declared limit."""
countable = total_pages - len(exempt_pages)
if countable > rule.max_countable_pages:
return [Violation(
page=rule.max_countable_pages + 1,
kind="page_count",
detail=f"{countable} countable pages > {rule.max_countable_pages}",
)]
return []
Margin enforcement uses pdfplumber’s per-character bounding boxes: the minimum x0, minimum top, page-width-minus-x1, and page-height-minus-bottom across all characters give the true printed margins, which are then compared against rule.min_margin_inches * 72 (PDF user-space units are 1/72 inch). Because embedded figures often bleed a caption into the margin, margin checks are scoped to the countable narrative zones rather than the whole page.
Agency-specific configuration
The three agencies diverge on every dimension of the envelope, and the divergence is the entire reason for a data-driven engine. Each profile is one FormattingRule instance; the table below captures the parameters that most often cause a rejection. Values shown are representative of current single-density narrative requirements — a specific solicitation always overrides the agency default, which is why the active NOFO is parameterized rather than assumed.
| Dimension | NIH | NSF | DoD (Broad Agency Announcement) |
|---|---|---|---|
| Minimum point size | 11 pt (text); 10 pt for figure legends | 10 pt | Per-BAA; commonly 10–12 pt |
| Approved font families | Arial, Helvetica, Palatino Linotype, Georgia | Arial, Courier New, Palatino Linotype, and equivalents | Times New Roman or equivalent serif, per BAA |
| Minimum margins | 0.5 in on all sides | 1.0 in on all sides | Per-BAA; commonly 1.0 in |
| Line spacing | No more than 6 lines per inch | Single or greater | Per-BAA |
| Countable-page example | 12 pages (Research Strategy on many mechanisms) | 15 pages (Project Description) | Volume/section-specific |
NIH_RULE = FormattingRule(
agency="NIH",
allowed_fonts={"Arial", "Helvetica", "Palatino Linotype", "Georgia"},
min_point_size=11.0,
min_margin_inches=0.5,
max_countable_pages=12,
)
NSF_RULE = FormattingRule(
agency="NSF",
allowed_fonts={"Arial", "Courier New", "Palatino Linotype"},
min_point_size=10.0,
min_margin_inches=1.0,
max_countable_pages=15,
)
DOD_BAA_RULE = FormattingRule(
agency="DoD",
allowed_fonts={"Times New Roman"},
min_point_size=10.0,
min_margin_inches=1.0,
max_countable_pages=20, # overridden per BAA at load time
)
DoD profiles are the volatile case: because each Broad Agency Announcement (BAA) restates its own typographic clause, the DoD rule is almost never used at its default and is instead hydrated from the extracted solicitation requirements. That extraction is handled upstream, in the DoD BAA requirement extraction workflow, whose output populates allowed_fonts, min_point_size, and max_countable_pages before the engine runs.
Error handling and edge cases
Real submissions break naive enforcement in predictable ways, and each failure mode needs an explicit branch rather than an unhandled exception:
- Scanned or flattened PDFs. A page with no extractable text layer yields zero spans, which would otherwise read as a trivial “pass.” Detect an empty or near-empty
get_text()result per page and route the document to OCR-based extraction or a manual-review queue rather than silently approving it. - Subset name prefixes and style suffixes. Embedded fonts arrive as
WXYZAB+Arial-BoldMT. The_normalizehelper strips the+prefix and the first-suffix so the family matchesarial; skipping this step misclassifies every bold or italic run as a disallowed font. - Type 3 and synthetic fonts. Some LaTeX pipelines emit Type 3 bitmap fonts whose reported
sizeis unreliable. Flag any span whose font flags mark it synthetic for review instead of trusting its point size. - Scaled text. Authors occasionally squeeze content by applying a horizontal or full text-matrix scale below 1.0. Reading the effective size (
abs(span["size"]), which PyMuPDF already reports post-transform) catches an 11 pt glyph scaled to 10.4 pt that a nominal-size check would miss. - Figure and table text. Captions and axis labels legitimately use the smaller legend floor (10 pt for NIH), so size checks must know whether a span sits inside a countable narrative zone or a figure, which is exactly the zoning that section mapping provides.
- Tolerance thrash. A document sitting on the boundary (10.98 pt against an 11 pt floor) should land in a warning band, not oscillate between pass and fail across re-exports; that band width comes from the calibrated
point_tolerance, not from ad-hoc code.
Integration with downstream pipeline
Enforcement is a stage, not an endpoint. Its structured Violation records feed Automated Checklist Generation, which converts each record into a human-readable, NOFO-specific deficiency line routed to whoever can fix it — a page reference for an over-length narrative, a page-and-preview for an offending font run. When the engine returns clean, the document proceeds to assembly and submission-portal sync; when it does not, the assembler halts and surfaces the report while there is still time to re-export. A worked, agency-specific walkthrough of the pagination half of this flow lives in Enforcing NIH 12-page limit rules programmatically.
The diagram below traces how metrics flow from document extraction through the tolerance-band comparison to a final routing decision.
Testing and verification
Because the engine’s promise is determinism, its test suite pins behavior against known-bad and known-good fixtures. Keep a small library of golden PDFs — one clean submission, one with an undersized caption, one with Calibri smuggled into a table, one that runs one page long — and assert the exact violation set each produces. Validate the rule schema itself, too, so a typo in an agency profile fails at load rather than at submission.
import pytest
def test_font_family_violation_is_flagged():
rule = NIH_RULE
violations = audit_typography("fixtures/nih_calibri_table.pdf", rule)
kinds = {v.kind for v in violations}
assert "font_family" in kinds
assert any("Calibri" in v.detail for v in violations)
def test_clean_submission_passes():
assert audit_typography("fixtures/nih_clean.pdf", NIH_RULE) == []
def test_over_length_narrative_is_flagged():
# 13 countable pages against a 12-page NIH limit.
result = audit_page_count(total_pages=18, exempt_pages=set(range(13, 18)), rule=NIH_RULE)
assert result and result[0].kind == "page_count"
def test_boundary_size_within_tolerance_passes():
# 10.98 pt against an 11 pt floor with 0.05 pt tolerance -> compliant.
assert NIH_RULE.size_floor() == pytest.approx(10.95)
def test_malformed_rule_rejected_at_load():
with pytest.raises(ValueError):
FormattingRule(
agency="Bad", allowed_fonts=frozenset({"Arial"}),
min_point_size=-1, min_margin_inches=0.5, max_countable_pages=12,
)
For a pre-submission gate, wrap the two audits and the schema load in a single entry point that returns a non-zero exit code on any violation, so it drops cleanly into a continuous-assembly pipeline or a Git pre-push hook. Confirm end to end that a document known to violate produces the expected report before you trust the green path.
Shifting from retrospective manual audits to proactive programmatic enforcement removes an entire class of preventable submission failures. A well-architected envelope check keeps page and font constraints deterministic, version-controlled, and auditable across an institution’s whole portfolio.
Related
- Required Section Mapping — isolates countable narrative from exempt material so the page count is correct.
- Threshold Tuning for Compliance — sets the tolerance bands that separate rendering artifacts from real violations.
- Automated Checklist Generation — turns structured violations into a routed, human-readable deficiency report.
- PDF Text Extraction with pdfplumber — the coordinate-aware extraction layer that supplies margins and character geometry.
- Enforcing NIH 12-page limit rules programmatically — an agency-specific walkthrough of the pagination half of this flow.
Up one level: Compliance Validation & Rule Engines