enoncepdf
Take a part of an exam paper in PDF — « Partie II », « questions 1 to 10 » — and get what to keep of each page: the page and the trim, without headers, footers or line numbers. Find the questions of any PDF, too.
Énoncé is French for the statement of an exam problem. The package reads the conventions of French exam papers — « Partie Physique », « Q 7. », « □ – 12. », « I.A.1) » — but works from typography and repetition, on any PDF with text.
The goal
A teacher builds a new assignment from parts of past papers. For each part, they need to know which pages it covers and how much to cut from each edge of each page, so that the slices show the part and nothing else: not the header that repeats the name of the competition, not the « Page 3/12 » footer, not the line numbers in the margin, not the end of the previous part nor the start of the next.
Doing it by hand means opening the PDF, measuring, and trying again. enoncepdf does it from a description as one would say it:
from enoncepdf import Statement
with Statement("subject.pdf") as statement:
for crop in statement.crop("Partie Physique", with_heading=False):
print(crop.page, crop.trim_cm)
3 (1.41, 2.43, 1.62, 4.97)
4 (1.41, 2.43, 1.62, 2.11)
…
Each line is a page and its trim: what to cut from the left, bottom, right and top, in
cm — the order of the trim key of graphicx. That is the output of the package; how
you write it is yours to choose (a table, JSON, \includegraphics lines, the command
of your own class).
The means
- Read the page. Every character, with its position, its font and its size, and the ink of the page (what the text alone does not show: figures, rules, logos). What nobody sees on the page — text clipped away, white on white, outside the page — is left out.
- Find the margins. A header or a footer is what repeats at the same place from page to page, or what reads « Page 3/12 »; line numbers are small numbers in a column beside the text. What lies between them is the body of the page.
- Find the structure. Headings are bold, or larger, or « Partie … »; their level
comes from the shape of their number (
I,I.A,I.A.1or1,1.1,1.1.1). Questions start with their number at the size of the running text, and follow one another. - Crop. A selection — a part, a range of questions, pages — becomes two positions in the flow of the document. On each page in between, the body is kept, then tightened on the ink. All the crops of one selection have the same width, so that stacked slices keep one scale.
Everything is read through pdfium (pypdfium2); the package is MIT-licensed, with no
AGPL dependency.
Installation
pip install enoncepdf
Python 3.11 or later. Dependencies: pypdfium2, numpy, Pillow; on Python 3.13 and later, latexdetok, to read LaTeX sources (see Compiled statements).
Command line
enoncepdf structure subject.pdf # headings, questions, margins found
enoncepdf crop subject.pdf --part II # page and trim of each page of part II
enoncepdf crop subject.pdf --questions Q1-10 --sheet crops.png
crop takes one selection:
| Option | Extract |
|---|---|
--part II, --part "Partie Physique" |
from the heading to the next one of the same level |
--within "Partie Physique" |
look for the part inside another, when a number comes back |
--without-heading |
leave the heading line out |
--intro |
only the text that opens the part, up to its first heading |
--questions 1-10, --questions Q1-Q10 |
from question 1 to whatever follows question 10 |
--pages 3-9 |
the body of whole pages |
and prints, by default, one line per page:
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 what to cut from that edge. 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.
The same crops, written otherwise:
enoncepdf crop subject.pdf --questions Q1-10 --json
enoncepdf crop subject.pdf --questions Q1-10 --latex
enoncepdf crop subject.pdf --questions Q1-10 --template '\myinclude[{page}]{{{trim}}}{{{file}}}'
[{"page": 2, "L": 1.23, "B": 1.72, "R": 1.88, "U": 26.25}, …]
\includegraphics[page=2, trim=1.23cm 1.72cm 1.88cm 26.25cm, clip]{subject}
\myinclude[2]{1.23cm 1.72cm 1.88cm 26.25cm}{subject}
A template is a Python format string (literal braces doubled), with these fields:
| Field | Value |
|---|---|
{page} |
the page, from 1 |
{left}, {bottom}, {right}, {top} |
the trim of each edge, in cm |
{trim} |
the four, as graphicx reads them: 1.23cm 1.72cm 1.88cm 26.25cm |
{width}, {height} |
the size of the kept area, in cm |
{file} |
--file, or the name of the PDF without .pdf |
The other commands: blocks subject.pdf 5 lists the bands of ink of page 5 and crops
some of them (--from 2 --to 4), when the structure is not recognised; sheet draws
the pages as thumbnails; numbering gives the patterns of the numbers ({R}.{A} -,
Q {1}.) and where questions restart at 1.
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), cm
print(statement.format(crops)) # \includegraphics lines
print(statement.format(crops, r"\myinclude[{page}]{{{trim}}}{{{file}}}"))
statement.contact_sheet(crops).save("crops.png")
try:
statement.crop("2.1") # a « 2.1 » in each of two parts
except SelectionError as error:
print(error) # lists both, suggests within=
statement.crop("2.1", within="Partie Physique")
Statement.crop takes one selection — a part ("II", "Partie Physique", a piece of
a heading, a Heading), questions=(first, last) or pages=(first, last), or nothing
for the whole document — with with_heading, intro and within. A selection that
names nothing, or several headings, raises SelectionError, whose message lists what
was found. All coordinates are in bp (1/72 in) from the top left of the page, pages
numbered from 1.
The full reference is docs/DOC.md.
Finding questions
The questions of a paper are found by Statement.questions. For any other PDF — a
model answer, a student's typed work —, locate reads them from their numbers alone,
on lines read by PdfiumDocument:
from enoncepdf import PdfiumDocument, locate, match
document = PdfiumDocument("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) # the question « Q12 » names, if any
The forms recognised are « 12) », « 12. », « 12 — », « Q12 », « Q 12. », « Question 12 : », « □ – 12. », « I.A.1) », « 1.a) ». Section numbers and quantities (« Q3 désigne la charge… ») are left out: only the longest increasing run of numbers is kept, part by part.
Compiled statements
Once the crops are pasted into a new assignment and compiled, where do its questions
fall in its PDF? Neither file says it: the source holds inclusions, not questions, and
a trimmed page keeps in the PDF the text its trim hides. CompiledStatement answers it:
it reads the inclusions in the source, the questions in the visible part of each
included page, and places them in the compiled PDF through SyncTeX, problem by problem
(each \section opens one). A program that shows the statement beside a student's
answer, or that links a marking scheme to it, needs this.
from enoncepdf import INCLUSION_COMMANDS, CompiledStatement
statement = CompiledStatement("assignment.tex", commands=(*INCLUSION_COMMANDS, "myinclude"))
for found in statement.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 of the
compilation, and latexdetok (Python 3.13 or later). The inclusions read are
\includegraphics and \includepdf; name in commands any command of your own that
takes graphicx's options (page, trim, scale) and a file.
Limits
- A scanned PDF without text has no lines: margins, headings and questions are not found; crops of pages and blocks still work, on the ink.
- A character whose font has no Unicode mapping (in some math fonts) is not read: « erf(χ) » may read « erf( ) ».
- Headings are recognised in French and English papers (« Partie », « Part »); in other languages, when they are numbered and bold.
- Pages turned by a
/Rotateentry are read unturned. Statementkeeps its file open and must not be shared between threads; pdfium is not thread-safe either.PdfiumDocumentopens and closes the file at each call.
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 papers with reportlab. scripts/survey.py runs the analyses over
a list of real PDFs and compares two versions of the package.
License
MIT — see LICENSE.
Metadata
Release files for enoncepdf 0.2.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.2.0.tar.gz | 70.3 kB | With provenance | Details for enoncepdf-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| enoncepdf-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for enoncepdf-0.2.0-py3-none-any.whl |
Total release size: 122.6 kB
Release files / enoncepdf-0.2.0.tar.gz
| Download URL | enoncepdf-0.2.0.tar.gz |
|---|---|
| Size | 70.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d54a94031f768737d4f7f72e248bbfe4aa886e3fd115dcd79dc08111ee10ffb9
|
|
BLAKE2b-256 checksum How to use checksums |
34d63e944a4622f8be9fcefe6f985a0b8f4e02b23cdb2a96742b0cc05a6bb346
|
| 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.2.0-py3-none-any.whl
| Download URL | enoncepdf-0.2.0-py3-none-any.whl |
|---|---|
| Size | 52.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3fbdd9db7ca1256c100a2f319828da5c9aec9768e042c807bd9055870e6fa11e
|
|
BLAKE2b-256 checksum How to use checksums |
70dac23ed3370dc2e157bd3fe05191a125e14b5cca6ded29fd01240ecdfabb07
|
| 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.2.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.2.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.