titlemark 1.2.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.
- titlemark-1.2.0/LICENSE +21 -0
- titlemark-1.2.0/PKG-INFO +234 -0
- titlemark-1.2.0/README.md +207 -0
- titlemark-1.2.0/pyproject.toml +42 -0
- titlemark-1.2.0/setup.cfg +4 -0
- titlemark-1.2.0/tests/test_ai.py +141 -0
- titlemark-1.2.0/tests/test_djvu_text.py +98 -0
- titlemark-1.2.0/tests/test_extractors.py +266 -0
- titlemark-1.2.0/tests/test_force_ocr.py +77 -0
- titlemark-1.2.0/tests/test_naming.py +125 -0
- titlemark-1.2.0/tests/test_renamer.py +109 -0
- titlemark-1.2.0/tests/test_signature.py +117 -0
- titlemark-1.2.0/tests/test_textclean.py +131 -0
- titlemark-1.2.0/tests/test_title_from_text.py +128 -0
- titlemark-1.2.0/tests/test_titlefinder.py +138 -0
- titlemark-1.2.0/tests/test_tools.py +81 -0
- titlemark-1.2.0/titlemark/__init__.py +8 -0
- titlemark-1.2.0/titlemark/__main__.py +6 -0
- titlemark-1.2.0/titlemark/ai.py +185 -0
- titlemark-1.2.0/titlemark/cli.py +224 -0
- titlemark-1.2.0/titlemark/extractors/__init__.py +56 -0
- titlemark-1.2.0/titlemark/extractors/base.py +135 -0
- titlemark-1.2.0/titlemark/extractors/djvu.py +100 -0
- titlemark-1.2.0/titlemark/extractors/docx.py +63 -0
- titlemark-1.2.0/titlemark/extractors/epub.py +63 -0
- titlemark-1.2.0/titlemark/extractors/fb2.py +78 -0
- titlemark-1.2.0/titlemark/extractors/mobi.py +99 -0
- titlemark-1.2.0/titlemark/extractors/pdf.py +74 -0
- titlemark-1.2.0/titlemark/models.py +48 -0
- titlemark-1.2.0/titlemark/names.py +91 -0
- titlemark-1.2.0/titlemark/naming.py +125 -0
- titlemark-1.2.0/titlemark/ocr.py +81 -0
- titlemark-1.2.0/titlemark/renamer.py +384 -0
- titlemark-1.2.0/titlemark/signature.py +96 -0
- titlemark-1.2.0/titlemark/textclean.py +344 -0
- titlemark-1.2.0/titlemark/titlefinder.py +343 -0
- titlemark-1.2.0/titlemark/tools.py +102 -0
- titlemark-1.2.0/titlemark.egg-info/PKG-INFO +234 -0
- titlemark-1.2.0/titlemark.egg-info/SOURCES.txt +40 -0
- titlemark-1.2.0/titlemark.egg-info/dependency_links.txt +1 -0
- titlemark-1.2.0/titlemark.egg-info/entry_points.txt +2 -0
- titlemark-1.2.0/titlemark.egg-info/top_level.txt +1 -0
titlemark-1.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergei Korneev (AI coded)
|
|
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.
|
titlemark-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: titlemark
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Titlemark — give every document its true name: "Author - Title [year]"
|
|
5
|
+
Author: Sergei Korneev (AI coded)
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sergei-Korneev/titlemark
|
|
8
|
+
Project-URL: Repository, https://github.com/Sergei-Korneev/titlemark
|
|
9
|
+
Project-URL: Issues, https://github.com/Sergei-Korneev/titlemark/issues
|
|
10
|
+
Keywords: rename,filename,title,author,metadata,ocr,pdf,epub,fb2,mobi,djvu,docx,cli
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Titlemark
|
|
29
|
+
|
|
30
|
+
> *titlemark (n.)* — in early printing, the short title set at the head of a
|
|
31
|
+
> page to mark the book it came from.
|
|
32
|
+
|
|
33
|
+
**Titlemark** is a console tool that reads a document, works out its *title*
|
|
34
|
+
and *author*, and renames the file into something a human would recognise:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
29623753.pdf -> David Knickerbocker - Network Science with Python [2023].pdf
|
|
38
|
+
43b2956cd82bc62159778acf….pdf -> Артур Думчев - ПОМНИТЬ ВСЕ ПРАКТИЧЕСКОЕ РУКОВОДСТВО ПО РАЗВИТИЮ ПАМЯТИ [2013].pdf
|
|
39
|
+
345852.fb2 -> Штюрмер Ю. А. - Карманный справочник туриста.fb2
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It works **fully offline** — no LLM, no external services. Just heuristics
|
|
43
|
+
over file metadata, the text layer, and OCR for scans.
|
|
44
|
+
|
|
45
|
+
## Supported formats
|
|
46
|
+
|
|
47
|
+
| Format | Metadata | Text | OCR |
|
|
48
|
+
|--------|----------|------|-----|
|
|
49
|
+
| PDF | pdfinfo | pdftotext (poppler) | pdftoppm + tesseract |
|
|
50
|
+
| EPUB | package.opf (dc:title, dc:creator, dc:date) | – | – |
|
|
51
|
+
| FB2 | title-info (book-title, author, date) | – | – |
|
|
52
|
+
| MOBI | PalmDB + EXTH (title, author, date) | – | – |
|
|
53
|
+
| DJVU | ANTa annotations (title, author) | djvutxt (djvulibre) | ddjvu + tesseract |
|
|
54
|
+
| DOCX | docProps/core.xml | first paragraphs | – |
|
|
55
|
+
|
|
56
|
+
**Files without an extension** (hash-named downloads) are recognised by their
|
|
57
|
+
signature — magic bytes for PDF/DjVu/MOBI, the container structure for
|
|
58
|
+
EPUB/DOCX, the `FictionBook` root for FB2 — and the renamed file gets the
|
|
59
|
+
proper extension. The same works for files with a wrong or unknown extension.
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
* Python ≥ 3.10, standard library only;
|
|
64
|
+
* for PDF: `poppler-utils` (`pdfinfo`, `pdftotext`, `pdftoppm`);
|
|
65
|
+
* for scans (optional): `tesseract` with `rus+eng` language packs;
|
|
66
|
+
* for DjVu (optional): `djvulibre` tools (`djvutxt`, `ddjvu`) — without them
|
|
67
|
+
DjVu text/OCR is unavailable (annotations ANTa are still read).
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Debian/Ubuntu
|
|
71
|
+
sudo apt install poppler-utils tesseract-ocr tesseract-ocr-rus tesseract-ocr-eng djvulibre-bin
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Install & run
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 1. straight from the source tree, no installation
|
|
78
|
+
cd titlemark
|
|
79
|
+
python3 -m titlemark --help
|
|
80
|
+
|
|
81
|
+
# 2. user install — the `titlemark` command works from anywhere
|
|
82
|
+
pip install --user .
|
|
83
|
+
titlemark --help
|
|
84
|
+
|
|
85
|
+
# 3. editable install for development (source edits apply immediately)
|
|
86
|
+
pip install --user -e .
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
After `pip install --user` the command lands in `~/.local/bin`
|
|
90
|
+
(make sure it is on your `PATH`). If you edit the project sources, either run
|
|
91
|
+
from the project folder or reinstall — the editable install (`-e`) picks up
|
|
92
|
+
changes without reinstalling.
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
titlemark book.pdf # show the plan (files are NOT touched)
|
|
98
|
+
titlemark --apply book.pdf # rename the file
|
|
99
|
+
titlemark -r ~/Downloads # plan for a whole folder, recursively
|
|
100
|
+
titlemark --apply -r ~/Downloads # rename everything in the folder
|
|
101
|
+
titlemark --report plan.csv -r . # save the plan as CSV
|
|
102
|
+
titlemark --force-ocr book.pdf # re-check a PDF whose text layer is broken
|
|
103
|
+
titlemark --ai book.pdf # let a vision LLM read the cover
|
|
104
|
+
titlemark --title-from-text book.pdf # title = whole page text, fragments joined
|
|
105
|
+
titlemark -n "{title}-{year}" book.pdf # custom name pattern
|
|
106
|
+
titlemark -v book.pdf # debug: title candidates, text snippet
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Options
|
|
110
|
+
|
|
111
|
+
| Option | Description |
|
|
112
|
+
|--------|-------------|
|
|
113
|
+
| `paths` | file(s) or folder(s) to process |
|
|
114
|
+
| `-a, --apply` | actually rename; **without this flag the tool only shows a plan (dry-run)** |
|
|
115
|
+
| `-r, --recursive` | walk folders recursively |
|
|
116
|
+
| `-n, --naming-template PATTERN` | name pattern with `{author}`, `{title}`, `{year}` placeholders; the extension is appended automatically (default: `{author} - {title} [{year}]`) |
|
|
117
|
+
| `--no-ocr` | never OCR scanned pages (faster) |
|
|
118
|
+
| `--force-ocr` | always OCR the leading pages and use that text instead of the built-in text layer (for PDFs with a broken encoding) |
|
|
119
|
+
| `--ocr-pages N` | how many leading pages to OCR (default: 2) |
|
|
120
|
+
| `--no-year` | do not append the year to file names |
|
|
121
|
+
| `--title-from-text` | build the title from the **whole** page text without special characters (title fragments are joined), instead of picking single candidate lines |
|
|
122
|
+
| `--title-pages N` | how many leading pages to take for `--title-from-text` (default: 1) |
|
|
123
|
+
| `--report CSV` | write a report: old name, new name, source, status |
|
|
124
|
+
| `-v, --verbose` | show title candidates and warnings |
|
|
125
|
+
| `-q, --quiet` | print only the summary |
|
|
126
|
+
|
|
127
|
+
### AI options (vision LLM, opt-in)
|
|
128
|
+
|
|
129
|
+
| Option | Description |
|
|
130
|
+
|--------|-------------|
|
|
131
|
+
| `--ai` | ask a vision LLM to read the cover; its answer outranks the heuristics |
|
|
132
|
+
| `--ai-url URL` | OpenAI-compatible endpoint (default `https://api.openai.com/v1`; Ollama: `http://localhost:11434/v1`) |
|
|
133
|
+
| `--ai-model MODEL` | model name (default `gpt-4o-mini`; Ollama: `moondream`, `llava`, `llama3.2-vision`) |
|
|
134
|
+
| `--ai-key KEY` | API key (or env `TITLEMARK_AI_KEY`; not needed for local Ollama) |
|
|
135
|
+
| `--ai-timeout SEC` | request timeout (default 60) |
|
|
136
|
+
|
|
137
|
+
Environment variables: `TITLEMARK_AI_URL`, `TITLEMARK_AI_MODEL`, `TITLEMARK_AI_KEY`.
|
|
138
|
+
|
|
139
|
+
The AI is used **only when `--ai` is passed**. Titlemark renders the first page
|
|
140
|
+
to PNG, shows it to the model together with the heuristic candidates, and gets
|
|
141
|
+
back strict JSON `{"title", "author", "year"}`. If the model is unreachable or
|
|
142
|
+
answers oddly, the heuristic result is kept and a warning is printed.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# cloud (OpenAI, DeepSeek, GigaChat, YandexGPT, … — any compatible API)
|
|
146
|
+
export TITLEMARK_AI_KEY=sk-...
|
|
147
|
+
titlemark --ai book.pdf
|
|
148
|
+
|
|
149
|
+
# local Ollama, fully offline
|
|
150
|
+
titlemark --ai --ai-url http://localhost:11434/v1 --ai-model moondream book.pdf
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## How the name is chosen
|
|
154
|
+
|
|
155
|
+
By default the name follows the pattern **`Author - Title [year].ext`**
|
|
156
|
+
(just `Title [year].ext` when no author is found). The pattern is
|
|
157
|
+
customizable: `--naming-template "{author} - {title}"`, `"{title}-{year}"`,
|
|
158
|
+
`"{year}_{title}"` — empty values are dropped together with their separators,
|
|
159
|
+
and the file extension is always appended automatically.
|
|
160
|
+
|
|
161
|
+
Sources are tried from most to least reliable:
|
|
162
|
+
|
|
163
|
+
1. **File metadata** — Title/Author/date. Junk values (`Microsoft Word - …`,
|
|
164
|
+
`Layout 1`, `Pamyat_text 02.indd`, hashes, upload ids) are discarded;
|
|
165
|
+
2. **Text layer of the leading pages** — lines are scored by casing
|
|
166
|
+
(ALL CAPS / Title Case), position on the page, length and punctuation.
|
|
167
|
+
Service lines (ISBN, UDC, running heads, URLs), publisher imprints
|
|
168
|
+
(«Москва · Санкт-Петербург», «O'Reilly cities»), section headings
|
|
169
|
+
(«Table of Contents», «Preface») and name-like lines are rejected;
|
|
170
|
+
subtitle lines are merged into the title;
|
|
171
|
+
3. **OCR of the leading pages** — when there is no text layer (a scan) or
|
|
172
|
+
it is broken: mojibake from exotic font encodings, split words
|
|
173
|
+
(«W ICKED COOL», «SHELL SCRIP T S»);
|
|
174
|
+
4. **The original file name** — used when the other sources produced only a
|
|
175
|
+
fragment («ВЕСЬ» of «Весь Linux…»), a cipher («М. МЦНМО, 2016»), a broken
|
|
176
|
+
bibliographic line or a bad OCR guess. Names like `Author - Title`,
|
|
177
|
+
`Title (Author)` and `03_Title_by_Author` are parsed apart.
|
|
178
|
+
|
|
179
|
+
Telling a short author name from a short title (both may be set in caps:
|
|
180
|
+
`АРТУР ДУМЧЕВ` vs `DARK MATTER`) is done with a compact dictionary of common
|
|
181
|
+
Russian/English first names and their transliterations
|
|
182
|
+
(`titlemark/names.py`) — easy to extend.
|
|
183
|
+
|
|
184
|
+
Name lines in the forms «Surname F. M.», «F. M. Surname», «Surname, Name»
|
|
185
|
+
and «by Jane Smith» are recognised by pattern. ALL-CAPS names are normalised
|
|
186
|
+
(`АРТУР ДУМЧЕВ` → `Артур Думчев`), and service words («And Unix Systems»)
|
|
187
|
+
never count as a person's name.
|
|
188
|
+
|
|
189
|
+
## Safety
|
|
190
|
+
|
|
191
|
+
* **nothing is renamed by default** — the tool prints a plan first;
|
|
192
|
+
* name collisions are resolved with ` (2)`, ` (3)` suffixes;
|
|
193
|
+
* the file extension is preserved, forbidden characters become spaces;
|
|
194
|
+
* names are capped at 200 **bytes** (the FS limit is 255 bytes per path
|
|
195
|
+
component, and Cyrillic takes 2 bytes per character), cut at a word
|
|
196
|
+
boundary with `…`;
|
|
197
|
+
* the year is appended only when it was found in metadata or in the
|
|
198
|
+
original file name.
|
|
199
|
+
|
|
200
|
+
## Tests
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
python3 -m unittest discover -s tests
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
128 tests: title/author heuristics, text cleaning, mojibake detection,
|
|
207
|
+
file-name building, file-name parsing, synthetic files for every supported
|
|
208
|
+
format, and rename scenarios.
|
|
209
|
+
|
|
210
|
+
## Project layout
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
titlemark/
|
|
214
|
+
├── titlemark/
|
|
215
|
+
│ ├── cli.py # command line, plan output
|
|
216
|
+
│ ├── renamer.py # rename planning and execution
|
|
217
|
+
│ ├── titlefinder.py # heuristics: title and author from text
|
|
218
|
+
│ ├── names.py # name dictionary: "author" vs "title"
|
|
219
|
+
│ ├── textclean.py # string cleanup, mojibake detection
|
|
220
|
+
│ ├── naming.py # file-name building, limits, collisions
|
|
221
|
+
│ ├── ocr.py # OCR of leading pages (pdftoppm + tesseract)
|
|
222
|
+
│ ├── signature.py # format detection by signature (extensionless files)
|
|
223
|
+
│ ├── ai.py # vision LLM cover recognition (--ai, OpenAI-compatible)
|
|
224
|
+
│ ├── models.py # DocumentInfo, Source, TextQuality
|
|
225
|
+
│ └── extractors/ # PDF, EPUB, FB2, MOBI, DJVU, DOCX
|
|
226
|
+
├── tests/
|
|
227
|
+
├── pyproject.toml
|
|
228
|
+
├── README.md # this file
|
|
229
|
+
└── README.ru.md # Russian documentation
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Author
|
|
233
|
+
|
|
234
|
+
**Sergei Korneev (AI coded)** — version 1.1.0 (`titlemark --version`).
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Titlemark
|
|
2
|
+
|
|
3
|
+
> *titlemark (n.)* — in early printing, the short title set at the head of a
|
|
4
|
+
> page to mark the book it came from.
|
|
5
|
+
|
|
6
|
+
**Titlemark** is a console tool that reads a document, works out its *title*
|
|
7
|
+
and *author*, and renames the file into something a human would recognise:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
29623753.pdf -> David Knickerbocker - Network Science with Python [2023].pdf
|
|
11
|
+
43b2956cd82bc62159778acf….pdf -> Артур Думчев - ПОМНИТЬ ВСЕ ПРАКТИЧЕСКОЕ РУКОВОДСТВО ПО РАЗВИТИЮ ПАМЯТИ [2013].pdf
|
|
12
|
+
345852.fb2 -> Штюрмер Ю. А. - Карманный справочник туриста.fb2
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It works **fully offline** — no LLM, no external services. Just heuristics
|
|
16
|
+
over file metadata, the text layer, and OCR for scans.
|
|
17
|
+
|
|
18
|
+
## Supported formats
|
|
19
|
+
|
|
20
|
+
| Format | Metadata | Text | OCR |
|
|
21
|
+
|--------|----------|------|-----|
|
|
22
|
+
| PDF | pdfinfo | pdftotext (poppler) | pdftoppm + tesseract |
|
|
23
|
+
| EPUB | package.opf (dc:title, dc:creator, dc:date) | – | – |
|
|
24
|
+
| FB2 | title-info (book-title, author, date) | – | – |
|
|
25
|
+
| MOBI | PalmDB + EXTH (title, author, date) | – | – |
|
|
26
|
+
| DJVU | ANTa annotations (title, author) | djvutxt (djvulibre) | ddjvu + tesseract |
|
|
27
|
+
| DOCX | docProps/core.xml | first paragraphs | – |
|
|
28
|
+
|
|
29
|
+
**Files without an extension** (hash-named downloads) are recognised by their
|
|
30
|
+
signature — magic bytes for PDF/DjVu/MOBI, the container structure for
|
|
31
|
+
EPUB/DOCX, the `FictionBook` root for FB2 — and the renamed file gets the
|
|
32
|
+
proper extension. The same works for files with a wrong or unknown extension.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
* Python ≥ 3.10, standard library only;
|
|
37
|
+
* for PDF: `poppler-utils` (`pdfinfo`, `pdftotext`, `pdftoppm`);
|
|
38
|
+
* for scans (optional): `tesseract` with `rus+eng` language packs;
|
|
39
|
+
* for DjVu (optional): `djvulibre` tools (`djvutxt`, `ddjvu`) — without them
|
|
40
|
+
DjVu text/OCR is unavailable (annotations ANTa are still read).
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Debian/Ubuntu
|
|
44
|
+
sudo apt install poppler-utils tesseract-ocr tesseract-ocr-rus tesseract-ocr-eng djvulibre-bin
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Install & run
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. straight from the source tree, no installation
|
|
51
|
+
cd titlemark
|
|
52
|
+
python3 -m titlemark --help
|
|
53
|
+
|
|
54
|
+
# 2. user install — the `titlemark` command works from anywhere
|
|
55
|
+
pip install --user .
|
|
56
|
+
titlemark --help
|
|
57
|
+
|
|
58
|
+
# 3. editable install for development (source edits apply immediately)
|
|
59
|
+
pip install --user -e .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
After `pip install --user` the command lands in `~/.local/bin`
|
|
63
|
+
(make sure it is on your `PATH`). If you edit the project sources, either run
|
|
64
|
+
from the project folder or reinstall — the editable install (`-e`) picks up
|
|
65
|
+
changes without reinstalling.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
titlemark book.pdf # show the plan (files are NOT touched)
|
|
71
|
+
titlemark --apply book.pdf # rename the file
|
|
72
|
+
titlemark -r ~/Downloads # plan for a whole folder, recursively
|
|
73
|
+
titlemark --apply -r ~/Downloads # rename everything in the folder
|
|
74
|
+
titlemark --report plan.csv -r . # save the plan as CSV
|
|
75
|
+
titlemark --force-ocr book.pdf # re-check a PDF whose text layer is broken
|
|
76
|
+
titlemark --ai book.pdf # let a vision LLM read the cover
|
|
77
|
+
titlemark --title-from-text book.pdf # title = whole page text, fragments joined
|
|
78
|
+
titlemark -n "{title}-{year}" book.pdf # custom name pattern
|
|
79
|
+
titlemark -v book.pdf # debug: title candidates, text snippet
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Option | Description |
|
|
85
|
+
|--------|-------------|
|
|
86
|
+
| `paths` | file(s) or folder(s) to process |
|
|
87
|
+
| `-a, --apply` | actually rename; **without this flag the tool only shows a plan (dry-run)** |
|
|
88
|
+
| `-r, --recursive` | walk folders recursively |
|
|
89
|
+
| `-n, --naming-template PATTERN` | name pattern with `{author}`, `{title}`, `{year}` placeholders; the extension is appended automatically (default: `{author} - {title} [{year}]`) |
|
|
90
|
+
| `--no-ocr` | never OCR scanned pages (faster) |
|
|
91
|
+
| `--force-ocr` | always OCR the leading pages and use that text instead of the built-in text layer (for PDFs with a broken encoding) |
|
|
92
|
+
| `--ocr-pages N` | how many leading pages to OCR (default: 2) |
|
|
93
|
+
| `--no-year` | do not append the year to file names |
|
|
94
|
+
| `--title-from-text` | build the title from the **whole** page text without special characters (title fragments are joined), instead of picking single candidate lines |
|
|
95
|
+
| `--title-pages N` | how many leading pages to take for `--title-from-text` (default: 1) |
|
|
96
|
+
| `--report CSV` | write a report: old name, new name, source, status |
|
|
97
|
+
| `-v, --verbose` | show title candidates and warnings |
|
|
98
|
+
| `-q, --quiet` | print only the summary |
|
|
99
|
+
|
|
100
|
+
### AI options (vision LLM, opt-in)
|
|
101
|
+
|
|
102
|
+
| Option | Description |
|
|
103
|
+
|--------|-------------|
|
|
104
|
+
| `--ai` | ask a vision LLM to read the cover; its answer outranks the heuristics |
|
|
105
|
+
| `--ai-url URL` | OpenAI-compatible endpoint (default `https://api.openai.com/v1`; Ollama: `http://localhost:11434/v1`) |
|
|
106
|
+
| `--ai-model MODEL` | model name (default `gpt-4o-mini`; Ollama: `moondream`, `llava`, `llama3.2-vision`) |
|
|
107
|
+
| `--ai-key KEY` | API key (or env `TITLEMARK_AI_KEY`; not needed for local Ollama) |
|
|
108
|
+
| `--ai-timeout SEC` | request timeout (default 60) |
|
|
109
|
+
|
|
110
|
+
Environment variables: `TITLEMARK_AI_URL`, `TITLEMARK_AI_MODEL`, `TITLEMARK_AI_KEY`.
|
|
111
|
+
|
|
112
|
+
The AI is used **only when `--ai` is passed**. Titlemark renders the first page
|
|
113
|
+
to PNG, shows it to the model together with the heuristic candidates, and gets
|
|
114
|
+
back strict JSON `{"title", "author", "year"}`. If the model is unreachable or
|
|
115
|
+
answers oddly, the heuristic result is kept and a warning is printed.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# cloud (OpenAI, DeepSeek, GigaChat, YandexGPT, … — any compatible API)
|
|
119
|
+
export TITLEMARK_AI_KEY=sk-...
|
|
120
|
+
titlemark --ai book.pdf
|
|
121
|
+
|
|
122
|
+
# local Ollama, fully offline
|
|
123
|
+
titlemark --ai --ai-url http://localhost:11434/v1 --ai-model moondream book.pdf
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## How the name is chosen
|
|
127
|
+
|
|
128
|
+
By default the name follows the pattern **`Author - Title [year].ext`**
|
|
129
|
+
(just `Title [year].ext` when no author is found). The pattern is
|
|
130
|
+
customizable: `--naming-template "{author} - {title}"`, `"{title}-{year}"`,
|
|
131
|
+
`"{year}_{title}"` — empty values are dropped together with their separators,
|
|
132
|
+
and the file extension is always appended automatically.
|
|
133
|
+
|
|
134
|
+
Sources are tried from most to least reliable:
|
|
135
|
+
|
|
136
|
+
1. **File metadata** — Title/Author/date. Junk values (`Microsoft Word - …`,
|
|
137
|
+
`Layout 1`, `Pamyat_text 02.indd`, hashes, upload ids) are discarded;
|
|
138
|
+
2. **Text layer of the leading pages** — lines are scored by casing
|
|
139
|
+
(ALL CAPS / Title Case), position on the page, length and punctuation.
|
|
140
|
+
Service lines (ISBN, UDC, running heads, URLs), publisher imprints
|
|
141
|
+
(«Москва · Санкт-Петербург», «O'Reilly cities»), section headings
|
|
142
|
+
(«Table of Contents», «Preface») and name-like lines are rejected;
|
|
143
|
+
subtitle lines are merged into the title;
|
|
144
|
+
3. **OCR of the leading pages** — when there is no text layer (a scan) or
|
|
145
|
+
it is broken: mojibake from exotic font encodings, split words
|
|
146
|
+
(«W ICKED COOL», «SHELL SCRIP T S»);
|
|
147
|
+
4. **The original file name** — used when the other sources produced only a
|
|
148
|
+
fragment («ВЕСЬ» of «Весь Linux…»), a cipher («М. МЦНМО, 2016»), a broken
|
|
149
|
+
bibliographic line or a bad OCR guess. Names like `Author - Title`,
|
|
150
|
+
`Title (Author)` and `03_Title_by_Author` are parsed apart.
|
|
151
|
+
|
|
152
|
+
Telling a short author name from a short title (both may be set in caps:
|
|
153
|
+
`АРТУР ДУМЧЕВ` vs `DARK MATTER`) is done with a compact dictionary of common
|
|
154
|
+
Russian/English first names and their transliterations
|
|
155
|
+
(`titlemark/names.py`) — easy to extend.
|
|
156
|
+
|
|
157
|
+
Name lines in the forms «Surname F. M.», «F. M. Surname», «Surname, Name»
|
|
158
|
+
and «by Jane Smith» are recognised by pattern. ALL-CAPS names are normalised
|
|
159
|
+
(`АРТУР ДУМЧЕВ` → `Артур Думчев`), and service words («And Unix Systems»)
|
|
160
|
+
never count as a person's name.
|
|
161
|
+
|
|
162
|
+
## Safety
|
|
163
|
+
|
|
164
|
+
* **nothing is renamed by default** — the tool prints a plan first;
|
|
165
|
+
* name collisions are resolved with ` (2)`, ` (3)` suffixes;
|
|
166
|
+
* the file extension is preserved, forbidden characters become spaces;
|
|
167
|
+
* names are capped at 200 **bytes** (the FS limit is 255 bytes per path
|
|
168
|
+
component, and Cyrillic takes 2 bytes per character), cut at a word
|
|
169
|
+
boundary with `…`;
|
|
170
|
+
* the year is appended only when it was found in metadata or in the
|
|
171
|
+
original file name.
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
python3 -m unittest discover -s tests
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
128 tests: title/author heuristics, text cleaning, mojibake detection,
|
|
180
|
+
file-name building, file-name parsing, synthetic files for every supported
|
|
181
|
+
format, and rename scenarios.
|
|
182
|
+
|
|
183
|
+
## Project layout
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
titlemark/
|
|
187
|
+
├── titlemark/
|
|
188
|
+
│ ├── cli.py # command line, plan output
|
|
189
|
+
│ ├── renamer.py # rename planning and execution
|
|
190
|
+
│ ├── titlefinder.py # heuristics: title and author from text
|
|
191
|
+
│ ├── names.py # name dictionary: "author" vs "title"
|
|
192
|
+
│ ├── textclean.py # string cleanup, mojibake detection
|
|
193
|
+
│ ├── naming.py # file-name building, limits, collisions
|
|
194
|
+
│ ├── ocr.py # OCR of leading pages (pdftoppm + tesseract)
|
|
195
|
+
│ ├── signature.py # format detection by signature (extensionless files)
|
|
196
|
+
│ ├── ai.py # vision LLM cover recognition (--ai, OpenAI-compatible)
|
|
197
|
+
│ ├── models.py # DocumentInfo, Source, TextQuality
|
|
198
|
+
│ └── extractors/ # PDF, EPUB, FB2, MOBI, DJVU, DOCX
|
|
199
|
+
├── tests/
|
|
200
|
+
├── pyproject.toml
|
|
201
|
+
├── README.md # this file
|
|
202
|
+
└── README.ru.md # Russian documentation
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Author
|
|
206
|
+
|
|
207
|
+
**Sergei Korneev (AI coded)** — version 1.1.0 (`titlemark --version`).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "titlemark"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Titlemark — give every document its true name: \"Author - Title [year]\""
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Sergei Korneev (AI coded)" }]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = [
|
|
14
|
+
"rename", "filename", "title", "author", "metadata", "ocr",
|
|
15
|
+
"pdf", "epub", "fb2", "mobi", "djvu", "docx", "cli",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: End Users/Desktop",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Utilities",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [] # standard library + external tools only
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Sergei-Korneev/titlemark"
|
|
35
|
+
Repository = "https://github.com/Sergei-Korneev/titlemark"
|
|
36
|
+
Issues = "https://github.com/Sergei-Korneev/titlemark/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
titlemark = "titlemark.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
include = ["titlemark*"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Тесты ИИ-разбора обложки (--ai): сеть подменяется, без реальных запросов."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
import urllib.error
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from titlemark.ai import (
|
|
11
|
+
AiConfig,
|
|
12
|
+
build_prompt,
|
|
13
|
+
enrich_with_ai,
|
|
14
|
+
parse_json_answer,
|
|
15
|
+
)
|
|
16
|
+
from titlemark.models import DocumentInfo, Source
|
|
17
|
+
from titlemark.renamer import plan_rename
|
|
18
|
+
|
|
19
|
+
AI_ANSWER = {"title": "A History of Modern Computing",
|
|
20
|
+
"author": "Paul E. Ceruzzi", "year": "2004"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fake_response(config, payload: dict) -> dict:
|
|
24
|
+
"""Ответ эндпоинта /chat/completions."""
|
|
25
|
+
return {"choices": [{"message": {"content": json.dumps(AI_ANSWER, ensure_ascii=False)}}]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestParseAnswer(unittest.TestCase):
|
|
29
|
+
def test_plain_json(self):
|
|
30
|
+
self.assertEqual(parse_json_answer('{"title": "Книга"}'), {"title": "Книга"})
|
|
31
|
+
|
|
32
|
+
def test_markdown_fence(self):
|
|
33
|
+
text = '```json\n{"title": "Книга", "author": "Автор"}\n```'
|
|
34
|
+
self.assertEqual(parse_json_answer(text)["title"], "Книга")
|
|
35
|
+
|
|
36
|
+
def test_text_around_json(self):
|
|
37
|
+
text = 'Вот ответ:\n{"title": "Книга"}\nГотово.'
|
|
38
|
+
self.assertEqual(parse_json_answer(text), {"title": "Книга"})
|
|
39
|
+
|
|
40
|
+
def test_garbage(self):
|
|
41
|
+
self.assertEqual(parse_json_answer("не понял вопрос"), {})
|
|
42
|
+
self.assertEqual(parse_json_answer(""), {})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestEnrichWithAi(unittest.TestCase):
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
48
|
+
self.addCleanup(self._tmp.cleanup)
|
|
49
|
+
self.path = Path(self._tmp.name) / "00acaf02b9dfa6495f4842bd5de2a675"
|
|
50
|
+
self.path.write_bytes(b"%PDF-1.4 stub")
|
|
51
|
+
self.info = DocumentInfo(path=self.path, title="Черновик черновика")
|
|
52
|
+
|
|
53
|
+
def test_ai_overrides_heuristics(self):
|
|
54
|
+
with mock.patch("titlemark.ai._request", side_effect=_fake_response), \
|
|
55
|
+
mock.patch("titlemark.ai.render_cover", return_value=b"png-bytes"):
|
|
56
|
+
ok = enrich_with_ai(self.path, self.info, AiConfig(api_key="k"))
|
|
57
|
+
self.assertTrue(ok)
|
|
58
|
+
self.assertEqual(self.info.title, "A History of Modern Computing")
|
|
59
|
+
self.assertEqual(self.info.author, "Paul E. Ceruzzi")
|
|
60
|
+
self.assertEqual(self.info.year, "2004")
|
|
61
|
+
self.assertIs(self.info.title_source, Source.AI)
|
|
62
|
+
|
|
63
|
+
def test_cover_attached_as_image(self):
|
|
64
|
+
captured = {}
|
|
65
|
+
|
|
66
|
+
def fake_request(config, payload):
|
|
67
|
+
captured.update(payload)
|
|
68
|
+
return _fake_response(config, payload)
|
|
69
|
+
|
|
70
|
+
with mock.patch("titlemark.ai._request", side_effect=fake_request), \
|
|
71
|
+
mock.patch("titlemark.ai.render_cover", return_value=b"png-bytes"):
|
|
72
|
+
enrich_with_ai(self.path, self.info, AiConfig(api_key="k"))
|
|
73
|
+
content = captured["messages"][1]["content"]
|
|
74
|
+
self.assertIsInstance(content, list)
|
|
75
|
+
self.assertEqual(content[0]["type"], "image_url")
|
|
76
|
+
self.assertIn("base64", content[0]["image_url"]["url"])
|
|
77
|
+
|
|
78
|
+
def test_text_only_when_no_cover(self):
|
|
79
|
+
captured = {}
|
|
80
|
+
|
|
81
|
+
def fake_request(config, payload):
|
|
82
|
+
captured.update(payload)
|
|
83
|
+
return _fake_response(config, payload)
|
|
84
|
+
|
|
85
|
+
with mock.patch("titlemark.ai._request", side_effect=fake_request), \
|
|
86
|
+
mock.patch("titlemark.ai.render_cover", return_value=None):
|
|
87
|
+
enrich_with_ai(self.path, self.info, AiConfig(api_key="k"))
|
|
88
|
+
self.assertIsInstance(captured["messages"][1]["content"], str)
|
|
89
|
+
self.assertTrue(any("cover" in w for w in self.info.warnings))
|
|
90
|
+
|
|
91
|
+
def test_network_error_keeps_heuristics(self):
|
|
92
|
+
with mock.patch("titlemark.ai.render_cover", return_value=b"png"), \
|
|
93
|
+
mock.patch("titlemark.ai._request",
|
|
94
|
+
side_effect=urllib.error.URLError("нет сети")):
|
|
95
|
+
ok = enrich_with_ai(self.path, self.info, AiConfig(api_key="k"))
|
|
96
|
+
self.assertFalse(ok)
|
|
97
|
+
self.assertEqual(self.info.title, "Черновик черновика") # не тронуто
|
|
98
|
+
self.assertTrue(any("the AI did not respond" in w for w in self.info.warnings))
|
|
99
|
+
|
|
100
|
+
def test_prompt_contains_hints(self):
|
|
101
|
+
self.info.title_candidates = [("Кандидат один", 5.0)]
|
|
102
|
+
prompt = build_prompt(self.info)
|
|
103
|
+
self.assertIn("Черновик черновика", prompt)
|
|
104
|
+
self.assertIn("Кандидат один", prompt)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestPlanWithAi(unittest.TestCase):
|
|
108
|
+
def test_plan_rename_uses_ai_title(self):
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
p = Path(tmp) / "00acaf02b9dfa6495f4842bd5de2a675"
|
|
111
|
+
p.write_bytes(b"%PDF-1.4 stub")
|
|
112
|
+
with mock.patch("titlemark.ai._request", side_effect=_fake_response), \
|
|
113
|
+
mock.patch("titlemark.ai.render_cover", return_value=b"png"):
|
|
114
|
+
plan = plan_rename(p, use_ocr=False, ai=AiConfig(api_key="k"))
|
|
115
|
+
self.assertEqual(plan.status, "ok")
|
|
116
|
+
self.assertEqual(
|
|
117
|
+
plan.new_name,
|
|
118
|
+
"Paul E. Ceruzzi - A History of Modern Computing [2004].pdf")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestCliAi(unittest.TestCase):
|
|
122
|
+
def test_cloud_requires_key(self):
|
|
123
|
+
from titlemark.cli import main
|
|
124
|
+
|
|
125
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf") as fh:
|
|
126
|
+
code = main(["--ai", fh.name])
|
|
127
|
+
self.assertEqual(code, 2)
|
|
128
|
+
|
|
129
|
+
def test_local_needs_no_key(self):
|
|
130
|
+
from titlemark.cli import main
|
|
131
|
+
|
|
132
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf") as fh:
|
|
133
|
+
code = main(["--ai", "--ai-url", "http://localhost:11434/v1",
|
|
134
|
+
"--no-ocr", fh.name])
|
|
135
|
+
# без ключа локальный запуск проходит валидацию (сеть в тесте не ходим:
|
|
136
|
+
# ИИ вернёт ошибку — это warning, не падение)
|
|
137
|
+
self.assertEqual(code, 0)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
unittest.main()
|