Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ body:
attributes:
label: PyMuPDF version
options:
- 1.26.7
- 1.26.6
- 1.26.5
- 1.26.4
Expand Down
10 changes: 9 additions & 1 deletion changes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ Change Log
==========


**Changes in version 1.26.6**
**Changes in version 1.26.7**

Other:

* Retrospectively mark `4756 <https://github.com/pymupdf/PyMuPDF/issues/4756>`_ as fixed in 1.26.6.


**Changes in version 1.26.6** (2025-11-05)

* Use MuPDF-1.26.11.

Expand All @@ -15,6 +22,7 @@ Change Log
* **Fixed** `4720 <https://github.com/pymupdf/PyMuPDF/issues/4720>`_: Memory leaking in rewrite_images?
* **Fixed** `4742 <https://github.com/pymupdf/PyMuPDF/issues/4742>`_: 'Rect' object has no attribute 'get_area'
* **Fixed** `4746 <https://github.com/pymupdf/PyMuPDF/issues/4746>`_: Document.__init__() got an unexpected keyword argument 'encoding'
* **Fixed** `4756 <https://github.com/pymupdf/PyMuPDF/issues/4756>`_: swig --version doesn't work in all versions of swig; -version should be used instead


**Changes in version 1.26.5** (2025-10-10)
Expand Down
120 changes: 105 additions & 15 deletions pipcl.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import inspect
import io
import os
import pickle
import platform
import re
import shlex
Expand Down Expand Up @@ -2690,15 +2691,17 @@ def run_if( command, out, *prerequisites, caller=1):

Args:
command:
The command to run. We write this into a file <out>.cmd so that we
know to run a command if the command itself has changed.
The command to run. We write this and a hash of argv[0] into a file
<out>.cmd so that we know to run a command if the command itself
has changed.
out:
Path of the output file.

prerequisites:
List of prerequisite paths or true/false/None items. If an item
is None it is ignored, otherwise if an item is not a string we
immediately return it cast to a bool.
immediately return it cast to a bool. We recurse into directories,
effectively using the newest file in the directory.

Returns:
True if we ran the command, otherwise None.
Expand Down Expand Up @@ -2757,25 +2760,75 @@ def run_if( command, out, *prerequisites, caller=1):

>>> run_if( f'touch {out}', out, prerequisite, caller=0)
pipcl.py:run_if(): Not running command because up to date: 'run_if_test_out'

We detect changes to the contents of argv[0]:

Create a shell script and run it:

>>> _ = subprocess.run('rm run_if_test_argv0.* 1>/dev/null 2>/dev/null || true', shell=1)
>>> with open('run_if_test_argv0.sh', 'w') as f:
... print('#! /bin/sh', file=f)
... print('echo hello world > run_if_test_argv0.out', file=f)
>>> _ = subprocess.run(f'chmod u+x run_if_test_argv0.sh', shell=1)
>>> run_if( f'./run_if_test_argv0.sh', f'run_if_test_argv0.out', caller=0)
pipcl.py:run_if(): Running command because: File does not exist: 'run_if_test_argv0.out'
pipcl.py:run_if(): Running: ./run_if_test_argv0.sh
True

Running it a second time does nothing:

>>> run_if( f'./run_if_test_argv0.sh', f'run_if_test_argv0.out', caller=0)
pipcl.py:run_if(): Not running command because up to date: 'run_if_test_argv0.out'

Modify the script.

>>> with open('run_if_test_argv0.sh', 'a') as f:
... print('\\necho hello >> run_if_test_argv0.out', file=f)

And now it is run because the hash of argv[0] has changed:

>>> run_if( f'./run_if_test_argv0.sh', f'run_if_test_argv0.out', caller=0)
pipcl.py:run_if(): Running command because: arg0 hash has changed.
pipcl.py:run_if(): Running: ./run_if_test_argv0.sh
True
'''
doit = False

# Path of file containing pickle data for command and hash of command's
# first arg.
cmd_path = f'{out}.cmd'

def hash_get(path):
try:
with open(path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
except Exception as e:
#log(f'Failed to get hash of {path=}: {e}')
return None

command_args = shlex.split(command or '')
command_arg0_path = fs_find_in_paths(command_args[0])
command_arg0_hash = hash_get(command_arg0_path)

cmd_args, cmd_arg0_hash = (None, None)
if os.path.isfile(cmd_path):
with open(cmd_path, 'rb') as f:
try:
cmd_args, cmd_arg0_hash = pickle.load(f)
except Exception as e:
#log(f'pickle.load() failed with {cmd_path=}: {e}')
pass

if not doit:
# Set doit if outfile does not exist.
out_mtime = _fs_mtime( out)
if out_mtime == 0:
doit = f'File does not exist: {out!r}'

if not doit:
if os.path.isfile( cmd_path):
with open( cmd_path) as f:
cmd = f.read()
else:
cmd = None
cmd_args = shlex.split(cmd or '')
command_args = shlex.split(command or '')
# Set doit if command has changed.
if command_args != cmd_args:
if cmd is None:
if cmd_args is None:
doit = 'No previous command stored'
else:
doit = f'Command has changed'
Expand All @@ -2791,8 +2844,8 @@ def run_if( command, out, *prerequisites, caller=1):
# shlex.split().
doit += ':\n'
lines = difflib.unified_diff(
cmd.split(),
command.split(),
cmd_args,
command_args,
lineterm='',
)
# Skip initial lines.
Expand All @@ -2801,6 +2854,13 @@ def run_if( command, out, *prerequisites, caller=1):
for line in lines:
doit += f' {line}\n'

if not doit:
# Set doit if argv[0] hash has changed.
#print(f'{cmd_arg0_hash=} {command_arg0_hash=}', file=sys.stderr)
if command_arg0_hash != cmd_arg0_hash:
doit = f'arg0 hash has changed.'
#doit = f'arg0 hash has changed from {cmd_arg0_hash=} to {command_arg0_hash=}..'

if not doit:
# See whether any prerequisites are newer than target.
def _make_prerequisites(p):
Expand Down Expand Up @@ -2845,8 +2905,9 @@ def _make_prerequisites(p):
run( command, caller=caller+1)

# Write the command we ran, into `cmd_path`.
with open( cmd_path, 'w') as f:
f.write( command)

with open(cmd_path, 'wb') as f:
pickle.dump((command_args, command_arg0_hash), f)
return True
else:
log1( f'Not running command because up to date: {out!r}', caller=caller+1)
Expand All @@ -2857,6 +2918,35 @@ def _make_prerequisites(p):
)


def fs_find_in_paths( name, paths=None, verbose=False):
'''
Looks for `name` in paths and returns complete path. `paths` is list/tuple
or `os.pathsep`-separated string; if `None` we use `$PATH`. If `name`
contains `/`, we return `name` itself if it is a file or None, regardless
of <paths>.
'''
if '/' in name:
return name if os.path.isfile( name) else None
if paths is None:
paths = os.environ.get( 'PATH', '')
if verbose:
log('From os.environ["PATH"]: {paths=}')
if isinstance( paths, str):
paths = paths.split( os.pathsep)
if verbose:
log('After split: {paths=}')
for path in paths:
p = os.path.join( path, name)
if verbose:
log('Checking {p=}')
if os.path.isfile( p):
if verbose:
log('Returning because is file: {p!r}')
return p
if verbose:
log('Returning None because not found: {name!r}')


def _get_prerequisites(path):
'''
Returns list of prerequisites from Makefile-style dependency file, e.g.
Expand Down
7 changes: 4 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ def sdist():
#

# PyMuPDF version.
version_p = '1.26.6'
version_p = '1.26.7'

version_mupdf = '1.26.11'

Expand Down Expand Up @@ -1412,8 +1412,9 @@ def platform_release_tuple():
print(f'OpenBSD: pip install of swig does not build; assuming `pkg_add swig`.')
elif PYMUPDF_SETUP_SWIG:
pass
elif darwin:
# 2025-10-27: new swig-4.4.0 fails badly at runtime.
elif darwin or os.environ.get('PYODIDE_ROOT'):
# 2025-10-27: new swig-4.4.0 fails badly at runtime on macos.
# 2025-11-06: similar for pyodide.
ret.append('swig==4.3.1')
else:
ret.append('swig')
Expand Down
8 changes: 8 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,11 @@ To skip tests before a particular test, set PYMUPDF_PYTEST_RESUME to the name
of the function.

For example PYMUPDF_PYTEST_RESUME=test_haslinks.


## Checks on all tests

All tests are wrapped by tests/conftest.py:wrap(), which does additional checking.

* Checks that pymupdf.TOOLS.mupdf_warnings() is empty.
* Checks tests do not modify globals such as small_glyph_heights.
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
# before tests start to run.
#
def install_required_packages():
PYODIDE_ROOT = os.environ.get('PYODIDE_ROOT')
if PYODIDE_ROOT:
# We can't run child processes, so rely on required test packages
# already being installed, e.g. in our wheel's <requires_dist>.
return
packages = 'pytest fontTools pymupdf-fonts flake8 pylint codespell'
if platform.system() == 'Windows' and int.bit_length(sys.maxsize+1) == 32:
# No pillow wheel available, and doesn't build easily.
Expand Down
17 changes: 16 additions & 1 deletion tests/test_pixmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import os
import platform
import re
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -521,7 +522,21 @@ def test_4435():
with pymupdf.open(path) as document:
page = document[2]
print(f'Calling page.get_pixmap().', flush=1)
pixmap = page.get_pixmap(alpha=False, dpi=120)
if os.environ.get('PYODIDE_ROOT'):
# 2025-11-07: Expect alloc failure.
try:
pixmap = page.get_pixmap(alpha=False, dpi=120)
except Exception as e:
print(f'Received exception: {type(e)=}: {e}')
assert isinstance(e, pymupdf.mupdf.FzErrorSystem), f'Unrecognised {type(e)=}'
m = re.match('code=2: malloc [(][0-9]+ bytes[)] failed', str(e))
assert m, f'Unrecognised exception text: {e}'
return
else:
# Hopefully this means that mupdf has been fixed.
assert 0, 'Expected alloc failure on pyodide'
else:
pixmap = page.get_pixmap(alpha=False, dpi=120)
print(f'Called page.get_pixmap().', flush=1)
if pymupdf.mupdf_version_tuple < (1, 27):
assert pymupdf.TOOLS.mupdf_warnings() == 'bogus font ascent/descent values (0 / 0)\n... repeated 9 times...'
Expand Down