pdfpixel 0.3.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.
- pdfpixel/__init__.py +3 -0
- pdfpixel/__main__.py +3 -0
- pdfpixel/cli.py +194 -0
- pdfpixel/core.py +174 -0
- pdfpixel/dialog.py +85 -0
- pdfpixel/notify.py +30 -0
- pdfpixel/pdfops.py +230 -0
- pdfpixel-0.3.1.dist-info/METADATA +166 -0
- pdfpixel-0.3.1.dist-info/RECORD +13 -0
- pdfpixel-0.3.1.dist-info/WHEEL +5 -0
- pdfpixel-0.3.1.dist-info/entry_points.txt +2 -0
- pdfpixel-0.3.1.dist-info/licenses/LICENSE +201 -0
- pdfpixel-0.3.1.dist-info/top_level.txt +1 -0
pdfpixel/__init__.py
ADDED
pdfpixel/__main__.py
ADDED
pdfpixel/cli.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""pdfpixel CLI — the single entry point every OS's context-menu shim invokes.
|
|
2
|
+
|
|
3
|
+
pdfpixel FILE [FILE...] convert all pages (default verb)
|
|
4
|
+
pdfpixel [--pages SPEC] [--ask] \
|
|
5
|
+
[--format {png,jpg,webp,tiff}] [--dpi N] FILE [...]
|
|
6
|
+
pdfpixel merge FILE.pdf FILE2.pdf... concatenate into one PDF
|
|
7
|
+
pdfpixel split FILE.pdf one single-page PDF per page
|
|
8
|
+
pdfpixel compress [--quality low|medium|high] FILE.pdf shrink images + qpdf
|
|
9
|
+
pdfpixel --version
|
|
10
|
+
|
|
11
|
+
The default verb is ``convert``: unless the first token is one of
|
|
12
|
+
{merge, split, compress}, the whole argv is routed to the convert path, so
|
|
13
|
+
every historical invocation (bare file, ``--pages``, ``--ask``) still works.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pdfpixel import __version__, core, pdfops
|
|
22
|
+
from pdfpixel.dialog import AskResult
|
|
23
|
+
from pdfpixel.notify import notify as _notify
|
|
24
|
+
|
|
25
|
+
_OPS = ("merge", "split", "compress")
|
|
26
|
+
_FORMATS = ("png", "jpg", "webp", "tiff")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_ask(path) -> AskResult:
|
|
30
|
+
from pdfpixel.dialog import ask_pages
|
|
31
|
+
return ask_pages(path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- convert (default verb) ----------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _build_convert_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="pdfpixel")
|
|
38
|
+
parser.add_argument("--pages", help="page range, e.g. 5-8 or 1,3-5,9")
|
|
39
|
+
parser.add_argument("--ask", action="store_true",
|
|
40
|
+
help="prompt for a page range (single file)")
|
|
41
|
+
parser.add_argument("--format", choices=_FORMATS, default="png",
|
|
42
|
+
help="output image format (default: png)")
|
|
43
|
+
parser.add_argument("--dpi", type=int, default=core.DEFAULT_DPI,
|
|
44
|
+
help=f"render resolution (default: {core.DEFAULT_DPI})")
|
|
45
|
+
parser.add_argument("files", nargs="*")
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_ask(result, fmt: str, dpi: int):
|
|
50
|
+
"""Coerce whatever ``ask`` returned into ``(spec, fmt, dpi)``.
|
|
51
|
+
|
|
52
|
+
Accepts an ``AskResult`` (whose fmt/dpi OVERRIDE the CLI ones) or a plain
|
|
53
|
+
string spec (which keeps the CLI's fmt/dpi). This keeps the injected-ask
|
|
54
|
+
seam working for both shapes.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(result, str):
|
|
57
|
+
return result, fmt, dpi
|
|
58
|
+
spec = getattr(result, "spec", "")
|
|
59
|
+
return spec, getattr(result, "fmt", fmt), getattr(result, "dpi", dpi)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _convert(argv, ask, notifier) -> int:
|
|
63
|
+
args = _build_convert_parser().parse_args(argv)
|
|
64
|
+
fmt, dpi = args.format, args.dpi
|
|
65
|
+
|
|
66
|
+
if args.ask:
|
|
67
|
+
if len(args.files) != 1:
|
|
68
|
+
print("usage: pdfpixel --ask FILE.pdf", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
spec, fmt, dpi = _normalize_ask(ask(args.files[0]), fmt, dpi)
|
|
71
|
+
if not spec.strip():
|
|
72
|
+
return 0 # cancelled / empty -> no-op
|
|
73
|
+
try:
|
|
74
|
+
segments = core.parse_pages(spec)
|
|
75
|
+
except ValueError:
|
|
76
|
+
notifier(f"Invalid page range: {spec}", "")
|
|
77
|
+
return 2
|
|
78
|
+
results = [core.convert_pdf(Path(args.files[0]), segments,
|
|
79
|
+
fmt=fmt, dpi=dpi)]
|
|
80
|
+
else:
|
|
81
|
+
if not args.files:
|
|
82
|
+
print("usage: pdfpixel [--pages RANGE] [--format FMT] [--dpi N] "
|
|
83
|
+
"FILE.pdf [FILE2.pdf ...]", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
segments = None
|
|
86
|
+
if args.pages is not None:
|
|
87
|
+
try:
|
|
88
|
+
segments = core.parse_pages(args.pages)
|
|
89
|
+
except ValueError:
|
|
90
|
+
notifier(f"Invalid page range: {args.pages}", "")
|
|
91
|
+
return 2
|
|
92
|
+
results = [core.convert_pdf(Path(p), segments, fmt=fmt, dpi=dpi)
|
|
93
|
+
for p in args.files]
|
|
94
|
+
|
|
95
|
+
summary, body = core.build_summary(results)
|
|
96
|
+
notifier(summary, body)
|
|
97
|
+
print(summary)
|
|
98
|
+
return 0 if all(r.ok for r in results) else 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- merge / split / compress --------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _merge(argv, notifier) -> int:
|
|
104
|
+
parser = argparse.ArgumentParser(prog="pdfpixel merge")
|
|
105
|
+
parser.add_argument("files", nargs="*")
|
|
106
|
+
args = parser.parse_args(argv)
|
|
107
|
+
if not args.files:
|
|
108
|
+
print("usage: pdfpixel merge FILE.pdf [FILE2.pdf ...]", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
r = pdfops.merge([Path(p) for p in args.files])
|
|
111
|
+
if not r.ok:
|
|
112
|
+
notifier(r.error, "")
|
|
113
|
+
return 1
|
|
114
|
+
summary = f"Merged {len(args.files)} PDFs"
|
|
115
|
+
notifier(summary, str(r.path))
|
|
116
|
+
print(summary)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _split(argv, notifier) -> int:
|
|
121
|
+
parser = argparse.ArgumentParser(prog="pdfpixel split")
|
|
122
|
+
parser.add_argument("file")
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
r = pdfops.split(Path(args.file))
|
|
125
|
+
if not r.ok:
|
|
126
|
+
notifier(r.error, "")
|
|
127
|
+
return 1
|
|
128
|
+
summary = f"Split into {r.pages} pages"
|
|
129
|
+
notifier(summary, "")
|
|
130
|
+
print(summary)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _hsize(n: int) -> str:
|
|
135
|
+
"""Human-readable byte size: 678 KB, 4.2 MB."""
|
|
136
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
137
|
+
if n < 1024 or unit == "GB":
|
|
138
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
139
|
+
n /= 1024.0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _compress(argv, notifier) -> int:
|
|
143
|
+
parser = argparse.ArgumentParser(prog="pdfpixel compress")
|
|
144
|
+
parser.add_argument("file")
|
|
145
|
+
parser.add_argument("--quality", choices=("low", "medium", "high"),
|
|
146
|
+
default="medium",
|
|
147
|
+
help="image compression level (default: medium)")
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
src = Path(args.file)
|
|
150
|
+
before = src.stat().st_size if src.exists() else 0
|
|
151
|
+
r = pdfops.compress(src, quality=args.quality)
|
|
152
|
+
if not r.ok:
|
|
153
|
+
notifier(r.error, "")
|
|
154
|
+
return 1
|
|
155
|
+
after = r.path.stat().st_size
|
|
156
|
+
if before and after < before:
|
|
157
|
+
summary = f"Compressed {_hsize(before)} → {_hsize(after)} (-{(1 - after / before) * 100:.0f}%)"
|
|
158
|
+
elif before:
|
|
159
|
+
summary = f"Compressed {_hsize(before)} → {_hsize(after)} (no size reduction)"
|
|
160
|
+
else:
|
|
161
|
+
summary = "Compressed"
|
|
162
|
+
notifier(summary, r.path.name)
|
|
163
|
+
print(summary)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- entry point ----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def main(argv, ask=None, notifier=None) -> int:
|
|
170
|
+
ask = ask or _default_ask
|
|
171
|
+
notifier = notifier or _notify
|
|
172
|
+
|
|
173
|
+
if argv and argv[0] == "--version":
|
|
174
|
+
print(f"pdfpixel {__version__}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if argv and argv[0] in _OPS:
|
|
178
|
+
verb, rest = argv[0], argv[1:]
|
|
179
|
+
if verb == "merge":
|
|
180
|
+
return _merge(rest, notifier)
|
|
181
|
+
if verb == "split":
|
|
182
|
+
return _split(rest, notifier)
|
|
183
|
+
return _compress(rest, notifier)
|
|
184
|
+
|
|
185
|
+
# default verb = convert: bare files, --pages/--ask/--format/--dpi, or empty
|
|
186
|
+
return _convert(argv, ask, notifier)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run(): # console-script / frozen entry point
|
|
190
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
run()
|
pdfpixel/core.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""pdfpixel core — portable PDF-to-PNG engine (pypdfium2 + Pillow).
|
|
2
|
+
|
|
3
|
+
Pure conversion/naming/range logic with no GUI or notification code, so it is
|
|
4
|
+
fully unit-testable and identical across Linux, Windows and macOS.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pypdfium2 as pdfium
|
|
14
|
+
|
|
15
|
+
DEFAULT_DPI = 200 # preserve current behaviour (pypdfium2 renders relative to 72 dpi)
|
|
16
|
+
|
|
17
|
+
# fmt -> output extension; "jpeg" is an alias of "jpg"
|
|
18
|
+
_FMT_EXT = {"png": "png", "jpg": "jpg", "jpeg": "jpg", "webp": "webp", "tiff": "tiff"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FileResult:
|
|
23
|
+
path: Path
|
|
24
|
+
ok: bool
|
|
25
|
+
pages: int = 0
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unique_output_dir(pdf_path: Path) -> Path:
|
|
30
|
+
"""Return a non-existing output dir beside the PDF, suffixed (n) on collision."""
|
|
31
|
+
pdf_path = Path(pdf_path)
|
|
32
|
+
candidate = pdf_path.parent / pdf_path.stem
|
|
33
|
+
n = 1
|
|
34
|
+
while candidate.exists():
|
|
35
|
+
candidate = pdf_path.parent / f"{pdf_path.stem} ({n})"
|
|
36
|
+
n += 1
|
|
37
|
+
return candidate
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- page-range parsing --------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _pos_int(s: str) -> int:
|
|
43
|
+
if not s.isdigit():
|
|
44
|
+
raise ValueError(f"not a positive integer: {s!r}")
|
|
45
|
+
v = int(s)
|
|
46
|
+
if v < 1:
|
|
47
|
+
raise ValueError(f"page numbers start at 1: {s!r}")
|
|
48
|
+
return v
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_segment(s: str):
|
|
52
|
+
"""Parse one contiguous segment into (first, last); None means open-ended."""
|
|
53
|
+
if "-" in s:
|
|
54
|
+
left, _, right = s.partition("-")
|
|
55
|
+
if "-" in right:
|
|
56
|
+
raise ValueError(f"too many dashes: {s!r}")
|
|
57
|
+
left, right = left.strip(), right.strip()
|
|
58
|
+
first = _pos_int(left) if left else None
|
|
59
|
+
last = _pos_int(right) if right else None
|
|
60
|
+
if first is None and last is None:
|
|
61
|
+
raise ValueError(f"empty range: {s!r}")
|
|
62
|
+
if first is not None and last is not None and first > last:
|
|
63
|
+
raise ValueError(f"reversed range: {s!r}")
|
|
64
|
+
return (first, last)
|
|
65
|
+
n = _pos_int(s)
|
|
66
|
+
return (n, n)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_pages(spec: str):
|
|
70
|
+
"""Parse a page spec into a list of (first, last) segments.
|
|
71
|
+
|
|
72
|
+
Comma-separated segments, each ``N`` -> (N, N), ``N-M`` -> (N, M),
|
|
73
|
+
``N-`` -> (N, None), ``-M`` -> (None, M). Pages are 1-based; None means
|
|
74
|
+
open-ended. Blank segments (stray commas) are ignored. Raises ValueError on
|
|
75
|
+
a malformed segment or if no segment remains.
|
|
76
|
+
"""
|
|
77
|
+
segments = []
|
|
78
|
+
for part in spec.split(","):
|
|
79
|
+
part = part.strip()
|
|
80
|
+
if not part:
|
|
81
|
+
continue
|
|
82
|
+
segments.append(_parse_segment(part))
|
|
83
|
+
if not segments:
|
|
84
|
+
raise ValueError(f"no pages: {spec!r}")
|
|
85
|
+
return segments
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _expand(segments, total: int):
|
|
89
|
+
"""Segments -> sorted, deduped list of page numbers clamped to [1, total].
|
|
90
|
+
|
|
91
|
+
Out-of-range pages are dropped (a mix like 1,50 on a 12-page doc yields [1])."""
|
|
92
|
+
pages = set()
|
|
93
|
+
for first, last in segments:
|
|
94
|
+
lo = 1 if first is None else first
|
|
95
|
+
hi = total if last is None else last
|
|
96
|
+
for n in range(lo, hi + 1):
|
|
97
|
+
if 1 <= n <= total:
|
|
98
|
+
pages.add(n)
|
|
99
|
+
return sorted(pages)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- conversion ----------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def page_count(pdf_path: Path):
|
|
105
|
+
"""Page count, or None if the PDF can't be read (encrypted/corrupt)."""
|
|
106
|
+
try:
|
|
107
|
+
doc = pdfium.PdfDocument(str(pdf_path))
|
|
108
|
+
except pdfium.PdfiumError:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return len(doc)
|
|
112
|
+
finally:
|
|
113
|
+
doc.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def convert_pdf(pdf_path: Path, segments=None, fmt: str = "png",
|
|
117
|
+
dpi: int = DEFAULT_DPI) -> FileResult:
|
|
118
|
+
"""Render pages of one PDF to images in a sibling folder.
|
|
119
|
+
|
|
120
|
+
``segments`` is None (all pages) or a list of (first, last) tuples
|
|
121
|
+
(None = open-ended). ``fmt`` is one of png/jpg/webp/tiff ("jpeg" aliases
|
|
122
|
+
"jpg"). ``dpi`` sets the render scale (dpi / 72). Images are named by
|
|
123
|
+
original page number, zero-padded to the document's page-count width, with
|
|
124
|
+
the format's extension. JPG has no alpha so the image is flattened to RGB.
|
|
125
|
+
"""
|
|
126
|
+
fmt = fmt.lower()
|
|
127
|
+
ext = _FMT_EXT.get(fmt)
|
|
128
|
+
if ext is None:
|
|
129
|
+
return FileResult(pdf_path, ok=False, error=f"unsupported format: {fmt}")
|
|
130
|
+
pdf_path = Path(pdf_path)
|
|
131
|
+
if not os.access(pdf_path.parent, os.W_OK):
|
|
132
|
+
return FileResult(pdf_path, ok=False, error="read-only directory")
|
|
133
|
+
try:
|
|
134
|
+
doc = pdfium.PdfDocument(str(pdf_path))
|
|
135
|
+
except pdfium.PdfiumError:
|
|
136
|
+
return FileResult(pdf_path, ok=False, error="encrypted or unreadable PDF")
|
|
137
|
+
try:
|
|
138
|
+
total = len(doc)
|
|
139
|
+
pages = list(range(1, total + 1)) if segments is None else _expand(segments, total)
|
|
140
|
+
if not pages: # nothing in range -> don't create an empty folder
|
|
141
|
+
return FileResult(pdf_path, ok=False, error="no pages produced")
|
|
142
|
+
out_dir = unique_output_dir(pdf_path)
|
|
143
|
+
try:
|
|
144
|
+
out_dir.mkdir()
|
|
145
|
+
except OSError as e:
|
|
146
|
+
return FileResult(pdf_path, ok=False, error=str(e))
|
|
147
|
+
width = len(str(total))
|
|
148
|
+
scale = dpi / 72.0
|
|
149
|
+
try:
|
|
150
|
+
for n in pages:
|
|
151
|
+
bitmap = doc[n - 1].render(scale=scale)
|
|
152
|
+
img = bitmap.to_pil()
|
|
153
|
+
if ext == "jpg": # JPG has no alpha channel
|
|
154
|
+
img = img.convert("RGB")
|
|
155
|
+
img.save(out_dir / f"{n:0{width}d}.{ext}")
|
|
156
|
+
except Exception as e: # render/save failure -> no half-written folder
|
|
157
|
+
shutil.rmtree(out_dir, ignore_errors=True)
|
|
158
|
+
return FileResult(pdf_path, ok=False, error=str(e))
|
|
159
|
+
return FileResult(pdf_path, ok=True, pages=len(pages))
|
|
160
|
+
finally:
|
|
161
|
+
doc.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_summary(results):
|
|
165
|
+
ok = [r for r in results if r.ok]
|
|
166
|
+
failed = [r for r in results if not r.ok]
|
|
167
|
+
total_pages = sum(r.pages for r in ok)
|
|
168
|
+
summary = f"Converted {len(ok)} PDF{'s' if len(ok) != 1 else ''}"
|
|
169
|
+
if failed:
|
|
170
|
+
summary += f" · {len(failed)} failed"
|
|
171
|
+
lines = [f"{total_pages} pages total"]
|
|
172
|
+
for r in failed:
|
|
173
|
+
lines.append(f"✗ {r.path.name}: {r.error}")
|
|
174
|
+
return summary, "\n".join(lines)
|
pdfpixel/dialog.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Cross-platform Custom… prompt via tkinter (bundled with the frozen
|
|
2
|
+
runtime). Returns an ``AskResult(spec, fmt, dpi)``; spec is "" if
|
|
3
|
+
cancelled/empty (the caller treats an empty spec as a no-op)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import namedtuple
|
|
7
|
+
|
|
8
|
+
from pdfpixel import core
|
|
9
|
+
|
|
10
|
+
AskResult = namedtuple("AskResult", "spec fmt dpi")
|
|
11
|
+
|
|
12
|
+
_PROMPT = "Pages to convert (e.g. 1 · 5-8 · -4 · 1,3-5,9 · 10-):"
|
|
13
|
+
|
|
14
|
+
# Format dropdown labels -> fmt string passed to core.convert_pdf
|
|
15
|
+
_FORMATS = [("PNG", "png"), ("JPG", "jpg"), ("WEBP", "webp"), ("TIFF", "tiff")]
|
|
16
|
+
|
|
17
|
+
# Resolution dropdown labels -> dpi int
|
|
18
|
+
_RESOLUTIONS = [
|
|
19
|
+
("Screen (96)", 96),
|
|
20
|
+
("Default (200)", 200),
|
|
21
|
+
("Print (300)", 300),
|
|
22
|
+
("High (600)", 600),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ask_pages(pdf_path) -> AskResult:
|
|
27
|
+
import tkinter as tk
|
|
28
|
+
from tkinter import ttk
|
|
29
|
+
|
|
30
|
+
n = core.page_count(pdf_path)
|
|
31
|
+
state = {"spec": "", "fmt": "png", "dpi": core.DEFAULT_DPI}
|
|
32
|
+
|
|
33
|
+
root = tk.Tk()
|
|
34
|
+
root.title("PDFPixel")
|
|
35
|
+
root.resizable(False, False)
|
|
36
|
+
|
|
37
|
+
frm = tk.Frame(root, padx=16, pady=12)
|
|
38
|
+
frm.pack()
|
|
39
|
+
if n:
|
|
40
|
+
tk.Label(frm, text=f"Document has {n} page{'' if n == 1 else 's'}").pack(
|
|
41
|
+
anchor="w")
|
|
42
|
+
|
|
43
|
+
fmt_labels = [label for label, _ in _FORMATS]
|
|
44
|
+
res_labels = [label for label, _ in _RESOLUTIONS]
|
|
45
|
+
fmt_var = tk.StringVar(value=fmt_labels[0]) # PNG
|
|
46
|
+
res_var = tk.StringVar(value=res_labels[1]) # Default (200)
|
|
47
|
+
|
|
48
|
+
tk.Label(frm, text="Format:").pack(anchor="w")
|
|
49
|
+
fmt_box = ttk.Combobox(frm, textvariable=fmt_var, values=fmt_labels,
|
|
50
|
+
state="readonly", width=36)
|
|
51
|
+
fmt_box.pack(fill="x", pady=(2, 8))
|
|
52
|
+
|
|
53
|
+
tk.Label(frm, text="Resolution:").pack(anchor="w")
|
|
54
|
+
res_box = ttk.Combobox(frm, textvariable=res_var, values=res_labels,
|
|
55
|
+
state="readonly", width=36)
|
|
56
|
+
res_box.pack(fill="x", pady=(2, 8))
|
|
57
|
+
|
|
58
|
+
tk.Label(frm, text=_PROMPT).pack(anchor="w")
|
|
59
|
+
|
|
60
|
+
entry = tk.Entry(frm, width=38)
|
|
61
|
+
entry.pack(fill="x", pady=(4, 10))
|
|
62
|
+
entry.focus_set()
|
|
63
|
+
|
|
64
|
+
def convert(*_):
|
|
65
|
+
state["spec"] = entry.get()
|
|
66
|
+
state["fmt"] = dict(_FORMATS)[fmt_var.get()]
|
|
67
|
+
state["dpi"] = dict(_RESOLUTIONS)[res_var.get()]
|
|
68
|
+
root.destroy()
|
|
69
|
+
|
|
70
|
+
def cancel(*_):
|
|
71
|
+
state["spec"] = ""
|
|
72
|
+
state["fmt"] = "png"
|
|
73
|
+
state["dpi"] = core.DEFAULT_DPI
|
|
74
|
+
root.destroy()
|
|
75
|
+
|
|
76
|
+
btns = tk.Frame(frm)
|
|
77
|
+
btns.pack(anchor="e")
|
|
78
|
+
tk.Button(btns, text="Cancel", command=cancel).pack(side="left", padx=4)
|
|
79
|
+
tk.Button(btns, text="Convert", command=convert, default="active").pack(side="left")
|
|
80
|
+
|
|
81
|
+
entry.bind("<Return>", convert)
|
|
82
|
+
root.bind("<Escape>", cancel)
|
|
83
|
+
|
|
84
|
+
root.mainloop()
|
|
85
|
+
return AskResult(state["spec"].strip(), state["fmt"], state["dpi"])
|
pdfpixel/notify.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Best-effort desktop notifications, per OS. Never raises."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def notify(summary: str, body: str = "") -> None:
|
|
10
|
+
system = platform.system()
|
|
11
|
+
try:
|
|
12
|
+
if system == "Linux":
|
|
13
|
+
if shutil.which("notify-send"):
|
|
14
|
+
subprocess.run(["notify-send", summary, body], check=False)
|
|
15
|
+
elif system == "Darwin":
|
|
16
|
+
safe = body.replace('"', "'")
|
|
17
|
+
title = summary.replace('"', "'")
|
|
18
|
+
subprocess.run(
|
|
19
|
+
["osascript", "-e",
|
|
20
|
+
f'display notification "{safe}" with title "{title}"'],
|
|
21
|
+
check=False,
|
|
22
|
+
)
|
|
23
|
+
elif system == "Windows":
|
|
24
|
+
try:
|
|
25
|
+
from winotify import Notification
|
|
26
|
+
Notification(app_id="PDFPixel", title=summary, msg=body).show()
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
except Exception:
|
|
30
|
+
pass # notifications are non-essential
|
pdfpixel/pdfops.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""pdfpixel pdfops — portable PDF operations (merge/split/compress) via pikepdf.
|
|
2
|
+
|
|
3
|
+
Pure file-level PDF manipulation with no GUI or notification code, so it is
|
|
4
|
+
fully unit-testable and identical across Linux, Windows and macOS. Reuses the
|
|
5
|
+
``FileResult`` dataclass from :mod:`pdfpixel.core` for the ``ok``/``error``
|
|
6
|
+
contract shared with the render engine.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pikepdf
|
|
14
|
+
from PIL import Image # noqa: F401 (kept for parity with the render engine)
|
|
15
|
+
|
|
16
|
+
from pdfpixel.core import FileResult
|
|
17
|
+
|
|
18
|
+
# Image-compression quality presets: (max_pixel_dimension | None, jpeg_quality).
|
|
19
|
+
QUALITY_PRESETS = {
|
|
20
|
+
"low": (1200, 60),
|
|
21
|
+
"medium": (2000, 75),
|
|
22
|
+
"high": (None, 85),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def unique_output_path(directory: Path, stem: str, suffix: str = ".pdf") -> Path:
|
|
27
|
+
"""Return a non-existing path ``stem{suffix}`` in ``directory``.
|
|
28
|
+
|
|
29
|
+
On collision, bump to ``stem-1``, ``stem-2``... so an existing file is never
|
|
30
|
+
overwritten.
|
|
31
|
+
"""
|
|
32
|
+
directory = Path(directory)
|
|
33
|
+
candidate = directory / f"{stem}{suffix}"
|
|
34
|
+
n = 1
|
|
35
|
+
while candidate.exists():
|
|
36
|
+
candidate = directory / f"{stem}-{n}{suffix}"
|
|
37
|
+
n += 1
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def merge(inputs: list[Path], out: Path | None = None) -> FileResult:
|
|
42
|
+
"""Concatenate all pages of ``inputs`` (in order) into one PDF.
|
|
43
|
+
|
|
44
|
+
``out`` defaults to a non-clobbering ``merged.pdf`` in ``inputs[0].parent``.
|
|
45
|
+
On any unreadable/encrypted input, or zero inputs, returns
|
|
46
|
+
``FileResult(ok=False, ...)`` and writes nothing. ``pages`` is the total
|
|
47
|
+
number of pages written.
|
|
48
|
+
"""
|
|
49
|
+
inputs = [Path(p) for p in inputs]
|
|
50
|
+
if not inputs:
|
|
51
|
+
return FileResult(Path(), ok=False, error="no input files")
|
|
52
|
+
|
|
53
|
+
merged = pikepdf.Pdf.new()
|
|
54
|
+
try:
|
|
55
|
+
total = 0
|
|
56
|
+
for src in inputs:
|
|
57
|
+
try:
|
|
58
|
+
with pikepdf.Pdf.open(src) as doc:
|
|
59
|
+
merged.pages.extend(doc.pages)
|
|
60
|
+
total += len(doc.pages)
|
|
61
|
+
except (pikepdf.PasswordError, pikepdf.PdfError):
|
|
62
|
+
return FileResult(src, ok=False, error="encrypted or unreadable PDF")
|
|
63
|
+
except OSError as e:
|
|
64
|
+
return FileResult(src, ok=False, error=str(e))
|
|
65
|
+
|
|
66
|
+
if out is None:
|
|
67
|
+
out = unique_output_path(inputs[0].parent, "merged")
|
|
68
|
+
else:
|
|
69
|
+
out = Path(out)
|
|
70
|
+
try:
|
|
71
|
+
merged.save(out)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
return FileResult(out, ok=False, error=str(e))
|
|
74
|
+
return FileResult(out, ok=True, pages=total)
|
|
75
|
+
finally:
|
|
76
|
+
merged.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def split(pdf_path: Path) -> FileResult:
|
|
80
|
+
"""Write one single-page PDF per page beside the source.
|
|
81
|
+
|
|
82
|
+
Files are named ``f"{stem}_p{n:0{width}d}.pdf"`` where ``width`` is the
|
|
83
|
+
page-count width. Never overwrites the source. ``pages`` is the number of
|
|
84
|
+
files written. Encrypted/unreadable input -> ``FileResult(ok=False, ...)``.
|
|
85
|
+
"""
|
|
86
|
+
pdf_path = Path(pdf_path)
|
|
87
|
+
try:
|
|
88
|
+
doc = pikepdf.Pdf.open(pdf_path)
|
|
89
|
+
except (pikepdf.PasswordError, pikepdf.PdfError):
|
|
90
|
+
return FileResult(pdf_path, ok=False, error="encrypted or unreadable PDF")
|
|
91
|
+
except OSError as e:
|
|
92
|
+
return FileResult(pdf_path, ok=False, error=str(e))
|
|
93
|
+
try:
|
|
94
|
+
total = len(doc.pages)
|
|
95
|
+
width = len(str(total))
|
|
96
|
+
written = 0
|
|
97
|
+
for n in range(1, total + 1):
|
|
98
|
+
single = pikepdf.Pdf.new()
|
|
99
|
+
try:
|
|
100
|
+
single.pages.append(doc.pages[n - 1])
|
|
101
|
+
out = pdf_path.parent / f"{pdf_path.stem}_p{n:0{width}d}.pdf"
|
|
102
|
+
single.save(out)
|
|
103
|
+
finally:
|
|
104
|
+
single.close()
|
|
105
|
+
written += 1
|
|
106
|
+
return FileResult(pdf_path, ok=True, pages=written)
|
|
107
|
+
finally:
|
|
108
|
+
doc.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _iter_image_xobjects(doc):
|
|
112
|
+
"""Yield each unique image XObject stream reachable from any page.
|
|
113
|
+
|
|
114
|
+
Walks page Resources and recurses into Form XObjects' Resources. Dedups by
|
|
115
|
+
object id so a shared image is visited (and recompressed) once.
|
|
116
|
+
"""
|
|
117
|
+
seen = set()
|
|
118
|
+
stack = []
|
|
119
|
+
for page in doc.pages:
|
|
120
|
+
res = page.get("/Resources")
|
|
121
|
+
if res is not None:
|
|
122
|
+
stack.append(res)
|
|
123
|
+
while stack:
|
|
124
|
+
res = stack.pop()
|
|
125
|
+
xobjs = res.get("/XObject")
|
|
126
|
+
if xobjs is None:
|
|
127
|
+
continue
|
|
128
|
+
for _name, xobj in xobjs.items():
|
|
129
|
+
try:
|
|
130
|
+
key = xobj.objgen
|
|
131
|
+
except Exception:
|
|
132
|
+
key = id(xobj)
|
|
133
|
+
if key in seen:
|
|
134
|
+
continue
|
|
135
|
+
seen.add(key)
|
|
136
|
+
subtype = str(xobj.get("/Subtype"))
|
|
137
|
+
if subtype == "/Image":
|
|
138
|
+
yield xobj
|
|
139
|
+
elif subtype == "/Form":
|
|
140
|
+
fres = xobj.get("/Resources")
|
|
141
|
+
if fres is not None:
|
|
142
|
+
stack.append(fres)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _recompress_image(xobj, max_dim, jpeg_quality):
|
|
146
|
+
"""Re-encode one image XObject to JPEG, downscaled to ``max_dim``.
|
|
147
|
+
|
|
148
|
+
Returns ``(jpeg_bytes, (w, h), mode)`` if the image is safe to recompress
|
|
149
|
+
AND the result is smaller than the current stream; otherwise ``None`` (leave
|
|
150
|
+
the original untouched). Safe = plain RGB/grayscale, 8-bit, no transparency
|
|
151
|
+
mask, not a stencil — so text, vectors, CMYK, indexed, and masked/alpha
|
|
152
|
+
images are never corrupted.
|
|
153
|
+
"""
|
|
154
|
+
if bool(xobj.get("/ImageMask", False)):
|
|
155
|
+
return None
|
|
156
|
+
if "/SMask" in xobj or "/Mask" in xobj:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
pil = pikepdf.PdfImage(xobj).as_pil_image()
|
|
160
|
+
except Exception:
|
|
161
|
+
return None # undecodable colorspace/filter -> leave as-is
|
|
162
|
+
if pil.mode not in ("RGB", "L"):
|
|
163
|
+
return None # indexed/CMYK/etc -> leave as-is
|
|
164
|
+
|
|
165
|
+
w, h = pil.size
|
|
166
|
+
if max_dim and max(w, h) > max_dim:
|
|
167
|
+
scale = max_dim / float(max(w, h))
|
|
168
|
+
pil = pil.resize((max(1, round(w * scale)), max(1, round(h * scale))))
|
|
169
|
+
|
|
170
|
+
buf = io.BytesIO()
|
|
171
|
+
pil.save(buf, format="JPEG", quality=jpeg_quality, optimize=True)
|
|
172
|
+
data = buf.getvalue()
|
|
173
|
+
try:
|
|
174
|
+
old = len(xobj.read_raw_bytes())
|
|
175
|
+
except Exception:
|
|
176
|
+
old = None
|
|
177
|
+
if old is not None and len(data) >= old:
|
|
178
|
+
return None # would not shrink -> keep original
|
|
179
|
+
return data, pil.size, pil.mode
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def compress(pdf_path: Path, quality: str = "medium") -> FileResult:
|
|
183
|
+
"""Write ``f"{stem}_compressed.pdf"`` beside source, shrinking images.
|
|
184
|
+
|
|
185
|
+
Walks the PDF's image XObjects and re-encodes the safe ones (RGB/grayscale,
|
|
186
|
+
no transparency) to downscaled JPEG per the ``quality`` preset, then applies
|
|
187
|
+
a qpdf structural pass (object streams + stream recompression). Text and
|
|
188
|
+
vectors are preserved; CMYK/indexed/masked images are left untouched. Never
|
|
189
|
+
overwrites the source. ``pages`` is the page count. Encrypted/unreadable
|
|
190
|
+
input or an unknown ``quality`` -> ``FileResult(ok=False, ...)``.
|
|
191
|
+
"""
|
|
192
|
+
pdf_path = Path(pdf_path)
|
|
193
|
+
if quality not in QUALITY_PRESETS:
|
|
194
|
+
return FileResult(pdf_path, ok=False, error=f"unknown quality: {quality}")
|
|
195
|
+
max_dim, jpeg_quality = QUALITY_PRESETS[quality]
|
|
196
|
+
try:
|
|
197
|
+
doc = pikepdf.Pdf.open(pdf_path)
|
|
198
|
+
except (pikepdf.PasswordError, pikepdf.PdfError):
|
|
199
|
+
return FileResult(pdf_path, ok=False, error="encrypted or unreadable PDF")
|
|
200
|
+
except OSError as e:
|
|
201
|
+
return FileResult(pdf_path, ok=False, error=str(e))
|
|
202
|
+
try:
|
|
203
|
+
for xobj in _iter_image_xobjects(doc):
|
|
204
|
+
res = _recompress_image(xobj, max_dim, jpeg_quality)
|
|
205
|
+
if res is None:
|
|
206
|
+
continue
|
|
207
|
+
data, (w, h), mode = res
|
|
208
|
+
xobj.write(data, filter=pikepdf.Name("/DCTDecode"))
|
|
209
|
+
xobj.ColorSpace = (
|
|
210
|
+
pikepdf.Name("/DeviceRGB") if mode == "RGB"
|
|
211
|
+
else pikepdf.Name("/DeviceGray")
|
|
212
|
+
)
|
|
213
|
+
xobj.Width, xobj.Height = w, h
|
|
214
|
+
xobj.BitsPerComponent = 8
|
|
215
|
+
for k in ("/Decode", "/DecodeParms"):
|
|
216
|
+
if k in xobj:
|
|
217
|
+
del xobj[k]
|
|
218
|
+
|
|
219
|
+
out = pdf_path.parent / f"{pdf_path.stem}_compressed.pdf"
|
|
220
|
+
try:
|
|
221
|
+
doc.save(
|
|
222
|
+
out,
|
|
223
|
+
compress_streams=True,
|
|
224
|
+
object_stream_mode=pikepdf.ObjectStreamMode.generate,
|
|
225
|
+
)
|
|
226
|
+
except OSError as e:
|
|
227
|
+
return FileResult(pdf_path, ok=False, error=str(e))
|
|
228
|
+
return FileResult(out, ok=True, pages=len(doc.pages))
|
|
229
|
+
finally:
|
|
230
|
+
doc.close()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdfpixel
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Right-click a PDF -> every page as a PNG. Cross-platform (Linux/Windows/macOS).
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pypdfium2>=4
|
|
10
|
+
Requires-Dist: pillow>=9
|
|
11
|
+
Requires-Dist: pikepdf
|
|
12
|
+
Requires-Dist: winotify>=1.1; platform_system == "Windows"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Requires-Dist: fpdf2>=2.7; extra == "dev"
|
|
16
|
+
Requires-Dist: pyinstaller>=6; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# PDFPixel
|
|
20
|
+
|
|
21
|
+
Right-click a PDF → the **PDFPixel** menu → turn its pages into images, or
|
|
22
|
+
merge / split / compress it — straight from your file manager, fully local, no
|
|
23
|
+
app to open. Works on **Linux, Windows, and macOS**.
|
|
24
|
+
|
|
25
|
+
- **Convert** every page (or a range) to **PNG / JPG / WEBP / TIFF** at any DPI.
|
|
26
|
+
- **Merge** several PDFs into one, **split** a PDF into per-page files.
|
|
27
|
+
- **Compress** — real image downsampling that keeps text sharp and selectable.
|
|
28
|
+
|
|
29
|
+
📖 [USAGE.md](USAGE.md) — every action, in and out · 🛠 [ARCHITECTURE.md](ARCHITECTURE.md) · 🤝 [CONTRIBUTING.md](CONTRIBUTING.md) · 📦 [DISTRIBUTING.md](packaging/linux/DISTRIBUTING.md)
|
|
30
|
+
|
|
31
|
+
## Supported platforms
|
|
32
|
+
|
|
33
|
+
| Platform | Status | How to run it |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| **Linux**, glibc ≥ 2.31 — Ubuntu 20.04+, Debian 11+, Mint 20+, Pop!_OS 20.04+, Fedora 32+, **RHEL/Rocky/Alma 9+**, openSUSE Leap 15.3+/Tumbleweed, Arch & derivatives | ✅ native | `.deb` / `.rpm` / AppImage / AUR |
|
|
36
|
+
| **Older or musl Linux** — RHEL/Rocky/Alma 8, Debian 10, Ubuntu 18.04, CentOS 7, Alpine (glibc < 2.31 or musl) | ✅ portable | **Flatpak** (own runtime, any distro) or `pipx install pdfpixel` |
|
|
37
|
+
| **CPU architectures** | ✅ | x86-64 (amd64) **and** ARM64 (aarch64) — both built in CI |
|
|
38
|
+
| **Windows** 10 / 11 | ✅ | `pdfpixel-setup.exe` (per-user, unsigned) |
|
|
39
|
+
| **macOS** 12+ (Intel & Apple Silicon) | ⚠️ experimental | `.dmg`, **unsigned** — Terminal install (below) |
|
|
40
|
+
|
|
41
|
+
> **Signing status — builds are unsigned.** We don't have an **Apple Developer
|
|
42
|
+
> ID**, so the macOS build can't be signed/notarized: expect a Gatekeeper prompt
|
|
43
|
+
> and use the Terminal install method below. Windows shows a one-time SmartScreen
|
|
44
|
+
> prompt. The native right-click menu ships only with the Linux `.deb`/`.rpm`/AUR
|
|
45
|
+
> packages — AppImage, Flatpak and pip are CLI/app-only (a sandbox/portability
|
|
46
|
+
> limit, not a choice).
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
Grab the artifact for your OS from the [Releases](../../releases) page.
|
|
51
|
+
|
|
52
|
+
### Linux
|
|
53
|
+
|
|
54
|
+
Native packages are built on glibc 2.31, so they run on **glibc ≥ 2.31**
|
|
55
|
+
(Ubuntu 20.04+, Debian 11+, Fedora 32+, RHEL/Rocky/Alma 9+, openSUSE Leap 15.3+),
|
|
56
|
+
amd64 **and** arm64, with no security prompt. On **older distros** (RHEL 8,
|
|
57
|
+
Debian 10, Ubuntu 18.04, CentOS 7) or **musl** (Alpine), use **Flatpak** or
|
|
58
|
+
`pipx install pdfpixel` instead.
|
|
59
|
+
|
|
60
|
+
| Distro family | Install |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Debian/Ubuntu/Mint/Pop!_OS | `sudo apt install ./pdfpixel_*.deb` |
|
|
63
|
+
| Fedora/RHEL/openSUSE | `sudo dnf install ./pdfpixel-*.rpm` |
|
|
64
|
+
| Arch/CachyOS/EndeavourOS | `yay -S pdfpixel` *(AUR)* |
|
|
65
|
+
| **Any** (no install) | `chmod +x PDFPixel-*.AppImage` then run it |
|
|
66
|
+
| Any (Python) | `pipx install pdfpixel` *(CLI only)* |
|
|
67
|
+
|
|
68
|
+
The PDF engine and dialog are bundled — no poppler, no system Python. The
|
|
69
|
+
`.deb`/`.rpm` install the **right-click menu** for GNOME Files (Nautilus),
|
|
70
|
+
Cinnamon (Nemo), MATE (Caja) and KDE Dolphin (install the matching binding —
|
|
71
|
+
`nautilus-python` / `nemo-python` / `python3-caja` — for the GTK ones); XFCE
|
|
72
|
+
Thunar takes a one-time manual step (`integrations/linux/thunar-actions.md`).
|
|
73
|
+
After installing, reload the file manager (`nautilus -q`) or log out/in.
|
|
74
|
+
|
|
75
|
+
The **AppImage** and **pip/Flatpak** builds are the app/CLI only (no host
|
|
76
|
+
right-click menu — a sandbox/portability limit), but run anywhere:
|
|
77
|
+
`./PDFPixel-*.AppImage --pages 1,3-5 file.pdf`.
|
|
78
|
+
|
|
79
|
+
*(From a checkout: `./install.sh` — uses system Python, needs `python3-tk`.)*
|
|
80
|
+
|
|
81
|
+
### Windows 10/11
|
|
82
|
+
|
|
83
|
+
1. Download **`pdfpixel-setup.exe`** and run it (**per-user, no admin**).
|
|
84
|
+
2. **SmartScreen** — "Windows protected your PC": **More info** → **Run anyway**.
|
|
85
|
+
3. Finish the installer (installs to `%LOCALAPPDATA%\Programs\PDFPixel`).
|
|
86
|
+
4. **Right-click a PDF** → **PDFPixel** → **All Pages** / **First Page** /
|
|
87
|
+
**Custom Range…** / **Split into Pages** / **Compress**
|
|
88
|
+
(Windows 11: under **Show more options**, or press **Shift+F10**).
|
|
89
|
+
*(Merge is command-line only on Windows — classic shell verbs run per file.)*
|
|
90
|
+
|
|
91
|
+
Uninstall via *Settings ▸ Apps* (removes the menu entry too).
|
|
92
|
+
|
|
93
|
+
### macOS *(experimental)*
|
|
94
|
+
|
|
95
|
+
The build is unsigned, and recent macOS no longer offers a right-click "Open" for
|
|
96
|
+
downloaded scripts, so use Terminal (it bypasses Gatekeeper's launch gate
|
|
97
|
+
cleanly):
|
|
98
|
+
|
|
99
|
+
1. Download **`pdfpixel-*.dmg`** and double-click to mount it.
|
|
100
|
+
2. In **Terminal**, run (prefix with `sudo` if it can't write to `/Applications`):
|
|
101
|
+
```bash
|
|
102
|
+
bash "/Volumes/PDFPixel/Install PDFPixel.command"
|
|
103
|
+
```
|
|
104
|
+
This copies the app to `/Applications/PDFPixel`, installs the Quick Actions to
|
|
105
|
+
`~/Library/Services`, and clears the quarantine flag.
|
|
106
|
+
3. **Right-click a PDF** in Finder → **Quick Actions** → **PDFPixel: All Pages** /
|
|
107
|
+
**First Page** / **Custom Range…** / **Split into Pages** / **Compress** /
|
|
108
|
+
**Merge PDFs** (select 2+ PDFs for Merge).
|
|
109
|
+
4. Not listed? Enable it under **System Settings → Privacy & Security →
|
|
110
|
+
Extensions → Finder** (or **Keyboard → Keyboard Shortcuts → Services**).
|
|
111
|
+
|
|
112
|
+
> No prompt at all: build it yourself on your Mac (locally-built files carry no
|
|
113
|
+
> quarantine) — `bash packaging/macos/build_dmg.sh`.
|
|
114
|
+
|
|
115
|
+
**Always-works fallback** (any OS) — run the bundled CLI directly:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
/Applications/PDFPixel/pdfpixel --pages 1,3-5 ~/file.pdf # macOS
|
|
119
|
+
"%LOCALAPPDATA%\Programs\PDFPixel\pdfpixel.exe" file.pdf # Windows
|
|
120
|
+
pdfpixel --pages 5-8 file.pdf # Linux
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Usage at a glance
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pdfpixel file.pdf # all pages → PNG @ 200 DPI
|
|
127
|
+
pdfpixel --pages 1,3-5,9 file.pdf # a page-range mix
|
|
128
|
+
pdfpixel --format jpg --dpi 300 file.pdf # JPG at print resolution
|
|
129
|
+
pdfpixel --ask file.pdf # pop the Custom… dialog
|
|
130
|
+
pdfpixel merge a.pdf b.pdf # → merged.pdf
|
|
131
|
+
pdfpixel split report.pdf # → report_p1.pdf, report_p2.pdf, …
|
|
132
|
+
pdfpixel compress --quality low scan.pdf # shrink images
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Outputs land beside the source and never overwrite it. Exit codes: `0` ok ·
|
|
136
|
+
`1` ≥1 file failed · `2` no files / malformed `--pages`. Full reference and the
|
|
137
|
+
right-click flow are in **[USAGE.md](USAGE.md)**.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e ".[dev]"
|
|
143
|
+
pytest -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The codebase is a thin file-manager shim that launches a self-contained CLI
|
|
147
|
+
(`pdfpixel/`: `core` rendering, `pdfops` merge/split/compress, `cli`, `dialog`,
|
|
148
|
+
`notify`); the menu is generated from one `ACTIONS` list. See
|
|
149
|
+
**[ARCHITECTURE.md](ARCHITECTURE.md)** for the design and
|
|
150
|
+
**[CONTRIBUTING.md](CONTRIBUTING.md)** for setup, build, and release steps.
|
|
151
|
+
|
|
152
|
+
## Notes / scope
|
|
153
|
+
|
|
154
|
+
- **Self-contained**: each installer bundles a frozen Python runtime, the PDF
|
|
155
|
+
engine (PDFium via `pypdfium2`, `pikepdf`), and the tkinter dialog. No system
|
|
156
|
+
Python or poppler required.
|
|
157
|
+
- **Unsigned** — hence the SmartScreen / Gatekeeper prompts above. macOS
|
|
158
|
+
signing/notarization needs an Apple Developer ID we don't currently have, so the
|
|
159
|
+
Terminal install is the supported macOS path; Windows signing may come later
|
|
160
|
+
with a certificate.
|
|
161
|
+
- **Sandboxed builds** (Flatpak/Snap) and the AppImage/pip CLI can't install a
|
|
162
|
+
host file-manager menu — that's a platform limit. The full right-click menu
|
|
163
|
+
ships with the `.deb`/`.rpm`/AUR packages.
|
|
164
|
+
- **Windows/macOS menus** now expose the full action set (All Pages, First Page,
|
|
165
|
+
Custom Range, Split, Compress; macOS also Merge). Windows Merge stays CLI-only
|
|
166
|
+
(classic shell verbs run per selected file). Lands in the next release build.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pdfpixel/__init__.py,sha256=NRvEfzkP6I6kOWbR1FBbRMj4M-XbwHAP_dAY7rBi0VU,94
|
|
2
|
+
pdfpixel/__main__.py,sha256=cDFZ557Ix845vVY25w63zFWACPL1EpG_2jUQrd_7Zj4,36
|
|
3
|
+
pdfpixel/cli.py,sha256=_TuV0Xku1mExbZM1qEo4cDFHWocIf3D9mV30q1qH2N8,6749
|
|
4
|
+
pdfpixel/core.py,sha256=WGxcnjp6-q85MswItU-9ndB9J7NlEh1mGT1NFWKn1eY,6100
|
|
5
|
+
pdfpixel/dialog.py,sha256=sc5yPff4Td7VEAuKZ_XSwCBXokpoVe1BMs1mibYFehc,2737
|
|
6
|
+
pdfpixel/notify.py,sha256=xlO2SpACsRCyiwCBDd25pL2h_RHfEu3FtGKroU5MmuE,992
|
|
7
|
+
pdfpixel/pdfops.py,sha256=4jjSPIRjEAG84xthaMgqRaGwipiWESXu-cJ-A_NbVe8,8328
|
|
8
|
+
pdfpixel-0.3.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
9
|
+
pdfpixel-0.3.1.dist-info/METADATA,sha256=-kRVm6S7XcvkHLqN2N3WQKPuH919ACd9oxgLQjWTQ-8,7960
|
|
10
|
+
pdfpixel-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
pdfpixel-0.3.1.dist-info/entry_points.txt,sha256=HymSgEcg3iZSly74RyVu7wIfqvKILPaKiSriOrC7N4k,46
|
|
12
|
+
pdfpixel-0.3.1.dist-info/top_level.txt,sha256=jMrWe6yDFoxTaQK6GSiNIMskEe8bOnzgJ5UVEYmm2ns,9
|
|
13
|
+
pdfpixel-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pdfpixel
|