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.

bash
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 as fitz) reads span-level font descriptors — family name, point size, flags, and origin coordinates — which is what typographic checks require.
  • pdfplumber reconstructs 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.

Two-lane compiled-PDF enforcement pipeline The final compiled PDF feeds two parallel lanes. The typographic lane uses PyMuPDF to read span-level data and resolve font family, effective point size, and font flags. The pagination lane uses pdfplumber to read per-character bounding boxes and derive margins and the countable page count after exempt sections are removed. Both lanes converge on a single evaluation against the FormattingRule inside its tolerance band, which routes the document to one of three verdicts: pass and submit, a tolerance-band warning for review, or fail and re-export. TYPOGRAPHIC LANE PAGINATION LANE Compiled PDF final upload · embedded fonts PyMuPDF get_text("dict") spans pdfplumber per-character bboxes Font family (normalized) Point size (post-scale) Font flags (synthetic?) Margins (min glyph offset) Countable page count total − exempt sections Evaluate vs FormattingRule within point_tolerance band PASS assemble & submit WARN tolerance review FAIL halt & re-export
One compiled PDF, two independent measurement lanes: PyMuPDF resolves span-level typography while pdfplumber reconstructs geometry and countable length. Both converge on a single tolerance-band evaluation that routes the document to pass, warn, or fail.

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):

  1. Font family — the resolved base font of each text span, after stripping the subset prefix.
  2. Point size — the effective rendered size, which is the nominal size multiplied by any text-matrix scale factor.
  3. Countable page count — total pages minus the exempt sections (references, biosketches, data management plans) identified by section mapping.
  4. 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:

python
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.

python
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:

python
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
python
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 _normalize helper strips the + prefix and the first - suffix so the family matches arial; 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 size is 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.

Page and font enforcement decision flow Extraction of text blocks and font data feeds a step that delineates countable from exempt sections. That step branches into two measurements: page count on the left and font family with point size on the right. The page-count measurement enters a decision — is the count within the limit? Within limit routes to Pass; over limit routes to Fail. The font measurement enters a second decision — is the font within the tolerance band? Compliant routes to Pass, borderline routes to Warning review, and non-compliant routes to Fail. Pass and Fail each collect edges from both decisions; Warning is reached only from the font decision. within limit over limit compliant borderline non-compliant Extract text blocks + font data Delineate countable vs exempt sections Measure page count Measure font family and point size Page count within limit? Font within tolerance band? PASS submit WARNING review FAIL re-export
The two measurements resolve independently: page count yields a binary pass/fail, while the font check adds a borderline warning band. Pass and Fail each collect verdicts from both branches; only the font decision can route to a warning review.

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.

python
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.

Up one level: Compliance Validation & Rule Engines