
Academic Pdf To Gfm
- 66 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Helps with ai & agent building tasks.
About
academic-pdf-to-gfm is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- academic-pdf-to-gfm
- AI & Agent Building
- AI-coding skill
Academic Pdf To Gfm by the numbers
- 66 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #5,968 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill academic-pdf-to-gfmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 66 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Academic PDF → GitHub GFM Conversion
A battle-tested workflow for converting academic/research PDF papers into GitHub-renderable GFM markdown with inline figures, mathematically correct LaTeX, and validated output.
Battle-tested on: López de Prado (2026) "How to Use the Sharpe Ratio" — 51 pages, 82 equations, 8 figures.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
Quick Start (3 Steps)
# Step 1: Extract prose (best structure preservation)
uv run --python 3.14 --with pymupdf4llm python3 -c "
import pymupdf4llm
md = pymupdf4llm.to_markdown('paper.pdf')
open('paper-raw.md', 'w').write(md)
"
# Step 2: Extract images
uv run --python 3.14 --with pymupdf python3 references/extract-images.py paper.pdf
# Step 3: Validate math before pushing
node references/validate-math.mjs paper.md---
CRITICAL: Detect PDF Type First
This determines the entire workflow. Getting it wrong wastes hours.
Type A — Word-Generated PDF (Most Modern Academic Papers)
Signs: Embedded fonts, copyable text, Unicode math chars when you copy-paste (∑, π, α, β, γ, →)
Math encoding: Math is Unicode text in PDF stream — NOT images, NOT glyph maps
Consequence: OCR tools like marker-pdf cannot extract LaTeX — they see text like "γ₄" not \gamma_4. They may return empty output or crash silently.
Required approach:
1. Use pymupdf4llm for prose extraction 2. Manually transcribe all equations from PDF screenshots — there is no shortcut 3. Read each formula visually, write LaTeX by hand
How to confirm: Run marker-pdf — if output is empty or has zero math content, it's Type A.
Type B — LaTeX-Generated PDF
Signs: Computer Modern fonts, precise mathematical spacing, arxiv.org source available
Math encoding: Glyph-mapped — structure is partially extractable
Approach: pymupdf4llm or pdftotext for text. If arxiv source exists, extract directly from .tex (vastly preferred over PDF conversion).
Type C — Scanned/Image PDF
Signs: All pages are raster images, zero copyable text
Approach: OCR pipeline — marker-pdf is best option, or tesseract
---
Tool Comparison
| Tool | Best For | Install | Key Limitation |
|---|---|---|---|
pymupdf4llm | Type A/B prose (best structure) | uv run --with pymupdf4llm | Math as Unicode, not LaTeX |
pdftotext | Quick plain text | brew install poppler | Loses table structure |
markitdown | Alternative prose | uv run --with 'markitdown[pdf]' | Slight over-spacing; same math limit |
marker-pdf | Type C scanned only | pip install marker-pdf | Fails silently on Type A (Unicode text bug) |
Never trust `marker-pdf` output on Type A/B PDFs — the apparent "success" with empty math sections is the failure mode.
---
Image Extraction
Save references/extract-images.py:
import fitz, os, sys
doc = fitz.open(sys.argv[1])
os.makedirs("references/media", exist_ok=True)
saved = []
for page_num in range(len(doc)):
for img_idx, img in enumerate(doc[page_num].get_images(full=True)):
xref = img[0]
base_image = doc.extract_image(xref)
img_bytes = base_image["image"]
if len(img_bytes) < 2048: # skip icons/logos/watermarks/rules
continue
ext = base_image["ext"]
fname = f"fig-p{page_num+1:02d}-{img_idx+1:02d}.{ext}"
with open(f"references/media/{fname}", "wb") as f:
f.write(img_bytes)
saved.append((page_num+1, fname, base_image.get("width"), base_image.get("height")))
print(f"Saved: {fname} ({len(img_bytes)//1024}KB, {base_image.get('width')}×{base_image.get('height')})")
doc.close()
print(f"\n{len(saved)} images saved to references/media/")Naming: fig-p{page:02d}-{idx:02d}.{ext} — page number in name for easy location matching.
Size filter: Skip < 2 KB (captures icons, watermarks, horizontal rules). Review everything ≥ 2 KB — some are decorative but most are figures.
Insert in markdown:
Place immediately after the nearest section heading or the paragraph that references the figure.
---
GitHub GFM Math Rendering Rules
The $$ vs `math ` Decision — Root Cause
GitHub's Markdown pre-processor runs BEFORE the math renderer. It treats \\ as an escaped backslash and collapses it to \. This breaks LaTeX line breaks in display math.
The rule is simple:
| Equation type | Use | Reason |
|---|---|---|
| Single-line display | $$...$$ | No \\ → pre-processor safe |
Multi-line (contains \\, \begin{aligned}, matrices) | `math ` | Pre-processor does NOT process code fences |
| Inline | $...$ | Standard |
````markdown
BROKEN on GitHub — \\ stripped by pre-processor:
$$ \begin{aligned} a &= b + c \\ d &= e + f \end{aligned} $$
CORRECT on GitHub:
\begin{aligned}
a &= b + c \\
d &= e + f
\end{aligned}````
````
Display Block Formatting Rules
$$must be on its own line — not$$formula$$on one line- Blank line required before AND after every
$$block - Blank line required between consecutive
$$blocks - These rules do NOT apply to `
`math`` blocks
Supported/Unsupported LaTeX
See references/github-math-support-table.md for the full table.
Key things to avoid:
| Command | Problem | Fix |
|---|---|---|
\begin{align} | ❌ Not supported by GitHub | Use \begin{aligned} |
\boxed{} | ⚠️ Can cause raw LaTeX passthrough | Remove or use bold text |
\operatorname{} | ⚠️ Active GitHub bug, inconsistent | Use \text{} or \mathrm{} |
\newcommand | ❌ Was briefly available, then pulled | Expand all macros inline |
x^_y | Superscript immediately before subscript | Write x^{*}_{i} with braces |
Common Gotchas
\\[8pt]vertical spacing inside$$→ eaten by pre-processor → move to ``math``\frac{1}{T}:\left(→ spurious colon after fraction → remove colon- Pearson vs excess kurtosis: most finance formulas need Pearson (γ₄ = 3 for Gaussian), not excess. Always document the kurtosis convention in the formula comment.
\begin{pmatrix}with\\→ must use ``math``\begin{cases}with multiple rows → must use ``math``
---
GitLab: No Workarounds Needed
Empirically verified 2026-03-15 on GitLab CE 18.9.2. Confirmed by Comrak source code analysis.
GitLab uses the Comrak Rust parser with math_dollars: true. When Comrak encounters $$, it calls handle_dollars which slices the raw input buffer directly and stores it as a NodeMath AST node — CommonMark's backslash handler is never invoked on math content. The raw LaTeX is passed to KaTeX via <span data-math-style="display/inline"> unchanged.
Every GitHub workaround is unnecessary on GitLab:
| GitHub problem | GitHub fix required | GitLab |
|---|---|---|
\\ in $$ stripped → broken multiline | Use `math ` | $$ works with \\ |
\left\{ → \left{ (delimiter error) | Use \left\lbrace | \left\{ works |
\{...\} set notation → invisible braces | Use \lbrace...\rbrace | \{...\} works |
\, in $$ → literal comma | Remove \, | \, works |
\, in inline $ → literal comma | Remove \, | \, works |
On GitLab you can write standard LaTeX without any platform-specific workarounds. If you're targeting GitLab (or hosting your own GitLab CE), skip all the \lbrace/\rbrace substitutions and `math ` conversions — plain $$ with standard LaTeX is correct.
GitLab.com Has a Hard 50-Span Per-Page Limit
GitLab.com (SaaS) enforces a limit of 50 total math spans per page (display + inline combined). After the 50th span, all subsequent equations silently fall back to raw LaTeX text. This limit exists to prevent DoS attacks and cannot be overridden on GitLab.com.
| Document math density | gitlab.com | Self-hosted CE |
|---|---|---|
| ≤ 50 total spans | ✅ Renders fully | ✅ |
| 51–100 spans | ⚠️ Partial render | ✅ |
| 100+ spans (academic papers) | ❌ Most equations raw text | ✅ Disable with math_rendering_limits_enabled: false |
Validated on: Sharpe ratio paper (341 spans) — breaks at span 51 on gitlab.com, renders fully on local CE.
The W6 check in `validate-math.mjs` warns when a file exceeds the limit.
Summary: which platform to use:
- GitHub.com: No math span limit. Use
\lbrace/\rbraceworkarounds (handled by--fix). - Self-hosted GitLab CE: No limit (disable math_rendering_limits_enabled). No workarounds needed.
- GitLab.com: Only suitable for documents with ≤ 50 math spans.
Self-hosting GitLab CE for Math-Heavy Documents
GitLab CE is free and runs on a single machine. On a 61 GB workstation with slim config:
- Memory footprint: ~3 GB (
puma['worker_processes'] = 2,sidekiq['concurrency'] = 5, monitoring disabled) - Push mirroring to GitHub: free on CE (syncs within 5 min)
glabCLI: first-party, comparable togh
# docker-compose.yml — slim GitLab CE
services:
gitlab:
image: gitlab/gitlab-ce:latest
restart: unless-stopped
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://YOUR_IP:8929'
puma['worker_processes'] = 2
sidekiq['concurrency'] = 5
prometheus_monitoring['enable'] = false
alertmanager['enable'] = false
node_exporter['enable'] = false
redis_exporter['enable'] = false
postgres_exporter['enable'] = false
gitlab_exporter['enable'] = false
ports: ["8929:8929", "8922:22"]
volumes:
- /srv/gitlab/config:/etc/gitlab
- /srv/gitlab/logs:/var/log/gitlab
- /srv/gitlab/data:/var/opt/gitlab---
Validation Pipeline
Step 1: Install KaTeX Validator
bun add -g katex # Bun-first per project policy
# or: npm install -g katexStep 2: Run Before Every Push
# Validate only (exit 1 on errors)
node references/validate-math.mjs your-file.md
# Validate + auto-fix correctable issues
node references/validate-math.mjs your-file.md --fixThe script is at references/validate-math.mjs. It runs two layers:
Layer 1 — KaTeX syntax: parse errors in $, $$, `math ` blocks Layer 2 — GFM structural (issues KaTeX passes but GitHub breaks):
| Code | Severity | Issue | Auto-fix |
|---|---|---|---|
| E0 | Error | \! \, \; \{ \} in $$ block — pre-processor strips backslash → parse error cascade | ✅ spacing removed; \{→\lbrace |
| E0b | Warning | \{ \} \, in inline $...$ — invisible braces or literal commas in prose | ✅ → \lbrace/\rbrace; \, removed |
| E1 | Error | $$ block with \\ — GitHub pre-processor strips backslashes | ✅ → `math ` |
| E2 | Error | Consecutive $$ blocks without blank line — orphaned delimiter cascade | ✅ add blank line |
| W1 | Warning | Bare ^* in $$ or $ block — markdown italic pairing eats the * | ✅ → ^{\ast} |
| W2 | Warning | \begin{align} — not supported on GitHub | ✗ manual |
| W3 | Warning | \boxed{} — can cause raw LaTeX passthrough | ✗ manual |
| W4 | Warning | \operatorname{} — inconsistent GitHub support | ✗ manual |
E0 is the most dangerous: a single failing $$ block exposes its $$ delimiters as literal text, creating an orphaned $ that shifts ALL subsequent inline $...$ pairings. One broken equation takes down the entire document.
`\{`/`\}` trap: In $$ blocks, \left\{ becomes \left{ (invalid KaTeX delimiter → "Missing or unrecognized delimiter") and \{...\} set notation becomes invisible grouping. Fix: use \lbrace/\rbrace (letter-based, CommonMark-immune). This affects every equation using set notation like \{\hat{SR}_k\} or \min_T\left\{...\right\}.
Exits code 1 on errors (CI-friendly). Warnings do not block CI but should be reviewed.
Local Preview Tools
# GitHub-accurate hot-reload preview
bun add -g @hyrious/gfm
gfm your-file.md --serve
# Offline binary (gh extension)
gh extension install thiagokokada/gh-gfm-preview
gh gfm-preview your-file.mdVS Code extensions:
shd101wyy.markdown-preview-enhanced— closest to GitHub renderingbierner.markdown-preview-github-styles— GitHub CSS styling
---
Multi-Agent Adversarial Equation Validation
For papers with 10+ equations, use this multi-agent pattern:
Phase 1 — Parallel Extraction
- Agent A: Extract prose with pymupdf4llm, transcribe math from PDF screenshots
- Agent B: Extract and categorize all images
Phase 2 — Parallel Validation
- Agent C: Validate equations against reference implementation (if code/repo exists)
- Agent D: Numerical spot-checks — compute paper's exhibit values, compare
Phase 3 — Discrepancy Handling
- For each discrepancy: write
/tmp/paper-discrepancy/eq-{N}.md - Spawn resolver agents to search online for authoritative third-party sources
- Authority rule: Paper is tentatively more authoritative than code implementation; a third independent source breaks ties
Phase 4 — Guarded Application
- Apply only HIGH-confidence fixes to the markdown
- For MEDIUM-confidence: spawn an independent audit agent before touching the file
- Document all discrepancies even if not fixed — future readers need to know
---
Anti-Patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
\!\left( or \, in $$ blocks | GH pre-processor strips \!→! before KaTeX — !\left( crashes KaTeX, cascades all | Remove \! \, \; (spacing only) — or use `math ` |
\left\{ or \{...\} in $$/$ blocks | \{→{ (CommonMark escape), so \left\{→\left{ = "Missing delimiter" error, and \{x\} renders without visible braces | Replace with \left\lbrace, \right\rbrace, \lbrace, \rbrace |
$$\begin{aligned}...\\...\end{aligned}$$ | \\ stripped by GH pre-processor | Use `math ` |
Trusting marker-pdf on Word PDFs | Returns no output or zero math (Unicode bug) | Read as screenshots, transcribe manually |
\begin{align} in display math | Not supported by GitHub | Replace with \begin{aligned} |
\operatorname{Cov} | Active GH bug — sometimes renders raw | Use \text{Cov} or \mathrm{Cov} |
KaTeX validation only, no `math ` conversion | KaTeX passes but GH pre-processor still breaks \\ | Also convert ALL multi-line blocks |
\boxed{} for highlighting | Can cause raw LaTeX passthrough on GitHub | Use bold text or a blockquote callout |
| Excess kurtosis in formulas expecting Pearson | Silent ~50% underestimate in variance formulas | Always document convention; use scipy.stats.kurtosis(fisher=False) |
Consecutive $$ blocks without blank lines | GitHub collapses them into one broken block | Add blank line between each block |
| Running validation AFTER pushing | Bugs visible in public repo | Validate locally before every push (--fix auto-corrects E0/E1/E2) |
---
References
| File | Purpose |
|---|---|
| validate-math.mjs | KaTeX batch validator for GFM files |
| pdf-type-detection.md | Detailed guide to detecting PDF type |
| github-math-support-table.md | Full supported/unsupported LaTeX table |
---
Related Skills
| Skill | Relationship |
|---|---|
| pandoc-pdf-generation | Opposite direction: markdown → PDF |
| documentation-standards | GFM formatting standards |
| quant-research:opendeviation-eval-metrics | Worked example: references/how-to-use-the-sharpe-ratio-2026.md |
Post-Execution Reflection
After this skill completes, reflect before closing the task:
0. Locate yourself. — Find this SKILL.md's canonical path before editing. 1. What failed? — Fix the instruction that caused it. 2. What worked better than expected? — Promote to recommended practice. 3. What drifted? — Fix any script, reference, or dependency that no longer matches reality. 4. Log it. — Evolution-log entry with trigger, fix, and evidence.
Do NOT defer. The next invocation inherits whatever you leave behind.
{
"skill_name": "academic-pdf-to-gfm",
"evals": [
{
"id": 0,
"prompt": "I downloaded a 40-page SSRN working paper in PDF — it's a finance paper about portfolio optimization with about 25 equations. When I copy text from it, the math symbols come through as Unicode (∑, π, σ, etc.). I want to put it on GitHub as a markdown file. Which extraction tool should I use, and how do I handle the math formulas?",
"expected_output": "Should identify this as a Type A Word-generated PDF based on the Unicode math copy-paste behavior. Should recommend pymupdf4llm for prose extraction. Should clearly state that marker-pdf will NOT work. Should explain that math must be manually transcribed from PDF screenshots since there is no automated LaTeX extraction for Word PDFs. Should mention the need to use $$...$$ for single-line equations and ```math...``` fenced blocks for multi-line equations with \\.",
"files": []
},
{
"id": 1,
"prompt": "I pushed a markdown file to GitHub and my matrix equation is showing as raw LaTeX instead of rendered math. Here's what I wrote:\n\n$$\n\\begin{pmatrix}\na & b \\\\\nc & d\n\\end{pmatrix}\n$$\n\nWhat's wrong and how do I fix it?",
"expected_output": "Should identify the root cause: GitHub's Markdown pre-processor strips \\\\ before the math renderer sees it, which breaks the matrix line break. Should recommend converting to ```math fenced block syntax. Should provide the corrected version using ```math...``` instead of $$. Should explain that this affects ANY equation with \\\\ line breaks.",
"files": []
},
{
"id": 2,
"prompt": "I have a 200-line markdown file with about 50 math equations. Some are inline ($...$), some are display blocks ($$...$$), and a few are ```math fenced blocks. Before I push this to GitHub, how do I validate all the equations are syntactically correct? I want a CI-friendly solution.",
"expected_output": "Should recommend installing KaTeX (bun add -g katex or npm install -g katex). Should provide or reference the validate-math.mjs script that extracts all three block types and validates each with KaTeX. Should note it exits with code 1 on errors (CI-friendly). Should also note the important caveat: KaTeX passing does NOT guarantee GitHub renders correctly — the user must also ensure multi-line blocks use ```math not $$.",
"files": []
}
]
}
GitHub GFM Math Support Table
Complete reference for LaTeX commands supported/unsupported on GitHub's GFM renderer (powered by MathJax with KaTeX-compatible subset).
Last verified: 2026-03 (López de Prado 2026 paper conversion, 82 equations)
Official Open Bug Reports
| Issue | What it documents |
|---|---|
| Community #17143 | \, \: \; \! stripped by markdown pre-processor (open bug) |
| Community #121416 | \\ double-backslash stripped in $$ display blocks (open bug) |
| Nico Schlömer analysis | Comprehensive catalog of GitHub math rendering bugs |
Gap: No existing FOSS tool detects the pre-processor stripping issues. They all run AFTER the markdown layer. validate-math.mjs in this skill is the only validator that simulates this layer statically.
---
Environments
| Environment | Status | Notes |
|---|---|---|
\begin{aligned}...\end{aligned} | ✅ Supported | Use inside `math ` if multi-line |
\begin{align}...\end{align} | ❌ NOT supported | Replace with aligned |
\begin{align*}...\end{align*} | ❌ NOT supported | Replace with aligned |
\begin{equation}...\end{equation} | ❌ NOT supported | Use $$ or `math ` block |
\begin{equation*}...\end{equation*} | ❌ NOT supported | Same |
\begin{gather}...\end{gather} | ❌ NOT supported | Use multiple $$ blocks |
\begin{pmatrix}...\end{pmatrix} | ✅ Supported | Needs `math ` if contains \\ |
\begin{bmatrix}...\end{bmatrix} | ✅ Supported | Needs `math ` if contains \\ |
\begin{vmatrix}...\end{vmatrix} | ✅ Supported | Needs `math ` if contains \\ |
\begin{cases}...\end{cases} | ✅ Supported | Needs `math ` if multi-row |
\begin{array}...\end{array} | ✅ Supported | Needs `math ` if multi-row |
---
Display Math Delimiters
| Delimiter | Status | Notes |
|---|---|---|
$$...$$ (block, own line) | ✅ Supported | Must have blank line before and after |
`math...` (fenced) | ✅ Supported | Pre-processor safe — use for any \\ content |
$...$ (inline) | ✅ Supported | Single line only |
\(...\) (inline) | ❌ Not supported | Use $...$ |
\[...\] (display) | ❌ Not supported | Use $$ or `math ` |
---
Common Math Commands
Fractions, Roots, Sums
| Command | Status | Notes |
|---|---|---|
\frac{}{} | ✅ | |
\dfrac{}{} | ✅ | Display-size fraction |
\sqrt{} | ✅ | |
\sqrt[n]{} | ✅ | nth root |
\sum, \prod, \int | ✅ | |
\sum_{i=1}^{n} | ✅ | With limits |
Operators and Functions
| Command | Status | Notes |
|---|---|---|
\log, \ln, \exp | ✅ | |
\sin, \cos, \tan | ✅ | |
\max, \min, \sup, \inf | ✅ | |
\text{} | ✅ | Text in math |
\mathrm{} | ✅ | Roman text in math |
\mathbf{} | ✅ | Bold math |
\mathbb{} | ✅ | Blackboard bold (ℝ, ℤ, etc.) |
\operatorname{} | ⚠️ Active GitHub bug | Inconsistent; use \text{} or \mathrm{} instead |
Accents and Decorators
| Command | Status | Notes |
|---|---|---|
\hat{} | ✅ | |
\widehat{} | ✅ | |
\bar{} | ✅ | |
\overline{} | ✅ | |
\tilde{} | ✅ | |
\vec{} | ✅ | |
\dot{}, \ddot{} | ✅ |
Greek Letters
| Command | Status |
|---|---|
\alpha, \beta, \gamma, \delta, \epsilon, \varepsilon | ✅ |
\zeta, \eta, \theta, \iota, \kappa, \lambda | ✅ |
\mu, \nu, \xi, \pi, \rho, \sigma | ✅ |
\tau, \upsilon, \phi, \varphi, \chi, \psi, \omega | ✅ |
\Gamma, \Delta, \Theta, \Lambda, \Xi, \Pi, \Sigma, \Phi, \Psi, \Omega | ✅ |
Arrows and Relations
| Command | Status |
|---|---|
\to, \rightarrow, \leftarrow | ✅ |
\Rightarrow, \Leftarrow, \Leftrightarrow | ✅ |
\leq, \geq, \neq, \approx, \equiv | ✅ |
\in, \notin, \subset, \supset | ✅ |
\sim, \propto | ✅ |
Delimiters
| Command | Status | Notes |
|---|---|---|
\left(, \right) | ✅ | |
\left[, \right] | ✅ | |
\left\{, \right\} | ⚠️ GFM-UNSAFE | CommonMark strips \{→{; use \left\lbrace instead |
\left\lbrace, \right\rbrace | ✅ GFM-safe | Letter-based; immune to CommonMark pre-processor |
| `\left\ | , \right\ | ` (double) |
---
Commands to Avoid
| Command | Problem | Replacement |
|---|---|---|
\left\{, \right\} | ⚠️ CommonMark strips \{→{ so \left\{→\left{ = "Missing or unrecognized delimiter" | \left\lbrace, \right\rbrace |
\{...\} set notation | ⚠️ CommonMark strips \{→{ making \{x\} render as invisible group (no visible braces) | \lbrace...\rbrace |
\boxed{} | ⚠️ Can cause raw LaTeX passthrough in some GitHub parsing contexts | Bold text **formula** or blockquote |
\operatorname{} | ⚠️ Active GitHub bug — renders raw in some contexts | \text{} or \mathrm{} |
\begin{align} | ❌ Not supported at all | \begin{aligned} |
\newcommand{} | ❌ Was briefly available, then removed by GitHub | Expand macros inline |
\DeclareMathOperator | ❌ Never supported | \mathrm{} per-use |
\\[8pt] spacing | Vertical spacing modifiers stripped by pre-processor | Remove or use `math ` |
x^_y | "Missing open brace for superscript" | x^{*}_{y} or brace the superscript |
---
The $$ vs `math ` Decision Tree
```` Does the equation contain any of:
- \\ (line breaks)
- \begin{aligned} with multiple rows
- \begin{pmatrix}, \begin{bmatrix} (matrices)
- \begin{cases} with multiple cases
- \\[8pt] or other vertical spacing
? ├── Yes → Use ``math ... ` └── No → Use $$ ... $$ ```
When in doubt, use ` ```math ``` ` — it is always safe. The only reason to prefer $$ is that it renders slightly faster and is more universally supported in non-GitHub renderers (VS Code, Jupyter, etc.).
---
Display Block Formatting Rules
# REQUIRED: blank line before and after each $$ block
$$
E = mc^2
$$
# REQUIRED: blank line between consecutive $$ blocks
$$
a + b = c
$$
$$
d + e = f
$$
# WRONG: consecutive blocks without blank lines
$$
a + b = c
$$
$$
d + e = f ← GitHub collapses this into the first block
$$---
KaTeX vs GitHub Rendering Differences
KaTeX validation (node validate-math.mjs) catches parse errors but NOT GitHub pre-processor issues.
After KaTeX passes, also check:
1. All multi-line blocks (\\ present) use `math ` not $$ 2. All $$ blocks have blank lines before and after 3. No \boxed{} usage 4. No \operatorname{} usage — use \text{} instead 5. No \begin{align} — use \begin{aligned}
PDF Type Detection Guide
Identifying the PDF type is the most important first step. Using the wrong extraction approach wastes hours.
Detection Checklist
Run these quick checks before choosing any tool:
Test 1: Copy-Paste Check (30 seconds)
Open the PDF in Preview (macOS) or any PDF viewer. Copy a formula or math expression. Paste into a text editor.
- You see Unicode math symbols (∑, π, α, β, γ, →, ≤, ≥, ∈): → Type A (Word-generated)
- You see garbled glyphs or nothing: → possibly Type B (LaTeX) or Type C (scanned)
- Copy is impossible (nothing selectable): → Type C (scanned/image)
Test 2: Font Inspection (2 minutes)
In macOS Preview: File → Properties → Fonts, or use:
pdffonts paper.pdf- Embedded fonts like "TimesNewRoman", "Arial", "Calibri": → Type A (Word/Office)
- Computer Modern, Latin Modern, CM-Super: → Type B (LaTeX/TeX)
- No fonts listed: → Type C (scanned images)
Test 3: marker-pdf Smoke Test (1 minute)
uv run --python 3.14 --with marker-pdf marker_single paper.pdf /tmp/marker-out --output_format markdown
cat /tmp/marker-out/*.md | head -50- Output has LaTeX math (
$\frac{...}$,\sum, etc.): → probably Type B or C - Output has zero math or replaces formulas with Unicode text: → Type A (marker-pdf's Unicode bug)
- Output is empty / crash with torch error: → Type A or Type C with incompatible paper size
Test 4: arxiv Check (10 seconds)
Look for the paper on arxiv.org. If found:
- Download the
.tar.gzsource:https://arxiv.org/src/{arxiv-id} - Extract and check for
.texfiles - If
.texexists: extract directly from source — skip PDF entirely
---
Type A: Word-Generated PDF (Most Common for Recent Academic Papers)
Examples: SSRN papers, working papers, journal submissions from MS Word
Identification
- Microsoft-style fonts (Times New Roman, Calibri, Arial, Georgia)
- Unicode math symbols visible when copy-pasted
pdffontsshows no math-specific fontsmarker-pdfreturns empty math sections or Unicode text instead of LaTeX
What Works
| Tool | Outcome |
|---|---|
pymupdf4llm | ✅ Best prose extraction with structure (tables, headings) |
pdftotext | ✅ Plain text, loses table structure |
markitdown | ✅ Alternative prose, slight over-spacing |
marker-pdf | ❌ Returns empty or Unicode-only output |
Math Extraction Approach
There is no automated solution. You must:
1. Read the PDF page by page 2. Screenshot each page containing equations (macOS: Cmd+Shift+4) 3. Write LaTeX manually by inspecting the visual formula 4. Cross-reference against any code implementations if available
Tools that help:
- Read PDF as screenshots within Claude Code using the Read tool on PNG files
- Use reference implementations (Python code, Julia notebooks) to verify formulas
- Search online for the specific formula + paper title to find existing LaTeX typesettings
Efficiency Tips for Manual Transcription
- Work top-to-bottom: prose first, then formulas in order
- Number equations as you go — reference numbers make validation easier
- For recurring symbols, decide convention early (e.g., $\hat{S}$ vs $\tilde{S}$)
- Use
alignedinside$$for single-line display (no\\),`math`for multi-line
---
Type B: LaTeX-Generated PDF
Examples: ArXiv preprints, ACM/IEEE proceedings, Springer/Elsevier journals
Identification
- Computer Modern or Latin Modern fonts
- Precise mathematical spacing (kerning around operators)
- Text is selectable but copy-pasted math may show glyph codes not Unicode
- arxiv.org source download yields
.texfiles
What Works
| Tool | Outcome |
|---|---|
pymupdf4llm | ✅ Good prose, Unicode math chars (still needs manual LaTeX) |
pdftotext -layout | ✅ Decent structure preservation |
marker-pdf | ⚠️ Partially works — may extract some LaTeX, but unreliable |
Math Extraction: Prefer Source
If arxiv source is available, skip PDF:
# Download source
curl -L "https://arxiv.org/src/2412.12345" -o paper-source.tar.gz
tar -xzf paper-source.tar.gz
ls *.tex
# Convert .tex to markdown (approximate)
pandoc main.tex -o paper.md --mathjaxWarning: Pandoc .tex → markdown conversion is imperfect for complex papers. Use it as a starting point and clean up.
---
Type C: Scanned/Image PDF
Examples: Older papers, scans of physical documents, digitized theses
Identification
- Zero selectable text
pdffontsreturns nothing- All pages are raster images at constant DPI
What Works
| Tool | Outcome |
|---|---|
marker-pdf | ✅ Best neural OCR pipeline |
tesseract | ✅ Open-source OCR fallback |
pymupdf4llm | ❌ Returns empty or headers only |
marker-pdf Pipeline
# Install (requires GPU or CPU inference)
pip install marker-pdf
# Convert single file
marker_single paper.pdf /tmp/marker-out --output_format markdown
# If torch error on Apple Silicon:
PYTORCH_ENABLE_MPS_FALLBACK=1 marker_single paper.pdf /tmp/marker-out
# Check output quality
cat /tmp/marker-out/*.md | wc -l # should be > 100 lines for a real paperQuality check: marker-pdf quality varies. Always review the output equations against the original PDF screenshots. Expect 80-90% accuracy on clean scans; less on degraded documents.
---
Decision Flowchart
Can you copy-paste text from the PDF?
├── No → TYPE C (scanned) → use marker-pdf
└── Yes
├── Copy-pasted math shows Unicode symbols (∑ π α)?
│ └── Yes → TYPE A (Word) → pymupdf4llm + manual math transcription
└── No
├── Is paper on arxiv with .tex source?
│ └── Yes → extract from .tex (bypass PDF entirely)
└── No → TYPE B (LaTeX) → pymupdf4llm + marker-pdf attempt#!/usr/bin/env node
// validate-math.mjs — KaTeX + GFM structural validator for GitHub-flavored markdown
// Usage: node validate-math.mjs <file.md> [--fix]
// Exits 1 on errors (CI-friendly)
//
// Two-layer validation:
// Layer 1 — KaTeX: parse errors in $, $$, ```math blocks
// Layer 2 — GFM: structural issues KaTeX passes but GitHub pre-processor breaks
//
// GFM checks:
// E1: multi-line $$ blocks with \\ → must use ```math (GitHub strips \\ in $$ mode)
// E2: consecutive $$ blocks without blank line → orphaned delimiter cascade
// W1: bare ^* in $$ blocks → markdown asterisk pairing eats the *
// W2: \begin{align} (not supported) → use \begin{aligned}
// W3: \boxed{} → can cause raw LaTeX passthrough
// W4: \operatorname{} → inconsistent GitHub support
//
// --fix: auto-corrects E1 ($$→```math), E2 (add blank line), W1 (^*→^{\ast})
import { readFileSync, writeFileSync } from 'fs';
import katex from 'katex';
const args = process.argv.slice(2);
const autoFix = args.includes('--fix');
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
console.error('Usage: node validate-math.mjs <file.md> [--fix]');
process.exit(2);
}
let src = readFileSync(filePath, 'utf8');
// Helper: get 1-based line number from a byte offset
const lineOf = (pos) => src.slice(0, pos).split('\n').length;
// Helper: collect ranges that are inside fenced code blocks (```...```)
// so we can skip them in structural checks
function buildCodeBlockRanges(text) {
const ranges = [];
const re = /^```[^\n]*\n[\s\S]*?^```/gm;
for (const m of text.matchAll(re)) {
ranges.push([m.index, m.index + m[0].length]);
}
return ranges;
}
function inCodeBlock(pos, ranges) {
return ranges.some(([s, e]) => pos >= s && pos < e);
}
const codeRanges = buildCodeBlockRanges(src);
// ═══════════════════════════════════════════════════════
// LAYER 1: KaTeX syntax validation
// ═══════════════════════════════════════════════════════
console.log('── KaTeX Syntax Check ──────────────────────────────────────');
const blockRegex = /```math\n([\s\S]+?)```|\$\$\n?([\s\S]+?)\n?\$\$|\$([^\$\n]+?)\$/g;
let katexErrors = 0;
let checked = 0;
for (const match of src.matchAll(blockRegex)) {
if (inCodeBlock(match.index, codeRanges)) continue;
const [full, fence, display, inline] = match;
const eq = (fence || display || inline).trim();
if (!eq) continue;
checked++;
const ln = lineOf(match.index);
try {
katex.renderToString(eq, {
throwOnError: true,
displayMode: !inline,
strict: false,
});
} catch (e) {
console.error(` [KATEX] Line ~${ln}: ${e.message}`);
console.error(` ${eq.slice(0, 100).replace(/\n/g, ' ')}`);
katexErrors++;
}
}
console.log(`${katexErrors === 0 ? '✓' : '✗'} ${checked} equations checked, ${katexErrors} KaTeX error(s)\n`);
// ═══════════════════════════════════════════════════════
// LAYER 2: GFM structural checks
// ═══════════════════════════════════════════════════════
console.log('── GFM Structural Checks ───────────────────────────────────');
let gfmErrors = 0;
let gfmWarnings = 0;
// E0: $$ and $...$ blocks containing CommonMark escape sequences:
// \! \, \; \: (spacing) and \{ \} (delimiter escapes)
// GitHub's markdown pre-processor strips these BEFORE the math renderer runs:
// \, → , \! → ! \{ → { \} → }
// \!\left( → !\left( triggers a KaTeX parse error → cascades all subsequent equations.
// \left\{ → \left{ triggers "Missing or unrecognized delimiter for \left" parse error.
// Fix: use \lbrace/\rbrace instead of \{/\}; remove \! \, \; (cosmetic spacing only).
for (const m of src.matchAll(/^\$\$(.+)\$\$$/gm)) {
if (inCodeBlock(m.index, codeRanges)) continue;
const inner = m[1];
const badSeqs = (inner.match(/\\[!,;:{}\|]/g) || []);
if (badSeqs.length > 0) {
const ln = lineOf(m.index);
const uniq = [...new Set(badSeqs)].join(' ');
console.error(` [E0 ERROR] Line ~${ln}: $$ block contains ${uniq} — pre-processor strips backslash → parse error + cascade`);
if (badSeqs.some(s => s === '\\{' || s === '\\}')) {
console.error(` Fix \\{/\\}: replace \\left\\{ → \\left\\lbrace, \\right\\} → \\right\\rbrace, \\{ → \\lbrace, \\} → \\rbrace`);
} else {
console.error(` Fix: remove spacing commands (cosmetic only). Use \`\`\`math\`\`\` if you need them.`);
}
gfmErrors++;
}
}
// E0b: Inline $...$ blocks containing \{ \} or \, (CommonMark strips backslash)
for (const m of src.matchAll(/\$([^\$\n]+?)\$/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
const inner = m[1];
const badSeqs = (inner.match(/\\[{},]/g) || []).filter(s => s !== '\\\\');
if (badSeqs.length > 0) {
const ln = lineOf(m.index);
const uniq = [...new Set(badSeqs)].join(' ');
console.warn(` [W5 WARN] Line ~${ln}: inline $...$ contains ${uniq} — pre-processor strips → invisible braces or commas`);
console.warn(` Fix: replace \\{ → \\lbrace, \\} → \\rbrace, \\, → (remove)`);
gfmWarnings++;
}
}
// E1: Standalone $$ blocks (own-line $$) containing \\
// GitHub's pre-processor strips \\ before the math renderer sees it
// Fix: convert to ```math blocks
const standaloneDisplayRe = /\n\$\$\n([\s\S]+?)\n\$\$\n/g;
for (const m of src.matchAll(standaloneDisplayRe)) {
if (inCodeBlock(m.index, codeRanges)) continue;
if (m[1].includes('\\\\')) {
const ln = lineOf(m.index);
console.error(` [E1 ERROR] Line ~${ln}: $$ block with \\\\ will break on GitHub — use \`\`\`math instead`);
console.error(` ${m[1].split('\n')[0].slice(0, 80)}`);
gfmErrors++;
}
}
// E2: Consecutive $$ blocks without a blank line between them
// The closing $$ of block N and opening $$ of block N+1 on adjacent lines
// creates an orphaned $ that shifts all subsequent equation delimiters
const lines = src.split('\n');
let prevCloseLine = -2;
let inDollarBlock = false;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!inDollarBlock && t === '$$') {
// Check if previous close was on the immediately preceding line
if (i - prevCloseLine === 1) {
console.error(` [E2 ERROR] Line ${i + 1}: consecutive $$ block missing blank line (causes orphaned delimiter cascade)`);
gfmErrors++;
}
inDollarBlock = true;
} else if (inDollarBlock && t === '$$') {
prevCloseLine = i;
inDollarBlock = false;
}
}
// W1: Bare ^* in $$ display blocks — markdown pairs the asterisks as italic markers
for (const m of src.matchAll(/\n\$\$\n([\s\S]+?)\n\$\$\n/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
if (/\^\*(?!\{)/.test(m[1])) {
const ln = lineOf(m.index);
console.warn(` [W1 WARN] Line ~${ln}: bare ^* in $$ block — use ^{\\ast} to prevent markdown italic pairing`);
gfmWarnings++;
}
}
// Also check inline $ blocks
for (const m of src.matchAll(/\$([^\$\n]+?)\$/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
if (/\^\*(?!\{)/.test(m[1])) {
const ln = lineOf(m.index);
console.warn(` [W1 WARN] Line ~${ln}: bare ^* in inline $...$ — use ^{\\ast}`);
gfmWarnings++;
}
}
// W2: \begin{align} (not aligned) — not supported on GitHub
for (const m of src.matchAll(/\\begin\{align\}(?!ed)/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
const ln = lineOf(m.index);
console.warn(` [W2 WARN] Line ~${ln}: \\begin{align} is NOT supported on GitHub — use \\begin{aligned}`);
gfmWarnings++;
}
// W3: \boxed{} — can cause raw LaTeX passthrough on GitHub
for (const m of src.matchAll(/\\boxed\{/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
const ln = lineOf(m.index);
console.warn(` [W3 WARN] Line ~${ln}: \\boxed{} can cause raw LaTeX passthrough — consider \\mathbf{} or a blockquote`);
gfmWarnings++;
}
// W4: \operatorname{} — inconsistent GitHub support (active bug)
for (const m of src.matchAll(/\\operatorname\{/g)) {
if (inCodeBlock(m.index, codeRanges)) continue;
const ln = lineOf(m.index);
console.warn(` [W4 WARN] Line ~${ln}: \\operatorname{} has inconsistent GitHub support — use \\text{} or \\mathrm{}`);
gfmWarnings++;
}
// W6: GitLab.com hard limit — 50 total math spans per page
// After the 50th span (display + inline combined), rendering silently stops.
// Confirmed 2026-03-15 against GitLab CE 18.9.2 / gitlab.com (issue #368009).
// Self-hosted GitLab CE: disable with `math_rendering_limits_enabled: false`.
// GitHub: no such limit.
const GITLAB_COM_LIMIT = 50;
if (checked > GITLAB_COM_LIMIT) {
console.warn(` [W6 WARN] ${checked} total math spans — exceeds GitLab.com's ${GITLAB_COM_LIMIT}-span per-page limit`);
console.warn(` Spans ${GITLAB_COM_LIMIT + 1}–${checked} will NOT render on gitlab.com (raw text fallback).`);
console.warn(` OK for: self-hosted GitLab CE (disable limit), GitHub (no limit).`);
gfmWarnings++;
}
const gfmStatus = gfmErrors === 0 ? '✓' : '✗';
console.log(`${gfmStatus} GFM structural: ${gfmErrors} error(s), ${gfmWarnings} warning(s)`);
// ═══════════════════════════════════════════════════════
// LAYER 3: Auto-fix (--fix flag)
// ═══════════════════════════════════════════════════════
if (autoFix) {
console.log('\n── Auto-Fix ────────────────────────────────────────────────');
let fixed = src;
let fixCount = 0;
// Fix E0: Fix CommonMark escape sequences in single-line $$ blocks
// - Remove spacing: \! \, \; \: (cosmetic only)
// - Replace delimiters: \{ → \lbrace, \} → \rbrace (prevents \left{ parse error)
fixed = fixed.replace(/^\$\$(.+)\$\$$/gm, (match, inner) => {
if (!/\\[!,;:{}\|]/.test(inner)) return match;
let cleaned = inner;
// Fix \left\{ and \right\} first
cleaned = cleaned.replace(/\\left\\{/g, '\\left\\lbrace');
cleaned = cleaned.replace(/\\right\\}/g, '\\right\\rbrace');
// Fix remaining \{ and \} — add space if followed by letter
cleaned = cleaned.replace(/\\{(?=[a-zA-Z0-9])/g, '\\lbrace ');
cleaned = cleaned.replace(/\\{/g, '\\lbrace');
cleaned = cleaned.replace(/\\}/g, '\\rbrace');
// Remove spacing commands
cleaned = cleaned.replace(/\\!/g, '').replace(/\\,/g, '').replace(/\\;/g, ' ').replace(/\\:/g, ' ');
if (cleaned !== inner) fixCount++;
return '$$' + cleaned + '$$';
});
// Fix E0b: Fix \{ \} in inline $...$ blocks
fixed = fixed.replace(/\$([^\$\n]+?)\$/g, (match, inner) => {
if (!/\\[{}]/.test(inner)) return match;
let cleaned = inner;
cleaned = cleaned.replace(/\\left\\{/g, '\\left\\lbrace');
cleaned = cleaned.replace(/\\right\\}/g, '\\right\\rbrace');
cleaned = cleaned.replace(/\\{(?=[a-zA-Z0-9])/g, '\\lbrace ');
cleaned = cleaned.replace(/\\{/g, '\\lbrace');
cleaned = cleaned.replace(/\\}/g, '\\rbrace');
cleaned = cleaned.replace(/\\,/g, '');
if (cleaned !== inner) { fixCount++; return '$' + cleaned + '$'; }
return match;
});
// Fix E1: Convert standalone $$ blocks containing \\ to ```math blocks
fixed = fixed.replace(/\n\$\$\n([\s\S]+?)\n\$\$\n/g, (match, inner) => {
if (!inner.includes('\\\\')) return match;
fixCount++;
return '\n```math\n' + inner + '\n```\n';
});
// Fix E2: Add blank line between consecutive $$ blocks
// Pattern: closing \n$$\n immediately followed by opening $$\n
const beforeE2 = fixed;
fixed = fixed.replace(/\n\$\$\n(\$\$\n)/g, '\n$$\n\n$1');
if (fixed !== beforeE2) {
const n = (beforeE2.match(/\n\$\$\n\$\$\n/g) || []).length;
fixCount += n;
}
// Fix W1: Replace bare ^* with ^{\ast} in $$ display blocks
fixed = fixed.replace(/(\n\$\$\n[\s\S]+?\n\$\$\n)/g, (block) => {
const r = block.replace(/\^\*(?!\{)/g, '^{\\ast}');
if (r !== block) fixCount++;
return r;
});
// And in inline $ blocks
fixed = fixed.replace(/\$([^\$\n]+?)\$/g, (match, inner) => {
const r = inner.replace(/\^\*(?!\{)/g, '^{\\ast}');
if (r !== inner) { fixCount++; return '$' + r + '$'; }
return match;
});
if (fixed !== src) {
writeFileSync(filePath, fixed, 'utf8');
console.log(`✓ Applied ${fixCount} auto-fix(es) — file updated`);
console.log(' Fixed: E1 ($$ → ```math for \\\\ blocks), E2 (blank lines), W1 (^* → ^{\\ast})');
} else {
console.log(' No auto-fixes needed.');
}
}
// ═══════════════════════════════════════════════════════
// Summary
// ═══════════════════════════════════════════════════════
const totalErrors = katexErrors + gfmErrors;
console.log('\n── Summary ─────────────────────────────────────────────────');
console.log(` Equations checked : ${checked} (gitlab.com limit: ${GITLAB_COM_LIMIT})`);
console.log(` KaTeX errors : ${katexErrors}`);
console.log(` GFM errors : ${gfmErrors}`);
console.log(` GFM warnings : ${gfmWarnings}`);
if (autoFix) console.log(' --fix was applied');
console.log('');
if (totalErrors > 0) {
console.log('Fix errors before pushing to GitHub.');
} else if (gfmWarnings > 0) {
console.log('No blocking errors. Review warnings before pushing.');
} else {
console.log('All checks passed — safe to push.');
}
process.exit(totalErrors > 0 ? 1 : 0);