titlemark 1.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """titlemark — give every document its true name.
2
+
3
+ Detects the title and author of a document (PDF, EPUB, FB2, MOBI, DJVU,
4
+ DOCX) and renames the file into "Author - Title [year].ext".
5
+ """
6
+
7
+ __version__ = "1.2.0"
8
+ __author__ = "Sergei Korneev (AI coded)"
titlemark/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Точка входа: python -m titlemark ..."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
titlemark/ai.py ADDED
@@ -0,0 +1,185 @@
1
+ """Интеграция с vision-LLM (OpenAI-совместимый API) для разбора обложки.
2
+
3
+ ИИ включается только явным флагом (--ai): утилита рендерит первую страницу
4
+ в картинку, показывает её модели вместе с кандидатами эвристик и получает
5
+ строгий JSON {"title", "author", "year"}. Понимает любые совместимые
6
+ эндпоинты: OpenAI, DeepSeek, GigaChat, YandexGPT, локальная Ollama
7
+ (http://localhost:11434/v1), llama.cpp, vLLM.
8
+
9
+ Внешних библиотек нет — только стандартная библиотека.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import urllib.error
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from .models import DocumentInfo, Source
26
+
27
+ DEFAULT_URL = "https://api.openai.com/v1"
28
+ DEFAULT_MODEL = "gpt-4o-mini"
29
+
30
+ _SYSTEM_PROMPT = (
31
+ "Ты — библиографический ассистент. По обложке или титулу документа "
32
+ "определи точное название, автора и год издания. "
33
+ "Ответь СТРОГО одним JSON-объектом вида "
34
+ '{"title": "...", "author": "...", "year": "YYYY"} без пояснений и markdown. '
35
+ "Название — на языке оригинала, ровно как на обложке (без служебных слов "
36
+ "вида «Скачать», «читать онлайн»). Автор — полное имя, как на обложке. "
37
+ "Если данных нет — пустая строка."
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class AiConfig:
43
+ """Параметры подключения к ИИ."""
44
+
45
+ url: str = DEFAULT_URL
46
+ model: str = DEFAULT_MODEL
47
+ api_key: str = ""
48
+ timeout: int = 60
49
+
50
+ @property
51
+ def is_local(self) -> bool:
52
+ return "localhost" in self.url or "127.0.0.1" in self.url
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # обложка
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def render_cover(path: Path, page: int = 1, dpi: int = 150) -> bytes | None:
60
+ """Отрендерить страницу документа в PNG (pdftoppm) или None."""
61
+ from .tools import find_tool, tool_env
62
+
63
+ exe = find_tool("pdftoppm")
64
+ if not exe:
65
+ return None
66
+ with tempfile.TemporaryDirectory(prefix="titlemark_ai_") as tmp:
67
+ out = Path(tmp) / "cover"
68
+ try:
69
+ subprocess.run(
70
+ [exe, "-f", str(page), "-l", str(page), "-r", str(dpi),
71
+ "-png", "-singlefile", str(path), str(out)],
72
+ capture_output=True, timeout=60, check=False,
73
+ env=tool_env(exe),
74
+ )
75
+ except (subprocess.TimeoutExpired, OSError):
76
+ return None
77
+ img = Path(tmp) / "cover.png"
78
+ return img.read_bytes() if img.exists() else None
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # запрос и разбор ответа
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def build_prompt(info: DocumentInfo) -> str:
86
+ """Текстовая часть запроса: подсказки от эвристик."""
87
+ lines = [f"Имя файла: {info.path.name}"]
88
+ if info.title:
89
+ lines.append(f"Что определили эвристики (может быть ошибкой): {info.title}")
90
+ if info.author:
91
+ lines.append(f"Предполагаемый автор: {info.author}")
92
+ if info.year:
93
+ lines.append(f"Предполагаемый год: {info.year}")
94
+ if info.title_candidates:
95
+ cands = "; ".join(c for c, _s in info.title_candidates[:5])
96
+ lines.append(f"Кандидаты в заголовок из текста: {cands}")
97
+ if info.text_snippet:
98
+ lines.append(f"Фрагмент текста: {info.text_snippet[:300]}")
99
+ return "\n".join(lines)
100
+
101
+
102
+ def _request(config: AiConfig, payload: dict) -> dict:
103
+ """POST /chat/completions. Выделено для подмены в тестах."""
104
+ url = config.url.rstrip("/") + "/chat/completions"
105
+ headers = {"Content-Type": "application/json"}
106
+ if config.api_key:
107
+ headers["Authorization"] = f"Bearer {config.api_key}"
108
+ req = urllib.request.Request(
109
+ url, data=json.dumps(payload).encode("utf-8"),
110
+ headers=headers, method="POST",
111
+ )
112
+ with urllib.request.urlopen(req, timeout=config.timeout) as resp:
113
+ return json.loads(resp.read().decode("utf-8"))
114
+
115
+
116
+ def parse_json_answer(text: str) -> dict:
117
+ """Достать JSON-объект из ответа модели (терпимо к markdown-обёрткам)."""
118
+ if not text:
119
+ return {}
120
+ cleaned = re.sub(r"```(?:json)?", "", text).strip()
121
+ m = re.search(r"\{.*?\}", cleaned, re.S)
122
+ if not m:
123
+ return {}
124
+ try:
125
+ data = json.loads(m.group(0))
126
+ except json.JSONDecodeError:
127
+ return {}
128
+ return data if isinstance(data, dict) else {}
129
+
130
+
131
+ def _apply_answer(info: DocumentInfo, data: dict) -> bool:
132
+ """Перенести поля ответа в DocumentInfo (пустые значения не мешают)."""
133
+ changed = False
134
+ for key, attr in (("title", "title"), ("author", "author"), ("year", "year")):
135
+ value = data.get(key)
136
+ if isinstance(value, str) and value.strip():
137
+ setattr(info, attr, value.strip())
138
+ changed = True
139
+ if changed:
140
+ if info.title:
141
+ info.title_source = Source.AI
142
+ if info.author:
143
+ info.author_source = Source.AI
144
+ return changed
145
+
146
+
147
+ def enrich_with_ai(path: Path, info: DocumentInfo, config: AiConfig) -> bool:
148
+ """Спросить ИИ про обложку и обновить info. False — ИИ не ответил."""
149
+ cover = render_cover(path)
150
+ prompt = build_prompt(info)
151
+
152
+ if cover:
153
+ content: str | list = [{
154
+ "type": "image_url",
155
+ "image_url": {
156
+ "url": "data:image/png;base64,"
157
+ + base64.b64encode(cover).decode("ascii"),
158
+ },
159
+ }, {"type": "text", "text": prompt}]
160
+ else:
161
+ info.warnings.append("could not render the cover — the AI got text only")
162
+ content = prompt
163
+
164
+ payload = {
165
+ "model": config.model,
166
+ "temperature": 0,
167
+ "messages": [
168
+ {"role": "system", "content": _SYSTEM_PROMPT},
169
+ {"role": "user", "content": content},
170
+ ],
171
+ }
172
+
173
+ try:
174
+ data = _request(config, payload)
175
+ answer = data["choices"][0]["message"]["content"]
176
+ except (urllib.error.URLError, OSError, KeyError, IndexError,
177
+ json.JSONDecodeError, ValueError) as exc:
178
+ info.warnings.append(f"the AI did not respond: {exc}")
179
+ return False
180
+
181
+ parsed = parse_json_answer(answer)
182
+ if not _apply_answer(info, parsed):
183
+ info.warnings.append("the AI returned no recognized data")
184
+ return False
185
+ return True
titlemark/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """Command line interface for titlemark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __author__, __version__
11
+ from .ai import DEFAULT_MODEL as DEFAULT_AI_MODEL, DEFAULT_URL as DEFAULT_AI_URL
12
+ from .extractors import supported_extensions
13
+ from .naming import DEFAULT_TEMPLATE, TEMPLATE_FIELDS, validate_template
14
+ from .renamer import apply_plans, plan_many, write_csv_report
15
+
16
+ _STATUS_MARK = {
17
+ "ok": "*",
18
+ "same": "=",
19
+ "no-title": "?",
20
+ "unsupported": "-",
21
+ "error": "!",
22
+ }
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="titlemark",
28
+ description=(
29
+ "Reads a document, detects its title and author, and renames the "
30
+ "file into something a human would recognise "
31
+ "(default pattern: \"Author - Title [year].ext\"). "
32
+ "By default it runs in dry-run mode: nothing is renamed, only a "
33
+ "plan is printed. "
34
+ f"Supported formats: {', '.join(supported_extensions())}."
35
+ ),
36
+ epilog=(
37
+ "Examples:\n"
38
+ " titlemark book.pdf # what would the file become\n"
39
+ " titlemark --apply book.pdf # rename it\n"
40
+ " titlemark --apply -r ~/Downloads # whole folder, recursively\n"
41
+ " titlemark --report plan.csv -r . # save the plan as CSV\n"
42
+ " titlemark --title-from-text book.pdf # title = whole page text\n"
43
+ " titlemark --ai book.pdf # vision LLM reads the cover\n"
44
+ ' titlemark --naming-template "{title}-{year}" book.pdf\n'
45
+ "\n"
46
+ f"Author: {__author__}\n"
47
+ f"Version: {__version__} (see also: titlemark --version)"
48
+ ),
49
+ formatter_class=argparse.RawDescriptionHelpFormatter,
50
+ )
51
+ parser.add_argument("--version", action="version",
52
+ version=f"%(prog)s {__version__} — {__author__}")
53
+ parser.add_argument("paths", nargs="+", type=Path,
54
+ help="file(s) or folder(s) with documents")
55
+ parser.add_argument("-a", "--apply", action="store_true",
56
+ help="actually rename (without this flag only a plan "
57
+ "is printed)")
58
+ parser.add_argument("-r", "--recursive", action="store_true",
59
+ help="walk folders recursively")
60
+ parser.add_argument("-n", "--naming-template", metavar="PATTERN",
61
+ default=None,
62
+ help=f"name pattern with placeholders "
63
+ f"{', '.join('{%s}' % t for t in TEMPLATE_FIELDS)}; "
64
+ f"the extension is appended automatically "
65
+ f"(default: \"{DEFAULT_TEMPLATE}\")")
66
+ ocr_group = parser.add_mutually_exclusive_group()
67
+ ocr_group.add_argument("--no-ocr", action="store_true",
68
+ help="never OCR scanned pages")
69
+ ocr_group.add_argument("--force-ocr", action="store_true",
70
+ help="always OCR the leading pages and use that "
71
+ "text instead of the built-in text layer "
72
+ "(for PDFs with a broken encoding)")
73
+ parser.add_argument("--ocr-pages", type=int, default=2, metavar="N",
74
+ help="how many leading pages to OCR (default: 2)")
75
+
76
+ ai_group = parser.add_argument_group(
77
+ "AI (vision LLM over the cover; enabled only by --ai)")
78
+ ai_group.add_argument("--ai", action="store_true",
79
+ help="ask a vision LLM to read the document cover; "
80
+ "its answer outranks the heuristics "
81
+ "(OpenAI-compatible API: cloud or local Ollama)")
82
+ ai_group.add_argument("--ai-url", metavar="URL",
83
+ default=os.environ.get("TITLEMARK_AI_URL", DEFAULT_AI_URL),
84
+ help=f"API endpoint (default: {DEFAULT_AI_URL}; "
85
+ "for Ollama: http://localhost:11434/v1; "
86
+ "env TITLEMARK_AI_URL)")
87
+ ai_group.add_argument("--ai-model", metavar="MODEL",
88
+ default=os.environ.get("TITLEMARK_AI_MODEL", DEFAULT_AI_MODEL),
89
+ help=f"model name (default: {DEFAULT_AI_MODEL}; "
90
+ "for Ollama: moondream, llava; "
91
+ "env TITLEMARK_AI_MODEL)")
92
+ ai_group.add_argument("--ai-key", metavar="KEY",
93
+ default=os.environ.get("TITLEMARK_AI_KEY", ""),
94
+ help="API key (env TITLEMARK_AI_KEY); "
95
+ "not needed for local Ollama")
96
+ ai_group.add_argument("--ai-timeout", type=int, default=60, metavar="SEC",
97
+ help="AI request timeout in seconds (default: 60)")
98
+ parser.add_argument("--no-year", action="store_true",
99
+ help="do not append the year to file names")
100
+ parser.add_argument("--title-from-text", action="store_true",
101
+ help="build the title from the WHOLE page text without "
102
+ "special characters (title fragments are joined "
103
+ "into one), instead of picking candidate lines")
104
+ parser.add_argument("--title-pages", type=int, default=1, metavar="N",
105
+ help="how many leading pages to take for "
106
+ "--title-from-text (default: 1)")
107
+ parser.add_argument("--report", type=Path, metavar="CSV",
108
+ help="save the plan as a CSV report")
109
+ parser.add_argument("-v", "--verbose", action="store_true",
110
+ help="show title candidates, warnings and text snippets")
111
+ parser.add_argument("-q", "--quiet", action="store_true",
112
+ help="print only the summary")
113
+ return parser
114
+
115
+
116
+ def _short(text: str, width: int) -> str:
117
+ return text if len(text) <= width else text[: width - 1] + "…"
118
+
119
+
120
+ def _print_plan(plans, verbose: bool = False) -> None:
121
+ for plan in plans:
122
+ mark = _STATUS_MARK.get(plan.status, "?")
123
+ if plan.status == "ok" and plan.new_name:
124
+ info = plan.info
125
+ src = info.title_source.value if info else "?"
126
+ print(f"{mark} {plan.path.name}")
127
+ print(f" -> {_short(plan.new_name, 150)} [{src}]")
128
+ elif plan.status == "same":
129
+ print(f"{mark} {plan.path.name} (name already fits)")
130
+ else:
131
+ print(f"{mark} {plan.path.name} ({plan.detail or plan.status})")
132
+
133
+ if verbose and plan.info and plan.status == "ok":
134
+ info = plan.info
135
+ if info.author:
136
+ print(f" author: {info.author} [{info.author_source.value}]")
137
+ if info.year:
138
+ print(f" year: {info.year}")
139
+ for cand, score in info.title_candidates[:3]:
140
+ print(f" candidate ({score}): {_short(cand, 90)}")
141
+ for warn in info.warnings:
142
+ print(f" ! {warn}")
143
+ if info.text_snippet:
144
+ snippet = _short(info.text_snippet.replace("\n", " ⏎ "), 160)
145
+ print(f" text: {snippet}")
146
+
147
+
148
+ def _print_summary(plans) -> None:
149
+ counts: dict[str, int] = {}
150
+ for plan in plans:
151
+ counts[plan.status] = counts.get(plan.status, 0) + 1
152
+ print()
153
+ print(f"Total files: {len(plans)}")
154
+ for status in ("ok", "same", "no-title", "unsupported", "error"):
155
+ if counts.get(status):
156
+ print(f" {status:<12}: {counts[status]}")
157
+
158
+
159
+ def main(argv: list[str] | None = None) -> int:
160
+ parser = build_parser()
161
+ args = parser.parse_args(argv)
162
+
163
+ if args.naming_template:
164
+ problem = validate_template(args.naming_template)
165
+ if problem:
166
+ print(f"bad --naming-template: {problem}", file=sys.stderr)
167
+ return 2
168
+
169
+ missing = [p for p in args.paths if not p.exists()]
170
+ if missing:
171
+ for path in missing:
172
+ print(f"path not found: {path}", file=sys.stderr)
173
+ return 2
174
+
175
+ ai_config = None
176
+ if args.ai:
177
+ from .ai import AiConfig
178
+
179
+ if not args.ai_key and not ("localhost" in args.ai_url
180
+ or "127.0.0.1" in args.ai_url):
181
+ print("a cloud API key is required: --ai-key or TITLEMARK_AI_KEY "
182
+ "(local Ollama needs no key)", file=sys.stderr)
183
+ return 2
184
+ ai_config = AiConfig(url=args.ai_url, model=args.ai_model,
185
+ api_key=args.ai_key, timeout=args.ai_timeout)
186
+
187
+ plans = plan_many(
188
+ args.paths,
189
+ recursive=args.recursive,
190
+ use_ocr=not args.no_ocr,
191
+ ocr_pages=args.ocr_pages,
192
+ include_year=not args.no_year,
193
+ force_ocr=args.force_ocr,
194
+ ai=ai_config,
195
+ title_from_text=args.title_pages if args.title_from_text else 0,
196
+ naming_template=args.naming_template,
197
+ )
198
+
199
+ if not plans:
200
+ print("No supported files found.", file=sys.stderr)
201
+ return 1
202
+
203
+ if not args.quiet:
204
+ mode = "RENAMING" if args.apply else "PLAN (dry-run, no files changed)"
205
+ print(f"=== {mode} ===")
206
+ _print_plan(plans, verbose=args.verbose)
207
+
208
+ if args.apply:
209
+ renamed = apply_plans(plans)
210
+ print(f"Renamed: {len(renamed)}")
211
+ else:
212
+ to_rename = sum(1 for p in plans if p.status == "ok")
213
+ print(f"Will rename: {to_rename} (run with --apply)")
214
+
215
+ if args.report:
216
+ write_csv_report(plans, args.report)
217
+ print(f"Report saved: {args.report}")
218
+
219
+ _print_summary(plans)
220
+ return 0
221
+
222
+
223
+ if __name__ == "__main__":
224
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ """Реестр экстракторов: выбираем обработчик по расширению файла.
2
+
3
+ Если расширения нет или оно неизвестное (файлы-хэши из папок загрузок),
4
+ формат определяется по сигнатуре — см. titlemark.signature.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from .base import Extractor
12
+ from .djvu import DjvuExtractor
13
+ from .docx import DocxExtractor
14
+ from .epub import EpubExtractor
15
+ from .fb2 import Fb2Extractor
16
+ from .mobi import MobiExtractor
17
+ from .pdf import PdfExtractor
18
+ from ..signature import detect_extension
19
+
20
+ _EXTRACTORS: list[Extractor] = [
21
+ PdfExtractor(),
22
+ EpubExtractor(),
23
+ Fb2Extractor(),
24
+ MobiExtractor(),
25
+ DjvuExtractor(),
26
+ DocxExtractor(),
27
+ ]
28
+
29
+ _BY_EXT: dict[str, Extractor] = {}
30
+ for _ext in _EXTRACTORS:
31
+ for _suffix in _ext.extensions:
32
+ _BY_EXT[_suffix] = _ext
33
+
34
+
35
+ def supported_extensions() -> tuple[str, ...]:
36
+ """Все расширения, которые умеет обрабатывать проект."""
37
+ return tuple(sorted(_BY_EXT))
38
+
39
+
40
+ def get_extractor(path: Path) -> Extractor | None:
41
+ """Экстрактор для файла или None, если формат не поддерживается.
42
+
43
+ Сначала смотрим на расширение; если его нет или оно неизвестное —
44
+ определяем формат по сигнатуре содержимого.
45
+ """
46
+ extractor = _BY_EXT.get(path.suffix.lower())
47
+ if extractor is not None:
48
+ return extractor
49
+ return _BY_EXT.get(detect_extension(path))
50
+
51
+
52
+ __all__ = [
53
+ "Extractor",
54
+ "get_extractor",
55
+ "supported_extensions",
56
+ ]
@@ -0,0 +1,135 @@
1
+ """Базовый интерфейс экстракторов и общий конвейер разбора текста."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from ..models import DocumentInfo, Source, TextQuality
9
+ from ..textclean import clean_author, clean_title, is_junk_title, text_quality
10
+ from ..titlefinder import find_author, find_title, merge_title
11
+
12
+
13
+ class Extractor(ABC):
14
+ """Извлекает сведения о документе из файла своего формата."""
15
+
16
+ #: расширения, которые обслуживает экстрактор
17
+ extensions: tuple[str, ...] = ()
18
+ #: умеет ли формат «лечить» отсутствие текста через OCR
19
+ ocr_capable: bool = False
20
+
21
+ @abstractmethod
22
+ def read_metadata(self, path: Path) -> tuple[str, str, str]:
23
+ """Вернуть (title, author, year) из метаданных файла."""
24
+
25
+ def read_text(self, path: Path, max_pages: int = 3) -> str:
26
+ """Вернуть текст первых страниц (пустая строка, если формат не умеет)."""
27
+ return ""
28
+
29
+ # ------------------------------------------------------------------
30
+ def extract(self, path: Path, use_ocr: bool = True, ocr_pages: int = 2,
31
+ force_ocr: bool = False,
32
+ title_from_full_text: int = 0) -> DocumentInfo:
33
+ """Полный разбор файла: метаданные → текстовый слой → (опц.) OCR.
34
+
35
+ force_ocr=True — распознать первые страницы через OCR обязательно
36
+ и использовать именно этот текст (штатный текстовый слой бывает
37
+ кривым: неверная кодировка, раздробленные слова).
38
+
39
+ title_from_full_text=N — название собирается из ВСЕГО текста первых
40
+ N страниц без спецсимволов, а не из отдельных строк-кандидатов.
41
+ """
42
+ info = DocumentInfo(path=path, format=path.suffix.lower().lstrip("."))
43
+
44
+ meta_title, meta_author, year = self.read_metadata(path)
45
+ # мусорность проверяем по СЫРОЙ строке метаданных («Pamyat_text 02.indd»)
46
+ # и ещё раз после очистки («… @bookinier», «… издательство …»)
47
+ if meta_title and not is_junk_title(meta_title):
48
+ meta_title = clean_title(meta_title)
49
+ if meta_title and not is_junk_title(meta_title):
50
+ info.title = meta_title
51
+ info.title_source = Source.METADATA
52
+ meta_author = clean_author(meta_author)
53
+ if meta_author:
54
+ info.author = meta_author
55
+ info.author_source = Source.METADATA
56
+ if year:
57
+ info.year = year
58
+
59
+ pages = title_from_full_text if title_from_full_text > 0 else 3
60
+ text = self.read_text(path, max_pages=pages)
61
+ quality = text_quality(text)
62
+ info.text_snippet = text[:400]
63
+ used_text, used_source = text, Source.TEXT
64
+
65
+ used_forced_ocr = False
66
+ if force_ocr and self.ocr_capable:
67
+ from ..ocr import ocr_first_pages
68
+
69
+ ocr_text = ocr_first_pages(path, pages=ocr_pages)
70
+ if ocr_text.strip():
71
+ info.warnings.append("OCR forced by --force-ocr")
72
+ self._fill_from_text(info, ocr_text, source=Source.OCR, override=True)
73
+ used_text, used_source = ocr_text, Source.OCR
74
+ used_forced_ocr = True
75
+ else:
76
+ info.warnings.append("OCR produced no text — text layer used")
77
+
78
+ if not used_forced_ocr:
79
+ if quality is TextQuality.GOOD:
80
+ self._fill_from_text(info, text, source=Source.TEXT)
81
+ elif use_ocr and self.ocr_capable:
82
+ from ..ocr import ocr_first_pages
83
+
84
+ ocr_text = ocr_first_pages(path, pages=ocr_pages)
85
+ if ocr_text.strip():
86
+ info.warnings.append("text layer missing/broken — OCR used")
87
+ self._fill_from_text(info, ocr_text, source=Source.OCR)
88
+ used_text, used_source = ocr_text, Source.OCR
89
+ elif not info.title:
90
+ info.warnings.append("no text layer and OCR failed")
91
+
92
+ # режим «название из всего текста»: склеиваем содержательные строки
93
+ if title_from_full_text > 0 and used_text.strip():
94
+ from ..titlefinder import full_text_title
95
+
96
+ full_title = full_text_title(used_text, exclude_author=info.author)
97
+ if full_title:
98
+ info.title = full_title
99
+ info.title_source = used_source
100
+ info.warnings.append(
101
+ f"title built from the full text of the first "
102
+ f"{title_from_full_text} page(s) (--title-from-text)")
103
+
104
+ return info
105
+
106
+ # ------------------------------------------------------------------
107
+ def _fill_from_text(self, info: DocumentInfo, text: str,
108
+ source: Source = Source.TEXT,
109
+ override: bool = False) -> None:
110
+ """Заполнить title/author из текста, если метаданных не хватило.
111
+
112
+ override=True — текст главнее метаданных (принудительный OCR).
113
+ """
114
+ from ..titlefinder import join_fragment_lines
115
+
116
+ lines = join_fragment_lines(
117
+ [ln.rstrip() for ln in text.splitlines() if ln.strip()])
118
+ if not lines:
119
+ return
120
+
121
+ title, _score, candidates = find_title(lines)
122
+ info.title_candidates = candidates
123
+ if title:
124
+ title = clean_title(merge_title(lines, title))
125
+ if title and (override or not info.title
126
+ or info.title_source is not Source.METADATA):
127
+ info.title = title
128
+ info.title_source = source
129
+
130
+ # строки, вошедшие в название, уже не автор
131
+ author = clean_author(find_author(lines, exclude_title=title))
132
+ if author and (override or not info.author
133
+ or info.author_source is not Source.METADATA):
134
+ info.author = author
135
+ info.author_source = source