pdfblinder 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 foivoskar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdfblinder
3
+ Version: 0.1.0
4
+ Summary: Reconstruct and sanitize PDFs for anonymous review.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pikepdf<11,>=10.17
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<10,>=9; extra == "dev"
12
+ Requires-Dist: Pillow>=10; extra == "dev"
13
+ Dynamic: license-file
14
+
15
+ # PDFblinder
16
+
17
+ A Python command-line tool that reconstructs PDFs with Ghostscript,
18
+ removes selected structural metadata and auxiliary objects with pikepdf,
19
+ and verifies the serialized result before creating the destination file.
20
+
21
+ Designed for preparing documents for double-blind review.
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.11 or newer.
26
+ - Ghostscript installed separately.
27
+ - Ghostscript available as `gs`, `gswin64c`, or `gswin32c`,
28
+ or supplied explicitly with `--gs`.
29
+
30
+ The current local test run covers macOS. Linux and Windows compatibility
31
+ must also be validated before claiming support on those systems.
32
+
33
+ ## Installation
34
+
35
+ From the project directory, create a virtual environment:
36
+
37
+ ```sh
38
+ python -m venv .venv
39
+ ```
40
+
41
+ Activate it on macOS or Linux:
42
+
43
+ ```sh
44
+ source .venv/bin/activate
45
+ ```
46
+
47
+ Or activate it in Windows PowerShell:
48
+
49
+ ```powershell
50
+ .\.venv\Scripts\Activate.ps1
51
+ ```
52
+
53
+ Install PDFblinder:
54
+
55
+ ```sh
56
+ python -m pip install .
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```sh
62
+ pdfblinder input.pdf output.pdf
63
+ ```
64
+
65
+ Paths containing spaces must be quoted:
66
+
67
+ ```sh
68
+ pdfblinder "original manuscript.pdf" "review copy.pdf"
69
+ ```
70
+
71
+ Select a Ghostscript executable or change its timeout:
72
+
73
+ ```sh
74
+ pdfblinder --gs "/path/to/gs" --timeout 300 input.pdf output.pdf
75
+ ```
76
+
77
+ The module entry point is also available:
78
+
79
+ ```sh
80
+ python -m pdfblinder input.pdf output.pdf
81
+ ```
82
+
83
+ The destination must not already exist. The original file is preserved.
84
+ The output directory must already exist.
85
+
86
+ Exit codes: `0` for success, `1` for processing failure,
87
+ `2` for invalid command-line arguments, and `130` for interruption.
88
+
89
+ ## Processing and verification
90
+
91
+ 1. Copy the input into a private temporary working directory.
92
+ 2. Reject encrypted PDFs, empty documents, and documents with AcroForm
93
+ forms or signature fields.
94
+ 3. Reconstruct pages using Ghostscript's `pdfwrite` device.
95
+ Annotations are discarded rather than painted onto the pages.
96
+ 4. Create a new PDF from the reconstructed pages. Restrict page fields,
97
+ strip selected auxiliary dictionary fields, and remove unused resources.
98
+ 5. Reopen the serialized candidate. Check the page count, document catalog,
99
+ absence of document information metadata, forbidden fields and object
100
+ types, document identifiers, and PDF syntax.
101
+ 6. Create the destination exclusively and copy the verified bytes into it.
102
+
103
+ Temporary working files are cleaned up on normal completion and handled
104
+ exceptions. Abrupt process termination or power loss may leave temporary
105
+ files or an incomplete destination.
106
+
107
+ ## Scope and limitations
108
+
109
+ PDFblinder performs structural sanitation, not guaranteed anonymization.
110
+
111
+ - Names, affiliations, acknowledgements, citations, watermarks, and other
112
+ identifying visible content remain.
113
+ - Embedded font and image payloads are not exhaustively inspected or
114
+ anonymized. Reconstruction can retain distinctive document features.
115
+ - The structural checks enforce a defined policy; they cannot prove that
116
+ every possible identifying byte or covert channel has been eliminated.
117
+ - Comments, highlights, links, and other annotations are discarded.
118
+ Bookmarks, document tags, and accessibility structure are not preserved.
119
+ - Forms and signature fields are rejected to avoid silently losing their
120
+ appearance or misrepresenting signatures.
121
+ - Page count and syntax checks do not establish full visual equivalence.
122
+ Inspect every output before submission.
123
+ - Output identifiers are newly generated; original identifiers are checked
124
+ for reuse. Filesystem timestamps and permissions are newly created.
125
+ The output filename is chosen by the user.
126
+ - Ghostscript and pikepdf must be kept updated. Ghostscript's SAFER mode
127
+ is enabled, but it is not a complete operating-system sandbox.
128
+ - The timeout applies to Ghostscript reconstruction, not the entire job.
129
+
130
+ ## Development
131
+
132
+ ```sh
133
+ python -m pip install -e '.[dev]'
134
+ python -m pytest -q
135
+ ```
136
+
137
+ Tests cover reconstruction, sanitation, verification, CLI behavior,
138
+ overwrite protection, failure handling, text preservation, and a rendered
139
+ comparison of a synthetic page containing text and vector graphics.
140
+
141
+ Test PDFs are generated in temporary directories. No private documents
142
+ are needed in the repository.
143
+
144
+ ## License
145
+
146
+ PDFblinder's original code and documentation are licensed under the
147
+ [MIT License](LICENSE), copyright (c) 2026 foivoskar.
148
+
149
+ Third-party dependencies retain their own licenses. Ghostscript is installed
150
+ separately and is not bundled with PDFblinder. It is available under the
151
+ GNU AGPL or a commercial license from Artifex.
152
+
153
+ The MIT license for PDFblinder does not waive any third-party obligations.
154
+ Before redistributing Ghostscript or deploying a hosted PDF-processing
155
+ service, evaluate the applicable Ghostscript licensing terms:
156
+ https://www.ghostscript.com/licensing/
@@ -0,0 +1,142 @@
1
+ # PDFblinder
2
+
3
+ A Python command-line tool that reconstructs PDFs with Ghostscript,
4
+ removes selected structural metadata and auxiliary objects with pikepdf,
5
+ and verifies the serialized result before creating the destination file.
6
+
7
+ Designed for preparing documents for double-blind review.
8
+
9
+ ## Requirements
10
+
11
+ - Python 3.11 or newer.
12
+ - Ghostscript installed separately.
13
+ - Ghostscript available as `gs`, `gswin64c`, or `gswin32c`,
14
+ or supplied explicitly with `--gs`.
15
+
16
+ The current local test run covers macOS. Linux and Windows compatibility
17
+ must also be validated before claiming support on those systems.
18
+
19
+ ## Installation
20
+
21
+ From the project directory, create a virtual environment:
22
+
23
+ ```sh
24
+ python -m venv .venv
25
+ ```
26
+
27
+ Activate it on macOS or Linux:
28
+
29
+ ```sh
30
+ source .venv/bin/activate
31
+ ```
32
+
33
+ Or activate it in Windows PowerShell:
34
+
35
+ ```powershell
36
+ .\.venv\Scripts\Activate.ps1
37
+ ```
38
+
39
+ Install PDFblinder:
40
+
41
+ ```sh
42
+ python -m pip install .
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```sh
48
+ pdfblinder input.pdf output.pdf
49
+ ```
50
+
51
+ Paths containing spaces must be quoted:
52
+
53
+ ```sh
54
+ pdfblinder "original manuscript.pdf" "review copy.pdf"
55
+ ```
56
+
57
+ Select a Ghostscript executable or change its timeout:
58
+
59
+ ```sh
60
+ pdfblinder --gs "/path/to/gs" --timeout 300 input.pdf output.pdf
61
+ ```
62
+
63
+ The module entry point is also available:
64
+
65
+ ```sh
66
+ python -m pdfblinder input.pdf output.pdf
67
+ ```
68
+
69
+ The destination must not already exist. The original file is preserved.
70
+ The output directory must already exist.
71
+
72
+ Exit codes: `0` for success, `1` for processing failure,
73
+ `2` for invalid command-line arguments, and `130` for interruption.
74
+
75
+ ## Processing and verification
76
+
77
+ 1. Copy the input into a private temporary working directory.
78
+ 2. Reject encrypted PDFs, empty documents, and documents with AcroForm
79
+ forms or signature fields.
80
+ 3. Reconstruct pages using Ghostscript's `pdfwrite` device.
81
+ Annotations are discarded rather than painted onto the pages.
82
+ 4. Create a new PDF from the reconstructed pages. Restrict page fields,
83
+ strip selected auxiliary dictionary fields, and remove unused resources.
84
+ 5. Reopen the serialized candidate. Check the page count, document catalog,
85
+ absence of document information metadata, forbidden fields and object
86
+ types, document identifiers, and PDF syntax.
87
+ 6. Create the destination exclusively and copy the verified bytes into it.
88
+
89
+ Temporary working files are cleaned up on normal completion and handled
90
+ exceptions. Abrupt process termination or power loss may leave temporary
91
+ files or an incomplete destination.
92
+
93
+ ## Scope and limitations
94
+
95
+ PDFblinder performs structural sanitation, not guaranteed anonymization.
96
+
97
+ - Names, affiliations, acknowledgements, citations, watermarks, and other
98
+ identifying visible content remain.
99
+ - Embedded font and image payloads are not exhaustively inspected or
100
+ anonymized. Reconstruction can retain distinctive document features.
101
+ - The structural checks enforce a defined policy; they cannot prove that
102
+ every possible identifying byte or covert channel has been eliminated.
103
+ - Comments, highlights, links, and other annotations are discarded.
104
+ Bookmarks, document tags, and accessibility structure are not preserved.
105
+ - Forms and signature fields are rejected to avoid silently losing their
106
+ appearance or misrepresenting signatures.
107
+ - Page count and syntax checks do not establish full visual equivalence.
108
+ Inspect every output before submission.
109
+ - Output identifiers are newly generated; original identifiers are checked
110
+ for reuse. Filesystem timestamps and permissions are newly created.
111
+ The output filename is chosen by the user.
112
+ - Ghostscript and pikepdf must be kept updated. Ghostscript's SAFER mode
113
+ is enabled, but it is not a complete operating-system sandbox.
114
+ - The timeout applies to Ghostscript reconstruction, not the entire job.
115
+
116
+ ## Development
117
+
118
+ ```sh
119
+ python -m pip install -e '.[dev]'
120
+ python -m pytest -q
121
+ ```
122
+
123
+ Tests cover reconstruction, sanitation, verification, CLI behavior,
124
+ overwrite protection, failure handling, text preservation, and a rendered
125
+ comparison of a synthetic page containing text and vector graphics.
126
+
127
+ Test PDFs are generated in temporary directories. No private documents
128
+ are needed in the repository.
129
+
130
+ ## License
131
+
132
+ PDFblinder's original code and documentation are licensed under the
133
+ [MIT License](LICENSE), copyright (c) 2026 foivoskar.
134
+
135
+ Third-party dependencies retain their own licenses. Ghostscript is installed
136
+ separately and is not bundled with PDFblinder. It is available under the
137
+ GNU AGPL or a commercial license from Artifex.
138
+
139
+ The MIT license for PDFblinder does not waive any third-party obligations.
140
+ Before redistributing Ghostscript or deploying a hosted PDF-processing
141
+ service, evaluate the applicable Ghostscript licensing terms:
142
+ https://www.ghostscript.com/licensing/
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pdfblinder"
7
+ version = "0.1.0"
8
+ description = "Reconstruct and sanitize PDFs for anonymous review."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ dependencies = ["pikepdf>=10.17,<11"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=9,<10", "Pillow>=10"]
17
+
18
+ [project.scripts]
19
+ pdfblinder = "pdfblinder.__main__:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """PDF reconstruction and sanitation for anonymous review."""
@@ -0,0 +1,86 @@
1
+ """Command-line entry point for PDFblinder."""
2
+
3
+ import argparse
4
+ from importlib.metadata import version
5
+ import math
6
+ from pathlib import Path
7
+
8
+ import pikepdf
9
+
10
+ from .core import InputError, blind_pdf
11
+ from .ghostscript import GhostscriptError
12
+ from .sanitize import SanitationError
13
+ from .verify import VerificationError
14
+
15
+
16
+ def positive_timeout(value: str) -> float:
17
+ try:
18
+ seconds = float(value)
19
+ except ValueError as exc:
20
+ raise argparse.ArgumentTypeError(
21
+ "Timeout must be a positive number of seconds."
22
+ ) from exc
23
+ if not math.isfinite(seconds) or seconds <= 0:
24
+ raise argparse.ArgumentTypeError(
25
+ "Timeout must be finite and greater than zero."
26
+ )
27
+ return seconds
28
+
29
+
30
+ def main() -> int:
31
+ parser = argparse.ArgumentParser(
32
+ prog="pdfblinder",
33
+ description="Reconstruct and structurally sanitize a PDF.",
34
+ epilog=(
35
+ "Annotations are discarded. Forms and encrypted PDFs are "
36
+ "rejected. Identifying visible content and embedded font/image "
37
+ "payloads are not anonymized. Review the output before submission."
38
+ ),
39
+ )
40
+ parser.add_argument(
41
+ "--version",
42
+ action="version",
43
+ version=f"%(prog)s {version('pdfblinder')}",
44
+ )
45
+ parser.add_argument("input", type=Path, help="Source PDF")
46
+ parser.add_argument(
47
+ "output", type=Path,
48
+ help="Destination PDF; must not already exist",
49
+ )
50
+ parser.add_argument(
51
+ "--gs", metavar="EXECUTABLE",
52
+ help="Ghostscript executable name or path",
53
+ )
54
+ parser.add_argument(
55
+ "--timeout", type=positive_timeout, default=120.0,
56
+ metavar="SECONDS",
57
+ help="Ghostscript time limit in seconds (default: 120)",
58
+ )
59
+ args = parser.parse_args()
60
+
61
+ try:
62
+ pages = blind_pdf(
63
+ args.input,
64
+ args.output,
65
+ executable=args.gs,
66
+ timeout=args.timeout,
67
+ )
68
+ except (
69
+ OSError,
70
+ pikepdf.PdfError,
71
+ InputError,
72
+ GhostscriptError,
73
+ SanitationError,
74
+ VerificationError,
75
+ ) as exc:
76
+ parser.exit(1, f"pdfblinder: error: {exc}\n")
77
+ except KeyboardInterrupt:
78
+ parser.exit(130, "pdfblinder: interrupted\n")
79
+
80
+ print(f"Created: {args.output} ({pages} pages)")
81
+ print("Structural verification passed. Review the output before submission.")
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ """Complete PDFblinder processing pipeline."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ from tempfile import TemporaryDirectory
7
+
8
+ import pikepdf
9
+
10
+ from .ghostscript import reconstructed_pdf
11
+ from .sanitize import sanitize_intermediate
12
+ from .verify import VerificationError, verify_pdf
13
+
14
+
15
+ class InputError(ValueError):
16
+ """The input is unsupported or unsuitable for processing."""
17
+
18
+
19
+ def blind_pdf(
20
+ source: Path,
21
+ destination: Path,
22
+ *,
23
+ executable: str | None = None,
24
+ timeout: float = 120,
25
+ ) -> int:
26
+ """Create a verified output without overwriting existing files.
27
+
28
+ Returns the page count. Structural sanitation does not remove
29
+ identifying visible content or guarantee complete anonymity.
30
+ """
31
+ source = Path(source).expanduser().resolve(strict=True)
32
+ destination = Path(destination).expanduser().absolute()
33
+
34
+ if not source.is_file():
35
+ raise InputError("Input must be a regular file.")
36
+ if destination.exists() or destination.is_symlink():
37
+ raise FileExistsError(f"Output already exists: {destination}")
38
+ if not destination.parent.is_dir():
39
+ raise FileNotFoundError(
40
+ f"Output directory does not exist: {destination.parent}"
41
+ )
42
+
43
+ with TemporaryDirectory(prefix="pdfblinder-job-") as directory:
44
+ work = Path(directory)
45
+ snapshot = work / "input.pdf"
46
+ candidate = work / "candidate.pdf"
47
+ shutil.copyfile(source, snapshot)
48
+
49
+ with pikepdf.Pdf.open(
50
+ snapshot, attempt_recovery=False
51
+ ) as original:
52
+ if original.is_encrypted:
53
+ raise InputError("Encrypted input PDFs are not supported.")
54
+ page_count = len(original.pages)
55
+ if page_count == 0:
56
+ raise InputError("Input PDF contains no pages.")
57
+ if "/AcroForm" in original.Root:
58
+ raise InputError(
59
+ "PDFs with forms or signature fields are not supported. "
60
+ "Export a static PDF first and check its appearance."
61
+ )
62
+ original_ids = tuple(
63
+ bytes(identifier)
64
+ for identifier in original.trailer.get("/ID", [])
65
+ )
66
+
67
+ with reconstructed_pdf(
68
+ snapshot, executable=executable, timeout=timeout
69
+ ) as intermediate:
70
+ with candidate.open("xb") as stream:
71
+ reconstructed_count = sanitize_intermediate(
72
+ intermediate, stream
73
+ )
74
+
75
+ if reconstructed_count != page_count:
76
+ raise VerificationError(
77
+ "Reconstruction changed the number of pages."
78
+ )
79
+
80
+ verify_pdf(
81
+ candidate,
82
+ expected_pages=page_count,
83
+ original_ids=original_ids,
84
+ )
85
+
86
+ # Exclusive creation also prevents overwriting a file that
87
+ # appeared after the initial destination check.
88
+ descriptor = os.open(
89
+ destination,
90
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
91
+ 0o600,
92
+ )
93
+ try:
94
+ with os.fdopen(descriptor, "wb") as output:
95
+ with candidate.open("rb") as verified:
96
+ shutil.copyfileobj(verified, output)
97
+ output.flush()
98
+ os.fsync(output.fileno())
99
+ except BaseException:
100
+ destination.unlink(missing_ok=True)
101
+ raise
102
+
103
+ return page_count
@@ -0,0 +1,95 @@
1
+ """Ghostscript reconstruction into a temporary intermediate PDF."""
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ from tempfile import TemporaryDirectory
8
+ from typing import Iterator
9
+
10
+
11
+ class GhostscriptError(RuntimeError):
12
+ """Ghostscript could not reconstruct the input."""
13
+
14
+
15
+ def find_ghostscript(executable: str | None = None) -> str:
16
+ candidates = (
17
+ [executable] if executable is not None
18
+ else ["gs", "gswin64c", "gswin32c"]
19
+ )
20
+ for candidate in candidates:
21
+ resolved = shutil.which(candidate)
22
+ if resolved:
23
+ return resolved
24
+ raise GhostscriptError(
25
+ "Ghostscript was not found. Install it or specify its executable."
26
+ )
27
+
28
+
29
+ @contextmanager
30
+ def reconstructed_pdf(
31
+ source: Path,
32
+ *,
33
+ executable: str | None = None,
34
+ timeout: float = 120,
35
+ ) -> Iterator[Path]:
36
+ """Yield an intermediate PDF; remove temporary files on exit.
37
+
38
+ This stage alone does not establish anonymity.
39
+ """
40
+ source = Path(source).resolve(strict=True)
41
+ if not source.is_file():
42
+ raise ValueError("Input must be a regular file.")
43
+ if timeout <= 0:
44
+ raise ValueError("Timeout must be positive.")
45
+
46
+ gs = find_ghostscript(executable)
47
+ with TemporaryDirectory(prefix="pdfblinder-") as directory:
48
+ output = Path(directory) / "reconstructed.pdf"
49
+ command = [
50
+ gs,
51
+ "-dSAFER",
52
+ "-dBATCH",
53
+ "-dNOPAUSE",
54
+ "-dPDFSTOPONERROR",
55
+ "-dPDFSTOPONWARNING",
56
+ "-sDEVICE=pdfwrite",
57
+ "-dCompatibilityLevel=1.7",
58
+ "-dPreserveAnnots=false",
59
+ "-dShowAnnots=false",
60
+ "-dPreserveMarkedContent=false",
61
+ "-dDownsampleColorImages=false",
62
+ "-dDownsampleGrayImages=false",
63
+ "-dDownsampleMonoImages=false",
64
+ f"-sOutputFile={output}",
65
+ "-f",
66
+ str(source),
67
+ ]
68
+ try:
69
+ result = subprocess.run(
70
+ command,
71
+ capture_output=True,
72
+ timeout=timeout,
73
+ check=False,
74
+ )
75
+ except subprocess.TimeoutExpired as exc:
76
+ raise GhostscriptError(
77
+ f"Ghostscript exceeded the {timeout:g}-second timeout."
78
+ ) from exc
79
+ except OSError as exc:
80
+ raise GhostscriptError(
81
+ f"Could not execute Ghostscript: {exc}"
82
+ ) from exc
83
+
84
+ if result.returncode != 0:
85
+ details = (result.stdout + result.stderr).decode(
86
+ "utf-8", errors="replace"
87
+ ).strip()
88
+ raise GhostscriptError(
89
+ f"Ghostscript failed with exit code {result.returncode}.\n"
90
+ f"{details[-8000:]}"
91
+ )
92
+ if not output.is_file() or output.stat().st_size == 0:
93
+ raise GhostscriptError("Ghostscript produced no PDF output.")
94
+
95
+ yield output