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.
Files changed (42) hide show
  1. titlemark-1.2.0/LICENSE +21 -0
  2. titlemark-1.2.0/PKG-INFO +234 -0
  3. titlemark-1.2.0/README.md +207 -0
  4. titlemark-1.2.0/pyproject.toml +42 -0
  5. titlemark-1.2.0/setup.cfg +4 -0
  6. titlemark-1.2.0/tests/test_ai.py +141 -0
  7. titlemark-1.2.0/tests/test_djvu_text.py +98 -0
  8. titlemark-1.2.0/tests/test_extractors.py +266 -0
  9. titlemark-1.2.0/tests/test_force_ocr.py +77 -0
  10. titlemark-1.2.0/tests/test_naming.py +125 -0
  11. titlemark-1.2.0/tests/test_renamer.py +109 -0
  12. titlemark-1.2.0/tests/test_signature.py +117 -0
  13. titlemark-1.2.0/tests/test_textclean.py +131 -0
  14. titlemark-1.2.0/tests/test_title_from_text.py +128 -0
  15. titlemark-1.2.0/tests/test_titlefinder.py +138 -0
  16. titlemark-1.2.0/tests/test_tools.py +81 -0
  17. titlemark-1.2.0/titlemark/__init__.py +8 -0
  18. titlemark-1.2.0/titlemark/__main__.py +6 -0
  19. titlemark-1.2.0/titlemark/ai.py +185 -0
  20. titlemark-1.2.0/titlemark/cli.py +224 -0
  21. titlemark-1.2.0/titlemark/extractors/__init__.py +56 -0
  22. titlemark-1.2.0/titlemark/extractors/base.py +135 -0
  23. titlemark-1.2.0/titlemark/extractors/djvu.py +100 -0
  24. titlemark-1.2.0/titlemark/extractors/docx.py +63 -0
  25. titlemark-1.2.0/titlemark/extractors/epub.py +63 -0
  26. titlemark-1.2.0/titlemark/extractors/fb2.py +78 -0
  27. titlemark-1.2.0/titlemark/extractors/mobi.py +99 -0
  28. titlemark-1.2.0/titlemark/extractors/pdf.py +74 -0
  29. titlemark-1.2.0/titlemark/models.py +48 -0
  30. titlemark-1.2.0/titlemark/names.py +91 -0
  31. titlemark-1.2.0/titlemark/naming.py +125 -0
  32. titlemark-1.2.0/titlemark/ocr.py +81 -0
  33. titlemark-1.2.0/titlemark/renamer.py +384 -0
  34. titlemark-1.2.0/titlemark/signature.py +96 -0
  35. titlemark-1.2.0/titlemark/textclean.py +344 -0
  36. titlemark-1.2.0/titlemark/titlefinder.py +343 -0
  37. titlemark-1.2.0/titlemark/tools.py +102 -0
  38. titlemark-1.2.0/titlemark.egg-info/PKG-INFO +234 -0
  39. titlemark-1.2.0/titlemark.egg-info/SOURCES.txt +40 -0
  40. titlemark-1.2.0/titlemark.egg-info/dependency_links.txt +1 -0
  41. titlemark-1.2.0/titlemark.egg-info/entry_points.txt +2 -0
  42. titlemark-1.2.0/titlemark.egg-info/top_level.txt +1 -0
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()