diff --git a/changes.txt b/changes.txt index b9f86835b..bf5f2faa2 100644 --- a/changes.txt +++ b/changes.txt @@ -6,9 +6,16 @@ Change Log * Fixed issues: + * **Fixed** `4928 `_: pymupdf.Document.scrub raises AttributeError for a document with annotations + * **Fixed** `4942 `_: bug: IndexError for Page.get_links after Page.clip_to_rect + * **Fixed** `4954 `_: get_drawings() returns incorrect lineJoin and width + * **Fixed** `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) @@ -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) diff --git a/docs/conf.py b/docs/conf.py index 353c3464b..d477cfa0d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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" + 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. diff --git a/pyproject.toml b/pyproject.toml index 92bfa9009..f565ccc71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["."] diff --git a/scripts/gh_release.py b/scripts/gh_release.py index 1cc836894..5d66e797e 100755 --- a/scripts/gh_release.py +++ b/scripts/gh_release.py @@ -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] diff --git a/scripts/sysinstall.py b/scripts/sysinstall.py index ea6597d6d..def60dfaa 100755 --- a/scripts/sysinstall.py +++ b/scripts/sysinstall.py @@ -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] diff --git a/scripts/test.py b/scripts/test.py index d716df52b..f41a37174 100755 --- a/scripts/test.py +++ b/scripts/test.py @@ -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] @@ -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') @@ -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}' @@ -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: diff --git a/setup.py b/setup.py index 651838312..20de5a19a 100755 --- a/setup.py +++ b/setup.py @@ -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. @@ -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): ''' @@ -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: @@ -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}') @@ -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') @@ -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 @@ -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.') @@ -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. @@ -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': @@ -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 diff --git a/pipcl.py b/src/pipcl.py similarity index 100% rename from pipcl.py rename to src/pipcl.py diff --git a/wdev.py b/src/wdev.py similarity index 100% rename from wdev.py rename to src/wdev.py diff --git a/tests/conftest.py b/tests/conftest.py index c79e69906..3c268c156 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ def install_required_packages(): # already being installed, e.g. in our wheel's . 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 diff --git a/tests/test_4767.py b/tests/test_4767.py index e8a88fe51..212becf27 100644 --- a/tests/test_4767.py +++ b/tests/test_4767.py @@ -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 diff --git a/tests/test_annots.py b/tests/test_annots.py index e48e4dd61..6eea26816 100644 --- a/tests/test_annots.py +++ b/tests/test_annots.py @@ -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() diff --git a/tests/test_codespell.py b/tests/test_codespell.py index cac5fe09a..7053d3f8e 100644 --- a/tests/test_codespell.py +++ b/tests/test_codespell.py @@ -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) diff --git a/tests/test_drawings.py b/tests/test_drawings.py index 1c2681b44..7f76e1fc9 100644 --- a/tests/test_drawings.py +++ b/tests/test_drawings.py @@ -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 diff --git a/tests/test_general.py b/tests/test_general.py index c3ab644c3..2f93b14d5 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -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, @@ -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] @@ -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: diff --git a/tests/test_memory.py b/tests/test_memory.py index 4d70d58fc..7a4971d4c 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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() diff --git a/tests/test_pylint.py b/tests/test_pylint.py index 38c6d017f..e460bbe4d 100644 --- a/tests/test_pylint.py +++ b/tests/test_pylint.py @@ -102,9 +102,7 @@ def test_pylint(): root = os.path.abspath(f'{__file__}/../..') - sys.path.insert(0, root) import pipcl - del sys.path[0] # We want to run pylist on all of our src/*.py files so we find them with # `pipcl.git_items()`. However this seems to fail on github windows with @@ -124,9 +122,11 @@ def test_pylint(): 'fitz___init__.py', 'fitz_table.py', 'fitz_utils.py', + 'pipcl.py', 'pymupdf.py', 'table.py', 'utils.py', + 'wdev.py', ] leafs.sort() try: @@ -139,6 +139,9 @@ def test_pylint(): leafs_git.sort() assert leafs_git == leafs, f'leafs:\n {leafs!r}\nleafs_git:\n {leafs_git!r}' for leaf in leafs: + if leaf == 'pipcl.py': + # Has various warnings but it's old code that we will eventually stop using. + continue command += f' {directory}/{leaf}' print(f'Running: {command}') subprocess.run(command, shell=1, check=1) diff --git a/tests/test_release.py b/tests/test_release.py index 9639ffa98..896ad46a7 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -8,11 +8,12 @@ g_root_abs = os.path.normpath(f'{__file__}/../../') sys.path.insert(0, g_root_abs) +sys.path.insert(0, f'{g_root_abs}/src') try: import pipcl import setup finally: - del sys.path[0] + del sys.path[0:2] g_root = pipcl.relpath(g_root_abs)