enoncepdf
Read exam statements in PDF the way a teacher reads them: find their headings and
their questions, and crop any part of them — a section, a range of questions,
some pages — into slices that leave out headers, footers and line numbers, ready to be
pasted into a new assignment with \includegraphics.
Énoncé is French for the statement of an exam problem. The package was written for French competitive exams (CPGE concours: Polytechnique, Centrale, Mines, CCINP…) and their reports, and it recognises their conventions — « Partie Physique », « Q 7. », « □ – 12. », « I.A.1) » — but nothing in it is tied to one competition: it works from typography and repetition, and on any PDF with text.
from enoncepdf import Statement
with Statement("subject.pdf") as statement:
crops = statement.crop("Partie Physique", with_heading=False)
print(statement.latex(crops))
\includetoolargegraphics[page=3, trim=1.41cm 2.43cm 1.62cm 4.97cm, clip]{subject}
\includetoolargegraphics[page=4, trim=1.41cm 2.43cm 1.62cm 2.11cm, clip]{subject}
…
Contents
- What it does
- Installation
- Command line
- Python
- Finding the questions of any PDF
- Statements compiled from crops
- How it works
- Limits
- Development
- License
What it does
- Margins. Headers, footers, timestamps, « Page 3/12 », repeated rules and logos, and the line numbers some papers print every five lines are found over the whole document — a footer is what repeats — and never kept in a crop.
- Structure. Headings and their levels (the subject's title, « Partie … », then
I,I.A,I.A.1or1,1.1,1.1.1), questions and their sub-questions, and the numbering profile of the statement: the patterns of its numbers ({R}.{A} -,Q {1}.) and where questions restart at 1. - Crops. Name an extract as you read it — a heading number, a piece of a heading, a range of questions, pages, blocks — and get one crop per page, tightened on the ink, all of the same width so that stacked slices keep one scale.
- Output.
\includetoolargegraphicslines, thetrimvalues in cm for any other tool, JSON, and contact sheets (PNG) to check crops at a glance. - Questions anywhere.
locatefinds the questions of any list of lines — a whole page, a crop, a model answer — from their numbers alone, keeping the longest increasing run so that section numbers and quantities (« Q3 désigne la charge… ») are left out. - Compiled statements. For an assignment compiled from such crops,
CompiledStatementreads the LaTeX source, its log and its SyncTeX, and places each question of the included papers in the compiled PDF.
Everything is read through pdfium
(pypdfium2): the package is MIT-licensed from end to end, with no AGPL dependency.
Installation
pip install enoncepdf
Python 3.11 or later. The dependencies are pypdfium2, numpy and Pillow;
on Python 3.13 and later, latexdetok comes along to read the LaTeX source of
compiled statements (without it, CompiledStatement.questions() returns nothing).
Command line
enoncepdf --help # or: python -m enoncepdf --help
| Command | What it gives |
|---|---|
structure subject.pdf |
headings with their levels, questions, margins left out |
crop subject.pdf … |
one \includetoolargegraphics line per cropped page |
trims subject.pdf … |
the trim of each page, L B R U in cm (--json for scripts) |
blocks subject.pdf 5 |
the bands of ink of page 5; --from 2 --to 5 crops them |
sheet subject.pdf |
the pages as thumbnails; --pages 3-8 for those only |
numbering subject.pdf |
the numbering patterns (parts, subparts, questions…) |
crop and trims take the same selections, one at a time:
| Option | Extract |
|---|---|
--part II, --part "Partie Physique" |
from the heading to the next one of the same level |
--part 2.1 --within "Partie Physique" |
the same, when a number comes back in two parts |
--part NASA --intro |
only the text that opens the part, up to its first heading |
--without-heading |
the part without its heading line |
--questions 1-10, --questions Q1-Q10 |
from question 1 to whatever follows question 10 |
--pages 3-9 |
the body of whole pages |
Trims of questions 1 to 10
enoncepdf trims subject.pdf --questions Q1-10
page 2 L=1.23cm B=1.72cm R=1.88cm U=26.25cm
page 3 L=1.23cm B=2.36cm R=1.88cm U=1.98cm
page 4 L=1.23cm B=18.52cm R=1.88cm U=2.05cm
Each value is the margin removed from that edge of the page, in the order of the
trim key of graphicx: left, bottom, right, top. On page 2 the kept band runs from
26.25 cm below the top to 1.72 cm above the bottom — question 1, at the bottom of the
page. Headers and footers are always outside the kept area; the last crop stops before
question 11 or the heading that follows question 10. These are exactly the numbers of
the trim= that crop writes.
enoncepdf trims subject.pdf --questions Q1-10 --json
[{"page": 2, "L": 1.23, "B": 1.72, "R": 1.88, "U": 26.25}]
To check before including anything:
enoncepdf crop subject.pdf --questions Q1-10 --sheet crops.png
Python
from enoncepdf import SelectionError, Statement
with Statement("subject.pdf") as statement:
for heading in statement.headings:
print(heading.level, heading.label, heading.text, heading.page)
crops = statement.crop(questions=(1, 10))
for crop in crops:
print(crop.page, crop.trim_cm) # (left, bottom, right, top) in cm
print(statement.latex(crops)) # the \includetoolargegraphics lines
statement.contact_sheet(crops).save("crops.png")
try:
statement.crop("2.1") # two « 2.1 », one in each part
except SelectionError as error:
print(error) # lists both, and suggests within=
statement.crop("2.1", within="Partie Physique")
Statement.crop takes one selection: a part ("II", "Partie Physique", a piece of
a heading, or a Heading), questions=(first, last) or pages=(first, last) — or
nothing, for the whole document — plus with_heading, intro and within. A
selection that names nothing, or several headings, raises SelectionError, whose
message lists what was found.
Statement.numbering() gives the numbering profile of the whole statement or of one
part:
>>> statement.numbering(within="Partie Physique")
NumberingProfile(part='{1}', subpart='{1}.{1}', subsubpart='', question='{1}.',
subquestion='{a} -', restart='none', examples=('1', '2.1', '1.', '2.'))
The lower layers are public too — PdfDocument (lines and ink masks),
LayoutAnalysis (margins, body, blocks), StructureAnalysis, NumberingAnalysis,
Cropper, ContactSheet — for a caller that needs one step only. All coordinates
are in bp (1/72 in), from the top left of the visible page, pages numbered from 1.
The full reference is in docs/DOC.md.
Finding the questions of any PDF
locate works on lines, wherever they come from. PdfiumDocument reads them the way
pdfium breaks them, holds nothing open between calls, and can restrict a page to an
area — the visible part of a trimmed page:
from enoncepdf import PdfiumDocument, locate, match
document = PdfiumDocument("model-answer.pdf")
lines = [line for page in range(1, document.page_count + 1) for line in document.lines(page)]
questions = locate(lines)
for q in questions:
print(q.part, q.ref, q.label, q.page, q.box)
match("Q12", questions) # by reference, else by number when it is unique
match("I.A.1", questions)
The forms recognised are « 12) », « 12. », « 12 — », « Q12 », « Q 12. »,
« Question 12 : », « □ – 12. », « I.A.1) », « II.3) », « 1.a) », with part headings
« II. Title », « I.A. — Title », « Partie 2 », « Part 2 ». When the numbering restarts
in each part, each question carries its part (q.part == "II").
Statements compiled from crops
An assignment built with the crops above is a LaTeX file that includes pages of
papers. CompiledStatement finds its questions in the papers — where their text
is — and places them in the compiled PDF, problem by problem (each \section opens a
problem):
from enoncepdf import CompiledStatement
for found in CompiledStatement("DS2.tex").questions():
print(found.problem, found.question.ref, found.question.page, round(found.top, 3))
It needs the source, the PDF, the SyncTeX file (-synctex=1) and the log, all next to
the source unless given. Included pages are found from the log, which says where LaTeX
took them; their visible part from the trim of each inclusion; their place from the
SyncTeX boxes. Text typed in the source itself is read from the compiled PDF.
How it works
- Reading. pdfium gives every character with its box, font and size. Characters nobody sees are dropped: outside the page, clipped away, white on white, or drawn twice for a fake bold. Lines are rebuilt by baseline, and a spacing accent that old TeX fonts draw after its letter is put back before it (« Pr´eambule » → « Préambule »).
- Style. PDF has no bold: a font is bold when it draws thick. The package reads the
font name, including TeX's (
CMBX10is bold,CMTI10italic), the descriptor flags, and the style the embedded font program declares, read by FreeType — the only way to know that a Word PDF's « CIDFont+F1 » is Calibri Bold. - Margins are what repeats at the same height across pages, what reads « Page 3/12 », and small numbers in a column beside the text. The body of each page lies between them; its width is that of the whole document.
- Headings are bold lines (or larger, or « Partie … » in italics); their level
comes from the shape of their number, so that
I/I.A/I.A.1and1/1.1/1.1.1get the same levels. Questions start with their number at the running size, and follow one another. - Crops come from one operation: keep the body between two positions of the flow, then tighten it on the ink.
Limits
- Scanned PDFs without a text layer have no lines: margins, headings and questions are then not found (crops of pages and blocks still work, on the ink).
- A character whose font has no Unicode mapping (some math fonts) reads as nothing: « erf(χ) » may read « erf( ) ».
- The vocabulary is that of French statements, with « Part » as well; headings in other languages are found when they are numbered and bold.
- Rotated pages are read in their unrotated frame.
PdfDocumentandStatementkeep the file open and are not thread-safe; pdfium is not either: a threaded caller takes a lock around each call, or usesPdfiumDocument, which opens and closes the file at each reading.
Development
uv sync --group dev
uv run ruff format src tests examples scripts
uv run ruff check src tests examples scripts
uv run mypy
uv run pytest -q
The tests draw their statements with reportlab: no real exam paper ships with the
package. scripts/survey.py runs the analyses over a list of real PDFs and compares
two versions; it is how a change is measured before release.
License
MIT — see LICENSE.
Metadata
Release files for enoncepdf 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| enoncepdf-0.1.0.tar.gz | 69.4 kB | With provenance | Details for enoncepdf-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| enoncepdf-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for enoncepdf-0.1.0-py3-none-any.whl |
Total release size: 121.8 kB
Release files / enoncepdf-0.1.0.tar.gz
| Download URL | enoncepdf-0.1.0.tar.gz |
|---|---|
| Size | 69.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d4d4256dad0b02cbd3101a14476e991495355f8808391f460831a31cabc1086a
|
|
BLAKE2b-256 checksum How to use checksums |
2b6f73fe0eb9d108a2bb82fee4729feb21e6654c95a1c406bf345ecb55527b42
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / enoncepdf-0.1.0-py3-none-any.whl
| Download URL | enoncepdf-0.1.0-py3-none-any.whl |
|---|---|
| Size | 52.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e712479dd904589090749e50ecdca8b1bc2f097f4de811152d98a15a21a524a2
|
|
BLAKE2b-256 checksum How to use checksums |
402a39bdcb5db982f60adf1634ba5e8798a0cc129b87a2386cf0deb0147842fd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logSecurity for enoncepdf 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for enoncepdf-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.