enoncepdf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. enoncepdf-0.1.0/.gitignore +16 -0
  2. enoncepdf-0.1.0/CHANGELOG.md +30 -0
  3. enoncepdf-0.1.0/LICENSE +21 -0
  4. enoncepdf-0.1.0/PKG-INFO +308 -0
  5. enoncepdf-0.1.0/README.md +271 -0
  6. enoncepdf-0.1.0/docs/DOC.md +585 -0
  7. enoncepdf-0.1.0/examples/crop_part.py +47 -0
  8. enoncepdf-0.1.0/examples/list_questions.py +31 -0
  9. enoncepdf-0.1.0/pyproject.toml +105 -0
  10. enoncepdf-0.1.0/src/enoncepdf/__init__.py +83 -0
  11. enoncepdf-0.1.0/src/enoncepdf/__main__.py +9 -0
  12. enoncepdf-0.1.0/src/enoncepdf/_pdfium.py +111 -0
  13. enoncepdf-0.1.0/src/enoncepdf/cli.py +187 -0
  14. enoncepdf-0.1.0/src/enoncepdf/compiled.py +521 -0
  15. enoncepdf-0.1.0/src/enoncepdf/cropping.py +226 -0
  16. enoncepdf-0.1.0/src/enoncepdf/document.py +366 -0
  17. enoncepdf-0.1.0/src/enoncepdf/layout.py +241 -0
  18. enoncepdf-0.1.0/src/enoncepdf/models.py +211 -0
  19. enoncepdf-0.1.0/src/enoncepdf/numbering.py +150 -0
  20. enoncepdf-0.1.0/src/enoncepdf/py.typed +0 -0
  21. enoncepdf-0.1.0/src/enoncepdf/questions.py +240 -0
  22. enoncepdf-0.1.0/src/enoncepdf/reading.py +161 -0
  23. enoncepdf-0.1.0/src/enoncepdf/sheet.py +63 -0
  24. enoncepdf-0.1.0/src/enoncepdf/statement.py +141 -0
  25. enoncepdf-0.1.0/src/enoncepdf/structure.py +207 -0
  26. enoncepdf-0.1.0/src/enoncepdf/text.py +23 -0
  27. enoncepdf-0.1.0/tests/conftest.py +223 -0
  28. enoncepdf-0.1.0/tests/test_cli.py +58 -0
  29. enoncepdf-0.1.0/tests/test_compiled.py +217 -0
  30. enoncepdf-0.1.0/tests/test_cropping.py +96 -0
  31. enoncepdf-0.1.0/tests/test_document.py +174 -0
  32. enoncepdf-0.1.0/tests/test_layout.py +43 -0
  33. enoncepdf-0.1.0/tests/test_models.py +41 -0
  34. enoncepdf-0.1.0/tests/test_numbering.py +49 -0
  35. enoncepdf-0.1.0/tests/test_questions.py +137 -0
  36. enoncepdf-0.1.0/tests/test_reading.py +86 -0
  37. enoncepdf-0.1.0/tests/test_sheet.py +13 -0
  38. enoncepdf-0.1.0/tests/test_statement.py +26 -0
  39. enoncepdf-0.1.0/tests/test_structure.py +52 -0
  40. enoncepdf-0.1.0/tests/test_text.py +17 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ # What the examples and the CLI write.
14
+ *.pdf
15
+ *.png
16
+ !docs/**/*.png
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] — 2026-10-18
7
+
8
+ First public release.
9
+
10
+ ### Added
11
+
12
+ - `Statement`: headings and their levels, questions and sub-questions, margins
13
+ (headers, footers, line numbers, repeated rules), and crops of a part, a range of
14
+ questions, pages or blocks, as `\includetoolargegraphics` lines or trims in cm.
15
+ - `NumberingAnalysis` and `pattern`: the numbering profile of a statement or of one
16
+ of its parts.
17
+ - `locate`, `match`: questions found at their number in any lines, keeping the
18
+ longest increasing run, part by part.
19
+ - `PdfiumDocument`: lines as pdfium breaks them, restricted to an area, with nothing
20
+ held open between calls.
21
+ - `CompiledStatement`: the questions of a LaTeX statement compiled from crops, placed
22
+ in its PDF from its source, log and SyncTeX.
23
+ - `ContactSheet`: thumbnails of pages and crops.
24
+ - The `enoncepdf` command: `structure`, `crop`, `trims`, `blocks`, `sheet`,
25
+ `numbering`.
26
+ - Font style read from the font name (TeX names included), the descriptor flags and
27
+ the embedded font program.
28
+ - `scripts/survey.py`, to measure a change on a set of real papers.
29
+
30
+ [0.1.0]: https://github.com/antnardo/enoncepdf/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 antnardo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,308 @@
1
+ Metadata-Version: 2.5
2
+ Name: enoncepdf
3
+ Version: 0.1.0
4
+ Summary: Read exam statements in PDF: find their headings and questions, and crop them into slices without headers, footers or line numbers.
5
+ Project-URL: Homepage, https://github.com/antnardo/enoncepdf
6
+ Project-URL: Documentation, https://github.com/antnardo/enoncepdf/blob/main/docs/DOC.md
7
+ Project-URL: Repository, https://github.com/antnardo/enoncepdf
8
+ Project-URL: Issues, https://github.com/antnardo/enoncepdf/issues
9
+ Project-URL: Changelog, https://github.com/antnardo/enoncepdf/blob/main/CHANGELOG.md
10
+ Author-email: antnardo <antnardo@users.noreply.github.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: crop,education,exam,latex,pdf,pdfium,questions,synctex
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Education
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Natural Language :: English
20
+ Classifier: Natural Language :: French
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Topic :: Education
28
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
29
+ Classifier: Topic :: Utilities
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.11
32
+ Requires-Dist: latexdetok>=0.7.0; python_version >= '3.13'
33
+ Requires-Dist: numpy>=1.26
34
+ Requires-Dist: pillow>=10.0
35
+ Requires-Dist: pypdfium2>=4.30
36
+ Description-Content-Type: text/markdown
37
+
38
+ # enoncepdf
39
+
40
+ Read exam statements in PDF the way a teacher reads them: find their **headings** and
41
+ their **questions**, and **crop** any part of them — a section, a range of questions,
42
+ some pages — into slices that leave out headers, footers and line numbers, ready to be
43
+ pasted into a new assignment with `\includegraphics`.
44
+
45
+ *Énoncé* is French for the statement of an exam problem. The package was written for
46
+ French competitive exams (CPGE *concours*: Polytechnique, Centrale, Mines, CCINP…) and
47
+ their reports, and it recognises their conventions — « Partie Physique », « Q 7. »,
48
+ « □ – 12. », « I.A.1) » — but nothing in it is tied to one competition: it works from
49
+ typography and repetition, and on any PDF with text.
50
+
51
+ ```python
52
+ from enoncepdf import Statement
53
+
54
+ with Statement("subject.pdf") as statement:
55
+ crops = statement.crop("Partie Physique", with_heading=False)
56
+ print(statement.latex(crops))
57
+ ```
58
+
59
+ ```text
60
+ \includetoolargegraphics[page=3, trim=1.41cm 2.43cm 1.62cm 4.97cm, clip]{subject}
61
+ \includetoolargegraphics[page=4, trim=1.41cm 2.43cm 1.62cm 2.11cm, clip]{subject}
62
+ …
63
+ ```
64
+
65
+ ## Contents
66
+
67
+ - [What it does](#what-it-does)
68
+ - [Installation](#installation)
69
+ - [Command line](#command-line)
70
+ - [Python](#python)
71
+ - [Finding the questions of any PDF](#finding-the-questions-of-any-pdf)
72
+ - [Statements compiled from crops](#statements-compiled-from-crops)
73
+ - [How it works](#how-it-works)
74
+ - [Limits](#limits)
75
+ - [Development](#development)
76
+ - [License](#license)
77
+
78
+ ## What it does
79
+
80
+ - **Margins.** Headers, footers, timestamps, « Page 3/12 », repeated rules and logos,
81
+ and the line numbers some papers print every five lines are found over the whole
82
+ document — a footer is what repeats — and never kept in a crop.
83
+ - **Structure.** Headings and their levels (the subject's title, « Partie … », then
84
+ `I`, `I.A`, `I.A.1` or `1`, `1.1`, `1.1.1`), questions and their sub-questions, and
85
+ the **numbering profile** of the statement: the patterns of its numbers
86
+ (`{R}.{A} -`, `Q {1}.`) and where questions restart at 1.
87
+ - **Crops.** Name an extract as you read it — a heading number, a piece of a heading,
88
+ a range of questions, pages, blocks — and get one crop per page, tightened on the
89
+ ink, all of the same width so that stacked slices keep one scale.
90
+ - **Output.** `\includetoolargegraphics` lines, the `trim` values in cm for any other
91
+ tool, JSON, and contact sheets (PNG) to check crops at a glance.
92
+ - **Questions anywhere.** `locate` finds the questions of any list of lines — a whole
93
+ page, a crop, a model answer — from their numbers alone, keeping the longest
94
+ increasing run so that section numbers and quantities (« Q3 désigne la charge… »)
95
+ are left out.
96
+ - **Compiled statements.** For an assignment compiled from such crops,
97
+ `CompiledStatement` reads the LaTeX source, its log and its SyncTeX, and places each
98
+ question of the included papers in the compiled PDF.
99
+
100
+ Everything is read through [pdfium](https://pdfium.googlesource.com/pdfium/)
101
+ (`pypdfium2`): the package is MIT-licensed from end to end, with no AGPL dependency.
102
+
103
+ ## Installation
104
+
105
+ ```bash
106
+ pip install enoncepdf
107
+ ```
108
+
109
+ Python 3.11 or later. The dependencies are `pypdfium2`, `numpy` and `Pillow`;
110
+ on Python 3.13 and later, `latexdetok` comes along to read the LaTeX source of
111
+ compiled statements (without it, `CompiledStatement.questions()` returns nothing).
112
+
113
+ ## Command line
114
+
115
+ ```bash
116
+ enoncepdf --help # or: python -m enoncepdf --help
117
+ ```
118
+
119
+ | Command | What it gives |
120
+ | --- | --- |
121
+ | `structure subject.pdf` | headings with their levels, questions, margins left out |
122
+ | `crop subject.pdf …` | one `\includetoolargegraphics` line per cropped page |
123
+ | `trims subject.pdf …` | the trim of each page, L B R U in cm (`--json` for scripts) |
124
+ | `blocks subject.pdf 5` | the bands of ink of page 5; `--from 2 --to 5` crops them |
125
+ | `sheet subject.pdf` | the pages as thumbnails; `--pages 3-8` for those only |
126
+ | `numbering subject.pdf` | the numbering patterns (parts, subparts, questions…) |
127
+
128
+ `crop` and `trims` take the same selections, one at a time:
129
+
130
+ | Option | Extract |
131
+ | --- | --- |
132
+ | `--part II`, `--part "Partie Physique"` | from the heading to the next one of the same level |
133
+ | `--part 2.1 --within "Partie Physique"` | the same, when a number comes back in two parts |
134
+ | `--part NASA --intro` | only the text that opens the part, up to its first heading |
135
+ | `--without-heading` | the part without its heading line |
136
+ | `--questions 1-10`, `--questions Q1-Q10` | from question 1 to whatever follows question 10 |
137
+ | `--pages 3-9` | the body of whole pages |
138
+
139
+ ### Trims of questions 1 to 10
140
+
141
+ ```bash
142
+ enoncepdf trims subject.pdf --questions Q1-10
143
+ ```
144
+
145
+ ```text
146
+ page 2 L=1.23cm B=1.72cm R=1.88cm U=26.25cm
147
+ page 3 L=1.23cm B=2.36cm R=1.88cm U=1.98cm
148
+ page 4 L=1.23cm B=18.52cm R=1.88cm U=2.05cm
149
+ ```
150
+
151
+ Each value is the margin **removed** from that edge of the page, in the order of the
152
+ `trim` key of graphicx: left, bottom, right, top. On page 2 the kept band runs from
153
+ 26.25 cm below the top to 1.72 cm above the bottom — question 1, at the bottom of the
154
+ page. Headers and footers are always outside the kept area; the last crop stops before
155
+ question 11 or the heading that follows question 10. These are exactly the numbers of
156
+ the `trim=` that `crop` writes.
157
+
158
+ ```bash
159
+ enoncepdf trims subject.pdf --questions Q1-10 --json
160
+ ```
161
+
162
+ ```json
163
+ [{"page": 2, "L": 1.23, "B": 1.72, "R": 1.88, "U": 26.25}]
164
+ ```
165
+
166
+ To check before including anything:
167
+
168
+ ```bash
169
+ enoncepdf crop subject.pdf --questions Q1-10 --sheet crops.png
170
+ ```
171
+
172
+ ## Python
173
+
174
+ ```python
175
+ from enoncepdf import SelectionError, Statement
176
+
177
+ with Statement("subject.pdf") as statement:
178
+ for heading in statement.headings:
179
+ print(heading.level, heading.label, heading.text, heading.page)
180
+
181
+ crops = statement.crop(questions=(1, 10))
182
+ for crop in crops:
183
+ print(crop.page, crop.trim_cm) # (left, bottom, right, top) in cm
184
+ print(statement.latex(crops)) # the \includetoolargegraphics lines
185
+ statement.contact_sheet(crops).save("crops.png")
186
+
187
+ try:
188
+ statement.crop("2.1") # two « 2.1 », one in each part
189
+ except SelectionError as error:
190
+ print(error) # lists both, and suggests within=
191
+ statement.crop("2.1", within="Partie Physique")
192
+ ```
193
+
194
+ `Statement.crop` takes one selection: a part (`"II"`, `"Partie Physique"`, a piece of
195
+ a heading, or a `Heading`), `questions=(first, last)` or `pages=(first, last)` — or
196
+ nothing, for the whole document — plus `with_heading`, `intro` and `within`. A
197
+ selection that names nothing, or several headings, raises `SelectionError`, whose
198
+ message lists what was found.
199
+
200
+ `Statement.numbering()` gives the numbering profile of the whole statement or of one
201
+ part:
202
+
203
+ ```python
204
+ >>> statement.numbering(within="Partie Physique")
205
+ NumberingProfile(part='{1}', subpart='{1}.{1}', subsubpart='', question='{1}.',
206
+ subquestion='{a} -', restart='none', examples=('1', '2.1', '1.', '2.'))
207
+ ```
208
+
209
+ The lower layers are public too — `PdfDocument` (lines and ink masks),
210
+ `LayoutAnalysis` (margins, body, blocks), `StructureAnalysis`, `NumberingAnalysis`,
211
+ `Cropper`, `ContactSheet` — for a caller that needs one step only. All coordinates
212
+ are in bp (1/72 in), from the top left of the visible page, pages numbered from 1.
213
+ The full reference is in [docs/DOC.md](docs/DOC.md).
214
+
215
+ ## Finding the questions of any PDF
216
+
217
+ `locate` works on lines, wherever they come from. `PdfiumDocument` reads them the way
218
+ pdfium breaks them, holds nothing open between calls, and can restrict a page to an
219
+ area — the visible part of a trimmed page:
220
+
221
+ ```python
222
+ from enoncepdf import PdfiumDocument, locate, match
223
+
224
+ document = PdfiumDocument("model-answer.pdf")
225
+ lines = [line for page in range(1, document.page_count + 1) for line in document.lines(page)]
226
+ questions = locate(lines)
227
+ for q in questions:
228
+ print(q.part, q.ref, q.label, q.page, q.box)
229
+
230
+ match("Q12", questions) # by reference, else by number when it is unique
231
+ match("I.A.1", questions)
232
+ ```
233
+
234
+ The forms recognised are « 12) », « 12. », « 12 — », « Q12 », « Q 12. »,
235
+ « Question 12 : », « □ – 12. », « I.A.1) », « II.3) », « 1.a) », with part headings
236
+ « II. Title », « I.A. — Title », « Partie 2 », « Part 2 ». When the numbering restarts
237
+ in each part, each question carries its part (`q.part == "II"`).
238
+
239
+ ## Statements compiled from crops
240
+
241
+ An assignment built with the crops above is a LaTeX file that includes pages of
242
+ papers. `CompiledStatement` finds its questions *in the papers* — where their text
243
+ is — and places them in the compiled PDF, problem by problem (each `\section` opens a
244
+ problem):
245
+
246
+ ```python
247
+ from enoncepdf import CompiledStatement
248
+
249
+ for found in CompiledStatement("DS2.tex").questions():
250
+ print(found.problem, found.question.ref, found.question.page, round(found.top, 3))
251
+ ```
252
+
253
+ It needs the source, the PDF, the SyncTeX file (`-synctex=1`) and the log, all next to
254
+ the source unless given. Included pages are found from the log, which says where LaTeX
255
+ took them; their visible part from the `trim` of each inclusion; their place from the
256
+ SyncTeX boxes. Text typed in the source itself is read from the compiled PDF.
257
+
258
+ ## How it works
259
+
260
+ - **Reading.** pdfium gives every character with its box, font and size. Characters
261
+ nobody sees are dropped: outside the page, clipped away, white on white, or drawn
262
+ twice for a fake bold. Lines are rebuilt by baseline, and a spacing accent that old
263
+ TeX fonts draw after its letter is put back before it (« Pr´eambule » →
264
+ « Préambule »).
265
+ - **Style.** PDF has no bold: a font is bold when it draws thick. The package reads the
266
+ font name, including TeX's (`CMBX10` is bold, `CMTI10` italic), the descriptor flags,
267
+ and the style the embedded font program declares, read by FreeType — the only way to
268
+ know that a Word PDF's « CIDFont+F1 » is Calibri Bold.
269
+ - **Margins** are what repeats at the same height across pages, what reads « Page
270
+ 3/12 », and small numbers in a column beside the text. The **body** of each page
271
+ lies between them; its width is that of the whole document.
272
+ - **Headings** are bold lines (or larger, or « Partie … » in italics); their level
273
+ comes from the *shape* of their number, so that `I/I.A/I.A.1` and `1/1.1/1.1.1` get
274
+ the same levels. **Questions** start with their number at the running size, and
275
+ follow one another.
276
+ - **Crops** come from one operation: keep the body between two positions of the flow,
277
+ then tighten it on the ink.
278
+
279
+ ## Limits
280
+
281
+ - Scanned PDFs without a text layer have no lines: margins, headings and questions are
282
+ then not found (crops of pages and blocks still work, on the ink).
283
+ - A character whose font has no Unicode mapping (some math fonts) reads as nothing:
284
+ « erf(χ) » may read « erf( ) ».
285
+ - The vocabulary is that of French statements, with « Part » as well; headings in
286
+ other languages are found when they are numbered and bold.
287
+ - Rotated pages are read in their unrotated frame.
288
+ - `PdfDocument` and `Statement` keep the file open and are not thread-safe; pdfium is
289
+ not either: a threaded caller takes a lock around each call, or uses
290
+ `PdfiumDocument`, which opens and closes the file at each reading.
291
+
292
+ ## Development
293
+
294
+ ```bash
295
+ uv sync --group dev
296
+ uv run ruff format src tests examples scripts
297
+ uv run ruff check src tests examples scripts
298
+ uv run mypy
299
+ uv run pytest -q
300
+ ```
301
+
302
+ The tests draw their statements with reportlab: no real exam paper ships with the
303
+ package. `scripts/survey.py` runs the analyses over a list of real PDFs and compares
304
+ two versions; it is how a change is measured before release.
305
+
306
+ ## License
307
+
308
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,271 @@
1
+ # enoncepdf
2
+
3
+ Read exam statements in PDF the way a teacher reads them: find their **headings** and
4
+ their **questions**, and **crop** any part of them — a section, a range of questions,
5
+ some pages — into slices that leave out headers, footers and line numbers, ready to be
6
+ pasted into a new assignment with `\includegraphics`.
7
+
8
+ *Énoncé* is French for the statement of an exam problem. The package was written for
9
+ French competitive exams (CPGE *concours*: Polytechnique, Centrale, Mines, CCINP…) and
10
+ their reports, and it recognises their conventions — « Partie Physique », « Q 7. »,
11
+ « □ – 12. », « I.A.1) » — but nothing in it is tied to one competition: it works from
12
+ typography and repetition, and on any PDF with text.
13
+
14
+ ```python
15
+ from enoncepdf import Statement
16
+
17
+ with Statement("subject.pdf") as statement:
18
+ crops = statement.crop("Partie Physique", with_heading=False)
19
+ print(statement.latex(crops))
20
+ ```
21
+
22
+ ```text
23
+ \includetoolargegraphics[page=3, trim=1.41cm 2.43cm 1.62cm 4.97cm, clip]{subject}
24
+ \includetoolargegraphics[page=4, trim=1.41cm 2.43cm 1.62cm 2.11cm, clip]{subject}
25
+ …
26
+ ```
27
+
28
+ ## Contents
29
+
30
+ - [What it does](#what-it-does)
31
+ - [Installation](#installation)
32
+ - [Command line](#command-line)
33
+ - [Python](#python)
34
+ - [Finding the questions of any PDF](#finding-the-questions-of-any-pdf)
35
+ - [Statements compiled from crops](#statements-compiled-from-crops)
36
+ - [How it works](#how-it-works)
37
+ - [Limits](#limits)
38
+ - [Development](#development)
39
+ - [License](#license)
40
+
41
+ ## What it does
42
+
43
+ - **Margins.** Headers, footers, timestamps, « Page 3/12 », repeated rules and logos,
44
+ and the line numbers some papers print every five lines are found over the whole
45
+ document — a footer is what repeats — and never kept in a crop.
46
+ - **Structure.** Headings and their levels (the subject's title, « Partie … », then
47
+ `I`, `I.A`, `I.A.1` or `1`, `1.1`, `1.1.1`), questions and their sub-questions, and
48
+ the **numbering profile** of the statement: the patterns of its numbers
49
+ (`{R}.{A} -`, `Q {1}.`) and where questions restart at 1.
50
+ - **Crops.** Name an extract as you read it — a heading number, a piece of a heading,
51
+ a range of questions, pages, blocks — and get one crop per page, tightened on the
52
+ ink, all of the same width so that stacked slices keep one scale.
53
+ - **Output.** `\includetoolargegraphics` lines, the `trim` values in cm for any other
54
+ tool, JSON, and contact sheets (PNG) to check crops at a glance.
55
+ - **Questions anywhere.** `locate` finds the questions of any list of lines — a whole
56
+ page, a crop, a model answer — from their numbers alone, keeping the longest
57
+ increasing run so that section numbers and quantities (« Q3 désigne la charge… »)
58
+ are left out.
59
+ - **Compiled statements.** For an assignment compiled from such crops,
60
+ `CompiledStatement` reads the LaTeX source, its log and its SyncTeX, and places each
61
+ question of the included papers in the compiled PDF.
62
+
63
+ Everything is read through [pdfium](https://pdfium.googlesource.com/pdfium/)
64
+ (`pypdfium2`): the package is MIT-licensed from end to end, with no AGPL dependency.
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pip install enoncepdf
70
+ ```
71
+
72
+ Python 3.11 or later. The dependencies are `pypdfium2`, `numpy` and `Pillow`;
73
+ on Python 3.13 and later, `latexdetok` comes along to read the LaTeX source of
74
+ compiled statements (without it, `CompiledStatement.questions()` returns nothing).
75
+
76
+ ## Command line
77
+
78
+ ```bash
79
+ enoncepdf --help # or: python -m enoncepdf --help
80
+ ```
81
+
82
+ | Command | What it gives |
83
+ | --- | --- |
84
+ | `structure subject.pdf` | headings with their levels, questions, margins left out |
85
+ | `crop subject.pdf …` | one `\includetoolargegraphics` line per cropped page |
86
+ | `trims subject.pdf …` | the trim of each page, L B R U in cm (`--json` for scripts) |
87
+ | `blocks subject.pdf 5` | the bands of ink of page 5; `--from 2 --to 5` crops them |
88
+ | `sheet subject.pdf` | the pages as thumbnails; `--pages 3-8` for those only |
89
+ | `numbering subject.pdf` | the numbering patterns (parts, subparts, questions…) |
90
+
91
+ `crop` and `trims` take the same selections, one at a time:
92
+
93
+ | Option | Extract |
94
+ | --- | --- |
95
+ | `--part II`, `--part "Partie Physique"` | from the heading to the next one of the same level |
96
+ | `--part 2.1 --within "Partie Physique"` | the same, when a number comes back in two parts |
97
+ | `--part NASA --intro` | only the text that opens the part, up to its first heading |
98
+ | `--without-heading` | the part without its heading line |
99
+ | `--questions 1-10`, `--questions Q1-Q10` | from question 1 to whatever follows question 10 |
100
+ | `--pages 3-9` | the body of whole pages |
101
+
102
+ ### Trims of questions 1 to 10
103
+
104
+ ```bash
105
+ enoncepdf trims subject.pdf --questions Q1-10
106
+ ```
107
+
108
+ ```text
109
+ page 2 L=1.23cm B=1.72cm R=1.88cm U=26.25cm
110
+ page 3 L=1.23cm B=2.36cm R=1.88cm U=1.98cm
111
+ page 4 L=1.23cm B=18.52cm R=1.88cm U=2.05cm
112
+ ```
113
+
114
+ Each value is the margin **removed** from that edge of the page, in the order of the
115
+ `trim` key of graphicx: left, bottom, right, top. On page 2 the kept band runs from
116
+ 26.25 cm below the top to 1.72 cm above the bottom — question 1, at the bottom of the
117
+ page. Headers and footers are always outside the kept area; the last crop stops before
118
+ question 11 or the heading that follows question 10. These are exactly the numbers of
119
+ the `trim=` that `crop` writes.
120
+
121
+ ```bash
122
+ enoncepdf trims subject.pdf --questions Q1-10 --json
123
+ ```
124
+
125
+ ```json
126
+ [{"page": 2, "L": 1.23, "B": 1.72, "R": 1.88, "U": 26.25}]
127
+ ```
128
+
129
+ To check before including anything:
130
+
131
+ ```bash
132
+ enoncepdf crop subject.pdf --questions Q1-10 --sheet crops.png
133
+ ```
134
+
135
+ ## Python
136
+
137
+ ```python
138
+ from enoncepdf import SelectionError, Statement
139
+
140
+ with Statement("subject.pdf") as statement:
141
+ for heading in statement.headings:
142
+ print(heading.level, heading.label, heading.text, heading.page)
143
+
144
+ crops = statement.crop(questions=(1, 10))
145
+ for crop in crops:
146
+ print(crop.page, crop.trim_cm) # (left, bottom, right, top) in cm
147
+ print(statement.latex(crops)) # the \includetoolargegraphics lines
148
+ statement.contact_sheet(crops).save("crops.png")
149
+
150
+ try:
151
+ statement.crop("2.1") # two « 2.1 », one in each part
152
+ except SelectionError as error:
153
+ print(error) # lists both, and suggests within=
154
+ statement.crop("2.1", within="Partie Physique")
155
+ ```
156
+
157
+ `Statement.crop` takes one selection: a part (`"II"`, `"Partie Physique"`, a piece of
158
+ a heading, or a `Heading`), `questions=(first, last)` or `pages=(first, last)` — or
159
+ nothing, for the whole document — plus `with_heading`, `intro` and `within`. A
160
+ selection that names nothing, or several headings, raises `SelectionError`, whose
161
+ message lists what was found.
162
+
163
+ `Statement.numbering()` gives the numbering profile of the whole statement or of one
164
+ part:
165
+
166
+ ```python
167
+ >>> statement.numbering(within="Partie Physique")
168
+ NumberingProfile(part='{1}', subpart='{1}.{1}', subsubpart='', question='{1}.',
169
+ subquestion='{a} -', restart='none', examples=('1', '2.1', '1.', '2.'))
170
+ ```
171
+
172
+ The lower layers are public too — `PdfDocument` (lines and ink masks),
173
+ `LayoutAnalysis` (margins, body, blocks), `StructureAnalysis`, `NumberingAnalysis`,
174
+ `Cropper`, `ContactSheet` — for a caller that needs one step only. All coordinates
175
+ are in bp (1/72 in), from the top left of the visible page, pages numbered from 1.
176
+ The full reference is in [docs/DOC.md](docs/DOC.md).
177
+
178
+ ## Finding the questions of any PDF
179
+
180
+ `locate` works on lines, wherever they come from. `PdfiumDocument` reads them the way
181
+ pdfium breaks them, holds nothing open between calls, and can restrict a page to an
182
+ area — the visible part of a trimmed page:
183
+
184
+ ```python
185
+ from enoncepdf import PdfiumDocument, locate, match
186
+
187
+ document = PdfiumDocument("model-answer.pdf")
188
+ lines = [line for page in range(1, document.page_count + 1) for line in document.lines(page)]
189
+ questions = locate(lines)
190
+ for q in questions:
191
+ print(q.part, q.ref, q.label, q.page, q.box)
192
+
193
+ match("Q12", questions) # by reference, else by number when it is unique
194
+ match("I.A.1", questions)
195
+ ```
196
+
197
+ The forms recognised are « 12) », « 12. », « 12 — », « Q12 », « Q 12. »,
198
+ « Question 12 : », « □ – 12. », « I.A.1) », « II.3) », « 1.a) », with part headings
199
+ « II. Title », « I.A. — Title », « Partie 2 », « Part 2 ». When the numbering restarts
200
+ in each part, each question carries its part (`q.part == "II"`).
201
+
202
+ ## Statements compiled from crops
203
+
204
+ An assignment built with the crops above is a LaTeX file that includes pages of
205
+ papers. `CompiledStatement` finds its questions *in the papers* — where their text
206
+ is — and places them in the compiled PDF, problem by problem (each `\section` opens a
207
+ problem):
208
+
209
+ ```python
210
+ from enoncepdf import CompiledStatement
211
+
212
+ for found in CompiledStatement("DS2.tex").questions():
213
+ print(found.problem, found.question.ref, found.question.page, round(found.top, 3))
214
+ ```
215
+
216
+ It needs the source, the PDF, the SyncTeX file (`-synctex=1`) and the log, all next to
217
+ the source unless given. Included pages are found from the log, which says where LaTeX
218
+ took them; their visible part from the `trim` of each inclusion; their place from the
219
+ SyncTeX boxes. Text typed in the source itself is read from the compiled PDF.
220
+
221
+ ## How it works
222
+
223
+ - **Reading.** pdfium gives every character with its box, font and size. Characters
224
+ nobody sees are dropped: outside the page, clipped away, white on white, or drawn
225
+ twice for a fake bold. Lines are rebuilt by baseline, and a spacing accent that old
226
+ TeX fonts draw after its letter is put back before it (« Pr´eambule » →
227
+ « Préambule »).
228
+ - **Style.** PDF has no bold: a font is bold when it draws thick. The package reads the
229
+ font name, including TeX's (`CMBX10` is bold, `CMTI10` italic), the descriptor flags,
230
+ and the style the embedded font program declares, read by FreeType — the only way to
231
+ know that a Word PDF's « CIDFont+F1 » is Calibri Bold.
232
+ - **Margins** are what repeats at the same height across pages, what reads « Page
233
+ 3/12 », and small numbers in a column beside the text. The **body** of each page
234
+ lies between them; its width is that of the whole document.
235
+ - **Headings** are bold lines (or larger, or « Partie … » in italics); their level
236
+ comes from the *shape* of their number, so that `I/I.A/I.A.1` and `1/1.1/1.1.1` get
237
+ the same levels. **Questions** start with their number at the running size, and
238
+ follow one another.
239
+ - **Crops** come from one operation: keep the body between two positions of the flow,
240
+ then tighten it on the ink.
241
+
242
+ ## Limits
243
+
244
+ - Scanned PDFs without a text layer have no lines: margins, headings and questions are
245
+ then not found (crops of pages and blocks still work, on the ink).
246
+ - A character whose font has no Unicode mapping (some math fonts) reads as nothing:
247
+ « erf(χ) » may read « erf( ) ».
248
+ - The vocabulary is that of French statements, with « Part » as well; headings in
249
+ other languages are found when they are numbered and bold.
250
+ - Rotated pages are read in their unrotated frame.
251
+ - `PdfDocument` and `Statement` keep the file open and are not thread-safe; pdfium is
252
+ not either: a threaded caller takes a lock around each call, or uses
253
+ `PdfiumDocument`, which opens and closes the file at each reading.
254
+
255
+ ## Development
256
+
257
+ ```bash
258
+ uv sync --group dev
259
+ uv run ruff format src tests examples scripts
260
+ uv run ruff check src tests examples scripts
261
+ uv run mypy
262
+ uv run pytest -q
263
+ ```
264
+
265
+ The tests draw their statements with reportlab: no real exam paper ships with the
266
+ package. `scripts/survey.py` runs the analyses over a list of real PDFs and compares
267
+ two versions; it is how a change is measured before release.
268
+
269
+ ## License
270
+
271
+ MIT — see [LICENSE](LICENSE).