isbnx 0.0.1__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.
isbnx/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from isbnx.config import Settings, configure, settings
2
+ from isbnx.isbnx import ISBNX, extract
3
+ from isbnx.models import Detect, ExtractResult, Locate, Meta, OCRResult
4
+
5
+ __all__ = [
6
+ "Settings",
7
+ "configure",
8
+ "settings",
9
+ "ISBNX",
10
+ "extract",
11
+ "Detect",
12
+ "Locate",
13
+ "Meta",
14
+ "OCRResult",
15
+ "ExtractResult",
16
+ ]
17
+
18
+
19
+ def main() -> None:
20
+ print("Hello from isbnx!")
isbnx/archive.py ADDED
@@ -0,0 +1,570 @@
1
+ """压缩包(zip/rar/uvz)ISBN 提取模块。
2
+
3
+ 数据来源优先级(按速度从高到低):
4
+
5
+ 1. **meta.xml** — 压缩包内的 XML 元数据文件,含 ``<ssid>`` / ``<isbn>`` 字段。
6
+ 纯文本解析,最快(~10-20ms)。编码兼容 UTF-8 / GB18030。
7
+ 2. **bookinfo.dat** — 超星 PDG 专用 INI 配置文件,含 ISBN / SSID。
8
+ 纯文本解析,较快(~5-10ms)。编码兼容 GB18030 / UTF-8。
9
+ 3. **leg001.pdg** — 版权页图片(通常是封面页/书名页),
10
+ 解码后 ONNX 检测 + OCR 提取。中等速度(~200-500ms)。
11
+ 4. **兜底 PDG** — 前 N 个 PDG 文件(由 ``pdg_fallback_count`` 控制),
12
+ 逐个解码 + ONNX + OCR。最慢(N × 200-500ms)。
13
+
14
+ 合并策略: meta.xml 和 bookinfo.dat 的结果会**合并**(互不覆盖,
15
+ 前面的来源优先级更高)。只要合并后 ISBN 或 SSID 任一有效即返回,
16
+ 不再走更慢的图片路径。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ctypes
22
+ import importlib.resources as resources
23
+ import io
24
+ import os
25
+ import re
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ import zipfile
30
+ from abc import ABC, abstractmethod
31
+ from pathlib import Path
32
+
33
+ from PIL import Image
34
+
35
+ from isbnx.config import settings
36
+ from isbnx.detector import Detector, get_detector
37
+ from isbnx.models import BookInfo, ExtractResult, Locate, Meta
38
+
39
+ # ── 图片文件签名 ──
40
+ _JPEG_HEADER = b"\xff\xd8\xff"
41
+ _PNG_HEADER = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
42
+
43
+ # ── 安全限制 ──
44
+ _MAX_BOOKINFO_SIZE = 1 * 1024 * 1024
45
+ _MAX_META_XML_SIZE = 256 * 1024
46
+ _MAX_PDG_SIZE = 50 * 1024 * 1024
47
+
48
+ # ── bookinfo.dat 字段映射(仅关注 ISBN) ──
49
+ _KEY_MAP: dict[str, str] = {
50
+ "isbn号": "isbn",
51
+ "isbn13": "isbn",
52
+ "isbn10": "isbn",
53
+ "isbn": "isbn",
54
+ "ss号": "ssid",
55
+ "ssid号": "ssid",
56
+ "ssid": "ssid",
57
+ "ss": "ssid",
58
+ }
59
+
60
+
61
+ # ═══════════════════════════════════════════════════════════
62
+ # 文件级工具函数
63
+ # ═══════════════════════════════════════════════════════════
64
+
65
+
66
+ def _get_names(arc) -> set[str]:
67
+ """获取压缩包内所有文件路径的集合。"""
68
+ return set(arc.namelist())
69
+
70
+
71
+ def _count_pdg(names: set[str]) -> int:
72
+ """统计压缩包内 *.pdg 文件数量。"""
73
+ return sum(1 for n in names if n.lower().endswith(".pdg"))
74
+
75
+
76
+ def _list_pdg(names: set[str]) -> list[str]:
77
+ """列出压缩包内所有 *.pdg 文件路径(按文件名排序)。"""
78
+ return sorted(n for n in names if n.lower().endswith(".pdg"))
79
+
80
+
81
+ # ── bookinfo.dat 解析 ──
82
+
83
+
84
+ def _decode_bookinfo(raw: bytes) -> tuple[str, str]:
85
+ """解码 bookinfo.dat,返回 (文本, 编码)。"""
86
+ try:
87
+ text = raw.decode("gb18030")
88
+ if "\ufffd" in text:
89
+ text = raw.decode("utf-8", errors="replace")
90
+ return text, "utf-8"
91
+ return text, "gb18030"
92
+ except UnicodeDecodeError:
93
+ text = raw.decode("utf-8", errors="replace")
94
+ return text, "utf-8"
95
+
96
+
97
+ def _parse_bookinfo(text: str) -> dict[str, str | None]:
98
+ """从 bookinfo.dat 文本中提取 ISBN 和 SSID。"""
99
+ result: dict[str, str | None] = {"isbn": None, "ssid": None}
100
+ section: str | None = None
101
+ for line in text.splitlines():
102
+ line = line.strip()
103
+ if not line:
104
+ continue
105
+ if line.startswith("[") and line.endswith("]"):
106
+ section = line[1:-1].strip().lower()
107
+ continue
108
+ if section is None:
109
+ continue
110
+ if "=" not in line:
111
+ continue
112
+ key, _, value = line.partition("=")
113
+ key = key.strip().lower()
114
+ value = value.strip()
115
+ if not value:
116
+ continue
117
+
118
+ mapped = _KEY_MAP.get(key)
119
+ if mapped is None:
120
+ continue
121
+ if result[mapped] is not None:
122
+ continue # 已有值则不覆盖
123
+
124
+ if mapped == "isbn":
125
+ cleaned = value.replace("-", "").replace(" ", "").upper()
126
+ if len(cleaned) == 10:
127
+ result["isbn"] = cleaned
128
+ elif len(cleaned) == 13 and cleaned.startswith(("978", "979")):
129
+ result["isbn"] = cleaned
130
+ else:
131
+ result[mapped] = value
132
+ return result
133
+
134
+
135
+ # ── PDG 文件检测与解码 ──
136
+
137
+
138
+ def _is_image_file(data: bytes) -> bool:
139
+ """检查字节数据是否为常见图片格式(jpg/png)。"""
140
+ return data.startswith(_JPEG_HEADER) or data.startswith(_PNG_HEADER)
141
+
142
+
143
+ def _pdg_to_image(data: bytes) -> Image.Image | None:
144
+ """将 PDG 字节数据解码为 PIL Image。
145
+
146
+ 流程:
147
+ 1. 检查文件头是否为常见图片格式(jpg/png)
148
+ 2. 是 → 直接用 PIL 读取(失败则继续)
149
+ 3. 否 → PdgView.dll 解码
150
+ """
151
+ if _is_image_file(data):
152
+ try:
153
+ return Image.open(io.BytesIO(data)).convert("RGB")
154
+ except Exception:
155
+ pass # 不要直接返回 None,继续尝试 PdgView.dll
156
+
157
+ # PdgView.dll 解码
158
+ try:
159
+ img = _pdg_decode_with_dll(data)
160
+ if img is not None:
161
+ return img
162
+ except Exception:
163
+ pass
164
+
165
+ # 最后兜底
166
+ try:
167
+ return Image.open(io.BytesIO(data)).convert("RGB")
168
+ except Exception:
169
+ return None
170
+
171
+
172
+ def _parse_pdg_header(data: bytes) -> tuple[int, int, int] | None:
173
+ """解析 PDG 文件头,返回 (width, height, pdg_type) 或 None。"""
174
+ if len(data) < 140:
175
+ return None
176
+ pdg_type = data[15]
177
+ if pdg_type == 0xFF or pdg_type == 0x10:
178
+ return None
179
+ x_pix = int.from_bytes(data[16:18], "little", signed=False)
180
+ y_pix = int.from_bytes(data[18:20], "little", signed=False)
181
+ if pdg_type in (0xAA, 0xAC):
182
+ x_pix, y_pix = 1120, 1568
183
+ elif pdg_type == 0xAB:
184
+ x_pix, y_pix = y_pix, x_pix
185
+ return x_pix, y_pix, pdg_type
186
+
187
+
188
+ def _pdg_decode_with_dll(data: bytes) -> Image.Image | None:
189
+ """使用 PdgView.dll 解码 PDG 文件(仅 Windows 平台支持)。"""
190
+ if sys.platform != "win32":
191
+ return None
192
+
193
+ dll_path = resources.files(__package__) / settings.archive.pdgview_path
194
+ if not dll_path.is_file():
195
+ return None
196
+
197
+ dll = ctypes.cdll.LoadLibrary(str(dll_path))
198
+ dll.pdgInit()
199
+
200
+ header = _parse_pdg_header(data)
201
+ if header is None:
202
+ return None
203
+ x_pix, y_pix, _ = header
204
+
205
+ fd, tmp_path = tempfile.mkstemp(suffix=".pdg")
206
+ decoded_ok = False
207
+ img_buffer_ptr = ctypes.c_void_p()
208
+ try:
209
+ os.write(fd, data)
210
+ os.close(fd)
211
+
212
+ size = ctypes.c_int()
213
+ imgtype = ctypes.c_int()
214
+ ret = dll.pdgDecode(
215
+ ctypes.c_char_p(tmp_path.encode("utf-8") + b"\0"),
216
+ ctypes.c_int(x_pix),
217
+ ctypes.c_int(y_pix),
218
+ ctypes.byref(img_buffer_ptr),
219
+ ctypes.byref(size),
220
+ ctypes.byref(imgtype),
221
+ )
222
+ if ret != 0 or not img_buffer_ptr:
223
+ return None
224
+ decoded_ok = True
225
+
226
+ buf = (ctypes.c_byte * size.value).from_address(img_buffer_ptr.value) # type: ignore[arg-type]
227
+ img = Image.open(io.BytesIO(bytes(buf))).convert("RGB")
228
+ return img
229
+ finally:
230
+ try:
231
+ os.unlink(tmp_path)
232
+ except Exception:
233
+ pass
234
+ if decoded_ok:
235
+ try:
236
+ dll.pdgFreeBuffer(img_buffer_ptr)
237
+ except Exception:
238
+ pass
239
+
240
+
241
+ def _extract_isbn_from_image(data: bytes, detector) -> str | None:
242
+ """将 PDG 字节解码为图片后运行 ONNX 检测 + OCR,返回 ISBN 字符串。"""
243
+ img = _pdg_to_image(data)
244
+ if img is None:
245
+ return None
246
+ det = detector or get_detector()
247
+ result = det.process(img)
248
+ return result.bookinfo.isbn13 if result.bookinfo.isbn else None
249
+
250
+
251
+ # ═══════════════════════════════════════════════════════════
252
+ # 压缩包抽象层(统一 zip / rar / uvz 接口)
253
+ # ═══════════════════════════════════════════════════════════
254
+
255
+
256
+ class _ArchiveReader(ABC):
257
+ """压缩包读取器抽象基类。"""
258
+
259
+ @abstractmethod
260
+ def namelist(self) -> list[str]: ...
261
+
262
+ @abstractmethod
263
+ def read(self, name: str) -> bytes: ...
264
+
265
+ @abstractmethod
266
+ def getinfo(self, name: str): ...
267
+
268
+ @abstractmethod
269
+ def is_encrypted(self) -> bool: ...
270
+
271
+ @abstractmethod
272
+ def close(self) -> None: ...
273
+
274
+ def __enter__(self):
275
+ return self
276
+
277
+ def __exit__(self, *args):
278
+ self.close()
279
+
280
+
281
+ class _ZipReader(_ArchiveReader):
282
+ """ZIP / UVZ 读取器。"""
283
+
284
+ def __init__(self, path: str) -> None:
285
+ self._zf = zipfile.ZipFile(path, "r")
286
+
287
+ def namelist(self) -> list[str]:
288
+ return self._zf.namelist()
289
+
290
+ def read(self, name: str) -> bytes:
291
+ return self._zf.read(name)
292
+
293
+ def getinfo(self, name: str):
294
+ return self._zf.getinfo(name)
295
+
296
+ def is_encrypted(self) -> bool:
297
+ for info in self._zf.infolist():
298
+ if info.flag_bits & 0x1:
299
+ return True
300
+ return False
301
+
302
+ def close(self) -> None:
303
+ self._zf.close()
304
+
305
+
306
+ class _RarReader(_ArchiveReader):
307
+ """RAR 读取器。"""
308
+
309
+ def __init__(self, path: str) -> None:
310
+ import rarfile # noqa: PLC0415
311
+
312
+ self._rf = rarfile.RarFile(path)
313
+
314
+ def namelist(self) -> list[str]:
315
+ return self._rf.namelist()
316
+
317
+ def read(self, name: str) -> bytes:
318
+ return self._rf.read(name)
319
+
320
+ def getinfo(self, name: str):
321
+ return self._rf.getinfo(name)
322
+
323
+ def is_encrypted(self) -> bool:
324
+ return any(getattr(info, "encrypted", False) for info in self._rf.infolist())
325
+
326
+ def close(self) -> None:
327
+ self._rf.close()
328
+
329
+
330
+ def _open_archive(path: Path) -> _ArchiveReader:
331
+ """根据文件扩展名打开压缩包。"""
332
+ ext = path.suffix.lower()
333
+ if ext in (".zip", ".uvz"):
334
+ return _ZipReader(str(path))
335
+ if ext == ".rar":
336
+ return _RarReader(str(path))
337
+ raise ValueError(f"不支持的压缩包格式: {ext}(支持 zip/rar/uvz)")
338
+
339
+
340
+ def _get_info_ignore_case(arc: _ArchiveReader, names: set[str], target: str):
341
+ """按文件名(忽略大小写、忽略目录层级)查找文件信息。"""
342
+ target_lower = target.lower()
343
+ for name in names:
344
+ if Path(name).name.lower() == target_lower:
345
+ try:
346
+ return arc.getinfo(name)
347
+ except Exception:
348
+ return None
349
+ return None
350
+
351
+
352
+ def _read_file_ignore_case(arc: _ArchiveReader, names: set[str], target: str) -> bytes | None:
353
+ """按文件名(忽略大小写、忽略目录层级)读取文件。"""
354
+ target_lower = target.lower()
355
+ for name in names:
356
+ if Path(name).name.lower() == target_lower:
357
+ try:
358
+ return arc.read(name)
359
+ except Exception:
360
+ return None
361
+ return None
362
+
363
+
364
+ # ── meta.xml 解析 ──
365
+
366
+
367
+ def _parse_meta_xml(raw: bytes) -> dict[str, str | None]:
368
+ """解析压缩包内的 meta.xml,提取 ISBN 和 SSID。
369
+
370
+ XML 格式示例::
371
+
372
+ <meta>
373
+ <ssid>96391214</ssid>
374
+ <title>李鸿藻书札</title>
375
+ <isbn>9787550845091</isbn>
376
+ <creator>陆德富,谢亚衡编</creator>
377
+ <publisher>西泠印社出版社</publisher>
378
+ <date>2024.06</date>
379
+ </meta>
380
+
381
+ Returns:
382
+ {"isbn": ..., "ssid": ...},未找到的字段为 None。
383
+ """
384
+ import xml.etree.ElementTree as ET
385
+
386
+ def _parse(raw: bytes) -> ET.Element | None:
387
+ """尝试解析 XML 字节数据,支持编码兜底。"""
388
+ # 1. 直接解析(适用于 UTF-8 / ASCII)
389
+ try:
390
+ return ET.fromstring(raw)
391
+ except (ET.ParseError, ValueError):
392
+ pass
393
+ # 2. 编码兜底:先按 GB18030 解码为文本,去除 XML 声明中的编码信息,
394
+ # 再重新编码为 UTF-8 后解析。解决 GB18030 编码或声明与实际不符的问题。
395
+ try:
396
+ text = raw.decode("gb18030")
397
+ # 移除/替换 XML 声明中的 encoding,避免声明与真实编码冲突
398
+ text = re.sub(
399
+ r'<\?xml\s+[^>]*encoding\s*=\s*["\'][^"\']+["\'][^>]*\?>',
400
+ '<?xml version="1.0" encoding="UTF-8"?>',
401
+ text,
402
+ )
403
+ return ET.fromstring(text.encode("utf-8"))
404
+ except (ET.ParseError, UnicodeDecodeError, UnicodeError, ValueError):
405
+ return None
406
+
407
+ result: dict[str, str | None] = {"isbn": None, "ssid": None}
408
+ root = _parse(raw)
409
+ if root is None:
410
+ return result
411
+
412
+ for field in ("isbn", "ssid"):
413
+ elem = root.find(field)
414
+ if elem is not None and elem.text:
415
+ value = elem.text.strip()
416
+ if field == "isbn":
417
+ cleaned = value.replace("-", "").replace(" ", "").upper()
418
+ if len(cleaned) == 10:
419
+ result["isbn"] = cleaned
420
+ elif len(cleaned) == 13 and cleaned.startswith(("978", "979")):
421
+ result["isbn"] = cleaned
422
+ else:
423
+ result[field] = value
424
+ return result
425
+
426
+
427
+ def _merge_metadata(*sources: dict[str, str | None]) -> dict[str, str | None]:
428
+ """合并多个来源的元数据,前面的来源优先级更高(已有值不覆写)。"""
429
+ merged: dict[str, str | None] = {}
430
+ for src in sources:
431
+ for key in ("isbn", "ssid"):
432
+ if key not in merged and src.get(key):
433
+ merged[key] = src[key]
434
+ return merged
435
+
436
+
437
+ # ═══════════════════════════════════════════════════════════
438
+ # ArchiveExtractor
439
+ # ═══════════════════════════════════════════════════════════
440
+
441
+
442
+ class ArchiveExtractor:
443
+ """压缩包(zip/rar/uvz)ISBN 提取器。"""
444
+
445
+ @classmethod
446
+ def extract(cls, archive_path: str | Path, detector: Detector | None = None) -> ExtractResult:
447
+ """从压缩包(zip/rar/uvz)中提取 ISBN。
448
+
449
+ Args:
450
+ archive_path: 压缩包文件路径。
451
+ detector: 外部传入的 Detector 实例,为 None 时使用全局单例。
452
+
453
+ Returns:
454
+ ExtractResult — 包含 ISBN、源信息等。
455
+ """
456
+ t0 = time.perf_counter()
457
+ archive_path = Path(archive_path)
458
+
459
+ try:
460
+ with _open_archive(archive_path) as arc:
461
+ names = _get_names(arc)
462
+
463
+ # ── 步骤 0: 密码检查 ──
464
+ if arc.is_encrypted():
465
+ return ExtractResult(
466
+ bookinfo=BookInfo(),
467
+ meta=Meta(source=str(archive_path), source_type="archive"),
468
+ error="压缩包有密码保护",
469
+ elapsed=time.perf_counter() - t0,
470
+ )
471
+
472
+ # ── 步骤 1: PDG 数量检查 ──
473
+ pdg_count = _count_pdg(names)
474
+ if pdg_count < settings.archive.pdg_min_count:
475
+ return ExtractResult(
476
+ bookinfo=BookInfo(),
477
+ meta=Meta(source=str(archive_path), source_type="archive"),
478
+ error=f"PDG 数量不足: {pdg_count} < {settings.archive.pdg_min_count}",
479
+ elapsed=time.perf_counter() - t0,
480
+ )
481
+
482
+ # ── 步骤 2: 元数据文件解析(meta.xml + bookinfo.dat)──
483
+ meta_parsed: dict[str, str | None] = {}
484
+ bookinfo_parsed: dict[str, str | None] = {}
485
+ encoding: str | None = None
486
+ locate_method: str | None = None
487
+
488
+ # 2a: meta.xml
489
+ mx_info = _get_info_ignore_case(arc, names, "meta.xml")
490
+ if mx_info is not None and getattr(mx_info, "file_size", 0) <= _MAX_META_XML_SIZE:
491
+ raw = _read_file_ignore_case(arc, names, "meta.xml")
492
+ if raw:
493
+ meta_parsed = _parse_meta_xml(raw)
494
+ if meta_parsed.get("isbn") or meta_parsed.get("ssid"):
495
+ locate_method = "meta"
496
+
497
+ # 2b: bookinfo.dat
498
+ bi_info = _get_info_ignore_case(arc, names, "bookinfo.dat")
499
+ if bi_info is not None and getattr(bi_info, "file_size", 0) <= _MAX_BOOKINFO_SIZE:
500
+ raw = _read_file_ignore_case(arc, names, "bookinfo.dat")
501
+ if raw:
502
+ text, enc = _decode_bookinfo(raw)
503
+ encoding = enc
504
+ bookinfo_parsed = _parse_bookinfo(text)
505
+ if bookinfo_parsed.get("isbn") or bookinfo_parsed.get("ssid"):
506
+ locate_method = "bookinfo"
507
+
508
+ # 2c: 合并两路结果
509
+ merged = _merge_metadata(meta_parsed, bookinfo_parsed)
510
+ if merged.get("isbn") or merged.get("ssid"):
511
+ bookinfo = BookInfo(**merged)
512
+ if bookinfo.is_valid():
513
+ # 页码区分来源: -20=bookinfo.dat, -21=meta.xml
514
+ locate_page = -20 if locate_method == "bookinfo" else -21
515
+ return ExtractResult(
516
+ bookinfo=bookinfo,
517
+ meta=Meta(source=str(archive_path), source_type="archive", encoding=encoding),
518
+ locate=Locate(page=locate_page, method=locate_method or "meta", extraction="text"),
519
+ elapsed=time.perf_counter() - t0,
520
+ )
521
+
522
+ # ── 步骤 3: leg001.pdg → 图片 → ONNX ──
523
+ leg_info = _get_info_ignore_case(arc, names, "leg001.pdg")
524
+ if leg_info is not None and getattr(leg_info, "file_size", 0) <= _MAX_PDG_SIZE:
525
+ raw_pdg = _read_file_ignore_case(arc, names, "leg001.pdg")
526
+ if raw_pdg:
527
+ isbn_str = _extract_isbn_from_image(raw_pdg, detector)
528
+ if isbn_str:
529
+ return ExtractResult(
530
+ bookinfo=BookInfo(isbn=isbn_str),
531
+ meta=Meta(source=str(archive_path), source_type="archive"),
532
+ locate=Locate(page=-10, method="leg001", extraction="ocr"),
533
+ elapsed=time.perf_counter() - t0,
534
+ )
535
+
536
+ # ── 步骤 4: 兜底 — 前 N 个 PDG ──
537
+ pdg_files = _list_pdg(names)
538
+ fallback_count = settings.archive.pdg_fallback_count
539
+ for idx, pdg_name in enumerate(pdg_files[:fallback_count]):
540
+ try:
541
+ info = arc.getinfo(pdg_name)
542
+ if getattr(info, "file_size", 0) > _MAX_PDG_SIZE:
543
+ continue
544
+ raw_pdg = arc.read(pdg_name)
545
+ except Exception:
546
+ continue
547
+ isbn_str = _extract_isbn_from_image(raw_pdg, detector)
548
+ if isbn_str:
549
+ return ExtractResult(
550
+ bookinfo=BookInfo(isbn=isbn_str),
551
+ meta=Meta(source=str(archive_path), source_type="archive"),
552
+ locate=Locate(page=idx + 1, method="pdg", extraction="ocr"),
553
+ elapsed=time.perf_counter() - t0,
554
+ )
555
+
556
+ # 所有步骤均失败
557
+ return ExtractResult(
558
+ bookinfo=BookInfo(),
559
+ meta=Meta(source=str(archive_path), source_type="archive"),
560
+ error="未提取到 ISBN",
561
+ elapsed=time.perf_counter() - t0,
562
+ )
563
+
564
+ except Exception as e:
565
+ return ExtractResult(
566
+ bookinfo=BookInfo(),
567
+ meta=Meta(source=str(archive_path), source_type="archive"),
568
+ error=f"压缩包提取异常: {e}",
569
+ elapsed=time.perf_counter() - t0,
570
+ )