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
10 changes: 10 additions & 0 deletions changes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ Change Log

* Fixed issues:

* **Fixed** `4928 <https://github.com/pymupdf/PyMuPDF/issues/4928>`_: pymupdf.Document.scrub raises AttributeError for a document with annotations
* **Fixed** `4942 <https://github.com/pymupdf/PyMuPDF/issues/4942>`_: bug: IndexError for Page.get_links after Page.clip_to_rect
* **Fixed** `4954 <https://github.com/pymupdf/PyMuPDF/issues/4954>`_: get_drawings() returns incorrect lineJoin and width
* **Fixed** `4958 <https://github.com/pymupdf/PyMuPDF/issues/4958>`_: bug: inserting rotated pages to another document messes up link coordinates

* Other:

* Fixed incorrect generation of `lineJoin j` in PDF content, introduced in 1.27.2.2.
* Allow build to (incorrectly) claim to be thread-safe, for #4760. See setup.py for details.
* Use pypi.org's pipcl package instead of our own pipcl.py file.


**Changes in version 1.27.2.2** (2026-03-20)
Expand Down Expand Up @@ -40,6 +47,9 @@ Change Log
just those within images. This means that OCR will now also be performed
for vector graphics, and for text containing illegible characters.

* Provide a Linux wheel for free-threading python,
specifically cp314-cp314t-manylinux_2_28_x86_64.


**Changes in version 1.27.1** (2026-02-11)

Expand Down
54 changes: 37 additions & 17 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,43 @@
#
# The full version, including alpha/beta/rc tags.

# PyMuPDF version is set in setup.py, so we import it here.
sys.path.insert(0, os.path.abspath(f'{__file__}/../..'))
try:
import setup
finally:
del sys.path[0]
version = setup.version_p
del setup # Necessary otherwise sphinx seems to do `setup()`.

# Supported Python versions are set in scripts.test.py.
sys.path.insert(0, os.path.abspath(f'{__file__}/../../scripts'))
try:
import test
finally:
del sys.path[0]
python_versions_minor = test.python_versions_minor
del test
if 1:
# Importing setup.py requires pipcl etc so instead of importing, we grep
# for the version info in setup.py and scripts/test.py.
setup_py_path = os.path.normpath(f'{__file__}/../../setup.py')
with open(setup_py_path) as f:
setup_py_text = f.read()
regex = "\nversion_p = '([0-9.]+)'\n"
Comment thread
JorjMcKie marked this conversation as resolved.
m = re.search(regex, setup_py_text)
assert m, f'Cannot find version number in {setup_py_path!r} with {regex=}.'
version = m.group(1)

test_py_path = os.path.normpath(f'{__file__}/../../scripts/test.py')
with open(test_py_path) as f:
test_py_text = f.read()
regex = '\npython_versions_minor = (.+)\n'
m = re.search(regex, test_py_text)
assert m, f'Cannot find python_versions_minor in {test_py_path!r} with {regex=}.'
python_versions_minor = m.group(1)
python_versions_minor = eval(m.group(1))
else:
# PyMuPDF version is set in setup.py, so we import it here.
sys.path.insert(0, os.path.abspath(f'{__file__}/../..'))
try:
import setup
finally:
del sys.path[0]
version = setup.version_p
del setup # Necessary otherwise sphinx seems to do `setup()`.

# Supported Python versions are set in scripts.test.py.
sys.path.insert(0, os.path.abspath(f'{__file__}/../../scripts'))
try:
import test
finally:
del sys.path[0]
python_versions_minor = test.python_versions_minor
del test
python_versions_list = [f'3.{i}' for i in python_versions_minor]
python_versions = ', '.join(python_versions_list[:-1]) + f' and {python_versions_list[-1]}'
# Make `|python_versions|` available in .rst files.
Expand Down
10 changes: 3 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
[build-system]
# We define required packages in setup.py:get_requires_for_build_wheel().
requires = []

# See pep-517.
#
build-backend = "setup"
backend-path = ["."]
requires = ['pipcl']
build-backend = "setup"
backend-path = ["."]
2 changes: 1 addition & 1 deletion scripts/gh_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@

pymupdf_dir = os.path.abspath( f'{__file__}/../..')

sys.path.insert(0, pymupdf_dir)
sys.path.insert(0, f'{pymupdf_dir}/src')
import pipcl
del sys.path[0]

Expand Down
2 changes: 1 addition & 1 deletion scripts/sysinstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@

pymupdf_dir = os.path.abspath( f'{__file__}/../..')

sys.path.insert(0, pymupdf_dir)
sys.path.insert(0, f'{pymupdf_dir}/src')
import pipcl
del sys.path[0]

Expand Down
20 changes: 9 additions & 11 deletions scripts/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@
pymupdf_dir_abs = os.path.abspath( f'{__file__}/../..')

try:
sys.path.insert(0, pymupdf_dir_abs)
sys.path.insert(0, f'{pymupdf_dir_abs}/src')
import pipcl
finally:
del sys.path[0]
Expand Down Expand Up @@ -1140,13 +1140,9 @@ def get_requires_for_build_wheel(config_settings=None):
with open(f'{testdir}/pyproject.toml', 'w') as f:
f.write(textwrap.dedent('''
[build-system]
# We define required packages in setup.py:get_requires_for_build_wheel().
requires = []

# See pep-517.
#
build-backend = "setup"
backend-path = ["."]
requires = ['pipcl']
build-backend = 'setup'
backend-path = ['.']
'''))

shutil.copy2(f'{pymupdf_dir_abs}/pipcl.py', f'{testdir}/pipcl.py')
Expand Down Expand Up @@ -1513,16 +1509,17 @@ def getmtime(path):

def get_pyproject_required(ppt=None):
'''
Returns space-separated names of required packages in pyproject.toml. We
Returns list of names of required packages in pyproject.toml. We
do not do a proper parse and rely on the packages being in a single line.
'''
if ppt is None:
ppt = os.path.abspath(f'{__file__}/../../pyproject.toml')
with open(ppt) as f:
for line in f:
m = re.match('^requires = \\[(.*)\\]$', line)
m = re.match('^ *requires = \\[(.*)\\]$', line)
if m:
names = m.group(1).replace(',', ' ').replace('"', '')
names = m.group(1).replace(',', ' ').replace('"', '').replace("'", '')
names = names.split()
return names
else:
assert 0, f'Failed to find "requires" line in {ppt}'
Expand All @@ -1538,6 +1535,7 @@ def wrap_get_requires_for_build_wheel(dir_):
ppt = os.path.join(dir_abs, 'pyproject.toml')
if os.path.exists(ppt):
ret += get_pyproject_required(ppt)
log(f'{ret=}')
if os.path.exists(os.path.join(dir_abs, 'setup.py')):
sys.path.insert(0, dir_abs)
try:
Expand Down
14 changes: 14 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@
debug
memento
release (default)

PYMUPDF_SETUP_FAKE_NOGIL
If '1' we (incorrectly) claim we are thread-safe.

PYMUPDF_SETUP_MUPDF_CLEAN
Unix only. If '1', we do a clean MuPDF build.
Expand Down Expand Up @@ -240,6 +243,7 @@
log(f'{PYMUPDF_SETUP_DUMMY=}')

PYMUPDF_SETUP_SWIG = os.environ.get('PYMUPDF_SETUP_SWIG')
PYMUPDF_SETUP_FAKE_NOGIL = os.environ.get('PYMUPDF_SETUP_FAKE_NOGIL')

def _fs_remove(path):
'''
Expand Down Expand Up @@ -604,6 +608,7 @@ def build():
g_py_limited_api,
PYMUPDF_SETUP_MUPDF_REFCHECK_IF,
PYMUPDF_SETUP_MUPDF_TRACE_IF,
PYMUPDF_SETUP_FAKE_NOGIL,
)
else:
if 'p' not in PYMUPDF_SETUP_FLAVOUR and 'b' not in PYMUPDF_SETUP_FLAVOUR:
Expand All @@ -619,6 +624,7 @@ def build():
PYMUPDF_SETUP_MUPDF_REFCHECK_IF,
PYMUPDF_SETUP_MUPDF_TRACE_IF,
PYMUPDF_SETUP_SWIG,
PYMUPDF_SETUP_FAKE_NOGIL,
)
log( f'build(): mupdf_build_dir={mupdf_build_dir!r}')

Expand Down Expand Up @@ -742,6 +748,7 @@ def int_or_0(text):
text += f'pymupdf_git_branch = {branch!r}\n'
text += f'swig_version = {swig_version!r}\n'
text += f'swig_version_tuple = {swig_version_tuple!r}\n'
text += f'fake_no_gil = {PYMUPDF_SETUP_FAKE_NOGIL=="1"!r}\n'
log(f'_build.py is:\n{textwrap.indent(text, " ")}')
add('p', text.encode(), f'{to_dir}/_build.py')

Expand Down Expand Up @@ -785,6 +792,7 @@ def build_mupdf_windows(
g_py_limited_api,
PYMUPDF_SETUP_MUPDF_REFCHECK_IF,
PYMUPDF_SETUP_MUPDF_TRACE_IF,
PYMUPDF_SETUP_FAKE_NOGIL,
):

assert mupdf_local
Expand Down Expand Up @@ -819,6 +827,8 @@ def build_mupdf_windows(

if g_py_limited_api:
windows_build_tail += f'-Py_LIMITED_API_{pipcl.current_py_limited_api()}'
if PYMUPDF_SETUP_FAKE_NOGIL == '1':
windows_build_tail += '-nogil'
windows_build_tail += f'-x{wp.cpu.bits}-py{wp.version}'
windows_build_dir = f'{mupdf_local}\\{windows_build_tail}'
#log( f'Building mupdf.')
Expand Down Expand Up @@ -900,6 +910,7 @@ def build_mupdf_unix(
PYMUPDF_SETUP_MUPDF_REFCHECK_IF,
PYMUPDF_SETUP_MUPDF_TRACE_IF,
PYMUPDF_SETUP_SWIG,
PYMUPDF_SETUP_FAKE_NOGIL,
):
'''
Builds MuPDF.
Expand Down Expand Up @@ -1010,6 +1021,8 @@ def build_mupdf_unix(
log(f'{g_py_limited_api=}')
if g_py_limited_api:
build_prefix += f'Py_LIMITED_API_{pipcl.current_py_limited_api()}-'
if PYMUPDF_SETUP_FAKE_NOGIL == '1':
build_prefix += 'nogil-'
unix_build_dir = f'{mupdf_local}/build/{build_prefix}{build_type}'
PYMUPDF_SETUP_MUPDF_CLEAN = os.environ.get('PYMUPDF_SETUP_MUPDF_CLEAN')
if PYMUPDF_SETUP_MUPDF_CLEAN == '1':
Expand Down Expand Up @@ -1129,6 +1142,7 @@ def _build_extension( mupdf_local, mupdf_build_dir, build_type, g_py_limited_api
prerequisites_link = libraries,
py_limited_api = g_py_limited_api,
swig = PYMUPDF_SETUP_SWIG,
nogil = (PYMUPDF_SETUP_FAKE_NOGIL=='1')
)

return path_so_leaf
Expand Down
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def install_required_packages():
# already being installed, e.g. in our wheel's <requires_dist>.
return
packages = 'pytest fontTools pymupdf-fonts flake8 pylint codespell mypy'
packages += ' pipcl'
if platform.system() == 'Windows' and int.bit_length(sys.maxsize+1) == 32:
# No pillow wheel available, and doesn't build easily.
pass
Expand Down
2 changes: 1 addition & 1 deletion tests/test_4767.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def get_stdout(cp):
Strips free-threading warning.
'''
stdout = cp.stdout
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1:
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1 and sys._is_gil_enabled():
line0, stdout = stdout.split('\n', 1)
assert 'The global interpreter lock (GIL) has been enabled to load module \'pymupdf._extra\',' in line0
return stdout
Expand Down
1 change: 1 addition & 0 deletions tests/test_annots.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def test_redact4():
line_art = page.get_drawings()
page.add_redact_annot(page.rect)
page.apply_redactions(graphics=0)
doc.save(os.path.normpath(f'{__file__}/../../tests/test_redact4_out.pdf'))
assert not page.get_text("words")
assert line_art == page.get_drawings()

Expand Down
6 changes: 1 addition & 5 deletions tests/test_codespell.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,7 @@ def test_codespell():
--ignore-multiline-regex 'codespell:ignore-begin.*codespell:ignore-end'
''')

sys.path.append(root)
try:
import pipcl
finally:
del sys.path[0]
import pipcl

git_files = pipcl.git_items(root)

Expand Down
36 changes: 36 additions & 0 deletions tests/test_drawings.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,39 @@ def test_3591():
paths = page.get_drawings()
for p in paths:
assert p["width"] == 15


def test_4954_1():
path_out = os.path.normpath(f'{__file__}/../../tests/test_4954_1.pdf')
with pymupdf.open() as document:
page = document.new_page(width=200, height=200)
shape = page.new_shape()
shape.draw_line((0, 0), (1, 1))
shape.finish(color=(0, 0, 0), width=0.1)
shape.commit()
content = b'q\n0.12 0 0 0.12 0 0 cm\n2 j\n6 w\n100 100 m\n800 100 l\nS\nQ\n'
document.update_stream(page.get_contents()[0], content, compress=0)
document.save(path_out)

with pymupdf.open(path_out) as document:
d = document[0].get_drawings()[-1]
print(f'{d["lineJoin"]=}') # Expected: 2, Actual: 0.24
assert d['lineJoin'] == 2


def test_4954_2():
path_out = os.path.normpath(f'{__file__}/../../tests/test_4954_2.pdf')
with pymupdf.open() as document:
page = document.new_page(width=200, height=200)
shape = page.new_shape()
shape.draw_line((0, 0), (1, 1))
shape.finish(color=(1, 0, 0), width=0.1)
shape.commit()
content = b'q\n2 0 0 3 0 0 cm\n1 w\n10 10 m\n90 10 l\nS\nQ\n'
document.update_stream(page.get_contents()[0], content, compress=0)
document.save(path_out)

with pymupdf.open(path_out) as document:
d = document[0].get_drawings()[-1]
print(f'{d["width"]=}') # Expected: 2.0, Actual: 1.0
assert abs(d['width'] - 2.449) < 0.01
16 changes: 4 additions & 12 deletions tests/test_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,15 +1082,11 @@ def test_cli_out():
import platform
import re
import subprocess
import pipcl
log_prefix = None
if os.environ.get('PYMUPDF_USE_EXTRA') == '0':
log_prefix = f'.+Using non-default setting from PYMUPDF_USE_EXTRA: \'0\''

sys.path.append(os.path.normpath(f'{__file__}/../..'))
try:
import pipcl
finally:
del sys.path[0]
pipcl.show_system()
def check(
expect_out,
Expand Down Expand Up @@ -1482,11 +1478,7 @@ def test_open2():
# of tests/resources/test_open2_expected.json regardless of the actual
# checkout directory.
print()
sys.path.append(root)
try:
import pipcl
finally:
del sys.path[0]
import pipcl
paths = pipcl.git_items(f'{root}/tests/resources')
paths = fnmatch.filter(paths, f'test_open2.*')
paths = [f'tests/resources/{i}' for i in paths]
Expand Down Expand Up @@ -2031,11 +2023,11 @@ def test_4392():

assert e1 == 5
if pymupdf.swig_version_tuple >= (4, 4):
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1:
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1 and sys._is_gil_enabled():
assert e2 == 4
else:
assert e2 == 5
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1:
if sysconfig.get_config_var('Py_GIL_DISABLED') == 1 and sys._is_gil_enabled():
# GIL warning results in failure because of -Werror.
assert e3 == 1
else:
Expand Down
6 changes: 1 addition & 5 deletions tests/test_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,7 @@ def test_4125():
import psutil

root = os.path.normpath(f'{__file__}/../..')
sys.path.insert(0, root)
try:
import pipcl
finally:
del sys.path[0]
import pipcl

process = psutil.Process()

Expand Down
Loading
Loading