diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7420ad301..66304973e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,7 +46,7 @@ body: attributes: label: PyMuPDF version options: - - + - 1.26.0 - 1.25.5 - 1.25.4 - 1.25.3 diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index a71331fdd..40d29767f 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -7,7 +7,7 @@ on: flavours: description: 'If set, we build separate PyMuPDF and PyMuPDFb wheels.' type: boolean - default: true + default: false sdist: type: boolean diff --git a/changes.txt b/changes.txt index 94271868c..341f9fa46 100644 --- a/changes.txt +++ b/changes.txt @@ -9,13 +9,20 @@ Change Log * Fixed issues: * **Fixed** `4324 `_: cluster_drawings() fails to cluster horizontal and vertical thin lines + * **Fixed** `4363 `_: Trouble with searching * **Fixed** `4404 `_: IndexError in page.get_links() * **Fixed** `4412 `_: Regression? Spurious error? in insert_pdf in v1.25.4 + * **Fixed** `4423 `_: pymupdf.mupdf.FzErrorFormat: code=7: cannot find object in xref error encountered after version 1.25.3 + * **Fixed** `4435 `_: get_pixmap method stuck on one page * **Fixed** `4439 `_: New Xml class from data does not work - bug in code + * **Fixed** `4445 `_: Broken XREF table incorrectly repaired * **Fixed** `4447 `_: Stroke color of annotations cannot be correctly set + * **Fixed** `4479 `_: set_layer_ui_config() toggles all layers rather than just one + * **Fixed** `4505 `_: Follow Widget flag values up its parent structure * Other: + * Partial fixed for `4457 `_: Wrong characters displayed after font subsetting (w/ native method) * Support image stamp annotations. * Support recoloring pages. * Added example of using Django's file storage API to open files with pymupdf. diff --git a/docs/version.rst b/docs/version.rst index f0acc4d30..3e52fced7 100644 --- a/docs/version.rst +++ b/docs/version.rst @@ -1,6 +1,6 @@ ---- -This documentation covers **PyMuPDF v1.25.5** features as of **2025-03-31 00:00:01**. +This documentation covers **PyMuPDF v1.26.0** features as of **2025-03-31 00:00:01**. The major and minor versions of |PyMuPDF| and |MuPDF| will always be the same. Only the third qualifier (patch level) may deviate from that of |MuPDF|. diff --git a/scripts/gh_release.py b/scripts/gh_release.py index e1f99aa20..1cc836894 100755 --- a/scripts/gh_release.py +++ b/scripts/gh_release.py @@ -448,7 +448,7 @@ def set_cibuild_test(): # Include MuPDF build-time files. flavour += 'd' env_set( 'PYMUPDF_SETUP_FLAVOUR', flavour, pass_=1) - run( f'cibuildwheel{platform_arg}', env_extra) + run( f'cibuildwheel{platform_arg}', env_extra=env_extra) run( 'echo after {flavour=}') run( 'ls -l wheelhouse') diff --git a/scripts/test.py b/scripts/test.py index c49492332..7c425c867 100755 --- a/scripts/test.py +++ b/scripts/test.py @@ -13,8 +13,8 @@ ./PyMuPDF/scripts/test.py --mupdf 'git:https://git.ghostscript.com/mupdf.git' buildtest Build and test with internal checkout of mupdf master. - ./PyMuPDF/scripts/test.py --mupdf 'git:--branch 1.25.x https://github.com/ArtifexSoftware/mupdf.git' buildtest - Build and test using internal checkout of mupdf 1.25.x branch from Github. + ./PyMuPDF/scripts/test.py --mupdf 'git:--branch 1.26.x https://github.com/ArtifexSoftware/mupdf.git' buildtest + Build and test using internal checkout of mupdf 1.26.x branch from Github. Usage: scripts/test.py diff --git a/setup.py b/setup.py index 007524c14..01bc391fd 100755 --- a/setup.py +++ b/setup.py @@ -1229,15 +1229,15 @@ def sdist(): # # PyMuPDF version. -version_p = '1.25.5' +version_p = '1.26.0' # PyMuPDFb version. This is the PyMuPDF version whose PyMuPDFb wheels we will # (re)use if generating separate PyMuPDFb wheels. Though as of PyMuPDF-1.24.11 # (2024-10-03) we no longer use PyMuPDFb wheels so this is actually unused. # -version_b = '1.25.3' +version_b = '1.26.0' -version_mupdf = '1.25.6' +version_mupdf = '1.26.1' if os.path.exists(f'{g_root}/{g_pymupdfb_sdist_marker}'): diff --git a/src/__init__.py b/src/__init__.py index 0c729e620..0a2aae5bc 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -375,7 +375,7 @@ def _int_rc(text): # Basic version information. # -pymupdf_version = "1.25.5" +pymupdf_version = "1.26.0" mupdf_version = mupdf.FZ_VERSION pymupdf_date = "2025-03-31 00:00:01" @@ -5809,7 +5809,7 @@ def update_object(self, xref, text, page=None): pdf = _as_pdf_document(self) xreflen = mupdf.pdf_xref_len(pdf) if not _INRANGE(xref, 1, xreflen-1): - RAISEPY("bad xref", MSG_BAD_XREF, PyExc_ValueError) + RAISEPY("bad xref", MSG_BAD_XREF) ENSURE_OPERATION(pdf) # create new object with passed-in string new_obj = JM_pdf_obj_from_str(pdf, text) diff --git a/tests/resources/test_4423.pdf b/tests/resources/test_4423.pdf new file mode 100644 index 000000000..55510b8cf Binary files /dev/null and b/tests/resources/test_4423.pdf differ diff --git a/tests/resources/test_4435.pdf b/tests/resources/test_4435.pdf new file mode 100644 index 000000000..c22a87c9a Binary files /dev/null and b/tests/resources/test_4435.pdf differ diff --git a/tests/resources/test_4479.pdf b/tests/resources/test_4479.pdf new file mode 100644 index 000000000..28efe43fe Binary files /dev/null and b/tests/resources/test_4479.pdf differ diff --git a/tests/test_font.py b/tests/test_font.py index 98e09cd23..4cc857751 100644 --- a/tests/test_font.py +++ b/tests/test_font.py @@ -1,8 +1,14 @@ """ Tests for the Font class. """ -import pymupdf import os +import platform +import pymupdf +import subprocess +import textwrap + +import util + def test_font1(): text = "PyMuPDF" @@ -218,3 +224,98 @@ def test_3887(): print(f'Have saved to: {path_pixmap=}') assert set(output)==set(text) + +def test_4457(): + print() + files = ( + ('https://arxiv.org/pdf/2504.13180', 'test_4457_a.pdf', None, 4), + ('https://arxiv.org/pdf/2504.13181', 'test_4457_b.pdf', None, 9), + ) + for url, name, size, rms_after_max in files: + path = util.download(url, name, size) + + with pymupdf.open(path) as document: + page = document[0] + + pixmap = document[0].get_pixmap() + path_pixmap = f'{path}.png' + pixmap.save(path_pixmap) + print(f'Have created: {path_pixmap=}') + + text = page.get_text() + path_before = f'{path}.before.pdf' + path_after = f'{path}.after.pdf' + document.ez_save(path_before, garbage=4) + print(f'Have created {path_before=}') + + document.subset_fonts() + document.ez_save(path_after, garbage=4) + print(f'Have created {path_after=}') + + with pymupdf.open(path_before) as document: + text_before = document[0].get_text() + pixmap_before = document[0].get_pixmap() + path_pixmap_before = f'{path_before}.png' + pixmap_before.save(path_pixmap_before) + print(f'Have created: {path_pixmap_before=}') + + with pymupdf.open(path_after) as document: + text_after = document[0].get_text() + pixmap_after = document[0].get_pixmap() + path_pixmap_after = f'{path_after}.png' + pixmap_after.save(path_pixmap_after) + print(f'Have created: {path_pixmap_after=}') + + import gentle_compare + rms_before = gentle_compare.pixmaps_rms(pixmap, pixmap_before) + rms_after = gentle_compare.pixmaps_rms(pixmap, pixmap_after) + print(f'{rms_before=}') + print(f'{rms_after=}') + + # Create .png file showing differences between and . + path_pixmap_after_diff = f'{path_after}.diff.png' + pixmap_after_diff = gentle_compare.pixmaps_diff(pixmap, pixmap_after) + pixmap_after_diff.save(path_pixmap_after_diff) + print(f'Have created: {path_pixmap_after_diff}') + + # Extract text from , and and write to + # files so we can show differences with `diff`. + path_text = os.path.normpath(f'{__file__}/../../tests/test_4457.txt') + path_text_before = f'{path_text}.before.txt' + path_text_after = f'{path_text}.after.txt' + with open(path_text, 'w', encoding='utf8') as f: + f.write(text) + with open(path_text_before, 'w', encoding='utf8') as f: + f.write(text_before) + with open(path_text_after, 'w', encoding='utf8') as f: + f.write(text_after) + + # Can't write text to stdout on Windows because of encoding errors. + if platform.system() != 'Windows': + print(f'text:\n{textwrap.indent(text, " ")}') + print(f'text_before:\n{textwrap.indent(text_before, " ")}') + print(f'text_after:\n{textwrap.indent(text_after, " ")}') + print(f'{path_text=}') + print(f'{path_text_before=}') + print(f'{path_text_after=}') + + command = f'diff -u {path_text} {path_text_before}' + print(f'Running: {command}', flush=1) + subprocess.run(command, shell=1) + + command = f'diff -u {path_text} {path_text_after}' + print(f'Running: {command}', flush=1) + subprocess.run(command, shell=1) + + assert text_before == text + assert rms_before == 0 + + # As of 2025-05-20 there are some differences in some characters, e.g. + # the non-ascii characters in `Philipp Krahenbuhl`. + # See and . + assert rms_after < rms_after_max + + # Avoid test failure caused by mupdf warnings. + wt = pymupdf.TOOLS.mupdf_warnings() + print(f'{wt=}') + assert wt == 'bogus font ascent/descent values (0 / 0)\n... repeated 5 times...' diff --git a/tests/test_general.py b/tests/test_general.py index c3a52990f..a923ad19c 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -1834,3 +1834,43 @@ def test_4466(): pixmap = page.get_pixmap(clip=(0, 0, 10, 10)) print(f'{pixmap.n=} {pixmap.size=} {pixmap.stride=} {pixmap.width=} {pixmap.height=} {pixmap.x=} {pixmap.y=}', flush=1) pixmap.is_unicolor # Used to crash. + + +def test_4479(): + # This passes with pymupdf-1.24.14, fails with pymupdf==1.25.*, passes with + # pymupdf-1.26.0. + print() + path = os.path.normpath(f'{__file__}/../../tests/resources/test_4479.pdf') + with pymupdf.open(path) as document: + + def show(items): + for item in items: + print(f' {repr(item)}') + + items = document.layer_ui_configs() + show(items) + assert items == [ + {'depth': 0, 'locked': 0, 'number': 0, 'on': 1, 'text': 'layer_0', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 1, 'on': 1, 'text': 'layer_1', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 2, 'on': 0, 'text': 'layer_2', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 3, 'on': 1, 'text': 'layer_3', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 4, 'on': 1, 'text': 'layer_4', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 5, 'on': 1, 'text': 'layer_5', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 6, 'on': 1, 'text': 'layer_6', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 7, 'on': 1, 'text': 'layer_7', 'type': 'checkbox'}, + ] + + document.set_layer_ui_config(0, pymupdf.PDF_OC_OFF) + items = document.layer_ui_configs() + show(items) + assert items == [ + {'depth': 0, 'locked': 0, 'number': 0, 'on': 0, 'text': 'layer_0', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 1, 'on': 1, 'text': 'layer_1', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 2, 'on': 0, 'text': 'layer_2', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 3, 'on': 1, 'text': 'layer_3', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 4, 'on': 1, 'text': 'layer_4', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 5, 'on': 1, 'text': 'layer_5', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 6, 'on': 1, 'text': 'layer_6', 'type': 'checkbox'}, + {'depth': 0, 'locked': 0, 'number': 7, 'on': 1, 'text': 'layer_7', 'type': 'checkbox'}, + ] + diff --git a/tests/test_pixmap.py b/tests/test_pixmap.py index daf8191b1..08556363a 100644 --- a/tests/test_pixmap.py +++ b/tests/test_pixmap.py @@ -510,3 +510,72 @@ def test_4336(): if cc_old: assert cc == cc_old + + +def test_4435(): + print(f'{pymupdf.version=}') + path = os.path.normpath(f'{__file__}/../../tests/resources/test_4435.pdf') + 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) + print(f'Called page.get_pixmap().', flush=1) + wt = pymupdf.TOOLS.mupdf_warnings() + assert wt == 'bogus font ascent/descent values (0 / 0)\n... repeated 9 times...' + + +def test_4423(): + path = os.path.normpath(f'{__file__}/../../tests/resources/test_4423.pdf') + with pymupdf.open(path) as document: + path2 = f'{path}.pdf' + ee = None + try: + document.save( + path2, + garbage=4, + expand=1, + deflate=True, + pretty=True, + no_new_id=True, + ) + except Exception as e: + print(f'Exception: {e}') + ee = e + + if (1, 25, 5) <= pymupdf.mupdf_version_tuple < (1, 26): + assert ee, f'Did not receive the expected exception.' + wt = pymupdf.TOOLS.mupdf_warnings() + assert wt == 'dropping unclosed output' + else: + assert not ee, f'Received unexpected exception: {e}' + wt = pymupdf.TOOLS.mupdf_warnings() + assert wt == 'format error: cannot find object in xref (56 0 R)\nformat error: cannot find object in xref (68 0 R)' + + +def test_4445(): + print() + # Test case is large so we download it instead of having it in PyMuPDF + # git. We put it in `cache/` directory do it is not removed by `git clean` + # (unless `-d` is specified). + import util + path = util.download( + 'https://github.com/user-attachments/files/19738242/ss.pdf', + 'test_4445.pdf', + size=2671185, + ) + with pymupdf.open(path) as document: + page = document[0] + pixmap = page.get_pixmap() + print(f'{pixmap.width=}') + print(f'{pixmap.height=}') + if pymupdf.mupdf_version_tuple >= (1, 26): + assert (pixmap.width, pixmap.height) == (792, 612) + else: + assert (pixmap.width, pixmap.height) == (612, 792) + if 0: + path_pixmap = f'{path}.png' + pixmap.save(path_pixmap) + print(f'Have created {path_pixmap=}') + wt = pymupdf.TOOLS.mupdf_warnings() + print(f'{wt=}') + assert wt == 'broken xref subsection, proceeding anyway.\nTrailer Size is off-by-one. Ignoring.' diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 000000000..dbb246581 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,28 @@ +import os +import subprocess + + +def download(url, name, size=None): + ''' + Downloads from to a local file and returns its path. + + If file already exists and matches we do not re-download it. + + We put local files within a `cache/` directory so that it is not deleted by + `git clean` (unless `-d` is specified). + ''' + path = os.path.normpath(f'{__file__}/../../tests/cache/{name}') + if os.path.isfile(path) and (not size or os.stat(path).st_size == size): + print(f'Using existing file {path=}.') + else: + print(f'Downloading from {url=}.') + subprocess.run(f'pip install -U requests', check=1, shell=1) + import requests + r = requests.get(url, path, timeout=10) + r.raise_for_status() + if size is not None: + assert len(r.content) == size + os.makedirs(os.path.dirname(path), exist_ok=1) + with open(path, 'wb') as f: + f.write(r.content) + return path