Skill Tracer
Skills › Scientific Agent Skills › venue-templates › validate_format.py

validate_format.py

340 lines · skills/venue-templates/scripts/validate_format.py at 5f2bc03

1#!/usr/bin/env python3
2"""Inspect a submission PDF using a verified limit or an explicit source.
3
4This tool does not infer where references or appendices begin and cannot prove
5margin or font-size compliance. Supply --content-pages after counting pages
6according to the official venue rule.
7
8Examples:
9 python scripts/validate_format.py --file paper.pdf --venue icml-2026 \
10 --content-pages 8
11 python scripts/validate_format.py --file proposal.pdf --max-pages 15 \
12 --content-pages 15 --source-url https://www.nsf.gov/policies/pappg
13"""
15from __future__ import annotations
17import argparse
18import subprocess
19from datetime import datetime, timezone
20from pathlib import Path
23PRESETS = {
24 "neurips-2026": {
25 "max_content_pages": 9,
26 "excluded": "references, checklist, and optional technical appendices; omit acknowledgments for review",
27 "source": "https://neurips.cc/Conferences/2026/MainTrackHandbook",
28 "checked": "2026-10-01",
29 },
30 "icml-2026": {
31 "max_content_pages": 8,
32 "excluded": "references and appendices",
33 "source": "https://icml.cc/Conferences/2026/AuthorInstructions",
34 "checked": "2026-10-01",
35 },
36 "iclr-2026": {
37 "max_content_pages": 9,
38 "excluded": "references and appendices",
39 "source": "https://iclr.cc/Conferences/2026/AuthorGuide",
40 "checked": "2026-10-01",
41 },
42 "cvpr-2026": {
43 "max_content_pages": 8,
44 "excluded": "cited references only",
45 "source": "https://cvpr.thecvf.com/Conferences/2026/AuthorGuidelines",
46 "checked": "2026-10-01",
47 },
48 "nsf-standard": {
49 "max_content_pages": 15,
50 "excluded": "all separately uploaded proposal components",
51 "source": "https://www.nsf.gov/policies/pappg",
52 "checked": "2026-10-01",
53 },
54 "nih-r01": {
55 "max_content_pages": 12,
56 "excluded": "all attachments outside the Research Strategy",
57 "source": (
58 "https://grants.nih.gov/grants-process/write-application/"
59 "how-to-apply-application-guide/page-limits"
60 ),
61 "checked": "2026-10-01",
62 },
66def run_poppler(command: str, pdf_path: Path) -> subprocess.CompletedProcess[str] | None:
67 try:
68 return subprocess.run(
69 [command, str(pdf_path.resolve())],
70 capture_output=True,
71 text=True,
72 check=True,
73 timeout=30,
74 )
75 except FileNotFoundError:
76 return None
77 except subprocess.TimeoutExpired as error:
78 raise RuntimeError(f"{command} timed out after 30 seconds") from error
79 except subprocess.CalledProcessError as error:
80 message = error.stderr.strip() or str(error)
81 raise RuntimeError(f"{command} failed: {message}") from error
84def pdf_info(pdf_path: Path) -> dict[str, str] | None:
85 result = run_poppler("pdfinfo", pdf_path)
86 if result is None:
87 return None
89 info = {}
90 for line in result.stdout.splitlines():
91 if ":" in line:
92 key, value = line.split(":", 1)
93 info[key.strip()] = value.strip()
94 return info
97def embedded_fonts(pdf_path: Path) -> list[dict[str, str | bool]] | None:
98 """Read all font rows, including fonts whose Poppler `emb` column is no."""
99 result = run_poppler("pdffonts", pdf_path)
100 if result is None:
101 return None
103 fonts = []
104 for line in result.stdout.splitlines()[2:]:
105 fields = line.split()
106 if not fields:
107 continue
108 # Type can contain spaces ("CID TrueType", "Type 1"). The last
109 # five columns are always emb, sub, uni, object number, generation.
110 if len(fields) < 7 or fields[-5] not in {"yes", "no"}:
111 raise RuntimeError("Unrecognized pdffonts row; inspect font output manually")
112 fonts.append({"name": fields[0], "embedded": fields[-5] == "yes"})
113 return fonts
116def page_count_result(
117 total_pages: int | None,
118 content_pages: int | None,
119 max_content_pages: int | None,
120 excluded: str | None,
121) -> dict[str, str]:
122 if total_pages is None:
123 return {
124 "status": "skip",
125 "message": "pdfinfo is unavailable; install Poppler to inspect the PDF.",
126 }
127 if max_content_pages is None:
128 return {
129 "status": "info",
130 "message": (
131 f"Total PDF pages: {total_pages}. No limit was supplied; verify the "
132 "official instructions manually."
133 ),
134 }
135 if content_pages is None:
136 exclusion_note = f" Excluded scope: {excluded}." if excluded else ""
137 return {
138 "status": "manual",
139 "message": (
140 f"Total PDF pages: {total_pages}; maximum content pages: "
141 f"{max_content_pages}.{exclusion_note} Re-run with --content-pages "
142 "after counting according to the official rule."
143 ),
144 }
145 if content_pages > total_pages:
146 return {
147 "status": "fail",
148 "message": (
149 f"Reported content pages ({content_pages}) exceed total PDF pages "
150 f"({total_pages})."
151 ),
152 }
153 if content_pages > max_content_pages:
154 return {
155 "status": "fail",
156 "message": (
157 f"Content-page limit exceeded: {content_pages}/{max_content_pages}."
158 ),
159 }
160 return {
161 "status": "pass",
162 "message": (
163 f"Content-page count is within the supplied limit: "
164 f"{content_pages}/{max_content_pages}. Total PDF pages: {total_pages}."
165 ),
166 }
169def font_result(fonts: list[dict[str, str | bool]] | None) -> dict[str, str]:
170 if fonts is None:
171 return {
172 "status": "skip",
173 "message": "pdffonts is unavailable; install Poppler to list embedded fonts.",
174 }
175 if not fonts:
176 return {
177 "status": "manual",
178 "message": "No font rows were reported; text may be outlined or rasterized. Inspect manually.",
179 }
180 missing = sorted({str(font["name"]) for font in fonts if not font["embedded"]})
181 if missing:
182 return {
183 "status": "fail",
184 "message": "Fonts not embedded: " + ", ".join(missing) + ". Re-export with fonts embedded.",
185 }
186 return {
187 "status": "info",
188 "message": (
189 "Embedded font names: "
190 + ", ".join(sorted({str(font["name"]) for font in fonts}))
191 + ". This does not verify font family or point size compliance."
192 ),
193 }
196def metadata_result(info: dict[str, str] | None) -> dict[str, str]:
197 if info is None:
198 return {
199 "status": "skip",
200 "message": "pdfinfo is unavailable; metadata was not inspected.",
201 }
202 fields = ["Title", "Author", "Creator", "Producer"]
203 values = [f"{field}={info[field]!r}" for field in fields if info.get(field)]
204 if not values:
205 return {"status": "info", "message": "No common identity metadata fields were set."}
206 return {
207 "status": "manual",
208 "message": (
209 "PDF metadata: "
210 + "; ".join(values)
211 + ". Check these fields for blind-review identity leaks."
212 ),
213 }
216def write_report(
217 report_path: Path,
218 pdf_path: Path,
219 source_url: str | None,
220 checked: str | None,
221 results: dict[str, dict[str, str]],
222) -> None:
223 lines = [
224 "Submission PDF Inspection",
225 "=" * 60,
226 f"Generated: {datetime.now(timezone.utc).isoformat()}",
227 f"File: {pdf_path}",
228 f"Official source: {source_url or 'not supplied'}",
229 f"Preset checked: {checked or 'not applicable'}",
230 "",
231 ]
232 for name, result in results.items():
233 lines.extend(
234 [
235 name.upper(),
236 f"Status: {result['status']}",
237 result["message"],
238 "",
239 ]
240 )
241 report_path.write_text("\n".join(lines), encoding="utf-8")
244def parse_args() -> argparse.Namespace:
245 parser = argparse.ArgumentParser(
246 description="Inspect page count, fonts, and metadata without claiming full compliance"
247 )
248 parser.add_argument("--file", required=True, help="PDF file to inspect")
249 limits = parser.add_mutually_exclusive_group()
250 limits.add_argument("--venue", choices=sorted(PRESETS), help="Dated initial-submission preset")
251 limits.add_argument("--max-pages", type=int, help="Explicit maximum content pages")
252 parser.add_argument(
253 "--content-pages",
254 type=int,
255 help="Content pages counted manually according to the official rule",
256 )
257 parser.add_argument("--source-url", help="Official source for an explicit limit")
258 parser.add_argument(
259 "--check",
260 default="page-count,fonts,metadata",
261 help="Comma-separated: page-count, fonts, metadata, all",
262 )
263 parser.add_argument("--report", help="Write a text report")
264 return parser.parse_args()
267def main() -> int:
268 args = parse_args()
269 pdf_path = Path(args.file)
270 if not pdf_path.is_file():
271 print(f"Error: file not found: {pdf_path}")
272 return 2
273 if pdf_path.suffix.lower() != ".pdf":
274 print(f"Error: expected a PDF file: {pdf_path}")
275 return 2
276 if args.max_pages is not None and args.max_pages < 1:
277 print("Error: --max-pages must be positive.")
278 return 2
279 if args.content_pages is not None and args.content_pages < 0:
280 print("Error: --content-pages cannot be negative.")
281 return 2
283 preset = PRESETS.get(args.venue, {})
284 max_content_pages = args.max_pages or preset.get("max_content_pages")
285 source_url = args.source_url or preset.get("source")
286 excluded = preset.get("excluded")
287 checked = preset.get("checked")
289 if args.max_pages is not None and not source_url:
290 print("Warning: supply --source-url so the explicit limit is auditable.")
292 checks = {item.strip() for item in args.check.split(",") if item.strip()}
293 if not checks:
294 print("Error: --check must select at least one inspection.")
295 return 2
296 allowed = {"page-count", "fonts", "metadata", "all"}
297 unknown = checks - allowed
298 if unknown:
299 print(f"Error: unknown checks: {', '.join(sorted(unknown))}")
300 return 2
301 if "all" in checks:
302 checks = {"page-count", "fonts", "metadata"}
304 try:
305 info = pdf_info(pdf_path)
306 total_pages = int(info["Pages"]) if info and info.get("Pages") else None
307 results = {}
308 if "page-count" in checks:
309 results["page-count"] = page_count_result(
310 total_pages,
311 args.content_pages,
312 max_content_pages,
313 excluded,
314 )
315 if "fonts" in checks:
316 results["fonts"] = font_result(embedded_fonts(pdf_path))
317 if "metadata" in checks:
318 results["metadata"] = metadata_result(info)
319 except (RuntimeError, ValueError) as error:
320 print(f"Error: {error}")
321 return 2
323 print(f"File: {pdf_path}")
324 if args.venue:
325 print(f"Preset: {args.venue} (checked {checked})")
326 print(f"Official source: {source_url or 'not supplied'}")
327 for name, result in results.items():
328 print(f"\n{name.upper()} [{result['status'].upper()}]")
329 print(result["message"])
331 if args.report:
332 report_path = Path(args.report)
333 write_report(report_path, pdf_path, source_url, checked, results)
334 print(f"\nReport saved to: {report_path}")
336 return 1 if any(result["status"] == "fail" for result in results.values()) else 0
339if __name__ == "__main__":
340 raise SystemExit(main())