bindery-cli 0.27.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.
bindery/validate.py ADDED
@@ -0,0 +1,254 @@
1
+ """epubcheck wrapper and the acceptance gate.
2
+
3
+ epubcheck is the external oracle. A repair is only worth keeping if it strictly
4
+ reduces problems and never introduces new ones. If epubcheck is not installed, the
5
+ gate degrades safely: validation is skipped and callers must decide whether to trust
6
+ the repair without it (the CLI requires --no-validate to do so).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ _SUMMARY_RE = re.compile(
20
+ r"Messages:\s*(\d+)\s+fatals?\s*/\s*(\d+)\s+errors?\s*/\s*(\d+)\s+warnings?"
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CheckResult:
26
+ fatals: int
27
+ errors: int
28
+ warnings: int
29
+
30
+ def __str__(self) -> str:
31
+ return f"{self.fatals}f/{self.errors}e/{self.warnings}w"
32
+
33
+
34
+ def epubcheck_available() -> bool:
35
+ return shutil.which("epubcheck") is not None
36
+
37
+
38
+ def _english_locale_env() -> dict[str, str]:
39
+ """The subprocess env with the JVM pinned to English (roadmap 5.2).
40
+
41
+ epubcheck localizes its human-readable summary, which broke the regex
42
+ fallback on non-English locales. Appended rather than assigned so a
43
+ user's existing JAVA_TOOL_OPTIONS (heap flags etc.) survive.
44
+ """
45
+ env = dict(os.environ)
46
+ opts = env.get("JAVA_TOOL_OPTIONS", "")
47
+ env["JAVA_TOOL_OPTIONS"] = f"{opts} -Duser.language=en -Duser.country=US".strip()
48
+ return env
49
+
50
+
51
+ def _counts_from_json(stdout: str) -> CheckResult | None:
52
+ """Parse counts from epubcheck's locale-independent `--json -` output.
53
+
54
+ epubcheck 5.x puts the totals under "checker" (nFatal/nError/nWarning,
55
+ verified against 5.3.0). Anything unexpected returns None so the caller
56
+ can fall back to the summary-line regex.
57
+ """
58
+ try:
59
+ data = json.loads(stdout)
60
+ except json.JSONDecodeError:
61
+ return None
62
+ checker = data.get("checker") if isinstance(data, dict) else None
63
+ if not isinstance(checker, dict):
64
+ return None
65
+ try:
66
+ return CheckResult(
67
+ int(checker["nFatal"]), int(checker["nError"]), int(checker["nWarning"])
68
+ )
69
+ except KeyError, TypeError, ValueError:
70
+ return None
71
+
72
+
73
+ _DAEMON_JAVA = """
74
+ import java.io.File;
75
+ import java.io.PrintWriter;
76
+ import java.util.Scanner;
77
+ import com.adobe.epubcheck.api.EpubCheck;
78
+ import com.adobe.epubcheck.reporting.CheckingReport;
79
+
80
+ public class FastDaemon {
81
+ public static void main(String[] args) throws Exception {
82
+ Scanner scanner = new Scanner(System.in);
83
+ while (scanner.hasNextLine()) {
84
+ String path = scanner.nextLine();
85
+ if (path.trim().isEmpty()) continue;
86
+ File epub = new File(path);
87
+ if (!epub.exists()) {
88
+ System.out.println("-1,-1,-1");
89
+ continue;
90
+ }
91
+ try {
92
+ PrintWriter out = new PrintWriter(new java.io.OutputStream() {
93
+ public void write(int b) {}
94
+ });
95
+ CheckingReport report = new CheckingReport(out, epub.getName());
96
+ EpubCheck check = new EpubCheck(epub, report);
97
+ check.doValidate();
98
+ System.out.println(report.getFatalErrorCount() + "," + report.getErrorCount() + "," + report.getWarningCount());
99
+ } catch (Exception e) {
100
+ System.out.println("-1,-1,-1");
101
+ }
102
+ }
103
+ }
104
+ }
105
+ """
106
+
107
+
108
+ class _EpubcheckDaemon:
109
+ def __init__(self):
110
+ self._proc = None
111
+ self._lock = __import__("threading").Lock()
112
+
113
+ def _start(self):
114
+ epubcheck_bin = shutil.which("epubcheck")
115
+ if not epubcheck_bin:
116
+ return False
117
+
118
+ try:
119
+ with open(epubcheck_bin) as f:
120
+ script = f.read()
121
+ m = re.search(
122
+ r"java\s+(?:-[^ ]+\s+)*-jar\s+[\"'\\]*([^\s\"'\\]+\.jar)", script
123
+ )
124
+ if not m:
125
+ return False
126
+ jar_path = os.path.expandvars(m.group(1))
127
+ if not os.path.exists(jar_path):
128
+ return False
129
+
130
+ self.workdir = __import__("tempfile").mkdtemp(prefix="bindery-daemon-")
131
+ java_file = os.path.join(self.workdir, "FastDaemon.java")
132
+ with open(java_file, "w") as f:
133
+ f.write(_DAEMON_JAVA)
134
+
135
+ subprocess.run(
136
+ ["javac", "-cp", jar_path, java_file],
137
+ check=True,
138
+ capture_output=True,
139
+ )
140
+
141
+ self._proc = subprocess.Popen(
142
+ ["java", "-cp", f".:{jar_path}", "FastDaemon"],
143
+ cwd=self.workdir,
144
+ stdin=subprocess.PIPE,
145
+ stdout=subprocess.PIPE,
146
+ stderr=subprocess.DEVNULL,
147
+ text=True,
148
+ bufsize=1,
149
+ )
150
+ __import__("atexit").register(self.stop)
151
+ return True
152
+ except Exception:
153
+ return False
154
+
155
+ def check(self, path: Path) -> CheckResult | None:
156
+ with self._lock:
157
+ if self._proc is None:
158
+ if not self._start():
159
+ return None
160
+ try:
161
+ self._proc.stdin.write(str(path.resolve()) + "\n")
162
+ self._proc.stdin.flush()
163
+ res = self._proc.stdout.readline().strip()
164
+ if not res or res == "-1,-1,-1":
165
+ return None
166
+ f, e, w = map(int, res.split(","))
167
+ return CheckResult(f, e, w)
168
+ except Exception:
169
+ return None
170
+
171
+ def stop(self):
172
+ if self._proc:
173
+ if self._proc.stdin:
174
+ try:
175
+ self._proc.stdin.close()
176
+ except OSError:
177
+ pass
178
+ self._proc.terminate()
179
+ self._proc.wait(timeout=2)
180
+ self._proc = None
181
+ if hasattr(self, "workdir") and os.path.exists(self.workdir):
182
+ shutil.rmtree(self.workdir, ignore_errors=True)
183
+
184
+
185
+ _daemon = _EpubcheckDaemon()
186
+
187
+
188
+ def run_epubcheck(path: Path, timeout: int = 300) -> CheckResult | None:
189
+ """Run epubcheck and return parsed counts, or None if it could not be parsed.
190
+
191
+ Counts come from `--json -` (locale-independent) first; the English summary-line
192
+ regex stays as the fallback for epubchecks too old for `--json`, kept meaningful
193
+ by the JVM locale pin in the env. The persistent daemon answers first when it is
194
+ available; None anywhere means the caller falls back to (or reports) failure.
195
+ """
196
+ res = _daemon.check(path)
197
+ if res is not None:
198
+ return res
199
+ try:
200
+ out = subprocess.run(
201
+ ["epubcheck", str(path), "--json", "-"],
202
+ capture_output=True,
203
+ text=True,
204
+ timeout=timeout,
205
+ env=_english_locale_env(),
206
+ check=False,
207
+ )
208
+ except FileNotFoundError, subprocess.TimeoutExpired:
209
+ return None
210
+ result = _counts_from_json(out.stdout)
211
+ if result is not None:
212
+ return result
213
+ m = _SUMMARY_RE.search(out.stdout + out.stderr)
214
+ if not m:
215
+ return None
216
+ return CheckResult(int(m.group(1)), int(m.group(2)), int(m.group(3)))
217
+
218
+
219
+ def gate(before: CheckResult, after: CheckResult) -> str:
220
+ """Classify a repair: 'accept', 'partial', 'reject', or 'noop'.
221
+
222
+ The metric depends on whether the book started with fatals, because a fatal parse
223
+ error halts epubcheck on that file and hides every downstream schema error. So:
224
+
225
+ - Started WITH fatals: success is fewer fatals. A rising error count is just those
226
+ latent errors becoming visible once the file parses (the book now opens), not a
227
+ regression. 'accept' if all fatals cleared, 'partial' if merely reduced.
228
+ - Started with NO fatals (pure error/NCX-001 cleanup): nothing was masking errors,
229
+ so an error increase is a real regression. Require a strict error decrease.
230
+
231
+ Introducing net-new fatals is always a 'reject'.
232
+ """
233
+ if after.fatals > before.fatals:
234
+ return "reject"
235
+ if before.fatals > 0:
236
+ if after.fatals == 0:
237
+ return "accept"
238
+ return "partial" if after.fatals < before.fatals else "noop"
239
+ if after.errors > before.errors:
240
+ return "reject"
241
+ return "accept" if after.errors < before.errors else "noop"
242
+
243
+
244
+ def no_worse(before: CheckResult, after: CheckResult) -> bool:
245
+ """The acceptance bar for a lossy content repair (page-number stripping), whose
246
+ benefit epubcheck cannot see. Unlike `gate`, it does not demand a measured
247
+ improvement; it only forbids a regression: no net-new fatals, and no new errors
248
+ unless fatals were already masking them. Mirrors oceanstrip's 'no more fatals or
249
+ errors than the original' bar."""
250
+ if after.fatals > before.fatals:
251
+ return False
252
+ if before.fatals == 0 and after.errors > before.errors:
253
+ return False
254
+ return True
bindery/watermark.py ADDED
@@ -0,0 +1,244 @@
1
+ """Locate and remove producer/conversion watermarks from (X)HTML documents.
2
+
3
+ A watermark is a run of markup whose visible text is a known producer stamp.
4
+ Producers inject it two ways, and oceanstrip handles both with the same
5
+ balanced-element removal so a well-formed document stays well-formed:
6
+
7
+ - **Anchored** (a link): an <a> whose start tag (href) carries a known signature.
8
+ OceanofPDF appends `<a href="...oceanofpdf.com">OceanofPDF.com</a>`; ABC Amber
9
+ wraps `<b>Generated by ABC Amber LIT Conv<a href="...processtext...">erter,
10
+ ...</a></b>` (the word "Converter" straddles the tag boundary). We find the <a>,
11
+ walk up to the OUTERMOST enclosing wrapper whose entire visible text is the
12
+ watermark, and delete that whole balanced element. If no such clean wrapper
13
+ exists (the link sits inline in real prose), only the <a> is removed.
14
+
15
+ - **Anchorless** (plain text): the stamp is literal text with the URL not linked,
16
+ e.g. `<p>ABC Amber LIT Converter http://www.processtext.com/abclit.html</p>` or a
17
+ `<div type="FOOTER"><p><span>...</span></p></div>`. There is no <a> to anchor on,
18
+ so we locate the stamp by a text signature and walk up to the outermost wrapper
19
+ whose entire visible text is the watermark (allowing the stamp to repeat), then
20
+ delete it. Real prose that merely mentions the URL is safe: its wrapper's text is
21
+ not *only* the watermark, so nothing is removed.
22
+
23
+ A flat regex cannot do either safely: it closes on the wrong tag and corrupts the
24
+ XML, or orphans the half of a phrase that sits outside the <a>. New producers are
25
+ one entry in WATERMARKS; the passes below are otherwise signature-agnostic.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass, field
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Watermark:
36
+ """A known producer watermark.
37
+
38
+ - ``href_sig``: lowercase substring expected in an anchored watermark's <a>
39
+ start tag (its href), or None if this producer never links the stamp.
40
+ - ``text_sig``: a lowercase substring of the stamp's *visible text* used to
41
+ locate an anchorless occurrence cheaply (e.g. "processtext.com/abclit").
42
+ - ``phrase``: a regex (case-insensitive) matching one occurrence of the stamp's
43
+ full visible text. A wrapper is removed only when its entire normalized text
44
+ is one or more repetitions of this phrase, which is what keeps real prose that
45
+ merely sits near the stamp untouched.
46
+
47
+ ``anchor`` and ``pure`` are the compiled patterns, built in __post_init__.
48
+ """
49
+
50
+ name: str
51
+ href_sig: str | None
52
+ text_sig: str
53
+ phrase: str
54
+ anchor: re.Pattern[str] | None = field(default=None, compare=False)
55
+ pure: re.Pattern[str] = field(default=None, compare=False) # type: ignore[assignment]
56
+
57
+ def __post_init__(self) -> None:
58
+ anchor = (
59
+ re.compile(
60
+ rf"<a\b[^>]*?{re.escape(self.href_sig)}[^>]*?>.*?</a>",
61
+ re.IGNORECASE | re.DOTALL,
62
+ )
63
+ if self.href_sig
64
+ else None
65
+ )
66
+ object.__setattr__(self, "anchor", anchor)
67
+ object.__setattr__(
68
+ self, "pure", re.compile(rf"(?:{self.phrase}\s*)+", re.IGNORECASE)
69
+ )
70
+
71
+
72
+ # The known watermarks. Append here to support a new producer; nothing else changes.
73
+ WATERMARKS: tuple[Watermark, ...] = (
74
+ Watermark(
75
+ name="OceanofPDF",
76
+ href_sig="oceanofpdf",
77
+ text_sig="oceanofpdf.com",
78
+ phrase=r"oceanofpdf\.com",
79
+ ),
80
+ Watermark(
81
+ name="ABC Amber LIT Converter",
82
+ href_sig="processtext",
83
+ text_sig="processtext.com/abclit",
84
+ phrase=r"(?:generated by )?abc amber lit converter,?\s*"
85
+ r"https?://(?:www\.)?processtext\.com/abclit\.html",
86
+ ),
87
+ )
88
+
89
+ # Any tag (open / close / self-closing), used for the balanced-element walk.
90
+ TAG = re.compile(r"<(/?)([a-zA-Z][\w:-]*)\b[^>]*?(/?)>", re.DOTALL)
91
+
92
+ # HTML void elements never have a closing tag; ignore them in balance tracking.
93
+ VOID = {
94
+ "area",
95
+ "base",
96
+ "br",
97
+ "col",
98
+ "embed",
99
+ "hr",
100
+ "img",
101
+ "input",
102
+ "link",
103
+ "meta",
104
+ "param",
105
+ "source",
106
+ "track",
107
+ "wbr",
108
+ }
109
+
110
+ # Removable watermark wrappers. Never body/html/section, so a page whose sole *text*
111
+ # is the watermark (e.g. an image-only title page) is not deleted wholesale. The
112
+ # inline elements (b/i/em/strong) are here because ABC Amber wraps its stamp in a
113
+ # <b>; the "visible text is only the watermark" guard below keeps the wider set
114
+ # safe: a real heading/paragraph/bold phrase won't have text that is a watermark.
115
+ WRAPPERS = {
116
+ "div",
117
+ "p",
118
+ "span",
119
+ "h1",
120
+ "h2",
121
+ "h3",
122
+ "h4",
123
+ "h5",
124
+ "h6",
125
+ "b",
126
+ "i",
127
+ "em",
128
+ "strong",
129
+ }
130
+
131
+ # If a candidate wrapper contains any of these it holds real content. don't remove.
132
+ MEDIA = re.compile(
133
+ r"<(?:img|image|svg|video|audio|picture|iframe|table|object)\b", re.IGNORECASE
134
+ )
135
+
136
+
137
+ def _norm(element: str) -> str:
138
+ """Visible text of an element fragment: tags stripped, nbsp and whitespace runs
139
+ collapsed to single spaces, stripped, lowercased."""
140
+ text = re.sub(r"<[^>]+>", "", element).replace("\xa0", " ")
141
+ return re.sub(r"\s+", " ", text).strip().lower()
142
+
143
+
144
+ def _matching_close(html: str, name: str, open_end: int) -> tuple[int, int] | None:
145
+ """Span of the close tag matching a `name` element already opened just before
146
+ `open_end` (which must point past that opening tag's '>')."""
147
+ depth = 1
148
+ pat = re.compile(
149
+ rf"<(/?)({re.escape(name)})\b[^>]*?(/?)>", re.IGNORECASE | re.DOTALL
150
+ )
151
+ for m in pat.finditer(html, open_end):
152
+ closing, _, selfclose = m.group(1), m.group(2), m.group(3)
153
+ if selfclose:
154
+ continue
155
+ if closing:
156
+ depth -= 1
157
+ if depth == 0:
158
+ return m.start(), m.end()
159
+ else:
160
+ depth += 1
161
+ return None
162
+
163
+
164
+ def _ancestors(html: str, a_start: int, a_end: int):
165
+ """Yield (open_start, close_end) for wrapper elements enclosing [a_start, a_end],
166
+ outermost first."""
167
+ stack: list[tuple[str, int, int]] = []
168
+ for m in TAG.finditer(html, 0, a_start):
169
+ closing, name, selfclose = m.group(1), m.group(2).lower(), m.group(3)
170
+ if name in VOID or selfclose:
171
+ continue
172
+ if closing:
173
+ for i in range(len(stack) - 1, -1, -1):
174
+ if stack[i][0] == name:
175
+ del stack[i:]
176
+ break
177
+ else:
178
+ stack.append((name, m.start(), m.end()))
179
+
180
+ for name, ostart, oend in stack:
181
+ if name not in WRAPPERS:
182
+ continue
183
+ close = _matching_close(html, name, oend)
184
+ if close and close[0] >= a_end:
185
+ yield ostart, close[1]
186
+
187
+
188
+ def _pure_wrapper_span(
189
+ html: str, start: int, end: int, wm: Watermark
190
+ ) -> tuple[int, int] | None:
191
+ """The outermost wrapper enclosing [start, end] whose entire visible text is the
192
+ watermark (one or more repetitions) and which holds no media; else None."""
193
+ for ostart, cend in _ancestors(html, start, end): # outermost first
194
+ element = html[ostart:cend]
195
+ if MEDIA.search(element):
196
+ continue
197
+ if wm.pure.fullmatch(_norm(element)):
198
+ return ostart, cend
199
+ return None
200
+
201
+
202
+ def _strip_anchored(html: str, wm: Watermark) -> tuple[str, int]:
203
+ """Remove anchored (linked) occurrences of `wm`. Deletes the outermost pure
204
+ wrapper around each watermark <a>, or the <a> itself when it sits inline."""
205
+ if wm.anchor is None:
206
+ return html, 0
207
+ count = 0
208
+ while (m := wm.anchor.search(html)) is not None:
209
+ span = _pure_wrapper_span(html, m.start(), m.end(), wm)
210
+ s, e = span if span else (m.start(), m.end())
211
+ html = html[:s] + html[e:]
212
+ count += 1
213
+ return html, count
214
+
215
+
216
+ def _strip_text(html: str, wm: Watermark) -> tuple[str, int]:
217
+ """Remove anchorless (plain-text) occurrences of `wm`. Finds the stamp by its
218
+ text signature and deletes the outermost wrapper whose whole text is the
219
+ watermark; an occurrence with no such wrapper (the URL sits in real prose) is
220
+ left untouched."""
221
+ count = 0
222
+ pos = 0
223
+ while (i := html.lower().find(wm.text_sig, pos)) != -1:
224
+ span = _pure_wrapper_span(html, i, i + len(wm.text_sig), wm)
225
+ if span is None:
226
+ pos = i + len(wm.text_sig) # not a pure watermark wrapper; skip it
227
+ continue
228
+ html = html[: span[0]] + html[span[1] :]
229
+ count += 1
230
+ pos = 0
231
+ return html, count
232
+
233
+
234
+ def strip_watermark_html(html: str) -> tuple[str, int]:
235
+ """Remove every known watermark (anchored and anchorless) from one document.
236
+
237
+ Returns (cleaned_html, number_of_watermarks_removed).
238
+ """
239
+ total = 0
240
+ for wm in WATERMARKS:
241
+ html, n_anchor = _strip_anchored(html, wm)
242
+ html, n_text = _strip_text(html, wm)
243
+ total += n_anchor + n_text
244
+ return html, total