ko-copycheck 0.1.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.
@@ -0,0 +1,11 @@
1
+ """ko-copycheck: offline plagiarism checker for Korean prose.
2
+
3
+ Public API: :func:`compare`, :class:`Config`, :class:`Report`. Everything else is
4
+ internal and may change.
5
+ """
6
+ from .engine import compare
7
+ from .config import Config
8
+ from .score import Report
9
+
10
+ __version__ = "0.1.1"
11
+ __all__ = ["compare", "Config", "Report", "__version__"]
ko_copycheck/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """Command line interface: scan, pair, extract."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ import unicodedata
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import Config
11
+ from .engine import compare
12
+ from .extract import SUPPORTED, Limits, SandboxedExtractor
13
+ from .report import write_csv, write_json, write_report
14
+ from .report.html import pair_page
15
+ from .normalize import normalize_text
16
+ from .score import Failure, Report
17
+
18
+ EXIT_OK, EXIT_ERROR = 0, 2
19
+
20
+
21
+ def _add_tuning(p: argparse.ArgumentParser) -> None:
22
+ d = Config()
23
+ p.add_argument("--prompt", action="append", default=[], metavar="FILE",
24
+ help="assignment text every submission may quote (repeatable); matching parts are ignored")
25
+ p.add_argument("--k", type=int, default=d.k, help=f"k-gram length in characters (default {d.k})")
26
+ p.add_argument("--w", type=int, default=d.w, help=f"winnowing window (default {d.w})")
27
+ p.add_argument("--min-match", type=int, default=d.min_match, help=f"shortest verbatim match in characters (default {d.min_match})")
28
+ p.add_argument("--dice", type=float, default=d.dice, help=f"paraphrase Dice threshold (default {d.dice})")
29
+ p.add_argument("--min-sentence", type=int, default=d.min_sentence, help=f"shortest sentence for paraphrase layer (default {d.min_sentence})")
30
+ p.add_argument("--group-ratio", type=float, default=d.group_ratio, help=f"share of documents that makes a passage common (default {d.group_ratio})")
31
+ p.add_argument("--timeout", type=float, default=d.timeout, help=f"seconds allowed per file extraction (default {d.timeout:g})")
32
+
33
+
34
+ def _config(a: argparse.Namespace) -> Config:
35
+ return Config(k=a.k, w=a.w, min_match=a.min_match, dice=a.dice, min_sentence=a.min_sentence,
36
+ group_ratio=a.group_ratio, timeout=a.timeout)
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ ap = argparse.ArgumentParser(
41
+ prog="ko-copycheck",
42
+ description="Offline plagiarism checker for Korean prose submissions. Compares files with each other only; nothing is uploaded.",
43
+ )
44
+ ap.add_argument("--version", action="version", version=f"ko-copycheck {__version__}")
45
+ sub = ap.add_subparsers(dest="command", required=True)
46
+
47
+ scan = sub.add_parser("scan", help="compare every pair of files in a folder")
48
+ scan.add_argument("folder", type=Path)
49
+ scan.add_argument("--out", type=Path, default=Path("report"), help="output folder (default ./report)")
50
+ scan.add_argument("--recursive", action="store_true", help="also read sub-folders")
51
+ scan.add_argument("--top", type=int, default=300, help="write side-by-side pages for at most this many pairs (default 300)")
52
+ _add_tuning(scan)
53
+
54
+ pair = sub.add_parser("pair", help="compare two files and write one side-by-side HTML page")
55
+ pair.add_argument("left", type=Path)
56
+ pair.add_argument("right", type=Path)
57
+ pair.add_argument("--out", type=Path, default=Path("pair.html"), help="output HTML file (default ./pair.html)")
58
+ _add_tuning(pair)
59
+
60
+ ex = sub.add_parser("extract", help="print the text extracted from one file (check how it is read)")
61
+ ex.add_argument("file", type=Path)
62
+ ex.add_argument("--timeout", type=float, default=Config().timeout, help="seconds allowed (default 60)")
63
+ return ap
64
+
65
+
66
+ def _say(msg: str) -> None:
67
+ print(msg, file=sys.stderr, flush=True)
68
+
69
+
70
+ def _collect(folder: Path, recursive: bool, exclude: set[Path]) -> list[Path]:
71
+ it = folder.rglob("*") if recursive else folder.glob("*")
72
+ files = [p for p in it if p.is_file() and p.suffix.lower() in SUPPORTED and not p.name.startswith((".", "~$"))]
73
+ return sorted(p for p in files if p.resolve() not in exclude)
74
+
75
+
76
+ def _extract_all(paths: list[Path], names: list[str], timeout: float, min_chars: int = 0) -> tuple[dict[str, str], list[Failure]]:
77
+ texts: dict[str, str] = {}
78
+ failures: list[Failure] = []
79
+ with SandboxedExtractor(timeout=timeout) as ex:
80
+ for i, (path, name) in enumerate(zip(paths, names), 1):
81
+ res = ex.extract(path)
82
+ if res.ok and res.text is not None and len(normalize_text(res.text)) < min_chars:
83
+ failures.append(Failure(name, f"no_text: fewer than {min_chars} readable characters (empty file?)"))
84
+ elif res.ok and res.text is not None:
85
+ texts[name] = res.text
86
+ else:
87
+ failures.append(Failure(name, res.error or "unknown"))
88
+ _say(f"extract {i}/{len(paths)} failed {len(failures)}")
89
+ return texts, failures
90
+
91
+
92
+ def _read_prompts(paths: list[str], timeout: float) -> list[str] | None:
93
+ files = [Path(p) for p in paths]
94
+ for f in files:
95
+ if not f.is_file():
96
+ _say(f"error: prompt file not found: {f}")
97
+ return None
98
+ if not files:
99
+ return []
100
+ texts, failures = _extract_all(files, [f.name for f in files], timeout)
101
+ if failures:
102
+ _say(f"error: cannot read prompt {failures[0].name}: {failures[0].reason}")
103
+ return None
104
+ return list(texts.values())
105
+
106
+
107
+ def _width(s: str) -> int:
108
+ return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
109
+
110
+
111
+ def _pad(s: str, n: int) -> str:
112
+ return s + " " * max(0, n - _width(s))
113
+
114
+
115
+ def print_top(report: Report, n: int = 10) -> None:
116
+ """Terminal ranking of the most similar pairs."""
117
+ rows = [p for p in report.pairs if p.rank_key > 0][:n]
118
+ if not rows:
119
+ print("no overlapping pairs found")
120
+ return
121
+ label = [f"{p.left} ~ {p.right}" for p in rows]
122
+ wid = max(_width(x) for x in label)
123
+ print(f"{_pad('pair', wid)} verbatim L<-R / R<-L paraphrase total L<-R / R<-L")
124
+ for text, p in zip(label, rows):
125
+ print(f"{_pad(text, wid)} {p.verbatim_lr * 100:5.1f}% / {p.verbatim_rl * 100:5.1f}% "
126
+ f"{p.para_sentences:6d} {p.score_lr * 100:5.1f}% / {p.score_rl * 100:5.1f}%")
127
+
128
+
129
+ def cmd_scan(a: argparse.Namespace) -> int:
130
+ cfg = _config(a)
131
+ cfg.validate()
132
+ if not a.folder.is_dir():
133
+ _say(f"error: not a folder: {a.folder}. Pass the folder that holds the submissions.")
134
+ return EXIT_ERROR
135
+ prompts = _read_prompts(a.prompt, cfg.timeout)
136
+ if prompts is None:
137
+ return EXIT_ERROR
138
+ exclude = {Path(p).resolve() for p in a.prompt}
139
+ paths = _collect(a.folder, a.recursive, exclude)
140
+ names = [p.relative_to(a.folder).as_posix() for p in paths]
141
+ if len(paths) < 2:
142
+ _say(f"error: need at least 2 files ({', '.join(SUPPORTED)}) in {a.folder}, found {len(paths)}. Try --recursive.")
143
+ return EXIT_ERROR
144
+ texts, failures = _extract_all(paths, names, cfg.timeout, cfg.k)
145
+ if len(texts) < 2:
146
+ _say("error: fewer than 2 files could be read; see the failure reasons above (run 'extract' on one file).")
147
+ for f in failures:
148
+ _say(f" {f.name}: {f.reason}")
149
+ return EXIT_ERROR
150
+ report = compare(texts, prompts, cfg, failures)
151
+ a.out.mkdir(parents=True, exist_ok=True)
152
+ write_csv(report, a.out / "pairs.csv")
153
+ write_json(report, a.out / "pairs.json")
154
+ index = write_report(report, a.out, top=a.top)
155
+ print_top(report)
156
+ for f in failures:
157
+ print(f"FAILED {f.name}: {f.reason}")
158
+ s = report.stats
159
+ print(f"\n{s['documents']} documents, {s['candidate_pairs']} candidate pairs of {s['all_pairs']}, {s['elapsed_seconds']}s")
160
+ print(f"report: {index}")
161
+ return EXIT_OK
162
+
163
+
164
+ def cmd_pair(a: argparse.Namespace) -> int:
165
+ cfg = _config(a)
166
+ cfg.validate()
167
+ for f in (a.left, a.right):
168
+ if not f.is_file():
169
+ _say(f"error: file not found: {f}")
170
+ return EXIT_ERROR
171
+ prompts = _read_prompts(a.prompt, cfg.timeout)
172
+ if prompts is None:
173
+ return EXIT_ERROR
174
+ left_name, right_name = a.left.name, a.right.name
175
+ if left_name == right_name:
176
+ left_name, right_name = str(a.left), str(a.right)
177
+ texts, failures = _extract_all([a.left, a.right], [left_name, right_name], cfg.timeout, cfg.k)
178
+ if failures:
179
+ for f in failures:
180
+ _say(f"error: cannot read {f.name}: {f.reason}")
181
+ return EXIT_ERROR
182
+ report = compare(texts, prompts, cfg)
183
+ result = report.pairs[0]
184
+ a.out.parent.mkdir(parents=True, exist_ok=True)
185
+ a.out.write_text(pair_page(report, result), encoding="utf-8")
186
+ print(f"{result.left} ~ {result.right}: verbatim {result.verbatim_lr * 100:.1f}% / {result.verbatim_rl * 100:.1f}%, "
187
+ f"paraphrase sentences {result.para_sentences}")
188
+ print(f"page: {a.out}")
189
+ return EXIT_OK
190
+
191
+
192
+ def cmd_extract(a: argparse.Namespace) -> int:
193
+ if not a.file.is_file():
194
+ _say(f"error: file not found: {a.file}")
195
+ return EXIT_ERROR
196
+ with SandboxedExtractor(timeout=a.timeout, limits=Limits()) as ex:
197
+ res = ex.extract(a.file)
198
+ if not res.ok or res.text is None:
199
+ _say(f"error: {res.error}")
200
+ return EXIT_ERROR
201
+ sys.stdout.write(res.text if res.text.endswith("\n") else res.text + "\n")
202
+ return EXIT_OK
203
+
204
+
205
+ def main(argv: list[str] | None = None) -> int:
206
+ for stream in (sys.stdout, sys.stderr):
207
+ if hasattr(stream, "reconfigure"):
208
+ stream.reconfigure(encoding="utf-8")
209
+ args = build_parser().parse_args(argv)
210
+ try:
211
+ if args.command == "scan":
212
+ return cmd_scan(args)
213
+ if args.command == "pair":
214
+ return cmd_pair(args)
215
+ return cmd_extract(args)
216
+ except ValueError as exc:
217
+ _say(f"error: {exc}")
218
+ return EXIT_ERROR
219
+ except KeyboardInterrupt:
220
+ _say("interrupted")
221
+ return 130
222
+
223
+
224
+ if __name__ == "__main__":
225
+ sys.exit(main())
ko_copycheck/config.py ADDED
@@ -0,0 +1,44 @@
1
+ """Tunable settings. Defaults are documented in the README option table."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Config:
9
+ """All knobs of a comparison run.
10
+
11
+ ``dice`` default comes from ``scripts/calibrate.py`` (hand-written calibration
12
+ corpus); it is a starting point, not a verdict. Tune it with ``--dice``.
13
+ """
14
+
15
+ k: int = 8 # characters per k-gram
16
+ w: int = 4 # winnowing window (guarantee threshold = w + k - 1 characters)
17
+ min_match: int = 20 # shortest verbatim match (normalized characters) that counts
18
+ dice: float = 0.30 # sentence bigram Dice threshold for the paraphrase layer
19
+ min_sentence: int = 10 # shortest sentence (normalized characters) for the paraphrase layer
20
+ group_ratio: float = 0.5 # k-grams shared by this share of documents are "common"...
21
+ group_min_docs: int = 3 # ...but only when at least this many documents share them
22
+ covered_skip: float = 0.8 # skip paraphrase pairs whose sentence is already this covered by verbatim
23
+ max_postings: int = 200 # ignore k-grams occurring more often than this in the index
24
+ timeout: float = 60.0 # seconds per file for extraction
25
+
26
+ def validate(self) -> None:
27
+ """Raise ``ValueError`` with a fix hint for settings that cannot work."""
28
+ if self.k < 3:
29
+ raise ValueError(f"k must be >= 3 (got {self.k}); try --k 8")
30
+ if self.w < 1:
31
+ raise ValueError(f"w must be >= 1 (got {self.w}); try --w 4")
32
+ if self.min_match < self.w + self.k - 1:
33
+ raise ValueError(
34
+ f"min_match ({self.min_match}) must be >= w + k - 1 ({self.w + self.k - 1}); "
35
+ "shorter matches are not guaranteed to be found. Try --min-match 20"
36
+ )
37
+ if not 0.0 < self.dice <= 1.0:
38
+ raise ValueError(f"dice must be in (0, 1] (got {self.dice}); try --dice {type(self).dice}")
39
+ if not 0.0 < self.group_ratio <= 1.0:
40
+ raise ValueError(f"group_ratio must be in (0, 1] (got {self.group_ratio})")
41
+ if self.min_sentence < 2:
42
+ raise ValueError(f"min_sentence must be >= 2 (got {self.min_sentence})")
43
+ if self.timeout <= 0:
44
+ raise ValueError(f"timeout must be > 0 seconds (got {self.timeout})")
ko_copycheck/engine.py ADDED
@@ -0,0 +1,131 @@
1
+ """Public entry point: compare a set of texts against each other."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from collections import defaultdict
6
+ from itertools import combinations
7
+ from typing import Mapping, Sequence
8
+
9
+ from .config import Config
10
+ from .index import candidate_hits, document_frequency
11
+ from .normalize import from_display_span, normalize
12
+ from .paraphrase import Sentence, bigram_set, matches_prefix, split_sentences
13
+ from .score import Doc, Failure, PairResult, ParaMatch, Report, finalize
14
+ from .verbatim import Span, covered, merge_spans, pair_spans, prompt_mask
15
+ from .winnow import kgram_hashes, winnow
16
+
17
+
18
+ def _overlap_share(span: Span, others: list[Span]) -> float:
19
+ """Share of ``span`` covered by the (merged, sorted) ``others``."""
20
+ s, e = span
21
+ if e <= s:
22
+ return 0.0
23
+ hit = sum(max(0, min(e, oe) - max(s, os)) for os, oe in others)
24
+ return hit / (e - s)
25
+
26
+
27
+ def compare(
28
+ texts: Mapping[str, str],
29
+ prompts: Sequence[str] = (),
30
+ config: Config | None = None,
31
+ failures: Sequence[Failure] = (),
32
+ ) -> Report:
33
+ """Compare every pair of ``texts`` (name -> text) and return a :class:`Report`.
34
+
35
+ ``prompts`` are the assignment texts every submission may quote; matching parts are
36
+ masked and never count. No network is used.
37
+ """
38
+ cfg = config or Config()
39
+ cfg.validate()
40
+ t0 = time.perf_counter()
41
+ names = sorted(texts)
42
+ norms = [normalize(texts[n]) for n in names]
43
+ prompt_norms = [normalize(p).text for p in prompts]
44
+ prompt_norms = [p for p in prompt_norms if len(p) >= cfg.k]
45
+
46
+ hash_lists = [kgram_hashes(n.text, cfg.k) for n in norms]
47
+ fps = [winnow(h, cfg.w) for h in hash_lists]
48
+ prompt_fps = [winnow(kgram_hashes(p, cfg.k), cfg.w) for p in prompt_norms]
49
+ df = document_frequency(hash_lists)
50
+ del hash_lists
51
+
52
+ docs: list[Doc] = []
53
+ for name, norm, doc_fps in zip(names, norms, fps):
54
+ mask = prompt_mask(norm.text, doc_fps, prompt_norms, prompt_fps, cfg.k)
55
+ sent_spans = [
56
+ from_display_span(norm.offsets, s, e) for s, e in split_sentences(norm.display, cfg.min_sentence)
57
+ ]
58
+ docs.append(Doc(name, norm, mask, sent_spans))
59
+
60
+ # Layer 1: verbatim matches only for pairs that share a (non-common) fingerprint.
61
+ hits = candidate_hits(fps, df, cfg)
62
+ verbatim: dict[tuple[int, int], tuple[list[Span], list[Span]]] = {}
63
+ for (i, j), pair_hits in hits.items():
64
+ sa, sb = pair_spans(norms[i].text, norms[j].text, pair_hits, docs[i].mask, docs[j].mask, cfg)
65
+ if sa:
66
+ verbatim[(i, j)] = (sa, sb)
67
+
68
+ # Layer 2: sentence Dice over all documents with the rare-bigram prefix filter.
69
+ sents: list[Sentence] = []
70
+ for di, doc in enumerate(docs):
71
+ for span in doc.sentences:
72
+ if _overlap_share(span, doc.mask) >= 0.5: # mostly prompt text
73
+ continue
74
+ grams = bigram_set(doc.norm.text[span[0] : span[1]])
75
+ if grams:
76
+ sents.append(Sentence(di, span, grams))
77
+ sent_pairs, compared = matches_prefix(sents, cfg.dice)
78
+ para_by_pair: dict[tuple[int, int], list[tuple[float, Span, Span]]] = defaultdict(list)
79
+ for a, b in sent_pairs:
80
+ sa, sb = sents[a], sents[b]
81
+ if sa.doc > sb.doc:
82
+ sa, sb = sb, sa
83
+ d = 2 * len(sa.bigrams & sb.bigrams) / (len(sa.bigrams) + len(sb.bigrams))
84
+ para_by_pair[(sa.doc, sb.doc)].append((d, sa.span, sb.span))
85
+
86
+ results: dict[tuple[int, int], PairResult] = {}
87
+ for (i, j), cand in para_by_pair.items():
88
+ vl, vr = verbatim.get((i, j), ([], []))
89
+ matches = _one_to_one(cand, vl, vr, cfg.covered_skip)
90
+ results[(i, j)] = finalize(docs[i], docs[j], vl, vr, matches)
91
+ for (i, j), (vl, vr) in verbatim.items():
92
+ if (i, j) not in results:
93
+ results[(i, j)] = finalize(docs[i], docs[j], vl, vr, [])
94
+ pairs = [results.get(p) or PairResult(docs[p[0]].name, docs[p[1]].name) for p in combinations(range(len(docs)), 2)]
95
+ pairs.sort(key=lambda p: (-p.rank_key, -(p.score_lr + p.score_rl), p.left, p.right))
96
+
97
+ n = len(docs)
98
+ stats = {
99
+ "documents": n,
100
+ "all_pairs": n * (n - 1) // 2,
101
+ "candidate_pairs": len(results), # pairs that reached either layer (= rows with a score)
102
+ "verbatim_pairs": len(verbatim),
103
+ "sentences": len(sents),
104
+ "sentence_candidates_compared": compared,
105
+ "elapsed_seconds": round(time.perf_counter() - t0, 3),
106
+ }
107
+ return Report({d.name: d for d in docs}, pairs, list(failures), cfg, stats, list(prompts))
108
+
109
+
110
+ def _one_to_one(
111
+ cand: list[tuple[float, Span, Span]], vl: list[Span], vr: list[Span], skip: float
112
+ ) -> list[ParaMatch]:
113
+ """Greedy one-to-one sentence matching, best Dice first.
114
+
115
+ Pairs whose sentence is already mostly explained by a verbatim match are skipped:
116
+ that text is reported by layer 1.
117
+ """
118
+ ml, mr = merge_spans(vl), merge_spans(vr)
119
+ used_l: set[Span] = set()
120
+ used_r: set[Span] = set()
121
+ out: list[ParaMatch] = []
122
+ for d, sl, sr in sorted(cand, key=lambda c: (-c[0], c[1], c[2])):
123
+ if sl in used_l or sr in used_r:
124
+ continue
125
+ if _overlap_share(sl, ml) >= skip or _overlap_share(sr, mr) >= skip:
126
+ continue
127
+ used_l.add(sl)
128
+ used_r.add(sr)
129
+ out.append(ParaMatch(sl, sr, round(d, 4)))
130
+ out.sort(key=lambda m: m.left)
131
+ return out
@@ -0,0 +1,5 @@
1
+ """Safe text extraction for txt, docx, hwpx and pdf submissions."""
2
+ from .formats import SUPPORTED, ExtractError, Limits, extract_file
3
+ from .sandbox import ExtractResult, SandboxedExtractor
4
+
5
+ __all__ = ["SUPPORTED", "ExtractError", "Limits", "extract_file", "ExtractResult", "SandboxedExtractor"]
@@ -0,0 +1,175 @@
1
+ """Format readers: txt, docx, hwpx, pdf. Run these inside the sandboxed worker."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import zipfile
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from xml.etree import ElementTree as ET
9
+
10
+ SUPPORTED = (".txt", ".docx", ".hwpx", ".pdf")
11
+
12
+
13
+ class ExtractError(Exception):
14
+ """A file cannot be read. ``code`` is a short machine-readable reason."""
15
+
16
+ def __init__(self, code: str, message: str = "") -> None:
17
+ super().__init__(message or code)
18
+ self.code = code
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Limits:
23
+ """Resource limits applied to every input file."""
24
+
25
+ max_file_bytes: int = 50 * 1024 * 1024
26
+ max_unzipped_bytes: int = 200 * 1024 * 1024
27
+ max_zip_entries: int = 5000
28
+
29
+
30
+ def extract_file(path: str, limits: Limits | None = None) -> str:
31
+ """Return the plain text of ``path``; raise :class:`ExtractError` on failure."""
32
+ limits = limits or Limits()
33
+ p = Path(path)
34
+ ext = p.suffix.lower()
35
+ if ext not in SUPPORTED:
36
+ hint = " (binary .hwp is not supported; save as .hwpx or .pdf)" if ext == ".hwp" else ""
37
+ raise ExtractError("unsupported", f"unsupported file type {ext or '(none)'}{hint}")
38
+ size = p.stat().st_size
39
+ if size > limits.max_file_bytes:
40
+ raise ExtractError("too_large", f"{size} bytes exceeds the {limits.max_file_bytes} byte limit")
41
+ if ext == ".txt":
42
+ return read_txt(p)
43
+ if ext == ".pdf":
44
+ return read_pdf(p)
45
+ check_zip_budget(p, limits)
46
+ return read_docx(p) if ext == ".docx" else read_hwpx(p)
47
+
48
+
49
+ def read_txt(path: Path) -> str:
50
+ """Decode a text file: UTF-16 with BOM, UTF-8 (with or without BOM), then CP949."""
51
+ raw = path.read_bytes()
52
+ if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
53
+ return raw.decode("utf-16")
54
+ for enc in ("utf-8-sig", "cp949"):
55
+ try:
56
+ return raw.decode(enc)
57
+ except UnicodeDecodeError:
58
+ continue
59
+ return raw.decode("utf-8", errors="replace")
60
+
61
+
62
+ def check_zip_budget(path: Path, limits: Limits) -> None:
63
+ """Refuse zip containers that expand beyond the limits.
64
+
65
+ Declared sizes in a zip header can lie, so every member is really decompressed in
66
+ small chunks and the *actual* byte count is checked.
67
+ """
68
+ try:
69
+ with zipfile.ZipFile(path) as zf:
70
+ infos = zf.infolist()
71
+ if len(infos) > limits.max_zip_entries:
72
+ raise ExtractError("too_large", f"{len(infos)} zip entries exceed {limits.max_zip_entries}")
73
+ total = 0
74
+ for info in infos:
75
+ with zf.open(info) as fh:
76
+ while chunk := fh.read(1 << 20):
77
+ total += len(chunk)
78
+ if total > limits.max_unzipped_bytes:
79
+ raise ExtractError(
80
+ "too_large", f"unzipped size exceeds {limits.max_unzipped_bytes} bytes"
81
+ )
82
+ except zipfile.BadZipFile as exc:
83
+ raise ExtractError("corrupt", f"not a valid zip container: {exc}") from exc
84
+
85
+
86
+ def read_docx(path: Path) -> str:
87
+ """Paragraphs and table cells of a .docx, in document order."""
88
+ from docx import Document # imported lazily: only the worker needs it
89
+
90
+ try:
91
+ doc = Document(str(path))
92
+ except Exception as exc: # python-docx raises several error types
93
+ raise ExtractError("corrupt", f"cannot open docx: {exc}") from exc
94
+ lines = [p.text for p in doc.paragraphs]
95
+ for table in doc.tables:
96
+ for row in table.rows:
97
+ lines.extend(cell.text for cell in row.cells)
98
+ return "\n".join(lines)
99
+
100
+
101
+ def _local(tag: str) -> str:
102
+ return tag.rsplit("}", 1)[-1]
103
+
104
+
105
+ def _hwpx_section_order(zf: zipfile.ZipFile) -> list[str]:
106
+ """Section part names in reading order: content.hpf spine, else numeric order."""
107
+ members = set(zf.namelist())
108
+ names = [n for n in members if re.search(r"(^|/)section\d+\.xml$", n)]
109
+ try:
110
+ root = ET.fromstring(zf.read("Contents/content.hpf"))
111
+ except (KeyError, ET.ParseError):
112
+ root = None
113
+ if root is not None:
114
+ manifest = {el.get("id"): el.get("href") for el in root.iter() if _local(el.tag) == "item"}
115
+ spine = [el.get("idref") for el in root.iter() if _local(el.tag) == "itemref"]
116
+ ordered = []
117
+ for idref in spine:
118
+ href = manifest.get(idref)
119
+ if not href:
120
+ continue
121
+ found = next((c for c in (href, f"Contents/{href}") if c in members), None)
122
+ if found and re.search(r"section\d+\.xml$", found):
123
+ ordered.append(found)
124
+ if ordered:
125
+ return ordered
126
+ return sorted(names, key=lambda n: int(re.search(r"section(\d+)\.xml$", n).group(1))) # type: ignore[union-attr]
127
+
128
+
129
+ def _collect_text(el: ET.Element, out: list[str]) -> None:
130
+ """Depth-first text of an OWPML element: <t> text, line breaks, paragraph ends."""
131
+ name = _local(el.tag)
132
+ if name == "t":
133
+ out.append(el.text or "")
134
+ for child in el:
135
+ if _local(child.tag) == "lineBreak":
136
+ out.append("\n")
137
+ elif _local(child.tag) == "tab":
138
+ out.append("\t")
139
+ out.append(child.tail or "")
140
+ return
141
+ for child in el:
142
+ _collect_text(child, out)
143
+ if name == "p":
144
+ out.append("\n")
145
+
146
+
147
+ def read_hwpx(path: Path) -> str:
148
+ """Body text of a .hwpx (OWPML zip), sections in spine order."""
149
+ try:
150
+ with zipfile.ZipFile(path) as zf:
151
+ sections = _hwpx_section_order(zf)
152
+ if not sections:
153
+ raise ExtractError("corrupt", "no Contents/section*.xml found; is this a real .hwpx?")
154
+ out: list[str] = []
155
+ for name in sections:
156
+ _collect_text(ET.fromstring(zf.read(name)), out)
157
+ except zipfile.BadZipFile as exc:
158
+ raise ExtractError("corrupt", f"not a valid hwpx (zip) file: {exc}") from exc
159
+ except ET.ParseError as exc:
160
+ raise ExtractError("corrupt", f"broken XML in hwpx: {exc}") from exc
161
+ return "".join(out)
162
+
163
+
164
+ def read_pdf(path: Path) -> str:
165
+ """Text layer of a PDF. Scanned PDFs have none; that is reported as an error."""
166
+ from pypdf import PdfReader # imported lazily: only the worker needs it
167
+
168
+ try:
169
+ reader = PdfReader(str(path))
170
+ text = "\n".join((page.extract_text() or "") for page in reader.pages)
171
+ except Exception as exc:
172
+ raise ExtractError("corrupt", f"cannot read pdf: {exc}") from exc
173
+ if len(text.strip()) < 5:
174
+ raise ExtractError("no_text", "no text layer found (scanned PDF? OCR is not supported)")
175
+ return text