modpdf 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.
- modpdf-0.1.0/.github/workflows/release.yml +135 -0
- modpdf-0.1.0/.github/workflows/security.yml +62 -0
- modpdf-0.1.0/.github/workflows/test.yml +62 -0
- modpdf-0.1.0/.gitignore +42 -0
- modpdf-0.1.0/.pre-commit-config.yaml +29 -0
- modpdf-0.1.0/CHANGELOG.md +155 -0
- modpdf-0.1.0/CONTRIBUTING.md +92 -0
- modpdf-0.1.0/LICENSE +202 -0
- modpdf-0.1.0/NOTICE +29 -0
- modpdf-0.1.0/PKG-INFO +292 -0
- modpdf-0.1.0/README.md +264 -0
- modpdf-0.1.0/SECURITY.md +47 -0
- modpdf-0.1.0/THREAT_MODEL.md +94 -0
- modpdf-0.1.0/docs/adr/0001-python-over-go-and-rust.md +67 -0
- modpdf-0.1.0/docs/adr/0002-pypdfium2-over-pymupdf.md +50 -0
- modpdf-0.1.0/docs/adr/0003-pikepdf-over-ghostscript.md +55 -0
- modpdf-0.1.0/docs/adr/0004-compression-quality-gate.md +62 -0
- modpdf-0.1.0/docs/adr/0005-no-network-at-runtime.md +49 -0
- modpdf-0.1.0/docs/adr/0006-one-lock-for-pdfium.md +61 -0
- modpdf-0.1.0/docs/compression.md +113 -0
- modpdf-0.1.0/docs/images/app-dark-compress.png +0 -0
- modpdf-0.1.0/docs/images/app-light.png +0 -0
- modpdf-0.1.0/packaging/macos/README.md +43 -0
- modpdf-0.1.0/packaging/macos/build.sh +37 -0
- modpdf-0.1.0/packaging/macos/make_icon.py +86 -0
- modpdf-0.1.0/packaging/macos/modpdf.spec +90 -0
- modpdf-0.1.0/pyproject.toml +120 -0
- modpdf-0.1.0/src/modpdf/__init__.py +17 -0
- modpdf-0.1.0/src/modpdf/cli.py +567 -0
- modpdf-0.1.0/src/modpdf/document.py +159 -0
- modpdf-0.1.0/src/modpdf/gui/__init__.py +21 -0
- modpdf-0.1.0/src/modpdf/gui/app.py +57 -0
- modpdf-0.1.0/src/modpdf/gui/grid.py +180 -0
- modpdf-0.1.0/src/modpdf/gui/icons.py +172 -0
- modpdf-0.1.0/src/modpdf/gui/session.py +160 -0
- modpdf-0.1.0/src/modpdf/gui/settings.py +79 -0
- modpdf-0.1.0/src/modpdf/gui/theme.py +237 -0
- modpdf-0.1.0/src/modpdf/gui/thumbnails.py +123 -0
- modpdf-0.1.0/src/modpdf/gui/widgets.py +401 -0
- modpdf-0.1.0/src/modpdf/gui/window.py +1572 -0
- modpdf-0.1.0/src/modpdf/gui/workers.py +124 -0
- modpdf-0.1.0/src/modpdf/inspection.py +335 -0
- modpdf-0.1.0/src/modpdf/ops/__init__.py +0 -0
- modpdf-0.1.0/src/modpdf/ops/compress.py +1113 -0
- modpdf-0.1.0/src/modpdf/ops/merge.py +45 -0
- modpdf-0.1.0/src/modpdf/ops/outline.py +200 -0
- modpdf-0.1.0/src/modpdf/ops/sanitize.py +213 -0
- modpdf-0.1.0/src/modpdf/ops/select.py +47 -0
- modpdf-0.1.0/src/modpdf/ops/split.py +64 -0
- modpdf-0.1.0/src/modpdf/pagespec.py +105 -0
- modpdf-0.1.0/src/modpdf/pdfium_lock.py +27 -0
- modpdf-0.1.0/src/modpdf/security/__init__.py +0 -0
- modpdf-0.1.0/src/modpdf/security/fs.py +284 -0
- modpdf-0.1.0/src/modpdf/security/limits.py +76 -0
- modpdf-0.1.0/src/modpdf/security/netguard.py +121 -0
- modpdf-0.1.0/src/modpdf/security/secrets.py +71 -0
- modpdf-0.1.0/src/modpdf/tasks.py +246 -0
- modpdf-0.1.0/src/modpdf/verify.py +248 -0
- modpdf-0.1.0/tests/__init__.py +0 -0
- modpdf-0.1.0/tests/conftest.py +474 -0
- modpdf-0.1.0/tests/gui/__init__.py +0 -0
- modpdf-0.1.0/tests/gui/conftest.py +77 -0
- modpdf-0.1.0/tests/gui/test_add_to_workspace.py +216 -0
- modpdf-0.1.0/tests/gui/test_compress_panel.py +254 -0
- modpdf-0.1.0/tests/gui/test_reorder.py +441 -0
- modpdf-0.1.0/tests/gui/test_session.py +88 -0
- modpdf-0.1.0/tests/gui/test_settings.py +527 -0
- modpdf-0.1.0/tests/gui/test_split_ranges.py +195 -0
- modpdf-0.1.0/tests/gui/test_thumbnails.py +142 -0
- modpdf-0.1.0/tests/gui/test_window.py +147 -0
- modpdf-0.1.0/tests/gui/test_workers.py +127 -0
- modpdf-0.1.0/tests/integration/__init__.py +0 -0
- modpdf-0.1.0/tests/integration/test_commands.py +346 -0
- modpdf-0.1.0/tests/property/__init__.py +0 -0
- modpdf-0.1.0/tests/property/test_roundtrip.py +107 -0
- modpdf-0.1.0/tests/security/__init__.py +0 -0
- modpdf-0.1.0/tests/security/test_hostile_input.py +254 -0
- modpdf-0.1.0/tests/security/test_no_network.py +157 -0
- modpdf-0.1.0/tests/security/test_secrets.py +155 -0
- modpdf-0.1.0/tests/security/test_synced_folders.py +138 -0
- modpdf-0.1.0/tests/unit/__init__.py +0 -0
- modpdf-0.1.0/tests/unit/test_compress.py +497 -0
- modpdf-0.1.0/tests/unit/test_fs.py +125 -0
- modpdf-0.1.0/tests/unit/test_inspection.py +164 -0
- modpdf-0.1.0/tests/unit/test_outline.py +159 -0
- modpdf-0.1.0/tests/unit/test_pagespec.py +110 -0
- modpdf-0.1.0/tests/unit/test_pdfium_lock.py +66 -0
- modpdf-0.1.0/tests/unit/test_sanitize.py +157 -0
- modpdf-0.1.0/tests/unit/test_tasks.py +95 -0
- modpdf-0.1.0/tests/unit/test_verify.py +173 -0
- modpdf-0.1.0/uv.lock +1820 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Pushing a tag like v0.1.0 publishes that version to PyPI, then creates a
|
|
4
|
+
# GitHub Release with the same files, an SBOM, and an unsigned macOS app.
|
|
5
|
+
#
|
|
6
|
+
# Running it by hand (Actions → release → Run workflow) is a dry run: it
|
|
7
|
+
# builds and publishes to TestPyPI instead, and creates no GitHub Release.
|
|
8
|
+
#
|
|
9
|
+
# Publishing uses PyPI's Trusted Publishing: PyPI trusts this workflow, in this
|
|
10
|
+
# repository, running in the `pypi` environment, and nothing else. There is no
|
|
11
|
+
# API token stored anywhere to be stolen.
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
tags: ["v*"]
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v5
|
|
25
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
26
|
+
|
|
27
|
+
# A tag that doesn't match pyproject.toml would publish one version
|
|
28
|
+
# under another's name. Stop before anything is built.
|
|
29
|
+
- name: Tag matches the project version
|
|
30
|
+
if: github.event_name == 'push'
|
|
31
|
+
run: |
|
|
32
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
33
|
+
if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then
|
|
34
|
+
echo "tag $GITHUB_REF_NAME does not match pyproject.toml version $version" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
- name: Build the sdist and wheel
|
|
39
|
+
run: uv build
|
|
40
|
+
|
|
41
|
+
# The SBOM lists every runtime dependency, desktop extra included, at the
|
|
42
|
+
# exact versions uv.lock pins.
|
|
43
|
+
- name: SBOM
|
|
44
|
+
run: |
|
|
45
|
+
uv export --locked --no-dev --extra gui --no-emit-project --no-hashes \
|
|
46
|
+
--output-file requirements-runtime.txt
|
|
47
|
+
uvx --from cyclonedx-bom==7.4.0 cyclonedx-py requirements \
|
|
48
|
+
--pyproject pyproject.toml --of JSON --output-reproducible \
|
|
49
|
+
-o sbom.cdx.json requirements-runtime.txt
|
|
50
|
+
|
|
51
|
+
- uses: actions/upload-artifact@v7
|
|
52
|
+
with:
|
|
53
|
+
name: dist
|
|
54
|
+
path: dist/
|
|
55
|
+
- uses: actions/upload-artifact@v7
|
|
56
|
+
with:
|
|
57
|
+
name: sbom
|
|
58
|
+
path: sbom.cdx.json
|
|
59
|
+
|
|
60
|
+
publish:
|
|
61
|
+
needs: build
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
environment:
|
|
64
|
+
name: pypi
|
|
65
|
+
url: https://pypi.org/p/modpdf
|
|
66
|
+
permissions:
|
|
67
|
+
id-token: write # what Trusted Publishing uses instead of a token
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/download-artifact@v8
|
|
70
|
+
with:
|
|
71
|
+
name: dist
|
|
72
|
+
path: dist/
|
|
73
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.2
|
|
74
|
+
with:
|
|
75
|
+
repository-url: ${{ github.event_name == 'workflow_dispatch' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }}
|
|
76
|
+
# A second dry run of the same version would otherwise fail on
|
|
77
|
+
# TestPyPI; a real release must never silently skip.
|
|
78
|
+
skip-existing: ${{ github.event_name == 'workflow_dispatch' }}
|
|
79
|
+
|
|
80
|
+
github-release:
|
|
81
|
+
needs: publish
|
|
82
|
+
if: github.event_name == 'push'
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
permissions:
|
|
85
|
+
contents: write
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v5
|
|
88
|
+
- uses: actions/download-artifact@v8
|
|
89
|
+
with:
|
|
90
|
+
name: dist
|
|
91
|
+
path: dist/
|
|
92
|
+
- uses: actions/download-artifact@v8
|
|
93
|
+
with:
|
|
94
|
+
name: sbom
|
|
95
|
+
|
|
96
|
+
# The release notes are this version's own section of CHANGELOG.md.
|
|
97
|
+
- name: Create the release
|
|
98
|
+
env:
|
|
99
|
+
GH_TOKEN: ${{ github.token }}
|
|
100
|
+
run: |
|
|
101
|
+
version="${GITHUB_REF_NAME#v}"
|
|
102
|
+
awk -v v="$version" '
|
|
103
|
+
$0 ~ "^## \\[" v "\\]" { found = 1; next }
|
|
104
|
+
found && /^## \[/ { exit }
|
|
105
|
+
found { print }
|
|
106
|
+
' CHANGELOG.md > notes.md
|
|
107
|
+
if [ ! -s notes.md ]; then
|
|
108
|
+
echo "CHANGELOG.md has no section for $version" >&2
|
|
109
|
+
exit 1
|
|
110
|
+
fi
|
|
111
|
+
gh release create "$GITHUB_REF_NAME" dist/* sbom.cdx.json \
|
|
112
|
+
--title "ModPDF $version" --notes-file notes.md
|
|
113
|
+
|
|
114
|
+
# Separate from the release above, so a problem building the app can't stop
|
|
115
|
+
# the PyPI release or the GitHub Release from happening.
|
|
116
|
+
macos-app:
|
|
117
|
+
needs: github-release
|
|
118
|
+
runs-on: macos-latest
|
|
119
|
+
permissions:
|
|
120
|
+
contents: write
|
|
121
|
+
steps:
|
|
122
|
+
- uses: actions/checkout@v5
|
|
123
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
124
|
+
- name: Install
|
|
125
|
+
run: |
|
|
126
|
+
uv sync --locked --all-groups --python 3.13
|
|
127
|
+
uv pip install pyinstaller
|
|
128
|
+
- name: Build ModPDF.app
|
|
129
|
+
run: packaging/macos/build.sh
|
|
130
|
+
- name: Attach it to the release
|
|
131
|
+
env:
|
|
132
|
+
GH_TOKEN: ${{ github.token }}
|
|
133
|
+
run: |
|
|
134
|
+
ditto -c -k --keepParent dist/ModPDF.app "ModPDF-${GITHUB_REF_NAME}-macos-arm64.zip"
|
|
135
|
+
gh release upload "$GITHUB_REF_NAME" "ModPDF-${GITHUB_REF_NAME}-macos-arm64.zip"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: security
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
schedule:
|
|
8
|
+
# Dependencies pick up CVEs while nobody is pushing. Check weekly.
|
|
9
|
+
- cron: "17 6 * * 1"
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
audit:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v5
|
|
19
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
20
|
+
|
|
21
|
+
- name: Install
|
|
22
|
+
run: uv sync --locked --all-groups
|
|
23
|
+
|
|
24
|
+
# Audit the lockfile rather than the environment. modpdf itself is not on
|
|
25
|
+
# PyPI, and auditing the installed set makes --strict fail on it -- as an
|
|
26
|
+
# unauditable dependency, or as a skipped one under --skip-editable. The
|
|
27
|
+
# export is every locked version and nothing else, which is what we
|
|
28
|
+
# actually want checked.
|
|
29
|
+
- name: Known vulnerabilities in dependencies
|
|
30
|
+
run: |
|
|
31
|
+
uv export --locked --all-groups --no-emit-project --no-hashes \
|
|
32
|
+
--output-file requirements-audit.txt
|
|
33
|
+
uv run pip-audit --strict --desc --no-deps -r requirements-audit.txt
|
|
34
|
+
|
|
35
|
+
- name: Static analysis of our own code
|
|
36
|
+
run: uv run bandit -r src/ -c pyproject.toml
|
|
37
|
+
|
|
38
|
+
# An independent check on the no-network promise. netguard blocks sockets
|
|
39
|
+
# inside the process; this blocks them outside it. If a dependency starts
|
|
40
|
+
# phoning home, one of the two will catch it.
|
|
41
|
+
offline:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
|
|
46
|
+
# PySide6 links against these at import time, even for the "offscreen"
|
|
47
|
+
# platform the GUI tests run under — there is no display on this runner.
|
|
48
|
+
- name: Qt runtime libraries
|
|
49
|
+
run: |
|
|
50
|
+
sudo apt-get update
|
|
51
|
+
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3
|
|
52
|
+
|
|
53
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
54
|
+
|
|
55
|
+
- name: Install
|
|
56
|
+
run: uv sync --locked --all-groups
|
|
57
|
+
|
|
58
|
+
# Run the suite inside a network namespace with no interfaces but
|
|
59
|
+
# loopback. Firewalling the whole runner would also cut its own link to
|
|
60
|
+
# GitHub, so the job could never report what it found.
|
|
61
|
+
- name: Tests must pass with no network at all
|
|
62
|
+
run: sudo unshare --net .venv/bin/python -m pytest -v
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
# Every OS on the newest Python, plus the oldest supported versions on Linux
|
|
17
|
+
# only. The older versions differ in the language, not the platform, so one
|
|
18
|
+
# OS is enough to catch them, and it keeps pull request feedback quick.
|
|
19
|
+
test:
|
|
20
|
+
runs-on: ${{ matrix.os }}
|
|
21
|
+
strategy:
|
|
22
|
+
fail-fast: false
|
|
23
|
+
matrix:
|
|
24
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
25
|
+
python: ["3.13"]
|
|
26
|
+
include:
|
|
27
|
+
- os: ubuntu-latest
|
|
28
|
+
python: "3.11"
|
|
29
|
+
- os: ubuntu-latest
|
|
30
|
+
python: "3.12"
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v5
|
|
34
|
+
|
|
35
|
+
# PySide6 links against these at import time, even for the "offscreen"
|
|
36
|
+
# platform the GUI tests run under — there is no display on this
|
|
37
|
+
# runner. Not needed on macOS or Windows: those wheels carry what they
|
|
38
|
+
# need.
|
|
39
|
+
- name: Qt runtime libraries (Linux only)
|
|
40
|
+
if: runner.os == 'Linux'
|
|
41
|
+
run: |
|
|
42
|
+
sudo apt-get update
|
|
43
|
+
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3
|
|
44
|
+
|
|
45
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
46
|
+
with:
|
|
47
|
+
enable-cache: true
|
|
48
|
+
|
|
49
|
+
- name: Install
|
|
50
|
+
run: uv sync --locked --all-groups --python ${{ matrix.python }}
|
|
51
|
+
|
|
52
|
+
- name: Lint
|
|
53
|
+
run: uv run ruff check --output-format=github .
|
|
54
|
+
|
|
55
|
+
- name: Format check
|
|
56
|
+
run: uv run ruff format --check .
|
|
57
|
+
|
|
58
|
+
- name: Types
|
|
59
|
+
run: uv run mypy
|
|
60
|
+
|
|
61
|
+
- name: Tests
|
|
62
|
+
run: uv run pytest -v --cov --cov-report=term-missing
|
modpdf-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.venv/
|
|
8
|
+
|
|
9
|
+
# Tooling
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
.hypothesis/
|
|
16
|
+
|
|
17
|
+
# Test corpus downloaded by scripts/fetch_corpus.py — never committed
|
|
18
|
+
tests/fixtures/corpus/
|
|
19
|
+
|
|
20
|
+
# Never commit real documents. This is a privacy tool; treat it like one.
|
|
21
|
+
*.pdf
|
|
22
|
+
!tests/fixtures/generated/*.pdf
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
|
|
27
|
+
# Written by the audit job from uv.lock; never committed.
|
|
28
|
+
requirements-audit.txt
|
|
29
|
+
|
|
30
|
+
# Local design-tool exports (mockups, canvas layout). Not shipped code;
|
|
31
|
+
# screenshots of the real, running app go in README.md instead.
|
|
32
|
+
/design/
|
|
33
|
+
|
|
34
|
+
# Local-only folders: documents to try the app on, and the helper that
|
|
35
|
+
# regenerates the README's screenshots. Neither belongs in the repository.
|
|
36
|
+
/PDFs/
|
|
37
|
+
/scripts/make_readme_screenshots.py
|
|
38
|
+
|
|
39
|
+
# Generated by packaging/macos/make_icon.py from mark_pixmap() — a build
|
|
40
|
+
# artifact, not source, the same as dist/ and build/ above.
|
|
41
|
+
packaging/macos/AppIcon.icns
|
|
42
|
+
packaging/macos/AppIcon.iconset/
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-merge-conflict
|
|
6
|
+
- id: check-toml
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: end-of-file-fixer
|
|
9
|
+
- id: trailing-whitespace
|
|
10
|
+
# This is a privacy tool. A stray real document in a commit would be
|
|
11
|
+
# embarrassing at best, so keep the size ceiling low.
|
|
12
|
+
- id: check-added-large-files
|
|
13
|
+
args: ["--maxkb=512"]
|
|
14
|
+
|
|
15
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
16
|
+
rev: v0.16.7 # keep in step with ruff's version in uv.lock, or the two disagree
|
|
17
|
+
hooks:
|
|
18
|
+
- id: ruff-check
|
|
19
|
+
args: [--fix]
|
|
20
|
+
- id: ruff-format
|
|
21
|
+
|
|
22
|
+
# The same scan the security workflow runs on src/. Here so a finding shows up
|
|
23
|
+
# before a push instead of after it, as a bare `assert` once did.
|
|
24
|
+
- repo: https://github.com/PyCQA/bandit
|
|
25
|
+
rev: 1.9.4 # keep in step with bandit's version in uv.lock
|
|
26
|
+
hooks:
|
|
27
|
+
- id: bandit
|
|
28
|
+
args: ["-c", "pyproject.toml", "-q"]
|
|
29
|
+
files: ^src/
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
uses [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `split`, `merge` and `reorder`. Page numbers are 1-based and inclusive, with
|
|
12
|
+
`-1` for the last page and `12-` for "to the end".
|
|
13
|
+
- Bookmarks are rebuilt against the new page order instead of being dropped.
|
|
14
|
+
Entries orphaned by a removed parent heading are promoted rather than deleted,
|
|
15
|
+
and named destinations are resolved from both `/Dests` and `/Names`.
|
|
16
|
+
- `inspect`, which reports what a PDF contains besides its pages: JavaScript,
|
|
17
|
+
automatic actions, embedded attachments, XFA forms, outbound links, metadata,
|
|
18
|
+
and earlier revisions still recoverable inside the file. `--json` included.
|
|
19
|
+
- `sanitize`, which removes all of the above by rebuilding the document from its
|
|
20
|
+
pages, so payloads are absent from the output rather than merely unreferenced.
|
|
21
|
+
Plain links are kept unless `--strip-links` is given.
|
|
22
|
+
- An enforced no-network guarantee. The process replaces its own socket, DNS and
|
|
23
|
+
TLS entry points with ones that refuse, and the test suite asserts both that
|
|
24
|
+
network calls fail and that PDF work still succeeds while they do.
|
|
25
|
+
- Warnings when output is written into an iCloud Drive, Dropbox, Google Drive or
|
|
26
|
+
OneDrive folder, because a local-only tool writing into a sync folder is not
|
|
27
|
+
local in any way the user cares about.
|
|
28
|
+
- Reporting when QPDF had to repair a damaged file, so a silently reconstructed
|
|
29
|
+
document is not mistaken for an intact one.
|
|
30
|
+
- Encrypted documents, via `--password-stdin` or `MODPDF_PASSWORD`. There is
|
|
31
|
+
deliberately no flag that takes a password as its value.
|
|
32
|
+
- File-size and page-count limits on every input, with clear refusals.
|
|
33
|
+
- Atomic, mode-0600 output writing; split directories at mode 0700.
|
|
34
|
+
- `compress`, with a quality gate (`modpdf/verify.py`) that renders every page
|
|
35
|
+
before and after and compares them, falling back to a lossless result — and
|
|
36
|
+
saying so — the moment any page looks different enough to matter.
|
|
37
|
+
- Oversized images are downsampled and re-encoded with the codec their actual
|
|
38
|
+
pixel values call for: CCITT Group 4 for anything overwhelmingly near-black
|
|
39
|
+
or near-white, whatever colour space it happens to be stored in, JPEG for
|
|
40
|
+
genuine photographs. CMYK images, indexed images and anything carrying a
|
|
41
|
+
transparency mask are left untouched rather than risked.
|
|
42
|
+
- `--lossless` and three tuned `--level` presets (`low`, `balanced`, `high`),
|
|
43
|
+
rather than a raw DPI number to guess at. Only `high` is allowed to accept
|
|
44
|
+
a more visibly different result in exchange for a smaller file; `low` and
|
|
45
|
+
`balanced` both still promise no visible loss.
|
|
46
|
+
- `high` re-encodes every eligible image even when it was not oversized.
|
|
47
|
+
Otherwise a document whose images were already reasonably sized had
|
|
48
|
+
nothing left for "maximum compression" to do beyond what `balanced`
|
|
49
|
+
already did, and the report says so when that happens.
|
|
50
|
+
- `balanced` and `high` can also rasterize a page whose own vector content —
|
|
51
|
+
not an image at all, a complex diagram exported as drawing commands — is
|
|
52
|
+
heavy enough to be worth it, each at its own resolution and JPEG quality,
|
|
53
|
+
since no image setting touches vector art. Every character of text on that
|
|
54
|
+
page, including text that is itself part of the diagram, stays exactly as
|
|
55
|
+
it was and stays selectable. `low` never does this. `balanced`'s flattened
|
|
56
|
+
result still has to clear the same strict quality gate as everything else
|
|
57
|
+
it does, so this did not loosen its no-visible-loss promise; only `high`
|
|
58
|
+
widens what the gate will accept.
|
|
59
|
+
- `high`'s own image resolution and JPEG quality are kept deliberately close
|
|
60
|
+
to lossless — an early, more aggressive version made a flattened diagram's
|
|
61
|
+
own text hard to read. One consequence worth knowing: on an ordinary photo
|
|
62
|
+
or scan with no vector page to flatten, `high` has little left to trade,
|
|
63
|
+
and `balanced` can end up producing the smaller file.
|
|
64
|
+
- A desktop application (`modpdf-gui`, optional extra `modpdf[gui]`, PySide6):
|
|
65
|
+
page thumbnails with selection, drag-to-reorder (tracked by the mouse
|
|
66
|
+
directly rather than through Qt's own drag-and-drop, which does not
|
|
67
|
+
reliably initiate a session at all), delete, duplicate, extract, split by
|
|
68
|
+
hand-built page ranges, compress, sanitize and merge-by-dropping. Edits are held in memory as an ordering of the source
|
|
69
|
+
pages, so nothing is written until you save and Revert is free. Thumbnails
|
|
70
|
+
are never written to disk and no recent-files list is kept.
|
|
71
|
+
- The desktop app's Open button now builds a workspace out of more than one
|
|
72
|
+
file: with a document already open, choosing (or dropping) another one adds
|
|
73
|
+
its pages after the first's instead of replacing it, honouring whatever
|
|
74
|
+
reordering or deletion was already pending. No second button — Open is
|
|
75
|
+
overloaded rather than duplicated, since "bring this file into the
|
|
76
|
+
workspace" is one action either way. Backed by `tasks.append_document` and
|
|
77
|
+
a private, per-window `0700` scratch directory
|
|
78
|
+
(`security/fs.private_scratch_dir`) that is removed when the window closes.
|
|
79
|
+
|
|
80
|
+
- A Settings dialog in the desktop app, behind a gear in the header: thumbnail
|
|
81
|
+
size (small, medium or large), the compression level the Compress panel
|
|
82
|
+
starts on, and light or dark. Every choice applies immediately. Switching
|
|
83
|
+
theme rebuilds the window in place and keeps the open document, page
|
|
84
|
+
selection, active panel, split ranges and compress choice as they were.
|
|
85
|
+
The three values are stored in the operating system's own per-user
|
|
86
|
+
settings location (`modpdf/gui/settings.py`). They are the only thing the
|
|
87
|
+
app remembers between runs, and none of them is a path or anything else
|
|
88
|
+
about a document.
|
|
89
|
+
- `packaging/macos/build.sh`, which builds an unsigned `ModPDF.app` with
|
|
90
|
+
PyInstaller. Its icon is generated from the same drawn mark the window
|
|
91
|
+
uses, so there is still no image asset in the repository. Signing and
|
|
92
|
+
notarization are not done yet; see `packaging/macos/README.md`.
|
|
93
|
+
|
|
94
|
+
- A release workflow. Pushing a version tag publishes to PyPI with Trusted
|
|
95
|
+
Publishing (no API token exists to leak), then creates a GitHub Release
|
|
96
|
+
with the package files, a CycloneDX SBOM of every runtime dependency, and
|
|
97
|
+
an unsigned macOS app. Running it by hand publishes to TestPyPI as a dry
|
|
98
|
+
run.
|
|
99
|
+
- `bandit` now runs in pre-commit, on the same files the security workflow
|
|
100
|
+
scans.
|
|
101
|
+
- ADRs for pikepdf over Ghostscript, the compression quality gate, blocking
|
|
102
|
+
the network at runtime, and the PDFium lock. `docs/compression.md` holds the
|
|
103
|
+
compression details that used to fill most of the README.
|
|
104
|
+
|
|
105
|
+
### Changed
|
|
106
|
+
|
|
107
|
+
- The README was rewritten to be shorter, with screenshots at the top.
|
|
108
|
+
- The desktop app's layout was redesigned: a new header and toolbar, a
|
|
109
|
+
drop-zone empty state, a document details card, and a set of line icons
|
|
110
|
+
drawn in code (`modpdf/gui/icons.py`) rather than loaded from files or an
|
|
111
|
+
icon font.
|
|
112
|
+
|
|
113
|
+
- Whole operations moved into `modpdf/tasks.py`, which the command line and the
|
|
114
|
+
desktop app both call. Neither interface implements a PDF operation of its own,
|
|
115
|
+
and neither can reach the filesystem without the security layer.
|
|
116
|
+
- `Pdf.save` options can now be threaded through `document.save_pdf`, so a
|
|
117
|
+
caller that computed a file's size with particular save options (compression
|
|
118
|
+
does, for its structural pass) writes the file with the same ones — the
|
|
119
|
+
number in a report always matches the number on disk.
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
|
|
123
|
+
- `inspect` always reported "0 images". Its scan skipped every object that
|
|
124
|
+
wasn't a plain dictionary, and an image is always a stream. Soft masks are
|
|
125
|
+
not counted as images of their own, so a transparent picture counts once.
|
|
126
|
+
- In dark mode, radio buttons were invisible until checked: the platform style
|
|
127
|
+
drew their rings in light-theme grey. They're now drawn by the app's own
|
|
128
|
+
stylesheet in both themes.
|
|
129
|
+
- Hints under the Compress panel's options could lose their second line. The
|
|
130
|
+
indent was stylesheet padding, which QLabel's word wrap doesn't measure, and
|
|
131
|
+
a panel taller than the window was squashed to fit. Hints now use a real
|
|
132
|
+
margin, and every inspector panel scrolls when it doesn't fit.
|
|
133
|
+
|
|
134
|
+
- The desktop app rendered every page thumbnail on its own window thread, so
|
|
135
|
+
opening a large document froze the window until the last page was done.
|
|
136
|
+
The renderer had been moved to a worker thread and then called directly —
|
|
137
|
+
and a direct call runs on the caller's thread regardless. Requests now go
|
|
138
|
+
out as queued signals. Each carries a generation number, so a thumbnail
|
|
139
|
+
still arriving for a document or size that has since been replaced is
|
|
140
|
+
dropped instead of landing on the wrong tile.
|
|
141
|
+
- PDFium is not thread-safe, even across unrelated documents, and the desktop
|
|
142
|
+
app could enter it from two threads at once: the thumbnail renderer, and a
|
|
143
|
+
compression running in the background whose quality gate renders every
|
|
144
|
+
page. That can crash the whole process. Every PDFium call now holds one
|
|
145
|
+
process-wide lock (`modpdf/pdfium_lock.py`). The command line is
|
|
146
|
+
single-threaded, so the lock costs it nothing.
|
|
147
|
+
|
|
148
|
+
- A desktop-app background job started from inside another job's own
|
|
149
|
+
completion callback — exactly what adding a second file does, by opening
|
|
150
|
+
the combined result it just wrote — could vanish silently: nothing kept the
|
|
151
|
+
first job's Python object alive between it emitting its result and the main
|
|
152
|
+
thread actually receiving it, so it was sometimes garbage collected in
|
|
153
|
+
between and the window was left saying "Adding…" forever, with no error.
|
|
154
|
+
`workers.run` now holds a reference to every job until its result has
|
|
155
|
+
actually been delivered.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
git clone https://github.com/BryanIGD/ModPDF.git
|
|
9
|
+
cd ModPDF
|
|
10
|
+
uv sync --all-groups
|
|
11
|
+
uv run pytest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Before opening a pull request, all of these should pass — they're exactly
|
|
15
|
+
what CI runs:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
uv run ruff check .
|
|
19
|
+
uv run ruff format --check .
|
|
20
|
+
uv run mypy
|
|
21
|
+
uv run pytest -v --cov
|
|
22
|
+
uv run bandit -r src/ -c pyproject.toml
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## YAGNI is enforced, not aspirational
|
|
26
|
+
|
|
27
|
+
This is a security-focused tool, and bloat in a security tool is worse than
|
|
28
|
+
bloat elsewhere: every abstraction is more code someone has to read before
|
|
29
|
+
they can trust it. Concretely:
|
|
30
|
+
|
|
31
|
+
- **Build only what a shipped command needs.** No feature flags, config
|
|
32
|
+
options, or extension points without a caller today.
|
|
33
|
+
- **No abstraction before a second implementation exists.** We call pikepdf
|
|
34
|
+
directly; there is no `PdfEngine` interface, because there is no second
|
|
35
|
+
engine. If one ever appears, extract the interface then, informed by real
|
|
36
|
+
requirements instead of a guess.
|
|
37
|
+
- **Every dependency must be load-bearing.** Adding one means updating the
|
|
38
|
+
comment in `pyproject.toml` that names the count, adding an inline comment
|
|
39
|
+
saying what it's for, and — if it changes the shape of a real decision, not
|
|
40
|
+
just a version bump — a note in `docs/adr/`.
|
|
41
|
+
- **Flat over deep.** One module per concept. Split a file when it's
|
|
42
|
+
genuinely hard to read, not in anticipation of it becoming so.
|
|
43
|
+
|
|
44
|
+
If a change adds a layer "for future flexibility," it needs a real, current
|
|
45
|
+
caller in the same pull request, or the flexibility waits until something
|
|
46
|
+
actually needs it.
|
|
47
|
+
|
|
48
|
+
## How this project writes documentation
|
|
49
|
+
|
|
50
|
+
The README and this file are written the way a person explains something to
|
|
51
|
+
another person, not the way software project marketing usually reads. In
|
|
52
|
+
practice:
|
|
53
|
+
|
|
54
|
+
- No emoji in headings, no "blazing fast," no badge walls.
|
|
55
|
+
- Examples show real output from the actual commands, not invented sample
|
|
56
|
+
text — the point of `tests/conftest.py`'s generated fixtures is that
|
|
57
|
+
examples can be captured and stay true.
|
|
58
|
+
- A limitations section should be specific and slightly unflattering. "This
|
|
59
|
+
won't shrink a text-only PDF much, here's why" is worth more than silence
|
|
60
|
+
on the subject.
|
|
61
|
+
- If a sentence could be deleted without losing information, delete it.
|
|
62
|
+
|
|
63
|
+
## Tests
|
|
64
|
+
|
|
65
|
+
Every PDF the test suite touches is generated at test time by
|
|
66
|
+
`tests/conftest.py`; no real document is ever committed, and pages carry a
|
|
67
|
+
marker so a test can assert that page 3 really is the page that started as
|
|
68
|
+
page 3 after a split or reorder — not just that the page *count* looks right.
|
|
69
|
+
|
|
70
|
+
- An operation change needs a test asserting page **identity**, not only page
|
|
71
|
+
count.
|
|
72
|
+
- A change to anything in `modpdf/security/` needs a test in
|
|
73
|
+
`tests/security/`, and if it touches the network guard specifically,
|
|
74
|
+
confirm the change still passes with the network blocked.
|
|
75
|
+
- A GUI change that reaches `modpdf.tasks` needs the window-level test to
|
|
76
|
+
call the task function directly (as the existing GUI tests do) rather than
|
|
77
|
+
only exercising it through a live `QThreadPool` job — except when the
|
|
78
|
+
threading itself is what's being tested, as in `tests/gui/test_workers.py`.
|
|
79
|
+
|
|
80
|
+
## Structure
|
|
81
|
+
|
|
82
|
+
The command line and the desktop app are two interfaces over one set of
|
|
83
|
+
functions in `modpdf/tasks.py`. Neither implements a PDF operation of its
|
|
84
|
+
own, and neither can reach the filesystem without going through
|
|
85
|
+
`modpdf/security/`. A change that adds behavior only one interface can use is
|
|
86
|
+
a sign it belongs in the wrong layer.
|
|
87
|
+
|
|
88
|
+
## Commit messages and pull requests
|
|
89
|
+
|
|
90
|
+
Say what changed and why, the way the rest of this codebase's comments do.
|
|
91
|
+
"Fix bug" is not that; "the page-count check ran before the password prompt,
|
|
92
|
+
so an encrypted file always failed" is.
|