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.
- enoncepdf-0.1.0/.gitignore +16 -0
- enoncepdf-0.1.0/CHANGELOG.md +30 -0
- enoncepdf-0.1.0/LICENSE +21 -0
- enoncepdf-0.1.0/PKG-INFO +308 -0
- enoncepdf-0.1.0/README.md +271 -0
- enoncepdf-0.1.0/docs/DOC.md +585 -0
- enoncepdf-0.1.0/examples/crop_part.py +47 -0
- enoncepdf-0.1.0/examples/list_questions.py +31 -0
- enoncepdf-0.1.0/pyproject.toml +105 -0
- enoncepdf-0.1.0/src/enoncepdf/__init__.py +83 -0
- enoncepdf-0.1.0/src/enoncepdf/__main__.py +9 -0
- enoncepdf-0.1.0/src/enoncepdf/_pdfium.py +111 -0
- enoncepdf-0.1.0/src/enoncepdf/cli.py +187 -0
- enoncepdf-0.1.0/src/enoncepdf/compiled.py +521 -0
- enoncepdf-0.1.0/src/enoncepdf/cropping.py +226 -0
- enoncepdf-0.1.0/src/enoncepdf/document.py +366 -0
- enoncepdf-0.1.0/src/enoncepdf/layout.py +241 -0
- enoncepdf-0.1.0/src/enoncepdf/models.py +211 -0
- enoncepdf-0.1.0/src/enoncepdf/numbering.py +150 -0
- enoncepdf-0.1.0/src/enoncepdf/py.typed +0 -0
- enoncepdf-0.1.0/src/enoncepdf/questions.py +240 -0
- enoncepdf-0.1.0/src/enoncepdf/reading.py +161 -0
- enoncepdf-0.1.0/src/enoncepdf/sheet.py +63 -0
- enoncepdf-0.1.0/src/enoncepdf/statement.py +141 -0
- enoncepdf-0.1.0/src/enoncepdf/structure.py +207 -0
- enoncepdf-0.1.0/src/enoncepdf/text.py +23 -0
- enoncepdf-0.1.0/tests/conftest.py +223 -0
- enoncepdf-0.1.0/tests/test_cli.py +58 -0
- enoncepdf-0.1.0/tests/test_compiled.py +217 -0
- enoncepdf-0.1.0/tests/test_cropping.py +96 -0
- enoncepdf-0.1.0/tests/test_document.py +174 -0
- enoncepdf-0.1.0/tests/test_layout.py +43 -0
- enoncepdf-0.1.0/tests/test_models.py +41 -0
- enoncepdf-0.1.0/tests/test_numbering.py +49 -0
- enoncepdf-0.1.0/tests/test_questions.py +137 -0
- enoncepdf-0.1.0/tests/test_reading.py +86 -0
- enoncepdf-0.1.0/tests/test_sheet.py +13 -0
- enoncepdf-0.1.0/tests/test_statement.py +26 -0
- enoncepdf-0.1.0/tests/test_structure.py +52 -0
- enoncepdf-0.1.0/tests/test_text.py +17 -0
|
@@ -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
|
enoncepdf-0.1.0/LICENSE
ADDED
|
@@ -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.
|
enoncepdf-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|