
Pdf To Chinese Latex
- 1 installs
- 21 repo stars
- Updated May 30, 2026
- wangyutao0915/pdf-to-chinese-latex
Translates academic PDF papers in any language into Chinese LaTeX projects that compile in Overleaf with XeLaTeX, preserving equations, tables, figures, and references.
About
This skill converts foreign-language academic PDFs into self-contained Chinese LaTeX projects compilable in Overleaf, keeping equation numbering, tables, figures, and bibliography aligned one-to-one. A developer or researcher uses it when translating papers into Chinese for literature review while preserving structural fidelity.
- Crops both raster and vector figures by caption-anchored rendering
- Keeps source and Chinese paragraphs in 1:1 correspondence for citation
Pdf To Chinese Latex by the numbers
- 1 all-time installs (skills.sh)
- Ranked #565 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wangyutao0915/pdf-to-chinese-latex --skill pdf-to-chinese-latexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | May 30, 2026 |
| Repository | wangyutao0915/pdf-to-chinese-latex ↗ |
What it does
Translates academic PDF papers in any language into Chinese LaTeX projects that compile in Overleaf with XeLaTeX, preserving equations, tables, figures, and references.
Files
PDF → Chinese LaTeX (Overleaf-ready)
What this skill does
Given one or more academic PDF papers in any source language, this skill produces, for each paper, a self-contained folder that can be uploaded to Overleaf and compiled to a Chinese version with XeLaTeX. Each output folder contains:
main.tex— full Chinese translation in actex-based LaTeX template, with all equations, tables, algorithms, and bibliography preserved and renumbered to mirror the original.images/— figures cropped from the original PDF by caption-anchored rendering (works for both raster and vector figures).raw.txt— page-by-page text dump of the original, kept as the translator's reference.README.md— Overleaf compile instructions.
The skill keeps source-language paragraphs and Chinese paragraphs in 1:1 correspondence so the user (typically a graduate student doing literature review) can quickly compare and cite. It is not a quick OCR-and-MT pipeline — it is a careful translation with structural fidelity.
Why this skill exists (the why, not just the what)
Naive PDF→LaTeX→translate pipelines break in three ways: (a) vector figures (route diagrams, network diagrams) disappear because they aren't raster images; (b) equation numbering drifts because the translator rewrites equations as plain text; (c) the output is a dead PDF instead of an editable LaTeX project, so the user can't add their own annotations or stitch the paper into their thesis literature review. This skill is built around fixing those three problems.
When to invoke
Trigger eagerly when the user:
- pastes one or more academic
.pdfpaths and asks for a Chinese version, 中文版, 中文化, 翻译; - mentions Overleaf / ctex / xelatex along with a paper they want translated;
- asks to build literature-review-ready 中文 LaTeX from a foreign paper.
Skip (or fall back to lighter tooling) when:
- the user just wants the text extracted (no translation, no LaTeX);
- the source is already in Chinese;
- the deliverable is a Word doc, a slide deck, or a PDF — use the corresponding skill (
docx,pptx,pdf) instead; - the source PDF is scanned (no embedded text layer — pure pixels). This skill needs real text for caption search, equation alignment, and bibliography parsing. Quick check:
pdftotext input.pdf -(one-liner from thepopplertoolkit). If the output is empty or near-empty, run OCR first (e.g.,ocrmypdf input.pdf input-ocr.pdf) and feed the OCR'd file to this skill.extract_text.pywill refuse loudly if it sees < 50 chars across 5+ pages.
Operating principles (read before extending this skill)
This skill grew from real translation failures (wrong figure cropped, Wiley watermark baked in, landscape figure rotated, figures floated to end of document, bibliography hand-transcribed by mistake, …). Every fix landed here was meant to be a universal principle internalized into the skill, not a per-paper workaround. Anyone (human contributor or AI agent) extending this skill must keep that intent. Three tests for every change:
1. Universality — generalize the rule, not the symptom. "Wiley fig9 has a vertical watermark on the right edge" is the symptom; "publisher watermarks sit within Npt of the page edge — search for the string, exclude their bbox" is the universal rule. Encode the rule so the skill handles unseen papers automatically; never hard-code a specific journal name, page number, figure number, or bbox unless it is a user-supplied override.
2. Internalization — a fresh user benefits without reading the commit history. After a change lands, a brand-new agent loading this skill (or a new researcher installing it) must benefit automatically, without re-discovering the lesson. Knowledge belongs in:
- Code defaults — scripts behave correctly out of the box (e.g.,
--auto, bbox-based clipping, density-based caption picker, watermark detection are all on by default). - `SKILL.md` workflow steps — hard checkpoints with concrete actions, e.g., Step 3.5 visually verify every extracted figure.
- `references/troubleshooting.md` — concrete
failure → cause → fixentries for everything the heuristics may still miss.
If a lesson lives only in a commit message, conversation transcript, or someone's head, it is not internalized — finish the work.
3. Low friction, high quality — keep the install-to-result path short. The skill is meant to be loaded by an agent that has no prior context with this user, and produce a publishable Chinese translation on the first try. Optimize for:
- Sane defaults so the agent doesn't ask the user to configure things that have an obvious correct answer (the
_figure_includes.texsidecar already pickswidth=from PNG aspect — the agent doesn't re-derive it). - Loud failure — when a heuristic doesn't apply, surface a visible signal (warning line, sidecar artifact, fallback log). Never silently ship broken output.
- Verification loops that close inside the tool, not in the user's head — e.g.,
_inspection.htmlmakes "did I crop the right region?" answerable in 30 seconds without opening the source PDF. - Token-conscious docs — SKILL.md describes what the agent must do, not the history of how we got here. War stories belong in commit messages and
troubleshooting.md, not in the main workflow.
When proposing a change, run the three tests:
1. Would this same fix help every future paper of this kind, or only the one paper that surfaced it?
2. After it lands, can a fresh agent / fresh user benefit without reading this conversation?
3. Does it reduce friction (fewer steps, fewer questions, fewer tokens, fewer surprises) for the next user?
If all three answers are yes, land it. If not, reshape it until they are.
Verify the install (do this once)
After installing the skill, run:
python scripts/self_check.pyThis generates a tiny synthetic PDF in a temp dir, runs the full pipeline (extract_text → render_figures --auto → minimal main.tex → check_tex → xelatex), and prints PASS / FAIL per stage. Exits 0 if everything works. xelatex compile shows SKIP if no TeX install is on PATH — that's only a problem if the user wants local PDF output (Overleaf doesn't need it). Anything FAIL means the skill won't produce correct output on a real paper either — fix the broken dep before triggering the workflow.
Workflow (7 steps + optional local compile)
Follow these in order. Each step has a clear stop/check; report progress to the user but don't ask permission between steps unless something genuinely ambiguous comes up.
Step 1 — Ask two scoping questions (don't skip)
Before extracting anything, call AskUserQuestion with two questions:
1. 图表处理 (figure/table handling):
- ① 从原 PDF 抽图嵌入 + 中文标题(推荐)
- ② 占位框 + 中文标题
- ③ 跳过图表
2. 翻译颗粒度 (translation depth):
- ① 全文逐段(推荐)
- ② 仅核心章节(摘要 / 引言 / 模型 / 算法)
- ③ 全文 + 中英术语对照表
These two choices determine whether to run render_figures.py (step 3), and how aggressively to compress sections 5--7 (experiments, results) during translation. Without them you risk doing 4× the work the user wants — or 1/4×.
Step 2 — Extract text per page
For each input PDF, run:
python scripts/extract_text.py "<pdf_path>" --out "<output_dir>/raw.txt"This writes one page per delimiter (========== PAGE N ==========). The model then reads raw.txt and uses it as the translation source. Don't try to translate directly from the PDF binary — the per-page text is much easier to align with figure pages, table positions, and reference numbering.
Watch for:
- Wiley/Elsevier journals embed a full download header on every page (~30 short lines like "Downloaded from ... Online Library ..."). Mentally skip these when translating.
- Subscripts and superscripts often land on the next line in extracted text — interpret based on context.
- Landscape (rotated) tables come out as reversed character strings; don't try to translate these line by line — summarize their key findings in prose instead, with a pointer to the original page numbers in the README.
Step 3 — Render figures (skip if user chose ② or ③ in step 1)
Fastest path: `--auto`. The script scans every page, picks the real caption per figure (density-based disambiguation), detects the actual image / drawing bbox (Voronoi-assigned per caption), auto-rotates landscape figures back to upright, and excludes publisher watermarks — all in one command:
python scripts/render_figures.py "<pdf>" --out "<output_dir>/images" --auto
# Wiley / Elsevier: add `--side-margin 30 --top-margin 100`Per-figure manual overrides via suffixes after the keyword in --figs. Overrides layer on top of --auto — only the listed figures get replaced:
| suffix | use when |
|---|---|
| `\ | bbox=x0,y0,x1,y1` |
| `\ | rotate=N` (0 / 90 / 180 / 270 CW) |
python scripts/render_figures.py "<pdf>" --out images --auto \
--figs "5:11:Figure 5.|bbox=50,200,540,560,9:24:Fig. 9.|rotate=90"Two sidecars are written next to `images/`:
_figure_includes.tex— paste-ready\begin{figure}[!htbp]blocks with aspect-awarewidth=(see Step 5 table). Closes the "huge figure floats to end of document" trap by default._inspection.html— see Step 3.5 for the visual checkpoint.
Algorithm details, failure modes, watermark-string list, and deeper troubleshooting live in `references/figure_extraction.md`. Read it only when _inspection.html shows a figure was cropped wrong. For paper-version edge cases (two-column papers, subfigure caption splitting, scanned PDFs, GB/T 7714 Chinese refs), see `references/troubleshooting.md`.
Step 3.5 — Visually verify every extracted figure (do not skip)
The script also writes _inspection.html next to images/ — open it in any browser. Each row shows the extracted figN.png on the left and the source PDF page with a red rectangle over the exact crop region on the right.
Scan every row in under a minute and check:
1. Does the red box contain the entire figure, including all sub-panels, axis labels, legend, and figure caption? 2. Is the extracted PNG oriented upright (not sideways or upside-down)? 3. For multi-figure pages, does each figN red box cover only its own figure and not bleed into neighbours? 4. For Wiley / Elsevier papers, is the right-edge "Downloaded from..." watermark and the top page-header band excluded?
0 fallback to full page in the script's exit summary does NOT guarantee correctness — the bbox / Voronoi / rotation heuristics can silently pick the wrong region on layouts they haven't seen before. This visual check is the hard checkpoint.
If something is wrong, fix it by passing |bbox=x0,y0,x1,y1 (manual crop in PDF points) or |rotate=N (0/90/180/270 CW) as a suffix on that figure's --figs entry, re-run, and verify again. Coordinates can be read off the red rectangle's clip values shown in the inspection HTML, or by inspecting the source PDF page in a viewer that shows PDF points.
Step 4 — Build the project skeleton
Create, for each PDF, a folder named {FirstAuthor}{Year}_{ShortTopic}_中文版/ (e.g., Tan2025_MDEVRPSTW_中文版/). Inside:
main.tex— start fromreferences/latex_template.tex(actexskeleton with all the packages already loaded).images/— figures from step 3.README.md— seereferences/readme_template.mdfor the user-facing compile instructions.raw.txt— already from step 2.
Step 5 — Translate section by section into main.tex
Replace the placeholders in latex_template.tex and fill in the body. Rules of thumb:
- Title / authors / affiliations: translate the title, romanize author names (don't translate them), translate affiliations.
- Abstract + keywords: translate as a single paragraph with a
\textbf{关键词:}...line at the end. - Body sections: paragraph-by-paragraph translation. Keep the original section/subsection numbering (
\section,\subsection). If the source uses (1), (2), ... for itemized lists, mirror withenumerate[label=(\arabic*)]. - Equations: every numbered equation in the source becomes a numbered equation in the translation, labeled
\label{eq:cN}where N matches the source equation number. Use\eqref{eq:cN}in prose so the cross-references re-render correctly. Inline math stays in$...$. - Tables: don't hand-transcribe — let
extract_tables.pybuild the skeleton for you:
python scripts/extract_tables.py "<pdf_path>" --out "<output_dir>/_tables.tex"Each table in the PDF is emitted as a paste-ready \begin{table}[!htbp] block with tabularx + booktabs, column types inferred from cell content (l for label cols, r for numeric). Paste blocks into main.tex where needed, fill in \caption{...} and translate header cells. Two real-world rough edges to fix manually: (a) text-strategy may over-extract (pulls an itemized paragraph as a "table") — just delete those blocks; (b) cells containing [1.516, 2.775] may split at the comma — merge the affected columns. Still ~30× faster than rebuilding by hand. For very large landscape tables (5+ columns × 20+ rows), prefer prose summary + page-number pointer to the source — don't burn a day on a table no one will read.
- Figures: paste from
<output_dir>/_figure_includes.tex(auto-generated in step 3) intomain.texwhere the figure should appear, then fill in\caption{...}and\label{fig:...}. The sidecar already picked\includegraphicswidths from each PNG's aspect ratio:
| aspect (W/H) | width | typical figure |
|---|---|---|
| ≥ 2.5 | 0.85\textwidth | very wide banner |
| 1.5–2.5 | 0.75\textwidth | standard landscape |
| 0.9–1.5 | 0.65\textwidth | square-ish (single panel) |
| < 0.9 | 0.55\textwidth | portrait (often rotated) |
Default placement is [!htbp], which lets LaTeX try here / top / bottom / float page — far less likely to defer a figure to the end of the document than [ht]. If a figure still defers, escalate to [H] (the float package is preloaded in latex_template.tex) — only as a last resort, since [H] can leave blank space on the page.
- Algorithms / pseudocode: use
algorithm + algpseudocode. Translate comments; keep variable names exactly as in the source. - References: list every entry in
\begin{thebibliography}{99}. Keep author/journal/title in English (translators searching for the original will need this); only translate fields like "Accessed: ..." or "访问于 ...". Don't hand-transcribe — letextract_bibliography.pybuild the skeleton for you:
python scripts/extract_bibliography.py "<output_dir>/raw.txt" \
--out "<output_dir>/_bib.tex"Locates the References / Bibliography section, parses each entry by its [N] prefix (IEEE/Wiley style) or by Surname, X., author-year start (Elsevier/OR style), and emits a ready-to-paste \begin{thebibliography} block. Paste it into main.tex and adjust line wrapping if pdfplumber lost any whitespace. Tested on 20-entry numbered (Tan2025) and 42-entry author-year (Liu2025) bibliographies.
- Citations: every
\cite{key}in the prose must have a matching\bibitem{key}— see step 6 for the static check.
The CTeX setup automatically handles Chinese fonts; you don't need to specify \setCJKmainfont manually.
Step 6 — Static validation
Before declaring done, run:
python scripts/check_tex.py "<output_dir>/main.tex"The script reports:
\begin{...}/\end{...}balance per environment;\cite↔\bibitemcorrespondence (missing bibitems, unused bibitems);\ref/\eqref↔\labelcorrespondence;- per-line
$...$count parity (catches stray dollar signs that escape into prose); - every
\includegraphics{X}resolves to a real image file (PNG / JPEG / PDF / TIFF / EPS magic-byte check, honors\graphicspath). This catches the failure mode where an upstream step wrote 12-byte placeholder PNGs or left a path pointing at a missing file — both of which xelatex would silently skip, leaving blank space where a figure should be.
Fix everything the script flags. If \cite keys are missing bibitems, either add the missing bibitem or remove the orphan citation. If equation labels referenced in prose don't exist, add them. If an image is reported as missing or as having unrecognized header bytes, re-run render_figures.py for that figure number.
Don't claim "compiles cleanly" without running this step — the user may not have a local LaTeX install (Overleaf-only is common), and untested broken .tex files cost them a round-trip to Overleaf to discover.
Step 7 — Package and hand off
For each output folder, create a same-named .zip next to it so the user can drag-drop into Overleaf:
Compress-Archive -Path <folder>/main.tex,<folder>/images,<folder>/README.md `
-DestinationPath <folder>.zip -Force(On bash: cd parent && zip -r <folder>.zip <folder>.)
End with a summary message that lists, per paper: the folder path, zip path, page count of source, number of equations/tables/figures preserved, and the Overleaf upload steps:
1. Overleaf → New Project → Upload Project → drag .zip. 2. Menu → Compiler → XeLaTeX (must be XeLaTeX; pdfLaTeX won't render ctex Chinese). 3. Recompile.
Step 8 (optional) — Compile to PDF locally
If the user has a local TeX distribution with XeLaTeX (MikTeX / TeX Live / MacTeX), you can skip the Overleaf round-trip entirely and compile in place:
python scripts/compile_pdf.py "<output_folder>"The script:
- Probes
xelatexon PATH (and on Windows also checks the default MikTeX install path); - Runs
xelatex -interaction=nonstopmode -halt-on-errortwice (the second pass resolves cross-references —\ref,\eqref,\citeall need it); - Reports the resulting PDF path, size, and page count;
- Cleans up
.aux/.log/.out/.toc/.synctex.gzso the output folder stays tidy (pass--keep-auxto preserve them when debugging).
If xelatex is not found, the script prints install commands (MikTeX / MacTeX / texlive-xetex) and the Overleaf fallback steps. The skill should not hard-require local TeX — but when it is available, running compile gives you a real end-to-end check that the .tex actually produces a clean PDF before declaring the task done. Static validation (step 6) is fast but doesn't catch issues like missing \usepackage{multirow} or font fallback warnings; only a full compile does.
When to run step 8:
- The user has MikTeX/TeX Live installed (check by running
where xelatex/which xelatex). - You want to verify ctex Chinese rendering — visually inspect the first page after compile.
- The user explicitly asks for a PDF (not just a
.tex) as the deliverable.
When to skip step 8:
- No local TeX install — recommend Overleaf as default.
- The user is on a system where installing TeX would be intrusive (CI containers, shared machines).
Output format invariants
These hold regardless of source language or paper length:
- One output folder per input PDF.
main.texalways has% !TEX program = xelatexas the first line.- Equation labels follow
eq:cNwhere N is the source equation number. - Figures are
fig1.png,fig2.png, ... matching\label{fig:...}in the body. - The README's "How to compile" section is identical across all output folders (use the template in
references/readme_template.md).
Common pitfalls and how to handle them
- Pypdf images come out as decorations: publisher logos, ORCID icons, journal banners get extracted as p01_0.jpg / p01_1.png etc. Don't bother filtering them — the figure rendering pipeline (step 3) doesn't use them. They're harmless leftovers.
- PyMuPDF caption search fails: the script tries
Fig. N./Figure N./FIGURE N/Abb. N.automatically, so capitalization is usually a non-issue. If it still falls back to full page, the figure caption is probably wrapped weirdly (line-break mid-keyword) — hand-specify with|bbox=x0,y0,x1,y1after the keyword in--figs. - Cropped image is the WRONG region (got body text instead of figure): an in-text reference like "see Figure 5" appeared higher on the page than the actual caption, and an older version of the script picked it. The current version picks the lowest hit, but verify by opening the rendered PNG. If wrong, pass
|bbox=...to lock the rect. - Figure has a Wiley/Elsevier download stamp baked in: auto-watermark detection only fires when the watermark string sits within 80pt of a page edge. If it slipped through, add
--side-margin 30 --top-margin 100to the command line. - Bibliography keys collide across translated batches: each paper gets its own
\begin{thebibliography}, so keys are scoped to the file. Don't bother prefixing keys with paper IDs unless the user explicitly asks for a merged bibliography. - Source uses non-standard equation numbering like (3.1): mirror with
eq:c3-1oreq:3-1. The static check only cares about cite/label consistency, not the naming convention. - User asks for the translation in a Word doc instead: that's a different skill (
docx). This skill emits.texonly — don't try to render to.docxfrom here.
Files in this skill
SKILL.md— this file.scripts/extract_text.py— pdfplumber-based per-page text dump.scripts/render_figures.py— PyMuPDF caption-anchored figure renderer (with--autodiscovery + watermark exclusion).scripts/extract_bibliography.py— turns the References section ofraw.txtinto a\begin{thebibliography}skeleton (handles numbered[N]and author-yearSurname, X.,styles).scripts/check_tex.py— static validator formain.tex(envs, cite/bibitem, ref/label,$parity, image-file existence + magic-byte check).scripts/compile_pdf.py— optional local XeLaTeX compile (skips Overleaf).references/latex_template.tex—ctexskeleton with all packages preloaded.references/readme_template.md— the per-paper README contents.references/troubleshooting.md— extended FAQ for tricky cases (multi-column papers, rotated tables, etc.).examples/— sample input/output pairs (see README).
name: CI
# Runs on push to main and on every PR. Catches regressions in the
# pipeline scripts BEFORE they land — anyone proposing a change to
# render_figures / extract_bibliography / check_tex / extract_tables /
# extract_text / compile_pdf will see whether the canonical tests
# still pass.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
self-check:
name: self_check + stress fixtures
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pdfplumber pypdf PyMuPDF Pillow
- name: Install XeLaTeX (TeX Live minimal)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
texlive-xetex \
texlive-lang-chinese \
texlive-fonts-recommended \
texlive-latex-recommended \
texlive-latex-extra \
fonts-noto-cjk
- name: self_check.py (end-to-end pipeline + xelatex compile)
run: python scripts/self_check.py
- name: Synthetic stress fixtures (regenerate)
run: python evals/_gen_stress_pdfs.py
- name: render_figures on stress-2col (must extract right-col fig)
run: |
python scripts/render_figures.py \
evals/test_inputs/stress-2col.pdf \
--out /tmp/stress-2col-out --auto
test -f /tmp/stress-2col-out/fig1.png
- name: render_figures on stress-subfigs (must produce one fig1.png)
run: |
python scripts/render_figures.py \
evals/test_inputs/stress-subfigs.pdf \
--out /tmp/stress-subfigs-out --auto
test -f /tmp/stress-subfigs-out/fig1.png
# there must NOT be a fig2.png — subfigures must stay merged by default
! test -f /tmp/stress-subfigs-out/fig2.png
- name: extract_bibliography on stress-gbt7714 (5 entries)
run: |
python scripts/extract_text.py \
evals/test_inputs/stress-gbt7714.pdf --out /tmp/gbt7714-raw.txt
python scripts/extract_bibliography.py \
/tmp/gbt7714-raw.txt --out /tmp/gbt7714-bib.tex
# Sanity: file must contain 5 \bibitem entries
test $(grep -c '\\bibitem{' /tmp/gbt7714-bib.tex) -eq 5
- name: render_figures on stress-caption-sublabels (one fig1.png)
run: |
python scripts/render_figures.py \
evals/test_inputs/stress-caption-sublabels.pdf \
--out /tmp/stress-cap-out --auto
test -f /tmp/stress-cap-out/fig1.png
- name: extract_text loud-failure on a 0-text 6-page PDF
run: |
python -c "
import fitz
d = fitz.open()
for _ in range(6): d.new_page(width=595, height=842)
d.save('/tmp/blank.pdf'); d.close()
"
# MUST exit 2 with loud failure
! python scripts/extract_text.py /tmp/blank.pdf --out /tmp/blank.txt
# check the exit was specifically 2 (not just any nonzero)
python scripts/extract_text.py /tmp/blank.pdf --out /tmp/blank.txt; rc=$?; \
test $rc -eq 2
- name: extract_tables on stress-longtable (≥1 table)
run: |
python scripts/extract_tables.py \
evals/test_inputs/stress-longtable.pdf \
--out /tmp/longtable.tex
# at least one \begin{table} should have been emitted
test $(grep -c '\\begin{table}' /tmp/longtable.tex) -ge 1
- name: check_tex follows \input{} (no false MISSING bibitems)
run: |
mkdir -p /tmp/split
cat > /tmp/split/main.tex <<'EOF'
\documentclass{article}\begin{document}
See \cite{key1}. \input{bib.tex}
\end{document}
EOF
cat > /tmp/split/bib.tex <<'EOF'
\begin{thebibliography}{99}
\bibitem{key1} A paper. 2024.
\end{thebibliography}
EOF
python scripts/check_tex.py /tmp/split/main.tex
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.pytest_cache/
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
*.swp
*.swo
# LaTeX build artifacts (in case anyone compiles locally inside the repo)
*.aux
*.log
*.out
*.toc
*.synctex.gz
*.fls
*.fdb_latexmk
# User output (translations go in user-chosen folders, not the skill repo)
output/
*_中文版/
*_中文版.zip
# -*- coding: utf-8 -*-
"""Generate synthetic layout stress-test PDFs for evals/test_inputs/.
These PDFs are NOT realistic papers — they are minimal fixtures
designed to stress-test specific layout failure modes that the
heuristics in `render_figures.py` and `extract_bibliography.py`
need to handle. Real-publisher coverage (actual Elsevier / IEEE
quirks) still requires fetching real papers separately.
Usage:
python evals/_gen_stress_pdfs.py
Generated:
evals/test_inputs/stress-2col.pdf — two-column body, fig in right column
evals/test_inputs/stress-subfigs.pdf — three subfigures under one caption
evals/test_inputs/stress-gbt7714.pdf — Chinese title + 参考文献 in GB/T 7714
"""
import os
import sys
from pathlib import Path
import fitz # PyMuPDF
OUT = Path(__file__).resolve().parent / "test_inputs"
OUT.mkdir(parents=True, exist_ok=True)
# Resolve a CJK-capable font. Windows ships SimSun / Microsoft YaHei in
# Fonts/. On macOS / Linux we fall back to Noto Sans CJK. fitz needs the
# file path; insert_text/insert_textbox then uses fontname="F0" etc.
def cjk_font_path():
candidates = [
r"C:\Windows\Fonts\simsun.ttc",
r"C:\Windows\Fonts\msyh.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
for p in candidates:
if os.path.isfile(p):
return p
return None
def gen_two_column(path):
"""Right-column figure under a body text page that uses two columns."""
doc = fitz.open()
page = doc.new_page(width=595, height=842)
pw = 595
# Title spans full width
page.insert_text((50, 60), "Two-column layout stress test",
fontsize=14, fontname="helv")
left = fitz.Rect(50, 100, 285, 720)
right = fitz.Rect(310, 100, 545, 720)
body = ("Left column body text body text body text body text body "
"text body text body text body text body text body text body "
"text body text body text body text body text body text body "
"text body text body text body text body text body text body "
"text body text body text body text body text. ") * 4
page.insert_textbox(left, body, fontsize=10, fontname="helv")
# Right column: short body + figure cluster + caption
page.insert_textbox(
fitz.Rect(310, 100, 545, 230),
("Right column lead-in paragraph. The figure below appears only "
"in this column, not spanning both columns."),
fontsize=10, fontname="helv",
)
shape = page.new_shape()
shape.draw_rect(fitz.Rect(330, 250, 520, 380))
shape.draw_circle(fitz.Point(425, 315), 35)
shape.finish(color=(0, 0, 0), width=1.2)
shape.commit()
page.insert_text((330, 410),
"Fig. 1. Single-column figure (right column only).",
fontsize=9, fontname="helv")
# Discussion text below the caption (right col continues)
page.insert_textbox(
fitz.Rect(310, 430, 545, 720),
("Discussion. " + ("Discussion text in the right column. " * 12)),
fontsize=10, fontname="helv",
)
# Page 2: references
p2 = doc.new_page(width=595, height=842)
p2.insert_text((50, 60), "References", fontsize=12, fontname="helv")
p2.insert_textbox(
fitz.Rect(50, 80, pw - 50, 200),
"[1] A. One, B. Two. A paper. J. Tests, 2024, 1(1): 1-10.\n"
"[2] C. Three. Another. J. Tests, 2024, 1(2): 11-20.\n"
"[3] D. Four. Third. Test Press, 2023.",
fontsize=10, fontname="helv",
)
doc.save(str(path))
doc.close()
def gen_subfigs(path):
"""Three subfigures (a)/(b)/(c) sharing a single 'Fig. 1.' caption."""
doc = fitz.open()
page = doc.new_page(width=595, height=842)
page.insert_text((50, 60), "Subfigure stress test",
fontsize=14, fontname="helv")
page.insert_textbox(
fitz.Rect(50, 90, 545, 180),
("This page tests the layout where three sub-figures share one "
"Fig. 1. caption — the script should extract the whole row as a "
"single fig1.png by default, not split it."),
fontsize=10, fontname="helv",
)
# Three sub-figures arranged horizontally
panels = [
(90, 220, 200, 350, "(a)"),
(220, 220, 330, 350, "(b)"),
(350, 220, 460, 350, "(c)"),
]
for x0, y0, x1, y1, label in panels:
shape = page.new_shape()
shape.draw_rect(fitz.Rect(x0, y0, x1, y1))
shape.finish(color=(0, 0, 0), width=1.2)
shape.commit()
cx = (x0 + x1) / 2 - 8
page.insert_text((cx, y1 + 14), label, fontsize=10, fontname="helv")
# Single caption for all three
page.insert_text((130, 390),
"Fig. 1. Three sub-panels: (a) box; (b) box; (c) box.",
fontsize=10, fontname="helv")
page.insert_textbox(
fitz.Rect(50, 420, 545, 700),
"Discussion. " + ("Discussion text continues. " * 15),
fontsize=10, fontname="helv",
)
p2 = doc.new_page(width=595, height=842)
p2.insert_text((50, 60), "References", fontsize=12, fontname="helv")
p2.insert_textbox(
fitz.Rect(50, 80, 545, 180),
"[1] X. One. A paper on subfigures. J. Layout, 2024.\n"
"[2] Y. Two. Another. J. Layout, 2024.",
fontsize=10, fontname="helv",
)
doc.save(str(path))
doc.close()
def gen_gbt7714(path):
"""Chinese-source paper with GB/T 7714 numbered references.
Skipped silently if no CJK font is available on this system —
the eval entry can still be defined; running it just needs a
machine with a CJK font.
"""
font_path = cjk_font_path()
if font_path is None:
print(f" [skip] {path.name}: no CJK font found on this system")
return False
doc = fitz.open()
page = doc.new_page(width=595, height=842)
page.insert_font(fontname="cjk", fontfile=font_path)
page.insert_text((50, 60), "多车场电动车路径优化研究", fontsize=14, fontname="cjk")
page.insert_text((50, 90), "(GB/T 7714 stress test)", fontsize=10, fontname="helv")
body = ("摘要:本文以多车场电动车的实际配送任务为背景,提出一个考虑软时间窗与"
"电池约束的混合整数规划模型,给出两类情景下的最优解策略。算例结果表明,"
"允许车辆在多个车场之间灵活返回可显著降低总配送费用。")
page.insert_textbox(fitz.Rect(50, 120, 545, 250),
body, fontsize=10, fontname="cjk")
page.insert_text((50, 290), "参考文献", fontsize=12, fontname="cjk")
refs = (
"[1] 张三, 李四, 王五. 多车场电动汽车路径优化研究[J]. 系统工程理论与实践, "
"2020, 40(5): 1234-1245.\n"
"[2] 赵六. 物流网络设计与优化[M]. 北京: 清华大学出版社, 2018: 56-78.\n"
"[3] 钱七, 孙八. 基于启发式的车辆路径问题求解算法[C]//"
"中国运筹学会年会论文集. 北京: 中国运筹学会, 2019: 100-110.\n"
"[4] 周九. 城市配送中心选址问题研究[D]. 上海: 同济大学, 2017.\n"
"[5] Smith J, Brown K. Vehicle routing problem with time windows[J]. "
"Operations Research, 2019, 67(4): 890-910."
)
page.insert_textbox(fitz.Rect(50, 310, 545, 700),
refs, fontsize=10, fontname="cjk")
# Subset the embedded CJK font so the output PDF stays committable.
# Without this a SimSun-embedded PDF is ~18 MB; subsetted it drops to ~50 KB.
try:
doc.subset_fonts()
except Exception:
pass
doc.save(str(path), garbage=4, deflate=True)
doc.close()
return True
def gen_theorem_proof(path):
"""A math-paper-shape PDF whose body uses theorem / proof environments
rendered as visible blocks. Tests whether the translator's LaTeX
template needs `amsthm` + custom theorem styles, whether check_tex
handles `\\begin{theorem}` / `\\end{proof}` correctly, and whether
extract_text preserves the theorem block boundaries.
"""
doc = fitz.open()
page = doc.new_page(width=595, height=842)
pw = 595
page.insert_text((50, 60), "Theorem-heavy stress test (math/physics)",
fontsize=14, fontname="helv")
page.insert_textbox(
fitz.Rect(50, 100, pw - 50, 180),
("This document tests whether the skill's LaTeX template handles "
"theorem / lemma / proof environments correctly. The body below "
"is a typical math-paper layout."),
fontsize=10, fontname="helv",
)
# Definition block (visually distinct prefix)
y = 200
page.insert_text((50, y), "Definition 1 (Smooth function).",
fontsize=10, fontname="hebo") # bold-ish
page.insert_textbox(
fitz.Rect(50, y + 14, pw - 50, y + 56),
("A function f: R^n -> R is said to be smooth if all its partial "
"derivatives of every order exist and are continuous on its domain."),
fontsize=10, fontname="helv",
)
# Theorem
y = 270
page.insert_text((50, y), "Theorem 1 (Convergence).",
fontsize=10, fontname="hebo")
page.insert_textbox(
fitz.Rect(50, y + 14, pw - 50, y + 70),
("Let f be smooth on a bounded domain. Then the gradient descent "
"iterates x_{k+1} = x_k - t_k grad f(x_k) with step sizes t_k "
"satisfying the Wolfe conditions converge to a stationary point."),
fontsize=10, fontname="helv",
)
# Proof block (longer)
y = 350
page.insert_text((50, y), "Proof.", fontsize=10, fontname="hebo")
proof_text = (
"By assumption f is smooth, hence its gradient is locally Lipschitz "
"continuous. Applying the descent lemma and summing telescopically "
"gives sum_k t_k ||grad f(x_k)||^2 < infty. Since each t_k is "
"bounded away from zero, ||grad f(x_k)|| -> 0. By compactness of "
"the sublevel sets, every limit point is stationary. QED."
)
page.insert_textbox(
fitz.Rect(50, y + 14, pw - 50, y + 100),
proof_text, fontsize=10, fontname="helv",
)
# Lemma + equation reference
y = 480
page.insert_text((50, y), "Lemma 1 (Cauchy-Schwarz).",
fontsize=10, fontname="hebo")
page.insert_textbox(
fitz.Rect(50, y + 14, pw - 50, y + 50),
"For any vectors u, v in R^n, |<u, v>| <= ||u|| * ||v||. "
"(See equation (3.4.1) for the discrete-time analogue.)",
fontsize=10, fontname="helv",
)
# Numbered equation in (n.m.p) format
page.insert_text((50, 560),
" | sum_{k=1}^{n} a_k b_k |^2 <= "
"(sum a_k^2) * (sum b_k^2) (3.4.1)",
fontsize=10, fontname="helv")
# References — needed for extract_bibliography
p2 = doc.new_page(width=595, height=842)
p2.insert_text((50, 60), "References", fontsize=12, fontname="helv")
p2.insert_textbox(
fitz.Rect(50, 80, pw - 50, 220),
"[1] Boyd, S., Vandenberghe, L. Convex Optimization. Cambridge, 2004.\n"
"[2] Nesterov, Y. Introductory Lectures on Convex Optimization. Kluwer, 2004.\n"
"[3] Bertsekas, D.P. Nonlinear Programming. 3rd ed. Athena Scientific, 2016.",
fontsize=10, fontname="helv",
)
doc.save(str(path))
doc.close()
def gen_longtable_multipage(path):
"""A regression-style output table that overflows across 2 pages.
Tests whether extract_text preserves row alignment when a table
crosses a page break (econ / stats paper failure mode).
"""
doc = fitz.open()
# Page 1: intro + table start
page = doc.new_page(width=595, height=842)
pw = 595
page.insert_text((50, 60), "Multi-page table stress test (econ/stats)",
fontsize=14, fontname="helv")
page.insert_textbox(
fitz.Rect(50, 100, pw - 50, 180),
("Table 1 reports OLS estimates for the panel regression spanning "
"2010-2023 across 28 industries. The table is intentionally long "
"so that it overflows to page 2."),
fontsize=10, fontname="helv",
)
page.insert_text((50, 200), "Table 1. OLS regression results (continued on next page).",
fontsize=9, fontname="hebo")
# Header row
headers = ["Variable", "Coef", "Std. Err.", "t-stat", "p-value", "95% CI"]
col_x = [50, 170, 250, 330, 400, 470]
y = 230
for i, h in enumerate(headers):
page.insert_text((col_x[i], y), h, fontsize=9, fontname="hebo")
# 20 rows on page 1
variables_p1 = [
("Intercept", "2.1456", "0.3214", "6.676", "0.000", "[1.516, 2.775]"),
("GDP_growth", "0.4823", "0.0512", "9.420", "0.000", "[0.382, 0.582]"),
("Energy_use", "-0.1247", "0.0331", "-3.767", "0.000", "[-0.190, -0.060]"),
("CO2_emissions", "0.0891", "0.0247", "3.607", "0.000", "[0.041, 0.137]"),
("Renewable_share","-0.2134", "0.0418", "-5.107", "0.000", "[-0.295, -0.131]"),
("Population", "0.0021", "0.0008", "2.625", "0.009", "[0.001, 0.004]"),
("Urbanization", "0.1542", "0.0287", "5.373", "0.000", "[0.098, 0.210]"),
("Industrial_GVA", "0.3211", "0.0445", "7.216", "0.000", "[0.234, 0.408]"),
("R_and_D_spend", "0.0871", "0.0156", "5.583", "0.000", "[0.057, 0.118]"),
("Trade_openness", "-0.0432", "0.0189", "-2.286", "0.022", "[-0.080, -0.006]"),
("Inflation", "-0.0156", "0.0093", "-1.677", "0.094", "[-0.034, 0.003]"),
("Unemployment", "-0.0298", "0.0142", "-2.099", "0.036", "[-0.058, -0.002]"),
("Interest_rate", "-0.1023", "0.0334", "-3.063", "0.002", "[-0.168, -0.037]"),
("Tax_rate", "-0.0567", "0.0211", "-2.687", "0.007", "[-0.098, -0.015]"),
("Education", "0.2103", "0.0367", "5.730", "0.000", "[0.138, 0.282]"),
("Tech_imports", "0.0934", "0.0218", "4.284", "0.000", "[0.051, 0.136]"),
("Public_invest", "0.1567", "0.0298", "5.258", "0.000", "[0.098, 0.215]"),
("Foreign_invest", "0.0823", "0.0223", "3.691", "0.000", "[0.039, 0.126]"),
("Labor_force", "0.0234", "0.0089", "2.629", "0.009", "[0.006, 0.041]"),
("Manufacturing", "0.2891", "0.0412", "7.017", "0.000", "[0.208, 0.370]"),
]
y = 245
for row in variables_p1:
for i, v in enumerate(row):
page.insert_text((col_x[i], y), v, fontsize=9, fontname="helv")
y += 14
page.insert_text((50, y + 10), "(continued on next page)",
fontsize=8, fontname="hebo")
# Page 2: table continues + intro of next section
p2 = doc.new_page(width=595, height=842)
p2.insert_text((50, 60), "Table 1 (continued).",
fontsize=9, fontname="hebo")
for i, h in enumerate(headers):
p2.insert_text((col_x[i], 90), h, fontsize=9, fontname="hebo")
variables_p2 = [
("Services", "0.1456", "0.0298", "4.886", "0.000", "[0.087, 0.204]"),
("Construction", "0.0789", "0.0234", "3.371", "0.001", "[0.033, 0.125]"),
("Agriculture", "-0.0234", "0.0156", "-1.500", "0.134", "[-0.054, 0.007]"),
("Mining", "-0.1023", "0.0287", "-3.566", "0.000", "[-0.158, -0.046]"),
("Transport", "0.1234", "0.0245", "5.037", "0.000", "[0.075, 0.171]"),
("Utilities", "0.0567", "0.0198", "2.864", "0.004", "[0.018, 0.095]"),
("Health", "0.0891", "0.0212", "4.202", "0.000", "[0.048, 0.130]"),
("Education_sec", "0.1023", "0.0234", "4.372", "0.000", "[0.057, 0.148]"),
]
y = 105
for row in variables_p2:
for i, v in enumerate(row):
p2.insert_text((col_x[i], y), v, fontsize=9, fontname="helv")
y += 14
# Notes + R-squared
p2.insert_text((50, y + 16),
"Notes: *** p<0.01, ** p<0.05, * p<0.10. "
"N=8,624. R-squared = 0.842. F-stat = 1,247.",
fontsize=8, fontname="helv")
p2.insert_textbox(
fitz.Rect(50, y + 50, pw - 50, y + 120),
("The estimates in Table 1 indicate that the renewable share and "
"energy use coefficients are both statistically significant. "
"Section 4 below discusses heterogeneity across subsamples."),
fontsize=10, fontname="helv",
)
# Page 3: References
p3 = doc.new_page(width=595, height=842)
p3.insert_text((50, 60), "References", fontsize=12, fontname="helv")
p3.insert_textbox(
fitz.Rect(50, 80, pw - 50, 220),
"[1] Wooldridge, J.M. Econometric Analysis of Cross Section and Panel Data. MIT Press, 2010.\n"
"[2] Cameron, A.C., Trivedi, P.K. Microeconometrics. Cambridge, 2005.\n"
"[3] Hsiao, C. Analysis of Panel Data. 3rd ed. Cambridge, 2014.",
fontsize=10, fontname="helv",
)
doc.save(str(path))
doc.close()
def gen_caption_with_sublabels(path):
"""A figure whose ONE caption contains multiple (a)/(b)/(c)/(d) sub-
labels and is much longer than the typical 1-line caption. Tests
whether `find_caption_rect` picks up the WHOLE caption (so the clip
rect includes the figure block) and whether the density-above
disambiguator still works when the caption itself is multi-line.
Biology / medicine papers commonly use this format.
"""
doc = fitz.open()
page = doc.new_page(width=595, height=842)
pw = 595
page.insert_text((50, 60), "Caption-with-sublabels stress test (biology/medicine)",
fontsize=14, fontname="helv")
page.insert_textbox(
fitz.Rect(50, 90, pw - 50, 160),
("This page tests whether the caption picker handles long captions "
"with multiple (a)/(b)/(c) sub-labels in the SAME caption body — "
"common in biology / medicine."),
fontsize=10, fontname="helv",
)
# Four sub-figures in a 2x2 grid
panels = [
(100, 200, 270, 340, "(a)"),
(300, 200, 470, 340, "(b)"),
(100, 360, 270, 500, "(c)"),
(300, 360, 470, 500, "(d)"),
]
for x0, y0, x1, y1, label in panels:
shape = page.new_shape()
shape.draw_rect(fitz.Rect(x0, y0, x1, y1))
shape.draw_circle(fitz.Point((x0+x1)/2, (y0+y1)/2), 25)
shape.finish(color=(0, 0, 0), width=1.0)
shape.commit()
page.insert_text((x0 + 6, y0 + 14), label,
fontsize=9, fontname="hebo")
# MULTI-LINE caption with sub-labels — the failure mode is that
# the picker stops at the first ". (a)" or similar.
caption_block = (
"Fig. 1. Comparison of treatment outcomes across four cohorts. "
"(a) Control cohort at 24 h: baseline morphology preserved with "
"no observable apoptosis. (b) Low-dose cohort at 24 h: ~12% of "
"cells show early apoptotic markers; nuclear condensation visible "
"in subset. (c) High-dose cohort at 24 h: extensive cytoplasmic "
"fragmentation; >60% of cells positive for Annexin V staining. "
"(d) Recovery cohort at 72 h post-washout: partial restoration of "
"baseline morphology; residual marker expression in ~15% of cells. "
"Scale bar: 50 micrometers. All images are representative of n=6 "
"biological replicates per condition."
)
page.insert_textbox(
fitz.Rect(50, 520, pw - 50, 720),
caption_block, fontsize=9, fontname="helv",
)
# Page 2: references (Vancouver-numbered, biomed style)
p2 = doc.new_page(width=595, height=842)
p2.insert_text((50, 60), "References", fontsize=12, fontname="helv")
p2.insert_textbox(
fitz.Rect(50, 80, pw - 50, 220),
"1. Kerr JF, Wyllie AH, Currie AR. Apoptosis: a basic biological "
"phenomenon with wide-ranging implications in tissue kinetics. "
"Br J Cancer. 1972;26(4):239-257.\n"
"2. Vermes I, Haanen C, Steffens-Nakken H, Reutelingsperger C. A "
"novel assay for apoptosis. J Immunol Methods. 1995;184(1):39-51.\n"
"3. Galluzzi L, Vitale I, et al. Molecular definitions of cell death "
"subroutines. Cell Death Differ. 2012;19(1):107-120.",
fontsize=10, fontname="helv",
)
doc.save(str(path))
doc.close()
def main():
print(f"Generating stress-test PDFs to {OUT}")
gen_two_column(OUT / "stress-2col.pdf")
print(" wrote stress-2col.pdf")
gen_subfigs(OUT / "stress-subfigs.pdf")
print(" wrote stress-subfigs.pdf")
if gen_gbt7714(OUT / "stress-gbt7714.pdf"):
print(" wrote stress-gbt7714.pdf")
gen_theorem_proof(OUT / "stress-theorems.pdf")
print(" wrote stress-theorems.pdf")
gen_longtable_multipage(OUT / "stress-longtable.pdf")
print(" wrote stress-longtable.pdf")
gen_caption_with_sublabels(OUT / "stress-caption-sublabels.pdf")
print(" wrote stress-caption-sublabels.pdf")
if __name__ == "__main__":
main()
{
"skill_name": "pdf-to-chinese-latex",
"evals": [
{
"id": "eval-0-tan-mdevrpstw",
"prompt": "Translate the academic PDF into a Chinese LaTeX project that compiles in Overleaf with XeLaTeX. Preserve equations (with original numbering), tables, figures, and bibliography 1:1 with the source. Use the ctex package for Chinese typesetting. The output directory must contain main.tex (full Chinese translation), images/ (extracted figures), and a short README with Overleaf compile steps. Assumptions for this run (since you can't ask the user interactively): extract figures from the original PDF and embed them with Chinese captions; do a full translation (abstract, every body section, references). At minimum produce title, authors, abstract, introduction, model/methods section, and the bibliography; continue with results/conclusion as time permits.",
"files": ["test_inputs/eval-0-input.pdf"],
"expected_output": "Folder containing main.tex (ctex), images/ with extracted figures, README.md. main.tex should compile under XeLaTeX without errors and render Chinese correctly."
},
{
"id": "eval-1-liu-drone-hbdn",
"prompt": "Translate the academic PDF into a Chinese LaTeX project that compiles in Overleaf with XeLaTeX. Preserve equations (with original numbering), tables, figures, algorithm pseudocode, and bibliography 1:1 with the source. Use the ctex package for Chinese typesetting. The output directory must contain main.tex (full Chinese translation), images/ (extracted figures), and a short README with Overleaf compile steps. Assumptions for this run (since you can't ask the user interactively): extract figures from the original PDF and embed them with Chinese captions; do a full translation. This paper contains vector network diagrams (not raster); pay special attention to preserving them. At minimum produce title, authors, abstract, introduction, problem formulation, the algorithm section with pseudocode, and the bibliography; continue with experiments/conclusion as time permits.",
"files": ["test_inputs/eval-1-input.pdf"],
"expected_output": "Folder containing main.tex (ctex), images/ with extracted figures (5+ figures expected; some are vector), README.md. main.tex should compile under XeLaTeX without errors and render Chinese, math, and algorithm pseudocode correctly."
},
{
"id": "eval-2-stress-2col",
"prompt": "This is a layout stress test against a synthetic 2-column-layout PDF. Run `python scripts/render_figures.py evals/test_inputs/stress-2col.pdf --out <out>/images --auto` and verify the resulting fig1.png contains only the right-column figure (rectangle + circle + caption), with NO left-column body text bleeding into the crop. Also run `python scripts/extract_bibliography.py` on the extracted raw.txt and verify all 3 references are parsed.",
"files": ["test_inputs/stress-2col.pdf"],
"expected_output": "fig1.png cropped to right column only (clip x ≈ 326..524); 3 bibitems parsed."
},
{
"id": "eval-3-stress-subfigs",
"prompt": "This is a layout stress test against a synthetic PDF where one Fig. 1 caption sits under three sub-figures (a)/(b)/(c) arranged horizontally. Run `python scripts/render_figures.py evals/test_inputs/stress-subfigs.pdf --out <out>/images --auto` and verify the resulting fig1.png contains ALL THREE sub-panels plus the caption (default behaviour — sub-figures are not split unless the user explicitly requests it).",
"files": ["test_inputs/stress-subfigs.pdf"],
"expected_output": "Single fig1.png covering all three sub-figures + the shared caption; clip aspect ≈ 2:1 landscape."
},
{
"id": "eval-4-stress-gbt7714",
"prompt": "This is a bibliography parsing stress test against a synthetic Chinese-source paper with GB/T 7714 numbered references. Run `python scripts/extract_text.py` then `python scripts/extract_bibliography.py` against evals/test_inputs/stress-gbt7714.pdf. Verify all 5 references (mix of Chinese [J]/[M]/[C]/[D] entries and one English entry) are parsed with content preserved.",
"files": ["test_inputs/stress-gbt7714.pdf"],
"expected_output": "5 bibitems parsed; Chinese characters intact; [J]/[M]/[C]/[D] document-type markers preserved in entry text."
},
{
"id": "eval-5-publisher-arxiv",
"prompt": "Pipeline regression on an arXiv preprint (no publisher watermark, no journal page header — only the right-edge vertical 'arXiv:NNNN.NNNNN' stamp). Run extract_text + render_figures --auto + extract_bibliography against evals/test_inputs/eval-5-arxiv-input.pdf. Verify all three stages exit 0 with non-trivial counts.",
"files": ["test_inputs/eval-5-arxiv-input.pdf"],
"expected_output": "extract_text writes ≥ 50K chars; render_figures --auto detects ≥ 8 figures with 0 fallback and 0 invalid PNGs (magic bytes); extract_bibliography parses ≥ 30 entries."
},
{
"id": "eval-6-publisher-mdpi",
"prompt": "Pipeline regression on an MDPI open-access paper (Drones journal). Verify the boilerplate Creative Commons / 'Copyright © by the authors' license text in the body doesn't break bibliography section detection, and figures crop correctly despite MDPI's narrow margins. Run the full pipeline against evals/test_inputs/eval-6-mdpi-input.pdf.",
"files": ["test_inputs/eval-6-mdpi-input.pdf"],
"expected_output": "extract_text writes ≥ 50K chars; render_figures --auto detects ≥ 5 figures with 0 fallback; extract_bibliography parses ≥ 30 entries."
},
{
"id": "eval-7-publisher-springer-scirep",
"prompt": "Pipeline regression on a Springer Nature paper (Scientific Reports) — distinct page-header style versus Elsevier/Wiley, longer bibliography. Run the full pipeline against evals/test_inputs/eval-7-springer-scirep-input.pdf.",
"files": ["test_inputs/eval-7-springer-scirep-input.pdf"],
"expected_output": "extract_text writes ≥ 60K chars; render_figures --auto detects ≥ 5 figures with 0 fallback; extract_bibliography parses ≥ 50 entries (Sci. Rep. papers often have 60+)."
},
{
"id": "eval-8-publisher-chinese-journal",
"prompt": "Pipeline regression on a Chinese-language journal paper. Verifies that the Chinese caption keywords (图 N. / 图N. / 图 N: / 图N: / 图 N:/ 图N: / 图 N / 图N) work, and that mixed-language bibliography is parsed. Run the full pipeline against evals/test_inputs/eval-8-chinese-journal-input.pdf.",
"files": ["test_inputs/eval-8-chinese-journal-input.pdf"],
"expected_output": "extract_text writes ≥ 40K chars of mostly Chinese text; render_figures --auto detects ≥ 3 figures via Chinese caption pattern with 0 fallback; extract_bibliography parses ≥ 15 entries."
},
{
"id": "eval-9-stress-theorem-proof",
"prompt": "Discipline stress test (math / theoretical physics). Synthetic fixture with `Theorem 1` / `Lemma 1` / `Proof` / `Definition 1` blocks rendered as labelled body text, plus an equation with (n.m.p) numbering. Run extract_text and verify the structural markers survive in raw.txt so a translator can map them to `\\begin{theorem}` / `\\begin{proof}` etc. The skill's latex_template.tex already preloads amsthm + \\newtheorem{theorem}{定理}/{lemma}{引理}/etc., so the produced main.tex must compile.",
"files": ["test_inputs/stress-theorems.pdf"],
"expected_output": "raw.txt contains 'Theorem 1', 'Lemma 1', 'Proof', 'Definition 1', and '(3.4.1)' each at least once; latex_template.tex already defines \\newtheorem environments for all of these in Chinese."
},
{
"id": "eval-10-stress-longtable",
"prompt": "Discipline stress test (econometrics / statistics). Synthetic fixture with an OLS regression output table containing 28 variable rows that overflows from page 1 to page 2, including a header row, numeric coefficients, standard errors, t-stats, p-values, and 95% CI brackets. Run extract_text and verify row alignment and numeric data survive the page break.",
"files": ["test_inputs/stress-longtable.pdf"],
"expected_output": "raw.txt contains all 28 variable names (Intercept ... Education_sec), the GDP_growth coefficient '0.4823', the page-break marker 'continued on next page', the '95% CI' header on page 2, and bracketed CI ranges like [0.382, 0.582] intact."
},
{
"id": "eval-11-stress-caption-sublabels",
"prompt": "Discipline stress test (biology / medicine). Synthetic fixture with one Fig. 1 whose caption contains FOUR sub-panel sub-labels (a)/(b)/(c)/(d), each followed by long descriptive prose totaling ~10 lines. Verify that render_figures --auto produces ONE fig1.png covering all four panels (not 4 separate files) and that the long caption text survives in raw.txt so the translator can reproduce the (a)/(b)/(c)/(d) sub-descriptions in Chinese.",
"files": ["test_inputs/stress-caption-sublabels.pdf"],
"expected_output": "Single fig1.png with all four sub-panels visible; raw.txt contains 'Fig. 1', '(a)', '(b)', '(c)', '(d)', 'Scale bar', and 'Annexin V' (the last sub-label's terminal phrase) each at least once."
}
],
"notes": {
"test_inputs_provenance": "eval-0 / eval-1 inputs are real published papers and not committed to this repo (separate workspace). eval-2 / eval-3 / eval-4 inputs are synthetic fixtures generated by `evals/_gen_stress_pdfs.py` and committed under `evals/test_inputs/`. eval-5..8 are real published papers committed to test_inputs/ as a publisher-coverage regression set (arXiv / MDPI / Springer Nature / Chinese journal).",
"publisher_coverage": "After eval-5..8 the regression set covers 8 distinct publisher / format combinations: Hindawi single-col (eval-0), Wiley single-col (eval-1), arXiv (eval-5), MDPI (eval-6), Springer Nature (eval-7), Chinese-language journal (eval-8), plus synthetic 2-col / sub-figures / GB/T 7714 stress fixtures (eval-2..4). Real Elsevier coverage lives in the project's `Elsevier批量测试输出/` folder (6 papers; see _summary.csv there).",
"discipline_coverage": "eval-9..11 add discipline-specific structural stress: theorem/proof environments (math/physics), multi-page longtable (econometrics/stats), and long captions with (a)/(b)/(c)/(d) sub-labels (biology/medicine). These were generated synthetically because the skill operates on structural primitives (text + figures + bib + math envs) rather than discipline semantics — most cross-discipline differences (chemistry symbols, electrical diagrams, gel images) are content-level not structure-level and don't expose new failure modes in the pipeline."
}
}
evals/
Test surface for the pdf-to-chinese-latex skill, organized for the skill-creator eval harness.
What's here
| File / dir | Purpose |
|---|---|
evals.json | Eval suite definitions (prompts, expected outputs) — fed to the harness |
test_inputs/ | Synthetic fixture PDFs (committed; reproducible from _gen_stress_pdfs.py) |
_gen_stress_pdfs.py | Regenerates the three synthetic stress PDFs in test_inputs/ |
The five evals
| id | input | what it stresses |
|---|---|---|
eval-0-tan-mdevrpstw | real 14-page Hindawi paper (MDEVRPSTW, 5 figures, 20 refs) | end-to-end real-paper baseline |
eval-1-liu-drone-hbdn | real 29-page Wiley paper (drone scheduling, 9 figures incl. vector + rotated, 42 refs) | watermark exclusion, rotated landscape figure, multi-figure pages, author-year bibliography |
eval-2-stress-2col | synthetic 2-page 2-column-body PDF | bbox detection must NOT include left-column body text in a right-column figure |
eval-3-stress-subfigs | synthetic 2-page PDF, three subfigures sharing one caption | default behaviour: crop the whole row as one fig1.png, do NOT split |
eval-4-stress-gbt7714 | synthetic 1-page Chinese paper, GB/T 7714 numbered references | Chinese text extraction + [J]/[M]/[C]/[D] document-type markers don't break entry segmentation |
eval-0 and eval-1 inputs are NOT in this repo (they are external published papers — see the eval workspace in 毕业论文研究/eval-workspace/). eval-2 through eval-4 inputs ARE committed under test_inputs/. Regenerate them any time with:
python evals/_gen_stress_pdfs.pyHonest coverage gap
Synthetic fixtures stress the layout heuristics in render_figures.py and extract_bibliography.py but they do NOT exercise real-publisher quirks:
- Elsevier
ScienceDirect.compage headers, specific watermark phrasing - IEEE single-column-numbered captions ("Fig. 1. ", multi-space)
- ACM
Permission to make digital or hard copies...footer band - Open-access banners (CC BY licenses), DOI strips
- Heavy raster banners that span the column gutter
The two real-paper evals (eval-0, eval-1) cover Hindawi and Wiley. Coverage of 5+ publishers requires adding real papers as test_inputs/ entries and corresponding evals.json blocks. PRs welcome.
How to actually run the suite
# from the skill-creator harness (see its README)
skill-creator eval --skill pdf-to-chinese-latex --suite evals.jsonFor the synthetic-fixture evals you can also just run the relevant scripts directly and inspect _inspection.html:
python scripts/render_figures.py evals/test_inputs/stress-2col.pdf \
--out /tmp/eval-2col/images --auto
# open /tmp/eval-2col/_inspection.html and verify the red box is in the right columnExamples
This folder is reserved for sample input PDFs and the resulting Chinese LaTeX projects. It is intentionally empty in the public repo to keep the clone small.
If you want to contribute an example, please ensure:
- The source PDF is open access / CC-licensed (do not commit copyrighted papers).
- Include both the original
.pdfand the produced<Author><Year>_<Topic>_中文版/
folder so others can compare.
- Add a one-line entry in this README pointing to the example.
MIT License
Copyright (c) 2026 the pdf-to-chinese-latex authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
pdf-to-chinese-latex
Translate any-language academic PDF papers → an editable Chinese LaTeX project that compiles in Overleaf with XeLaTeX, with equation numbering, tables, figures, and bibliography preserved 1:1 with the source.
This is a Claude Code skill: once installed, telling Claude "translate this PDF to Chinese" triggers the full 7-step workflow automatically. The seven scripts under scripts/ are also usable standalone (no skill / no Claude required).
For Chinese / 中文版 readme, see `README.md`.
---
Why this exists
Naive "AI translate this PDF" tools break in three ways:
1. Vector figures disappear — network diagrams, routing graphs, schematic illustrations are drawn as vector paths, not raster images. Standard PDF parsers extract nothing. 2. Equation numbering drifts — translators treat equations as flowing text. Original (3.4) becomes a screenshot, a typo, or vanishes. 3. Output is a dead PDF — you can't paste the result into your own thesis literature review and keep editing it.
This skill targets these three problems specifically:
- Caption-anchored vector rendering via PyMuPDF, with per-figure bbox
detection + Voronoi-style assignment for multi-figure pages + auto- rotation for landscape figures embedded in portrait pages.
- Equation labels are mirrored —
\label{eq:cN}matches source
equation (N), and \eqref{eq:cN} in prose keeps cross-references intact.
- Output is a `.tex` project — the deliverable is editable LaTeX,
not a frozen PDF.
---
Target users
- Graduate students translating English / Japanese / German / Indonesian
/ French academic papers into Chinese for thesis literature review.
- Lecturers preparing classroom Chinese versions of foreign-language
research papers.
- Anyone producing editable Chinese LaTeX, not a read-only PDF.
Not a fit for: text OCR / one-line summary use cases (use lighter tools); PDFs that are already in Chinese; conversion to Word/PowerPoint (use the corresponding docx / pptx skills).
---
Install
As a Claude Code skill (recommended)
# 1. Clone into your skill directory
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git \
~/.claude/skills/pdf-to-chinese-latex # macOS / Linux
# Windows PowerShell
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git `
"$env:USERPROFILE\.claude\skills\pdf-to-chinese-latex"
# 2. Install Python dependencies
pip install pdfplumber pypdf PyMuPDF Pillow
# 3. Verify install
python ~/.claude/skills/pdf-to-chinese-latex/scripts/self_check.py
# expects: 6 passed, 0 failedClaude Code auto-discovers the skill on next startup. Then ask Claude:
Translate these papers into Chinese LaTeX:~/path/to/paper1.pdf,~/path/to/paper2.pdf
The 7-step workflow runs automatically.
Standalone scripts (no Claude required)
pip install pdfplumber pypdf PyMuPDF Pillow
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git
cd pdf-to-chinese-latex
# Per-paper pipeline:
python scripts/extract_text.py input.pdf --out work/raw.txt
python scripts/render_figures.py input.pdf --out work/images --auto \
--side-margin 30 --top-margin 100 # Wiley/Elsevier flags
python scripts/extract_bibliography.py work/raw.txt --out work/_bib.tex
python scripts/extract_tables.py input.pdf --out work/_tables.tex
# ↑ paste from _figure_includes.tex + _tables.tex into main.tex
python scripts/check_tex.py work/main.tex
python scripts/compile_pdf.py work # local xelatex if installed---
What the 7-step workflow does
1. Ask two scoping questions — figure handling (embed / placeholder / skip) and translation granularity (full / core-sections / full + glossary). 2. Extract text per page — pdfplumber writes raw.txt with ========== PAGE N ========== delimiters. 3. Render figures — PyMuPDF does bbox + Voronoi caption assignment, auto-rotates sideways figures, excludes publisher watermarks, emits _figure_includes.tex (paste-ready blocks) and _inspection.html (visual checkpoint).
- 3.5 — Visually verify every extracted figure. Open
_inspection.html in any browser; each row shows the extracted figN.png left, the source PDF page with a red overlay of the exact crop region right. 4. Build project skeleton — {Author}{Year}_{Topic}_中文版/ from the ctex template under references/latex_template.tex. 5. Translate section by section — paragraph-by-paragraph, with equations / tables / algorithms / refs preserved structurally. extract_bibliography.py builds the \bibitem skeleton. extract_tables.py builds the \begin{table} skeleton. 6. Static validation — check_tex.py checks \begin/\end balance, \cite ↔ \bibitem, \ref ↔ \label, inline $...$ parity, and that every \includegraphics{X} points at a real image file. Follows \input{...} directives. 7. Package + handoff — zip the {main.tex, images/, README.md} folder; user uploads to Overleaf and compiles with XeLaTeX. 8. (Optional) Local compile — if XeLaTeX is installed, compile_pdf.py runs two passes and outputs main.pdf.
---
Tested coverage
| Publisher / format | Tested | Notes |
|---|---|---|
| Hindawi 1-col | ✓ canonical Tan2025 | |
| Wiley 1-col | ✓ canonical Liu2025 (rotated figures + watermarks) | |
| Elsevier 1-col + 2-col | ✓ 6 real papers; full end-to-end translation on Wang2026 + Ai2021 | |
| arXiv preprint | ✓ no publisher header; vertical arXiv stamp | |
| MDPI open-access | ✓ Creative Commons disclaimer handled | |
| Springer Nature (Sci. Rep.) | ✓ 61-entry bibliography | |
| Chinese-language journal | ✓ 图 N caption keywords; mixed-language bibliography |
| Structural stress fixture | What it covers |
|---|---|
| 2-column with figure in one column | Voronoi assignment + |
| Subfigures sharing one caption | default: one image; manual split via |
| GB/T 7714 (Chinese) bibliography | numbered Chinese + [J]/[M]/[C]/[D] markers |
| Theorem / Lemma / Proof environments | template preloads \newtheorem in Chinese |
| Multi-page longtable | row alignment preserved across page break |
| Long caption with (a)/(b)/(c)/(d) sub-labels | one figN.png covering all panels |
Full evaluation harness in `evals/evals.json`.
---
Dependencies
| Package | Use | Install | License |
|---|---|---|---|
pdfplumber | Text + table extraction | pip install pdfplumber | MIT |
pypdf | Backup PDF processing | pip install pypdf | BSD |
PyMuPDF | Vector figure rendering | pip install PyMuPDF | AGPL-3.0 |
Pillow | Image rotation | pip install Pillow | MIT |
| XeLaTeX (optional) | Local compile | MiKTeX / MacTeX / TeX Live | varies |
⚠ License note: PyMuPDF is AGPL-3.0. Using it in a commercial/closed product requires a commercial license from Artifex. For personal/academic use (the stated target audience) AGPL is not a concern.
Python 3.8+. Overleaf-side no setup needed — the free tier compiles XeLaTeX.
---
Operating principles
Every change to this skill must pass three tests (see `SKILL.md` for the full statement):
1. Universality — generalize the rule, not the symptom. 2. Internalization — a fresh user benefits without reading the commit history. 3. Low friction, high quality — sane defaults, loud failure, in-tool verification loops, token-conscious docs.
---
License
MIT
pdf-to-chinese-latex
把任意语种的学术 PDF 论文 → 翻译成中文 → 产出可在 Overleaf 用 XeLaTeX 编译的 LaTeX 工程,公式编号 / 表格 / 图 / 参考文献全部和原文一一对应。
这是一个 Claude Code skill,安装之后跟 Claude 说一句"把这篇 PDF 翻成中文版"它就会自动跑全流程。也可以脱离 skill 直接用其中的脚本(scripts/extract_text.py、scripts/render_figures.py、scripts/check_tex.py)。
---
解决了什么问题
把一篇 30 页的英文论文翻译成中文,自己手工做大概是这个流程:抠图、转 LaTeX、翻文字、对公式编号、对参考文献。一篇下来半天到一天。常见的"AI 一键翻译 PDF" 工具有三个硬伤:
1. 矢量图丢失 —— 复杂的网络图、路径图都是矢量绘制,普通 PDF 解析器抽不出来; 2. 公式编号崩了 —— 翻译器把公式当文字处理,原文 (3.4) 在译文里可能变成图片或乱码; 3. 交付的是只读 PDF —— 想往自己的文献综述里粘几句就只能复制粘贴,没办法二次编辑。
本 skill 用三个针对性的设计解决这三件事:
- caption 锚定裁图:用 PyMuPDF 在 PDF 里搜 "Fig. N." 关键字定位图题,把图题上方那块区域以 3× 比例栅格化导出 PNG —— 不管原图是矢量还是位图都能完整保留;
- 公式编号映射:每个
\label{eq:cN}严格对应原文式 (N),正文里用\eqref{}引用,所以交叉引用永远不会错位; - 交付 LaTeX 源码 + 静态校验:产物是可继续编辑的
.tex工程,附带一个 Python 校验脚本检查\begin/\end配对、\cite ↔ \bibitem配对、\ref ↔ \label配对、行内$...$配对。
适用场景
- 英文 / 日文 / 印尼文 / 德文 / 法文等任意语种的学术论文 PDF;
- 4 ~ 40 页;含公式、表格、算法伪代码、参考文献;
- 目标是把论文翻成中文版供文献综述 / 课题报告 / 课堂讲解使用;
- 希望产出可继续编辑的 LaTeX 源码,而不是只读的中文 PDF。
不适合:
- 仅需要文字 OCR / 摘要 / 一句话总结 —— 用其他更轻量的工具;
- 原文已是中文;
- 想要 Word 文档 / PPT / 长篇 PDF 的纯排版 —— 用对应的 skill(docx / pptx / pdf)。
安装
作为 Claude Code skill 使用(推荐)
# 1. 克隆这个仓库到你的 skill 目录
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git \
~/.claude/skills/pdf-to-chinese-latex # macOS / Linux
# Windows PowerShell
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git `
"$env:USERPROFILE\.claude\skills\pdf-to-chinese-latex"
# 2. 安装 Python 依赖
pip install pdfplumber pypdf PyMuPDF启动 Claude Code 时它会自动发现 ~/.claude/skills/ 下的 skill。
装完先跑一次自检确认环境通:
python ~/.claude/skills/pdf-to-chinese-latex/scripts/self_check.py会临时生成一个合成 PDF 走完 extract → render → check_tex → xelatex 整条链路,打印每一步 PASS / FAIL。10 秒内出结果。xelatex 那一步出 SKIP 是因为没装 TeX,只想用 Overleaf 编译可以忽略;出 FAIL 就要先修依赖再用。
然后这样使唤它:
我有这两篇文献D:\path\to\paper1.pdf和D:\path\to\paper2.pdf,请帮我翻成中文 LaTeX 版本。
Claude 会触发本 skill,先用 AskUserQuestion 问你两个关键选项(图表处理 / 翻译颗粒度),然后跑完整 7 步流程。
单独使用脚本(不需要 Claude Code)
pip install pdfplumber pypdf PyMuPDF
git clone https://github.com/<your-name>/pdf-to-chinese-latex.git
cd pdf-to-chinese-latex
# 抽取全文文本
python scripts/extract_text.py input.pdf --out output_dir/raw.txt
# 抽取图(自动扫描所有页,按 Fig. N. / Figure N. 锚定裁切)
python scripts/render_figures.py input.pdf --out output_dir/images --auto
# Wiley / Elsevier 类带下载水印的论文加上:--side-margin 30 --top-margin 100
# 想手动指定某几张图:--figs "1:3:Fig. 1.,2:5:Fig. 2.,3:7:Fig. 3."(可与 --auto 叠加 override)
# 从 raw.txt 直接抽出 \bibitem 骨架,省去手抄 20~40 条参考文献
python scripts/extract_bibliography.py output_dir/raw.txt --out output_dir/_bib.tex
# 翻译后做静态校验(环境配对 / cite-bibitem / ref-label / $ 配对 / 图文件真伪)
python scripts/check_tex.py output_dir/main.tex
# 可选:本地 XeLaTeX 编译(不走 Overleaf)
python scripts/compile_pdf.py output_dir工作流(7 步)
详细解释见 `SKILL.md`。简版:
1. 询问选项:图表处理(抽图嵌入 / 占位 / 跳过)、翻译颗粒度(全文 / 核心章节 / 全文+术语表); 2. 抽文本:pdfplumber 按页输出到 raw.txt; 3. 抽图:PyMuPDF 按图题关键字定位 caption 矩形,向上裁切并以 3× 比例渲染成 PNG; 4. 建工程:{Author}{Year}_{Topic}_中文版/main.tex + images/ + README.md,基于 references/latex_template.tex 的 ctex 模板; 5. 逐段翻译:摘要 / 引言 / 模型 / 算法 / 实验 / 结论 / 参考文献全部覆盖,公式 / 表格 / 算法伪代码逐一重排; 6. 静态校验:scripts/check_tex.py 检查环境配对、引用配对、行内公式配对; 7. 打包:把 {main.tex, images/, README.md} 压成同名 zip,给出 Overleaf 上传步骤。 8. (可选)本地编译:装了 MikTeX / TeX Live 的用户可以 python scripts/compile_pdf.py <output_folder>,两遍 XeLaTeX 直出 PDF,省去 Overleaf round-trip。
输出示例
skill 跑完一篇 14 页的论文后产出大致这样的结构:
Tan2025_MDEVRPSTW_中文版/
├── main.tex # 全文中译,500+ 行,含 35 条 MILP 约束公式
├── images/
│ ├── fig1.png # MDEVRPTW 路径示例(图题锚定自动裁切)
│ ├── fig2.png
│ ├── ...
│ └── fig5.png
├── raw.txt # 原文逐页文本,校对用
└── README.md # Overleaf 编译说明
Tan2025_MDEVRPSTW_中文版.zip # 同名打包,可直接上传 OverleafOverleaf 上 Compiler 选 XeLaTeX,Recompile 即得 14 页中文 PDF。
项目结构
pdf-to-chinese-latex/
├── SKILL.md # skill 主文档(被 Claude 读取的部分)
├── README.md # 你正在看的这份
├── LICENSE # MIT
├── .gitignore
├── scripts/
│ ├── extract_text.py # pdfplumber 抽文本
│ ├── render_figures.py # PyMuPDF caption 锚定裁图(含 --auto 全扫描 + 水印剔除)
│ ├── extract_bibliography.py # raw.txt → \bibitem 骨架(numbered / 作者-年 两种格式)
│ ├── check_tex.py # main.tex 静态校验(含 \includegraphics 真伪 magic-byte 检查)
│ └── compile_pdf.py # 可选:本地 XeLaTeX 编译直出 PDF
├── references/
│ ├── latex_template.tex # ctex 中文 LaTeX 骨架
│ ├── readme_template.md # 每篇译稿用的 README 模板
│ └── troubleshooting.md # 常见坑与排错
└── examples/ # (可选)放置示例输入/输出对依赖
| 包 | 用途 | 安装 |
|---|---|---|
| pdfplumber | 抽取 PDF 文本 | pip install pdfplumber |
| pypdf | 备用 PDF 处理 | pip install pypdf |
| PyMuPDF | 渲染图片(包含矢量图) | pip install PyMuPDF |
| XeLaTeX (可选) | 本地直接编译 PDF | Windows: winget install MiKTeX.MiKTeX;macOS: brew install --cask mactex;Linux: apt install texlive-xetex texlive-lang-chinese |
Python 3.8+。Overleaf 端无需任何配置,免费账号即可编译。装了 XeLaTeX 之后可以 python scripts/compile_pdf.py <output_folder> 直接在本地出 PDF,完全脱离 Overleaf。
给贡献者 / 修 skill 的人:核心主旨
这个 skill 的所有改动都遵循一个核心主旨——所有工作的最终目的,是把通用原则内化进 skill 本身,让任何人装载之后能在低消耗下拿到高质量产出。具体到三条测试:
1. 普适性:修 bug 时把根因抽成规则,不要硬编码 Wiley / 某页 / 某图。规则才能复用到未见过的论文。 2. 内化到 skill:新来的人不用读 commit history 就能受益——把知识塞进代码默认行为(脚本开箱即用)、SKILL.md 流程(带强制检查点)、troubleshooting.md(具体的 失败→原因→修法)。只留在某次对话或 commit message 里 ≠ 内化。 3. 低摩擦高质量:装载到出成品的路径要短。合理默认(不让 agent 反复问用户)、显式失败(绝不静默吐废品)、验证闭环在工具里(如 _inspection.html 让"图截对没"30 秒就能扫完)、文档省 token(SKILL.md 只讲做什么,不讲怎么演化来的)。
提 PR 前自问:
- 这个修法能帮每一篇未来同类论文,还是只解决眼前这一篇?
- 落地后,新用户不读这段对话也能受益吗?
- 它是减少还是增加了下一个用户的摩擦?
三个都是 yes 才合并。否则改到三个都 yes 为止。完整版见 `SKILL.md`。
致谢
本 skill 诞生于一次具体的研究生工作:把两篇英文运筹学论文翻译成中文 LaTeX 版本以用作毕业论文文献综述。流程沉淀过程详见 实际工作流博客 / 学习日志(如果你也想分享,欢迎 PR 加链接)。
License
MIT
Figure extraction — how render_figures.py decides what to crop
This document goes one level deeper than SKILL.md Step 3. Read it only when _inspection.html shows a figure that was cropped wrong and you need to understand which knob to turn.
The pipeline in one paragraph
For each figure number N the script (a) locates a caption rect via keyword search + text-density disambiguation, (b) collects every raster XObject and every vector drawing ≥30pt on the same page as bbox candidates, (c) assigns each candidate to its nearest caption via a Voronoi-style rule, (d) takes the bbox union of all candidates owned by the current caption as the clip, (e) auto-detects whether the clip's text reads sideways and rotates the rendered PNG back to upright, and (f) auto-detects publisher watermarks near the page edges and shrinks the clip to exclude them. The final pixmap is rendered at 3× scale (≈216 DPI) and saved as fig{N}.png.
Pitfall 1 — In-text reference vs real caption
Figure 5 may appear inside body text ("as shown in Figure 5"), inside another figure's caption text, AND under the actual figure ("Figure 5. Route of Scenario 2"). Naive search_for returns all of them.
The script disambiguates by text density in the 100pt strip immediately above each candidate — a real caption sits below an image (sparse / empty above), an in-text reference is embedded in body prose (dense above). The lowest-density candidate wins. This handles both common layouts correctly:
- Figure placed after its first text mention (Tan-style): real caption is
the lowest hit on the page.
- Figure placed before its first text mention (Liu-style page 7, where
Fig.3 / 4 / 5 stack at the top of the page and the body-text discussion is at the bottom): real caption is HIGHER than the in-text refs, but still has the least text above it.
If the heuristic still picks wrong (e.g. the figure is wrapped by a table or a sidebar block), override with |bbox=x0,y0,x1,y1 after the keyword:
--figs "5:11:Figure 5.|bbox=50,200,540,560"Pitfall 2 — Landscape figures rotated 90° into a portrait page
Large multi-panel comparisons and wide network diagrams are often rotated 90° when embedded in a portrait journal page. Rendering the clip as-is leaves the figure sideways.
The script reads line.dir from text spans inside the clip — if ≥60% of characters point a non-horizontal direction, the rendered PNG is auto-rotated back to upright (uses Pillow, a transitive pdfplumber dependency, so no extra install is required). Force or disable via per-fig |rotate=N (N = 0/90/180/270 CW):
--figs "9:24:Fig. 9.|rotate=90" # force 90° CW
--figs "9:24:Fig. 9.|rotate=0" # disable auto-rotationPitfall 3 — Publisher watermarks (Wiley / Elsevier / IEEE)
These journals stamp a vertical "Downloaded from onlinelibrary.wiley.com" sidebar on the right edge and a journal-header band at the top of each page. Both intrude into figure crops if you take the clip as-is.
render_figures.py knows a short list of watermark strings and shrinks the clip rect to avoid any bbox within 80pt of a page edge that matches one of them. For stubborn cases (watermark uses an unlisted phrase or sits further inside the page), pass:
--side-margin 30— drops 30pt from each horizontal edge of the clip.--top-margin 100— raises the floor of the first-figure-on-page clip
from 72pt to 100pt.
Recommended baseline for Wiley papers (all 9 Liu2025 figures clean after this):
python scripts/render_figures.py "<pdf>" --out images \
--side-margin 30 --top-margin 100 --auto--top-margin and --side-margin apply ONLY to the caption-anchored fallback path. The primary bbox-based path uses the figure's real extent and ignores them. Watermark auto-exclusion runs in both paths.
Pitfall 4 — No caption keyword matched
If a caption keyword isn't found on its expected page, the script falls back to rendering the full page. That's a useful signal in the output — re-check the page number and try alternative keywords (Figure 1. vs Fig. 1., with/without trailing period).
The script automatically tries several common variants (Fig. N., Figure N., FIGURE N, Abb. N.), so a single keyword like Fig. 1. will still find a paper that uses Figure 1..
When all heuristics fail: bbox override
The hard escape hatch for any figure the heuristics get wrong is |bbox=x0,y0,x1,y1 after the keyword. Coordinates are in PDF points (72 pt = 1 inch). Read them off the inspection HTML's clip meta (e.g. clip = (113, 100) → (480, 380)) and adjust by 10~30 pt as needed.
For deeper failure modes (two-column layouts, subfigures with their own captions, etc.), see troubleshooting.md.
% !TEX program = xelatex
% =========================================================
% 中文译稿模板 -- pdf-to-chinese-latex skill
%
% 使用方法:
% 1. 把所有 <...> 占位符替换为译稿内容;
% 2. 在 images/ 目录下放置 fig1.png, fig2.png, ... ;
% 3. Overleaf 编译方式:Menu -> Compiler 选 XeLaTeX -> Recompile。
%
% 本模板预加载的包足以支撑大部分学术论文:
% - ctex 中文支持(XeLaTeX)
% - amsmath/amssymb/amsthm 公式
% - graphicx + booktabs + tabularx + longtable + multirow 图与表
% - algorithm + algpseudocode 算法伪代码
% - hyperref + url 超链接
% - enumitem 自定义列表
% =========================================================
\documentclass[a4paper,11pt]{article}
\usepackage[UTF8,heading=true]{ctex}
\usepackage[a4paper,margin=2.5cm]{geometry}
\usepackage{amsmath,amssymb,amsthm}
\usepackage{graphicx}
\usepackage{booktabs}
\usepackage{array}
\usepackage{longtable}
\usepackage{caption}
\usepackage{enumitem}
\usepackage{algorithm}
\usepackage{algpseudocode}
\usepackage{xcolor}
\usepackage[hidelinks,colorlinks=false]{hyperref}
\usepackage{url}
\usepackage{tabularx}
\usepackage{multirow}
\usepackage{float} % provides the [H] specifier to FORCE a figure
% into position (no floating). Use only when a
% regular [!htbp] still defers the figure.
\graphicspath{{images/}} % so \includegraphics{fig1.png} resolves to images/fig1.png
% --- Figure inclusion convention (read before pasting figures) -----------
% Default to [!htbp] placement and pick width from the PNG's aspect ratio:
%
% aspect ≥ 2.5 → \includegraphics[width=0.85\textwidth]{figN.png} (very wide banner)
% 1.5 ≤ < 2.5 → \includegraphics[width=0.75\textwidth]{figN.png} (standard landscape)
% 0.9 ≤ < 1.5 → \includegraphics[width=0.65\textwidth]{figN.png} (square-ish)
% < 0.9 → \includegraphics[width=0.55\textwidth]{figN.png} (portrait)
%
% render_figures.py emits an `_figure_includes.tex` sidecar with these
% widths already filled in for every extracted figure — paste from there.
% -------------------------------------------------------------------------
\graphicspath{{images/}}
\captionsetup{font=small,labelfont=bf}
\setlength{\parskip}{0.4em}
% 自定义环境
\newtheorem{definition}{定义}
\newtheorem{assumption}{假设}
\newtheorem{theorem}{定理}
\newtheorem{lemma}{引理}
\newtheorem{proposition}{命题}
\title{\bfseries <中文标题>}
\author{<罗马字作者 1>\textsuperscript{*}\quad <罗马字作者 2>\\[2pt]
\small\itshape <作者机构 1>\\
\small\itshape <作者机构 2>\\
\small 通讯作者邮箱:\href{mailto:<email>}{<email>}}
\date{原文发表于 \emph{<期刊全名>},<年份>,第 <卷> 卷 第 <期> 期,pp.\ <起>--<止>\\
\small DOI:\ \href{<DOI URL>}{<DOI>}}
\begin{document}
\maketitle
\begin{abstract}
\noindent <摘要正文翻译>
\noindent\textbf{关键词:}<关键词 1>;<关键词 2>;<关键词 3>
\end{abstract}
% =========================================================
\section{引言}
\label{sec:intro}
<引言正文翻译...>
% =========================================================
\section{<第二节标题>}
\label{sec:methods}
<正文翻译...>
% --- 公式示例 ---
% 用 \label{eq:cN} 对应原文的 (N) 式,正文里用 \eqref{eq:cN} 引用。
\begin{equation}
\label{eq:c1}
\min z = \sum_{i=1}^{n} c_i x_i.
\end{equation}
约束如式 \eqref{eq:c2} 所示:
\begin{equation}
\label{eq:c2}
\sum_{j=1}^{m} a_{ij} x_j \le b_i,\quad \forall i.
\end{equation}
% --- 图示例 ---
\begin{figure}[ht]
\centering
\includegraphics[width=0.8\textwidth]{fig1.png}
\caption{<图 1 的中文标题>}
\label{fig:f1}
\end{figure}
% --- 表示例(用 tabularx + booktabs)---
\begin{table}[ht]
\centering
\caption{<表 1 的中文标题>}
\label{tab:t1}
\begin{tabularx}{0.85\textwidth}{c X r}
\toprule
参数 & 含义 & 数值 \\
\midrule
$\alpha$ & <含义说明> & 1.0 \\
$\beta$ & <含义说明> & 0.5 \\
\bottomrule
\end{tabularx}
\end{table}
% --- 算法示例 ---
\begin{algorithm}[ht]
\caption{<算法名称>}
\label{alg:a1}
\begin{algorithmic}[1]
\State \textbf{输入:}<输入说明>
\State \textbf{输出:}<输出说明>
\For{$i=1$ to $n$}
\If{<条件>}
\State <操作>
\EndIf
\EndFor
\State \Return <返回值>
\end{algorithmic}
\end{algorithm}
% =========================================================
\section{结论}
\label{sec:conclusion}
<结论翻译...>
\subsection*{致谢}
<致谢翻译...>
% =========================================================
\begin{thebibliography}{99}
\small
\bibitem{Key1} <Author>, <Title>, <Journal>, <Year>. doi: <DOI>.
\bibitem{Key2} <Author>, <Title>, <Journal>, <Year>. doi: <DOI>.
\end{thebibliography}
\end{document}
<作者-年份-主题> 中文版 — Overleaf 编译说明
文件结构
<output_folder>/
├── main.tex # 主文档(中文翻译 + LaTeX 源码)
├── images/ # 从原 PDF 提取的图(fig1.png ~ figN.png)
├── raw.txt # 原文(外文)逐页文本,仅作译者校对用,不参与编译
└── README.md # 本说明在 Overleaf 编译
1. 打开 Overleaf,点击 New Project → Upload Project。 2. 上传本文件夹同名的 .zip 文件。 3. 进入项目后,Menu → Compiler → XeLaTeX(必须 XeLaTeX,否则 ctex 中文无法渲染)。 4. Recompile。
在本地编译
需要安装 TeX Live 2020+ 或 MikTeX 22+:
xelatex main.tex
xelatex main.tex # 第二次编译以解决交叉引用译稿说明
- 使用
ctex包做中文排版,所有汉字直接以 UTF-8 写入。 - 公式编号与原文一一对应:原文式 (N) 在本译稿中对应
\label{eq:cN},用\eqref{eq:cN}引用。 - 表格用
tabularx + booktabs重排为中文版,数据与原表保持一致。 - 算法伪代码用
algorithm + algpseudocode。 - 参考文献使用手工
thebibliography列出(条目保留英文,便于检索原文)。
已知简化(如有)
- 原文中 <第 N 页/第 M 节> 的大型横向表格因数据规模较大,本译稿以要点形式概括,未逐字复刻;如需原始数据请参阅原 PDF 对应页。
Troubleshooting / FAQ
Read this only when the main workflow runs into a specific snag. Each entry is a self-contained problem → cause → fix.
抽取 / Extraction
文本里出现大量 "Downloaded from / Online Library" 噪声行
原因: Wiley / Elsevier 等出版社会在每页 PDF 上嵌入一个版权下载头,pdfplumber 会把它当成正文文本提取出来。 处理: 翻译时跳过这些行,不要把它们也译成中文。可以在 raw.txt 里写一个简单的过滤脚本,或者翻译时直接忽略。这种页眉/页脚是 PDF 标准做法,不影响公式与图。
下标/上标在 raw.txt 里跑到了下一行
原因: pdfplumber 对脚标/数学符号的行内对齐有时会失败。 处理: 翻译时按上下文恢复,例如 t 1 应解读为 $t_1$;v 1 v 8 解读为 $\nu_1\nu_8$。这是常态,不必修。
Landscape(横向)表格抽出来全是反序文字
原因: 原 PDF 把整页旋转 90° 排版了,pdfplumber 按原始坐标系读出来字符顺序就反了。 处理:
- 简短表:直接看原 PDF 用眼睛读,手动重排成中文
tabularx。 - 巨型数据表:总结要点写到译稿正文,README 里加一行注明 "原始数据见原 PDF 第 X 页"。不要花一整天逐字复刻一张读者根本不会看的数据表。
抽图 / Figure rendering
render_figures.py 报 "NOT FOUND on page X"
原因: caption 关键字大小写或缩写不一样。常见组合:
Fig. 1./Figure 1./Fig 1.(注意空格 + 点)FIGURE 1(全大写,无点)- 出版社特殊缩写如
Abb. 1.(德文)
处理: 用 pdfplumber 或 PyMuPDF 直接 grep 那一页的文本,看 caption 实际写法。然后改 --figs 里那条记录的 keyword。
图被裁切多了或少了(包含太多正文/缺一部分图)
原因: caption 上方可能还有其他文字段(如承接段、表题),上一张 caption 的位置识别有偏差。 处理: 用 --figs 单独跑那一张图,先用 --scale 1.0 快速预览整页,确认图实际占的 y 范围,再考虑:
- 加宽:把
--top-margin改大(如从 72 改成 100~120),下次 fallback 时图框更靠下。 - 直接 override:在那条
--figs项里加|bbox=x0,y0,x1,y1手动指定矩形。
抽出来的根本不是图,是一段英文正文("Example 2 of this scenario results in...")
原因: Figure N / Fig. N 字样在页面上同时出现在多处——正文 in-text 引用("as shown in Figure 5")、其他图的 caption 文字里、以及真正的图下方的 caption。早期脚本用 search_for()[0] 拿第一个匹配(reading order 中正文在前),就把 in-text reference 上方的段落当成了"图"。 处理: 当前 render_figures.py 使用上方文字密度判定真 caption:每个候选命中位置上方 100pt 内的字符数最少者胜。逻辑:真 caption 上方是图(稀疏 / 空白),in-text 引用上方是正文段落(密集)。两种常见布局都能正确:
- 图在首次提到之后(Tan 类):真 caption 在页面下方,但上方是图区,文字稀少。
- 图在首次提到之前(Liu 第 7 页,三张图竖排在页面顶部,正文在底部讨论):真 caption y 值较小,上方只有页眉,文字密度更低,依然胜出。
如果还是错(比如图被表格或 sidebar 包围),给那张图加 |bbox=x0,y0,x1,y1 手动指定矩形:
--figs "5:11:Figure 5.|bbox=50,200,540,560"bbox 坐标是 PDF point(72 pt = 1 inch),可以先 --scale 1.0 渲染整页找位置。
同一篇论文有多张图同号 caption "Fig. N." 出现在多页
原因: 不同章节的图可能错位排版到不同页(尤其同一组图集中排在一页时),加上正文 in-text 引用,page.search_for 跨页可能命中 3~4 处同名字段。 处理: --auto 模式现在跨页收集所有命中候选,用上方文字密度统一打分(密度越低越像真 caption),最终选全局最优。如果你跑 --auto 发现某张图明显不对,先单页跑 --scale 1.0 --figs "N:<page>:Fig. N." 看是哪一页错了,再用 |bbox=... override。
图里带着 Wiley/Elsevier 下载水印(右侧竖排 "Downloaded from..." 或顶部 "C. Liu et al. / ...")
原因: 出版社把水印盖到了每一页边缘,按整页宽度裁就会把水印带进来。 处理: 新版 render_figures.py 会自动搜 Downloaded from / onlinelibrary.wiley / Wiley Online Library 等模式,把它们的 bbox 从 clip 矩形里挖掉(只在水印靠页边缘 80pt 之内时收缩,避免误伤)。如果水印用了别的字符串或者距离边缘超过 80pt,手动加:
--side-margin 30 --top-margin 100Wiley 系刊物的推荐组合就是 --side-margin 30 --top-margin 100。Elsevier 类似但 sidebar 可能在 25 pt,可以再细调。
是矢量路径图,PyMuPDF 渲染出来模糊
原因: scale=3.0 对极致清晰度的论文不够。 处理: --scale 4.0 或 5.0。文件会大一点(几 MB),但 Overleaf 完全能撑住。
中文期刊论文图全部抽不出来(n_figs=0 但论文明明有图)
症状: 跑 render_figures.py --auto 在某中文期刊论文上返回 0 figures,但 PDF 里明明有图。 两种可能:
1. Caption 用中文模式但带不同符号:现支持的中文形式有 图 N. / 图N. / 图 N: / 图N: / 图 N: / 图N: / 图 N / 图N 。如果你的论文用了别的符号(如 图N、 顿号、图-N 减号)→ 给那张图加 |bbox= override,或临时在 render_figures.py 顶部 _STRONG_KW_FORMS 加一行。
2. Caption 被栅格化进了图片里(中文期刊老论文常见做法):图 N XXX说明 整段文字是图片的一部分,page.search_for("图 N") 在 PDF 文字层里搜不到任何东西。这种情况启发式无解,必须:
- 用 PDF viewer 量出每张图的 bbox(PDF point),手动指定:
--figs "1:3:图 1|bbox=70,180,540,400"- 或者先用 OCR(如
ocrmypdf)把图片里的文字提取成 PDF 文字层,再喂给本 skill。
判断属于哪种:在 raw.txt 里 grep 图 1 看是否有命中。命中但只在正文段落里(如 "如图 1 所示")→ 情况 (2);连一处都没有 → PDF 文字层完全没 caption,必然是 (2)。
抽出来的图整张是横倒的(90° 转过来才看得懂)
原因: 原 PDF 把横向的大图旋转 90° 塞进竖向的页面里(多面板对比图、宽网络示意图最常见)。PyMuPDF 按原页面坐标渲染,所以保留了原始旋转方向。 处理: 新版 render_figures.py 会读取 clip 区域内 text span 的 line.dir 向量。如果 ≥60% 字符方向不是水平的,就自动反向旋转回正(用 Pillow,是 pdfplumber 的依赖之一,本来就装着)。如果检测器误判,手动 override:
--figs "9:24:Fig. 9.|rotate=90" # 强制 90° CW
--figs "9:24:Fig. 9.|rotate=0" # 关闭自动旋转rotate 只接 0 / 90 / 180 / 270。
抽出来的图不完整:旋转后边缘被裁掉一截
原因: 原始 caption-anchored cropping 假设"图在 caption 上方"。对旋转图来说这个假设破产——caption 实际在图的侧边,clip 矩形只覆盖到 caption 的 y 位置就停了,旋回正后那部分变成左/右边缘缺失。 处理: 当前 render_figures.py 使用真实 image / drawing bbox 定 clip:先用 page.get_images() 和 page.get_drawings() 收集所有 ≥30pt 的图区候选,再按 Voronoi 距离(横排 caption 用「candidate 上方最近的 caption」规则)把候选分给对应 caption,然后取那一组的 bbox 并集做 clip。旋转图的全部内容、同页多图的精确分割都能拿到正确边界。
子图带各自的 caption(Fig.5(a) / (b) / (c)),想分别抽成 fig5a.png / fig5b.png …
症状: 论文里一张大 Fig.5 下面挂着 (a)/(b)/(c) 三个子图,每个有独立 caption "(a) Random distribution"。当前 skill 默认把整张 Fig.5 当一张 fig5.png 抽出来,没法分别引用子图。多数情况下这是想要的——译稿里直接 \includegraphics{fig5.png} + 中文 caption 描述三个子图就够了。 处理(想分别抽): 用 |bbox= 指定每个子图各自的矩形:
1. 目测拆分:在 _inspection.html 看红框内的整张 Fig.5,估计 (a)/(b)/(c) 的横向分隔位置(典型并排 3 子图:每个占 1/3 宽,间距 10~20pt)。 2. 配置:
--figs "5a:11:Figure 5.|bbox=80,400,260,520, \
5b:11:Figure 5.|bbox=270,400,440,520, \
5c:11:Figure 5.|bbox=450,400,610,520"注意:fig_num 字段必须是数字,不接受 5a/5b 这种字符串。当前实现下要拆子图,只能用不同的整数(如 51:11:...|bbox=... 渲染成 fig51.png 作为 5(a))然后在 main.tex 里 alias 引用。 3. 在 LaTeX 里:用 subcaption 包并排排版:
\usepackage{subcaption}
...
\begin{figure}[!htbp]\centering
\begin{subfigure}{0.3\textwidth}
\includegraphics[width=\linewidth]{fig51.png}
\caption{随机分布}
\end{subfigure}\hfill
... 5b, 5c 同上 ...
\caption{LBS 与 NPS 在 FC 选择上的对比}
\label{fig:lbs-nps}
\end{figure}默认建议: 不拆。整张 fig5.png 一起放、在 caption 里写「图 5:…(a) 随机分布;(b) 偏态分布;(c) 中间分布」是论文界惯例。只在子图需要独立 \ref{} 引用时才拆。
双栏论文(IEEE / Elsevier 双栏版式)抽出来的图横向包含了邻栏的正文/公式
症状: _inspection.html 里红框横跨整页宽,但其实该图只占左栏(或右栏),抽出 PNG 里能看到一边是图、另一边是密密麻麻的正文段落或一截公式。 原因: bbox 检测把邻栏的 raster / 大块 vector drawing 也吸进了 candidates 列表;Voronoi 归属用 "纵向最近的 caption" 判定,没有横向列边界的概念,于是邻栏候选被错分给了这个 caption。 处理: 当前没有自动判定双栏布局的能力,需要手动给那张图加 x 范围限定的 |bbox=:
1. 读列边界:用 PDF viewer (Sumatra/Adobe) 打开源 PDF,量一下左栏文字 x 范围(典型 A4 双栏:左栏 ≈ 50~280pt,右栏 ≈ 315~545pt,栏间距 ≈ 30~40pt)。或者跑 --scale 1.0 --figs "N:<page>:<kw>" 单独渲染一次整页,肉眼比对图所在 x 范围。 2. 覆盖:
--figs "3:7:Fig. 3.|bbox=50,180,280,360" # 锁在左栏
--figs "5:7:Fig. 5.|bbox=315,180,545,360" # 锁在右栏3. 跨栏图:少数大图会占两栏宽(学术论文常见)。这种情况不需要 override,bbox 检测会自然包含两栏。看 _inspection.html 确认。
未来若发现频繁需要 override 双栏论文,可以扩 find_figure_region_near 加列边界检测(用 text blocks x 范围分布判定 1 栏 vs 2 栏)。
_inspection.html 里某张图明显裁错(启发式没顶住边界)
原因: 当前 bbox + Voronoi + rotation pipeline 是为常见学术论文版式调好的,但具体到某一篇可能遇到没编码过的新边界条件——双栏布局、子图独立 caption (Fig. 5(a))、表格和图共享 bbox、广告/插画混在图区里等等。启发式静默通过(0 fallback),但红框框错了位置。 处理: 单图 override,一行参数搞定。两种方式按你看到的问题选:
- 位置 / 范围错了 → 手动指定 bbox(PDF point 单位,1 inch = 72 pt):
--figs "5:11:Figure 5.|bbox=50,200,540,560"bbox 坐标可以从 _inspection.html 的 clip meta 文字反推(比如它显示 clip = (113, 100) → (480, 380),往左/上/右/下推几十 pt 就能扩到包含完整图),或用 PDF 阅读器(Adobe / Sumatra)的"测量"工具读。
- 旋转方向错了 → 强制或关闭旋转:
--figs "9:24:Fig. 9.|rotate=90" # 强制顺时针 90°
--figs "9:24:Fig. 9.|rotate=0" # 关闭自动旋转override 之后重跑 --auto --figs "..." (--auto 会自动发现其余的图,--figs 只覆盖你显式指定的几张),再看一遍 _inspection.html 确认修对了。
图顶端被额外裁掉一截(最上方的 node / 标签缺失)
原因: bbox-based 路径里如果有「图 y0 接近 top_margin 时强制钳到 top_margin」的 safety floor,会把图实际起始位置高于 top_margin 的那部分裁掉。Tan2025 fig3 在 page 9 上 y0=57,但 top_margin=72 把 clip 钳到了 72,丢了顶 15pt(node 10 + 上面的连接箭头)。 处理: 已修复——bbox-based 路径不再对 figure_region.y0 做 top_margin 钳制(图区是真实检测到的,本来就准);top_margin / side_margin 只在 caption-anchored fallback 路径里作用。watermark 自动剔除照常工作,所以 Wiley 顶页眉仍然会被避开。
同页多张图被 cross-contaminate(一张图里包含其他图的内容)
原因: 一张 PDF 页面上常常有 2~3 张图竖排(如 Liu fig3/4/5 共在 page 7)。如果 clip 直接按 caption y 锚定,一旦图区检测半径设大就会把邻居图也拉进来;设小又可能遗漏。 处理: find_figure_region_near 接受 other_captions 参数做 Voronoi 归属判定——每个 image/drawing 候选先找它纵向上最近的下方 caption(横排 caption 时)或Euclidean 最近的 caption(旋转 caption 时),只有"最近 = 当前 anchor"时才纳入。find_all_caption_rects_on_page 帮 render_one 收集页面上所有 fig caption 位置。
旋转后的图把整页吃掉、被 LaTeX 浮动算法甩到文末
原因: 原本横长的图(landscape,宽 > 高)被旋正 90° 之后变成竖长(portrait,高 > 宽)。如果你照其他 landscape 图那样写 \includegraphics[width=0.9\textwidth],渲染出来会接近"满版半张纸",[ht] placement 在当前页和下一页都塞不下,浮动算法只能一路推到文末。 处理: 给"被旋转过的 portrait 图"单独设置:
\begin{figure}[!htbp] %% 改宽松的 placement
\centering
\includegraphics[width=0.55\textwidth]{fig9.png} %% 改小宽度
\caption{...}
\label{fig:...}
\end{figure}width=0.55\textwidth让图渲染成约 9cm × 12cm(A4 双栏可读区的 60% 左右),能与正文同页共存[!htbp]让 LaTeX 在「这里 / 顶部 / 底部 / 浮动页」四个位置都尝试- 若还是漂走,加
\usepackage{float}+[H]强制定位(代价是会把当前页剩余空间空出来)
判断哪张图需要这样处理:跑完 render_figures.py 看 "auto-rotated" 字样,然后 python -c "from PIL import Image; img=Image.open('images/figN.png'); print(img.width/img.height)"——aspect < 0.9 的就属于 portrait,建议宽度 0.5~0.6\textwidth。
翻译 / Translation
公式编号在 PDF 里不是 (1)(2)(3) 而是 (3.1)(3.2)
处理: 在 \label{} 里照搬:\label{eq:c3-1}、\label{eq:c3-2}。check_tex.py 只检查 cite/label 一致性,不在意命名约定。
一篇论文里反复出现一些专有术语,要不要每次都解释?
处理: 首次出现时给中文 + 英文括注,例如 "六边形配送网络(hexagon-based delivery network, HBDN)"。之后用中文或缩写均可。如果用户选了"全文 + 中英术语对照表",把每个术语首次出现都加进文末附录表。
参考文献条目要不要翻成中文?
默认 NOT。 保留英文条目方便读者去搜原文。如果遇到中文文献(GB/T 引用风格),保留原中文条目。
中文论文(参考文献用 GB/T 7714 格式)能不能直接抽 bibitem?
能,开箱即用。 extract_bibliography.py 的 numbered 路径同时识别:
- 英文段标题
References/Bibliography和中文参考文献/文献参考 [1][2][3]前缀(含 GB/T 7714 的[J]/[M]/[C]/[D]/[N]文献类型标识——这些在条目内部不影响切分)
跑法和英文论文一样:
python scripts/extract_bibliography.py raw.txt --out _bib.tex中英混排的条目(部分中文文献 + 部分英文文献)也会正确切分并保留原样。唯一需要手工干预的是:若该论文用作者-年份 (张三 (2020). 标题…) 而非编号格式,当前不识别——把它改成 [1] 张三 (2020). 标题… 一次性手工编号即可。这种格式在中文学术圈罕见。
校验 / Validation
check_tex.py 报 ODD $ count
最常见原因: 把 $x$ 写成 $x 漏了一个 $;或者把美元符号当真实美元用了(应该转义为 \$)。 处理: 看脚本输出的具体行号,到 main.tex 那一行去找,往往一眼就看出来。
check_tex.py 报 MISSING bibitems
原因: 翻译时手快写了 \cite{Smith2020},但 \begin{thebibliography} 里忘了加。 处理: 要么加 bibitem,要么删 cite。永远不要忽略这个警告 — Overleaf 编译时会报 undefined citation。
compile_pdf.py 报 "Undefined control sequence" 比如 \multirow
原因: main.tex 用到了某个命令,但 preamble 里漏装了对应的包。最常见的就是 \multirow(需要 \usepackage{multirow})、\toprule(需要 \usepackage{booktabs})。check_tex.py 是纯静态检查,不知道每个控制序列归属哪个包,所以抓不到这类问题。 处理: 看脚本输出里 "Undefined control sequence <recently read> \xxx" 那一行,在 preamble 加上对应的 \usepackage{xxx},重跑。如果不确定哪个包提供这个命令,搜 "ctan \xxx package"。
check_tex.py 全过但 Overleaf 还是编译报错
最常见两个原因: 1. Compiler 没选 XeLaTeX:ctex 必须 XeLaTeX,pdfLaTeX 直接报错。Menu → Compiler → XeLaTeX。 2. 某个包 Overleaf 没装:通常 Overleaf 自带 ctex、tabularx、algpseudocode 等常见包。如果用了冷门包,看 Overleaf 报错的 Log 面板第一条 ! LaTeX Error: File 'xxx.sty' not found.,然后在 main.tex 把那条 \usepackage{xxx} 去掉换等效写法。
一般性建议
- 先小后大:第一次跑别上来就 30 页,先用 5 页测试整条 pipeline,再上完整稿。
- 保留 raw.txt:调好之后别删,做引文对照很有用,也方便后面回头再翻一遍。
- 结果应当能再编辑:交付物是
.tex源码,不是只读 PDF。用户拿到后会自己改、自己加批注,不要把内容塞进图片或扁平化。
# -*- coding: utf-8 -*-
"""Static-validate a LaTeX file before sending it to Overleaf.
Checks:
* \\begin{env} / \\end{env} balance per environment
* \\cite{key} <-> \\bibitem{key} correspondence
* \\ref / \\eqref <-> \\label correspondence
* each line's $...$ count is even (catches stray $ in prose)
* every \\includegraphics{...} target exists AND has valid image
magic bytes (catches placeholder text files masquerading as PNGs)
Exits 0 if everything looks fine, 1 otherwise. Use this before declaring
a translation "done" -- a clean static check catches the majority of bugs
that would otherwise force a round-trip to Overleaf.
Usage:
python check_tex.py path/to/main.tex
"""
import argparse
import io
import os
import re
import sys
from collections import Counter
# Image format magic-byte signatures. The check looks for one of these
# at the start of the file; anything else (including 12-byte ASCII
# placeholders saved as `placeholder\n`) fails as "not a real image".
IMG_MAGIC = [
("PNG", b"\x89PNG\r\n\x1a\n"),
("JPEG", b"\xff\xd8\xff"),
("PDF", b"%PDF"),
("GIF87", b"GIF87a"),
("GIF89", b"GIF89a"),
("TIFF_LE", b"II*\x00"),
("TIFF_BE", b"MM\x00*"),
("EPS", b"%!PS"),
("BMP", b"BM"),
("WebP", b"RIFF"), # generic RIFF header; webp specifically is RIFF....WEBP
]
# Extensions LaTeX will silently auto-append when \includegraphics{X}
# is given without one.
LATEX_GRAPHICS_EXTS = (".png", ".jpg", ".jpeg", ".pdf", ".eps", ".tif", ".tiff", ".bmp")
def check_image_file(path: str) -> "tuple[str | None, str]":
"""Return (kind, error). kind is None when the file isn't a valid image."""
if not os.path.isfile(path):
return None, "file not found"
try:
size = os.path.getsize(path)
except OSError as e:
return None, f"stat failed: {e}"
if size == 0:
return None, "0-byte file"
try:
with open(path, "rb") as f:
head = f.read(16)
except OSError as e:
return None, f"read failed: {e}"
for name, sig in IMG_MAGIC:
if head.startswith(sig):
return name, f"OK {name} ({size} B)"
# Provide a hint about what was actually in the file
try:
snippet = head.decode("ascii", errors="replace").replace("\r", "").replace("\n", "\\n")
except Exception:
snippet = repr(head[:8])
return None, f"unrecognized header {snippet!r} ({size} B) -- placeholder or corrupted?"
def expand_inputs(path: str, _seen: "set[str] | None" = None, _depth: int = 0) -> str:
"""Read `path` and recursively inline every `\\input{X}` / `\\include{X}`
referenced from it. Resolves X relative to `path`'s directory; tries the
target as-is, then with a `.tex` extension. Catches circular includes
via `_seen`. Comments (lines that start with %) are skipped so that
commented-out \\input statements don't trigger expansion.
Universal: lets check_tex.py handle modular main.tex projects that
split body / bibliography / preamble across files via \\input{}.
Previously, `\\bibitem` entries in an \\input'd file were invisible to
cite/bibitem matching, causing false-positive MISSING reports.
"""
if _seen is None:
_seen = set()
abs_path = os.path.abspath(path)
if abs_path in _seen or _depth > 10:
return "" # circular or absurd depth — bail
_seen.add(abs_path)
base_dir = os.path.dirname(abs_path)
try:
with open(path, encoding="utf-8") as f:
txt = f.read()
except OSError:
return ""
inc_re = re.compile(r"\\(?:input|include)\s*\{([^}]+)\}")
def replace(m: "re.Match[str]") -> str:
target = m.group(1).strip()
# try as-is, then with .tex appended
for cand in (target, target + ".tex"):
full = cand if os.path.isabs(cand) else os.path.join(base_dir, cand)
if os.path.isfile(full):
return "\n" + expand_inputs(full, _seen, _depth + 1) + "\n"
# leave the directive in place if we can't find the file — the
# downstream checks will flag the resulting missing bibitems / etc.
return m.group(0)
# Mask out commented lines so commented \input doesn't get expanded.
def is_commented(start: int) -> bool:
line_start = txt.rfind("\n", 0, start) + 1
prefix = txt[line_start:start]
i = 0
while i < len(prefix):
if prefix[i] == "\\" and i + 1 < len(prefix):
i += 2; continue
if prefix[i] == "%":
return True
i += 1
return False
out = []
pos = 0
for m in inc_re.finditer(txt):
out.append(txt[pos:m.start()])
if is_commented(m.start()):
out.append(m.group(0))
else:
out.append(replace(m))
pos = m.end()
out.append(txt[pos:])
return "".join(out)
def check(path: str) -> int:
# Inline \input{}/\include{} so bibitems / labels / images defined in
# split-out files are visible to the static checks below.
txt = expand_inputs(path)
if not txt:
# Fall back to raw read if expansion path failed
with open(path, encoding="utf-8") as f:
txt = f.read()
issues = 0
# 1. begin/end balance
begs = re.findall(r"\\begin\{([^}]+)\}", txt)
ends = re.findall(r"\\end\{([^}]+)\}", txt)
bc, ec = Counter(begs), Counter(ends)
for k in set(list(bc) + list(ec)):
if bc[k] != ec[k]:
print(f" MISMATCH \\begin{{{k}}}={bc[k]} \\end{{{k}}}={ec[k]}")
issues += 1
print(f" begin total={sum(bc.values())}, end total={sum(ec.values())}")
# 2. cite ↔ bibitem
cites = re.findall(r"\\cite[a-zA-Z]*\{([^}]+)\}", txt)
cite_keys = set()
for c in cites:
for k in c.split(","):
cite_keys.add(k.strip())
bibs = set(re.findall(r"\\bibitem\{([^}]+)\}", txt))
missing = cite_keys - bibs
unused = bibs - cite_keys
if missing:
print(f" MISSING bibitems for: {sorted(missing)}")
issues += 1
if unused:
print(f" UNUSED bibitems: {sorted(unused)}")
# unused is usually a soft warning, not an error
print(f" cited={len(cite_keys)} bibitems={len(bibs)}")
# 3. ref / eqref ↔ label
labels = set(re.findall(r"\\label\{([^}]+)\}", txt))
refs = set(re.findall(r"\\ref\{([^}]+)\}", txt)) | set(
re.findall(r"\\eqref\{([^}]+)\}", txt)
)
missing_lbl = refs - labels
if missing_lbl:
print(f" REFS without matching \\label: {sorted(missing_lbl)}")
issues += 1
print(f" labels={len(labels)} refs={len(refs)}")
# 4. per-line $...$ parity
odd_lines = []
for i, ln in enumerate(txt.split("\n"), 1):
s = ln
cnt = 0
j = 0
while j < len(s):
if s[j] == "\\" and j + 1 < len(s):
# skip escaped char (e.g., \$)
j += 2
continue
if s[j] == "$":
cnt += 1
j += 1
if cnt % 2:
odd_lines.append((i, cnt, ln[:80]))
if odd_lines:
print(f" ODD $ count on {len(odd_lines)} line(s):")
for i, c, l in odd_lines[:10]:
print(f" line {i} (count={c}): {l}")
issues += 1
# 5. \includegraphics target must be a real image, not a placeholder.
# Honors \graphicspath{{dir1/}{dir2/}} from the preamble.
base_dir = os.path.dirname(os.path.abspath(path))
# Collect search dirs: main.tex dir first, then each graphicspath entry.
search_dirs = [base_dir]
gp_re = re.compile(r"\\graphicspath\s*\{((?:\{[^}]*\}\s*)+)\}")
for gp_match in gp_re.findall(txt):
for d in re.findall(r"\{([^}]*)\}", gp_match):
if d:
resolved = d if os.path.isabs(d) else os.path.join(base_dir, d)
search_dirs.append(resolved)
inc_re = re.compile(r"\\includegraphics(?:\s*\[[^\]]*\])?\s*\{([^}]+)\}")
img_paths = inc_re.findall(txt)
bad_imgs = []
for ref in img_paths:
ref = ref.strip()
# If ref has no extension, LaTeX would try extensions in order.
extensions_to_try = [""] if any(
ref.lower().endswith(e) for e in LATEX_GRAPHICS_EXTS
) else [""] + list(LATEX_GRAPHICS_EXTS)
kind = None
last_err = "no candidate matched"
for ext in extensions_to_try:
candidate_name = ref + ext if ext else ref
for d in search_dirs:
abs_path = candidate_name if os.path.isabs(candidate_name) \
else os.path.join(d, candidate_name)
k, err = check_image_file(abs_path)
if k:
kind = k
break
last_err = err
if kind:
break
if not kind:
bad_imgs.append((ref, last_err))
if bad_imgs:
print(f" BAD \\includegraphics targets ({len(bad_imgs)}):")
for r, e in bad_imgs[:10]:
print(f" {r}: {e}")
issues += 1
print(f" images={len(img_paths)} valid={len(img_paths) - len(bad_imgs)}"
+ (f" (search_dirs: {[os.path.basename(d) or '.' for d in search_dirs]})"
if len(search_dirs) > 1 else ""))
if issues == 0:
print("OK: no issues detected.")
else:
print(f"FAIL: {issues} issue(s) detected.")
return issues
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("tex", help="path to main.tex")
args = parser.parse_args()
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.exit(1 if check(args.tex) else 0)
if __name__ == "__main__":
main()
# -*- coding: utf-8 -*-
"""Compile a Chinese LaTeX project (ctex + XeLaTeX) to PDF locally.
Runs xelatex twice (second pass resolves cross-references) on the
main.tex inside the given project directory. If xelatex is not in PATH,
prints installation guidance and the Overleaf-upload fallback instead.
Usage:
python compile_pdf.py path/to/project_folder
python compile_pdf.py path/to/project_folder --main main.tex --keep-aux
By default the script removes auxiliary files (.aux/.log/.out/.toc/.synctex.gz)
after a successful compile, keeping only main.pdf next to main.tex. Pass
--keep-aux to preserve them (useful for debugging compile errors).
Requires: a local TeX distribution with XeLaTeX. Recommended:
- Windows : winget install MiKTeX.MiKTeX
- macOS : brew install --cask mactex
- Linux : apt install texlive-xetex texlive-lang-chinese
"""
import argparse
import io
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
AUX_SUFFIXES = (".aux", ".log", ".out", ".toc", ".synctex.gz", ".fls", ".fdb_latexmk")
def find_xelatex() -> Optional[str]:
"""Return path to xelatex.exe/xelatex, or None if not in PATH."""
p = shutil.which("xelatex")
if p:
return p
# On Windows, MikTeX may not be in current shell's PATH right after install.
# Try common install paths as a last resort.
if sys.platform == "win32":
candidates = [
os.path.expandvars(r"%LOCALAPPDATA%\Programs\MiKTeX\miktex\bin\x64\xelatex.exe"),
r"C:\Program Files\MiKTeX\miktex\bin\x64\xelatex.exe",
r"C:\texlive\2024\bin\windows\xelatex.exe",
r"C:\texlive\2025\bin\windows\xelatex.exe",
]
for c in candidates:
if os.path.isfile(c):
return c
return None
def print_install_help() -> None:
print("[ERROR] xelatex not found on PATH.\n")
print("To compile locally, install a TeX distribution with XeLaTeX:")
print(" Windows : winget install MiKTeX.MiKTeX")
print(" macOS : brew install --cask mactex")
print(" Linux : sudo apt install texlive-xetex texlive-lang-chinese\n")
print("Or, as a fallback, upload to Overleaf:")
print(" 1. Zip up {main.tex, images/, README.md}")
print(" 2. New Project -> Upload Project on https://www.overleaf.com")
print(" 3. Menu -> Compiler -> XeLaTeX -> Recompile\n")
def compile_one(project_dir: Path, main_name: str, keep_aux: bool, xelatex: str) -> int:
main_tex = project_dir / main_name
if not main_tex.is_file():
print(f"[ERROR] {main_tex} does not exist")
return 2
print(f"[1/2] xelatex pass 1: {main_tex.name}")
r1 = subprocess.run(
[xelatex, "-interaction=nonstopmode", "-halt-on-error", main_name],
cwd=str(project_dir),
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if r1.returncode != 0:
print(f"[ERROR] pass 1 failed (exit={r1.returncode}). Last lines of stdout:")
for ln in (r1.stdout or "").splitlines()[-25:]:
print(f" {ln}")
return r1.returncode
print("[2/2] xelatex pass 2 (resolve cross-references)")
r2 = subprocess.run(
[xelatex, "-interaction=nonstopmode", "-halt-on-error", main_name],
cwd=str(project_dir),
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if r2.returncode != 0:
print(f"[ERROR] pass 2 failed (exit={r2.returncode}). Last lines of stdout:")
for ln in (r2.stdout or "").splitlines()[-25:]:
print(f" {ln}")
return r2.returncode
# Inspect the resulting PDF
pdf_path = main_tex.with_suffix(".pdf")
if not pdf_path.is_file():
print("[ERROR] xelatex exited 0 but no PDF was written.")
return 3
size_kb = pdf_path.stat().st_size / 1024
n_pages = "?"
try:
import fitz
with fitz.open(str(pdf_path)) as d:
n_pages = len(d)
except Exception:
pass
print(f"[OK] {pdf_path} ({size_kb:.1f} KB, {n_pages} pages)")
# Clean up aux files unless --keep-aux
if not keep_aux:
stem = main_tex.stem
for sfx in AUX_SUFFIXES:
f = project_dir / f"{stem}{sfx}"
if f.is_file():
try:
f.unlink()
except OSError:
pass
return 0
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"project_dir",
help="directory containing main.tex (and images/)",
)
parser.add_argument(
"--main", default="main.tex",
help="main .tex filename inside project_dir (default: main.tex)",
)
parser.add_argument(
"--keep-aux", action="store_true",
help="keep .aux/.log/.out/etc. after compile (useful for debugging)",
)
args = parser.parse_args()
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
xelatex = find_xelatex()
if not xelatex:
print_install_help()
sys.exit(127)
project_dir = Path(args.project_dir).resolve()
if not project_dir.is_dir():
print(f"[ERROR] {project_dir} is not a directory")
sys.exit(2)
print(f"Using xelatex: {xelatex}")
sys.exit(compile_one(project_dir, args.main, args.keep_aux, xelatex))
if __name__ == "__main__":
main()
# -*- coding: utf-8 -*-
"""Extract page-by-page text from a PDF.
Usage:
python extract_text.py input.pdf --out output_dir/raw.txt
The output uses `========== PAGE N ==========` delimiters so a translator
(human or LLM) can find specific pages by line search.
Requires: pdfplumber (`pip install pdfplumber`).
"""
import argparse
import io
import os
import sys
import pdfplumber
def extract(pdf_path: str, out_path: str) -> int:
"""Extract text. Returns total chars written. Raises SystemExit if the
PDF appears to have no text layer (scanned image PDF — needs OCR first).
"""
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
total_chars = 0
with pdfplumber.open(pdf_path) as pdf, open(out_path, "w", encoding="utf-8") as f:
n = len(pdf.pages)
for i, page in enumerate(pdf.pages, 1):
f.write(f"\n========== PAGE {i} ==========\n")
text = page.extract_text() or ""
total_chars += len(text)
f.write(text)
print(f"Wrote {n} pages -> {out_path} ({total_chars} chars)")
# Loud-failure guard: if a 5+ page PDF has < 50 chars in total, it's
# almost certainly a scanned image PDF with no text layer. The rest
# of the skill (caption search, bibliography extraction, equation
# alignment) all depend on real text and will silently produce
# garbage. Fail here with an actionable message instead.
if n >= 5 and total_chars < 50:
sys.stderr.write(
"\n[ERROR] This PDF appears to have no text layer "
f"({total_chars} chars across {n} pages).\n"
"This skill needs a born-digital PDF or one that has been OCR'd.\n"
"Quick check: pdftotext input.pdf -\n"
"If empty, run OCR first (e.g. `ocrmypdf input.pdf input-ocr.pdf`)\n"
"and re-run this skill with the OCR'd file.\n"
)
sys.exit(2)
return total_chars
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("pdf", help="path to the input PDF")
parser.add_argument(
"--out", required=True,
help="path to the output .txt file (parent dir will be created)",
)
args = parser.parse_args()
# Force UTF-8 stdout on Windows so prints don't crash on non-ASCII paths.
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
extract(args.pdf, args.out)
if __name__ == "__main__":
main()