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 ADDED
@@ -0,0 +1,3 @@
1
+ """PDFPixel — render each page of one or more PDFs to PNG images."""
2
+
3
+ __version__ = "0.3.1"
pdfpixel/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pdfpixel.cli import run
2
+
3
+ run()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdfpixel = pdfpixel.cli:run
@@ -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