Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package pdfarranger for openSUSE:Factory 
checked in at 2026-11-04 19:46:20
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/pdfarranger (Old)
 and      /work/SRC/openSUSE:Factory/.pdfarranger.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "pdfarranger"

Wed Nov  4 19:46:20 2026 rev:16 rq:1394867 version:1.14.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/pdfarranger/pdfarranger.changes  2026-09-02 
17:00:31.035706679 +0200
+++ /work/SRC/openSUSE:Factory/.pdfarranger.new.3759290/pdfarranger.changes     
2026-11-04 19:50:04.822007686 +0100
@@ -1,0 +2,6 @@
+Wed Nov  4 08:40:03 UTC 2026 - Christophe Marin <[email protected]>
+
+- Add upstream changes to fix build with newer pikepdf versions:
+  * 0001-Add-changes-for-newer-pikepdf.patch
+
+-------------------------------------------------------------------

New:
----
  0001-Add-changes-for-newer-pikepdf.patch

----------(New B)----------
  New:- Add upstream changes to fix build with newer pikepdf versions:
  * 0001-Add-changes-for-newer-pikepdf.patch
----------(New E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ pdfarranger.spec ++++++
--- /var/tmp/diff_new_pack.bakiRq/_old  2026-11-04 19:50:05.930053892 +0100
+++ /var/tmp/diff_new_pack.bakiRq/_new  2026-11-04 19:50:05.937054184 +0100
@@ -30,7 +30,9 @@
 License:        GPL-3.0-only
 URL:            https://github.com/pdfarranger/pdfarranger
 Source0:        
https://github.com/pdfarranger/pdfarranger/archive/%{version}.tar.gz#/%{name}-%{version}.tar.gz
-Source1:        %{name}.rpmlintrc
+Source1:        pdfarranger.rpmlintrc
+# PATCH-FIX-UPSTREAM
+Patch0:         0001-Add-changes-for-newer-pikepdf.patch
 BuildRequires:  %{python_module base}
 BuildRequires:  %{python_module pip}
 BuildRequires:  %{python_module setuptools}
@@ -74,8 +76,10 @@
 
 %install
 %pyproject_install
+
 %find_lang %{name} %{?no_lang_C}
-%fdupes -s %{buildroot}
+
+%fdupes %{buildroot}
 
 %check
 %pytest
@@ -83,12 +87,12 @@
 %files
 %license COPYING
 %doc README.md
-%{_bindir}/%{name}
-%{python_sitelib}/%{name}
-%{python_sitelib}/%{name}-%{version}*.*-info
+%{_bindir}/pdfarranger
+%{python_sitelib}/pdfarranger
+%{python_sitelib}/pdfarranger-%{version}*.*-info
 %{_datadir}/applications/com.github.jeromerobert.pdfarranger.desktop
-%{_mandir}/man1/%{name}.1%{?ext_man}
-%{_datadir}/%{name}/
+%{_mandir}/man1/pdfarranger.1%{?ext_man}
+%{_datadir}/pdfarranger/
 %{_datadir}/icons/hicolor/*/apps/*
 %{_datadir}/metainfo/com.github.jeromerobert.pdfarranger.metainfo.xml
 

++++++ 0001-Add-changes-for-newer-pikepdf.patch ++++++
>From 7291f7f9c354d5cf6a579da5b57f98e49a914268 Mon Sep 17 00:00:00 2001
From: Christophe Marin <[email protected]>
Date: Wed, 4 Nov 2026 09:39:35 +0100
Subject: [PATCH] Add changes for newer pikepdf

---
 pdfarranger/exporter_outlines.py | 136 +++++++++++++++----
 tests/test_exporter_outlines.py  | 224 ++++++++++++++++++++++++++++++-
 2 files changed, 326 insertions(+), 34 deletions(-)

diff --git a/pdfarranger/exporter_outlines.py b/pdfarranger/exporter_outlines.py
index c6ab3c8..32b6a8f 100644
--- a/pdfarranger/exporter_outlines.py
+++ b/pdfarranger/exporter_outlines.py
@@ -100,11 +100,7 @@ class OutlineRemapper:
     def _new_dest_array(
         self, dest_array, target_page_obj, file_idx, scale, original_name
     ):
-        new_dest_array = pikepdf.Array()
-        new_dest_array.append(target_page_obj)
         d_type = dest_array[1]
-        new_dest_array.append(d_type)
-        # Identify coordinate parameters based on destination type
         coord_indices = []
         if d_type == pikepdf.Name.XYZ:
             coord_indices = [2, 3]  # left, top (zoom ratio at index 4 is not 
scaled)
@@ -117,15 +113,18 @@ class OutlineRemapper:
             coord_indices = [2]  # [top] or [left]
         elif d_type == pikepdf.Name.FitR:
             coord_indices = [2, 3, 4, 5]  # [left, bottom, right, top]
-        # Apply scale directly to coordinates
+
+        items = [target_page_obj, d_type]
         for i in range(2, len(dest_array)):
             val = dest_array[i]
             if i in coord_indices and isinstance(val, (int, float, 
decimal.Decimal)):
-                new_dest_array.append(float(val) * scale)
+                items.append(float(val) * scale)
             elif isinstance(val, pikepdf.Object) and val.is_indirect:
-                new_dest_array.append(self.pdf_output.copy_foreign(val))
+                items.append(self.pdf_output.copy_foreign(val))
             else:
-                new_dest_array.append(val)
+                items.append(val)  # None (PDF null) is fine here
+
+        new_dest_array = pikepdf.Array(items)
         if original_name:
             new_name_str = f"f{file_idx}-{original_name}"
             self.new_named_dests.append((new_name_str, new_dest_array))
@@ -133,13 +132,36 @@ class OutlineRemapper:
         return new_dest_array
 
 
+# Dictionary keys that pikepdf's OutlineItem manages natively via its own
+# constructor kwargs / properties. Anything else (color, font flags,
+# structure elements, vendor-specific keys) must be stashed and reapplied
+# after the outline's `with` block closes: pikepdf >= 10.11.0 silently
+# drops non-managed keys supplied via OutlineItem(obj=...) or set directly
+# on a not-yet-committed item once the outline tree is serialized.
+_MANAGED_OUTLINE_KEYS = frozenset(
+    [
+        pikepdf.Name.Title,
+        pikepdf.Name.Dest,
+        pikepdf.Name.A,
+        pikepdf.Name.Parent,
+        pikepdf.Name.First,
+        pikepdf.Name.Last,
+        pikepdf.Name.Next,
+        pikepdf.Name.Prev,
+        pikepdf.Name.Count,
+    ]
+)
+
+
 class OutlineCopier:
     """Copy outline items from a source PDF, remapping destinations."""
 
-    def __init__(self, remapper, file_idx):
+    def __init__(self, remapper, file_idx, source_pdf, pdf_output):
         """Initialize with a remapper and the source file index."""
         self.remapper = remapper
         self.file_idx = file_idx
+        self.source_pdf = source_pdf
+        self.pdf_output = pdf_output
 
     def _get_mapped_dest(self, source_item):
         """Extract and remap the destination from a source outline item."""
@@ -151,27 +173,79 @@ class OutlineCopier:
             return self.remapper.remap_destination(self.file_idx, dest)
         return None
 
-    def _copy_styles_and_state(self, source_item, new_item):
-        """Preserve color, text style flags, and default open/closed state."""
-        if source_item.is_closed:
-            new_item.is_closed = True
-        for key in (pikepdf.Name.C, pikepdf.Name.F):
-            if key in source_item.obj:
-                if new_item.obj is None:
-                    new_item.obj = pikepdf.Dictionary()
-                new_item.obj[key] = source_item.obj[key]
+    def _build_valid_tree(self, source_item):
+        """Pass 1: Recursively filter and build a clean Python tree of 
surviving nodes."""
+        final_dest = self._get_mapped_dest(source_item)
+
+        valid_children = []
+        for child in source_item.children:
+            child_node = self._build_valid_tree(child)
+            if child_node is not None:
+                valid_children.append(child_node)
+
+        # An item is kept if it has a valid target destination OR contains 
surviving children
+        if final_dest is not None or valid_children:
+            return {
+                "source_item": source_item,
+                "destination": final_dest,
+                "children": valid_children,
+            }
+        return None
+
+    def _insert_tree_node(self, node, pikepdf_parent_list):
+        """Pass 2: Insert nodes into the hierarchy using strict parent-first 
order."""
+        source_item = node["source_item"]
+        final_dest = node["destination"]
+
+        # 1. Instantiate the temporary OutlineItem object.
+        # NOTE: we deliberately do NOT pass obj=... here (see
+        # _MANAGED_OUTLINE_KEYS comment above) — any custom keys from the
+        # source dict are stashed below and reapplied once the outline
+        # commits, rather than being supplied at construction time.
+        new_item = pikepdf.OutlineItem(
+            title=source_item.title,
+            destination=final_dest,
+        )
+
+        new_item.is_closed = (
+            source_item.is_closed or pikepdf.Name.Count not in source_item.obj
+        )
+
+        copied_obj = self.pdf_output.copy_foreign(
+            self.source_pdf.make_indirect(source_item.obj)
+        )
+        extra_items = {
+            k: v for k, v in copied_obj.items() if k not in 
_MANAGED_OUTLINE_KEYS
+        }
+        if extra_items:
+            new_item._extra_dict_items = extra_items
+
+        # 3. Append to the parent list
+        pikepdf_parent_list.append(new_item)
+
+        # 4. Recursively insert children into the new item's children list
+        for child_node in node["children"]:
+            self._insert_tree_node(child_node, new_item.children)
+
+        return new_item
 
     def copy_item(self, source_item, new_parent_list):
         """Copy a single outline item and its children, dropping invalid 
destinations."""
-        final_dest = self._get_mapped_dest(source_item)
-        new_item = pikepdf.OutlineItem(title=source_item.title, 
destination=final_dest)
-        # Recursively process children first so we know if new_item has valid 
children
-        for child in source_item.children:
-            self.copy_item(child, new_item.children)
-        # Only copy bookmark if it has a valid destination or valid surviving 
children
-        if final_dest is not None or new_item.children:
-            self._copy_styles_and_state(source_item, new_item)
-            new_parent_list.append(new_item)
+        node = self._build_valid_tree(source_item)
+        if node is not None:
+            return self._insert_tree_node(node, new_parent_list)
+        return None
+
+
+def _apply_stashed_extras(items):
+    """Recursively reapply dictionary keys stashed by _insert_tree_node,
+    after the outline's `with` block has closed (see _MANAGED_OUTLINE_KEYS)."""
+    for item in items:
+        extras = getattr(item, "_extra_dict_items", None)
+        if extras:
+            for k, v in extras.items():
+                item.obj[k] = v
+        _apply_stashed_extras(item.children)
 
 
 def write_named_dests(pdf, named_dests):
@@ -194,6 +268,7 @@ def rebuild_outlines(pdf_input, pdf_output, pages):
     remapper = OutlineRemapper(pdf_input, pdf_output, pages)
     # preserve first-appearance order of source files, deduplicated
     ordered_file_indices = list(dict.fromkeys(row.nfile - 1 for row in pages))
+    top_level_new_items = []
     with pdf_output.open_outline() as new_outline:
         for file_idx in ordered_file_indices:
             source_pdf = pdf_input[file_idx]
@@ -201,12 +276,15 @@ def rebuild_outlines(pdf_input, pdf_output, pages):
                 continue
             try:
                 with source_pdf.open_outline() as source_outline:
-                    copier = OutlineCopier(remapper, file_idx)
+                    copier = OutlineCopier(remapper, file_idx, source_pdf, 
pdf_output)
                     for item in source_outline.root:
-                        copier.copy_item(item, new_outline.root)
+                        new_item = copier.copy_item(item, new_outline.root)
+                        if new_item is not None:
+                            top_level_new_items.append(new_item)
             except pikepdf.PdfError as e:
                 warnings.warn(
                     f"Failed to copy bookmarks from document {file_idx + 1}: 
{e}"
                 )
+    _apply_stashed_extras(top_level_new_items)
     if remapper.new_named_dests:
         write_named_dests(pdf_output, remapper.new_named_dests)
diff --git a/tests/test_exporter_outlines.py b/tests/test_exporter_outlines.py
index eade41a..396c76f 100644
--- a/tests/test_exporter_outlines.py
+++ b/tests/test_exporter_outlines.py
@@ -16,6 +16,7 @@ import builtins
 import pikepdf
 import pytest
 from unittest.mock import patch
+from collections import namedtuple
 
 from pdfarranger.exporter_outlines import rebuild_outlines, write_named_dests
 
@@ -823,18 +824,21 @@ class TestOutlineStylesAndState:
     def test_styles_and_closed_state_preserved(self):
         """Color, font flags, and default closed state should survive 
remapping."""
         src = make_pdf(2)
+        # NOTE: raw obj[Name.C]/[Name.F] mutation must happen after the
+        # `with src.open_outline()` block fully closes — pikepdf >= 10.11.0
+        # silently drops such mutations made on a not-yet-committed item,
+        # even after it's been appended, as long as the outline context
+        # manager it was appended under is still open.
         with src.open_outline() as ol:
             parent = pikepdf.OutlineItem("Styled Parent", 0)
-            # Give the new item a dictionary to hold custom styles ---
-            parent.obj = pikepdf.Dictionary()
-            # Set color to red [1.0, 0.0, 0.0] and font to bold-italic (3)
-            parent.obj[pikepdf.Name.C] = pikepdf.Array([1.0, 0.0, 0.0])
-            parent.obj[pikepdf.Name.F] = 3
             # Add a child so the parent has something to collapse
             child = pikepdf.OutlineItem("Child", 1)
             parent.children.append(child)
             parent.is_closed = True
             ol.root.append(parent)
+        # Set color to red [1.0, 0.0, 0.0] and font to bold-italic (3)
+        parent.obj[pikepdf.Name.C] = pikepdf.Array([1.0, 0.0, 0.0])
+        parent.obj[pikepdf.Name.F] = 3
         src = roundtrip(src)
         out = make_pdf(2)
         pages = [Row(1, 1), Row(1, 2)]
@@ -850,3 +854,213 @@ class TestOutlineStylesAndState:
             assert parent_out.obj[pikepdf.Name.F] == 3
             # Verify it is still closed
             assert parent_out.is_closed is True
+
+
+# ---------------------------------------------------------------------------
+# Issue 1395
+# ---------------------------------------------------------------------------
+
+
+def test_null_coordinates_in_xyz_destination():
+    """
+    XYZ destinations with null coordinates (e.g. [page /XYZ null null null])
+    must survive remapping. This is the standard PDF idiom meaning 'inherit
+    current position/zoom'. Broke in pikepdf 10.6+ after the pybind11→nanobind
+    migration (Array.append(None) stopped accepting None).
+    """
+    src = make_pdf(2)
+    with src.open_outline() as ol:
+        for i, title in enumerate(["Chapter 1", "Chapter 2"]):
+            ol.root.append(
+                pikepdf.OutlineItem(
+                    title,
+                    pikepdf.Array(
+                        [src.pages[i].obj, pikepdf.Name.XYZ, None, None, None]
+                    ),
+                )
+            )
+    src = roundtrip(src)
+
+    out = make_pdf(2)
+    pages = [Row(1, 1), Row(1, 2)]
+    rebuild_outlines([src], out, pages)
+    out = roundtrip(out)
+
+    with out.open_outline() as ol:
+        assert len(ol.root) == 2
+        assert ol.root[0].title == "Chapter 1"
+        assert ol.root[1].title == "Chapter 2"
+        assert dest_page_index(out, ol.root[0].destination) == 0
+        assert dest_page_index(out, ol.root[1].destination) == 1
+        # Null coordinates must be preserved, not dropped or mangled
+        for item in ol.root:
+            dest = item.destination
+            assert dest[1] == pikepdf.Name.XYZ
+            assert dest[2] is None
+            assert dest[3] is None
+            assert dest[4] is None
+
+
+# Mock the Row object that pdfarranger uses for page sequences
+PageRow = namedtuple("PageRow", ["nfile", "npage", "scale"])
+
+
[email protected]
+def sample_input_pdf(tmp_path):
+    """Generates an input PDF with a strict nested outline hierarchy."""
+    pdf_path = tmp_path / "input.pdf"
+    with pikepdf.Pdf.new() as pdf:
+        # Add 3 blank pages to map destinations to
+        for _ in range(3):
+            pdf.add_blank_page()
+
+        with pdf.open_outline() as outline:
+            # Level 1 Parent
+            p1 = pikepdf.OutlineItem("Root Parent", 0)
+            # Level 2 Child
+            c1 = pikepdf.OutlineItem("Child Level 1", 1)
+            # Level 3 Grandchild
+            g1 = pikepdf.OutlineItem("Grandchild Level 2", 2)
+
+            # Assemble tree manually
+            c1.children.append(g1)
+            p1.children.append(c1)
+            outline.root.append(p1)
+
+        pdf.save(pdf_path)
+    return pdf_path
+
+
+def test_rebuild_outlines_preserves_nested_hierarchy(sample_input_pdf):
+    # Open the generated test source PDF
+    with pikepdf.open(sample_input_pdf) as pdf_in:
+        pdf_input = [pdf_in]
+
+        # Create an output PDF context
+        with pikepdf.Pdf.new() as pdf_out:
+            # Replicate pages sequence mapping from file 1
+            pdf_out.add_blank_page()
+            pdf_out.add_blank_page()
+            pdf_out.add_blank_page()
+
+            pages_map = [
+                PageRow(nfile=1, npage=1, scale=1.0),
+                PageRow(nfile=1, npage=2, scale=1.0),
+                PageRow(nfile=1, npage=3, scale=1.0),
+            ]
+
+            # Execute the processor
+            rebuild_outlines(pdf_input, pdf_out, pages_map)
+
+            # Verify structural health of output outlines
+            with pdf_out.open_outline() as out_outline:
+                assert len(out_outline.root) == 1
+
+                root_node = out_outline.root[0]
+                assert root_node.title == "Root Parent"
+                assert len(root_node.children) == 1
+
+                child_node = root_node.children[0]
+                assert child_node.title == "Child Level 1"
+                assert len(child_node.children) == 1
+
+                grandchild_node = child_node.children[0]
+                assert grandchild_node.title == "Grandchild Level 2"
+                assert len(grandchild_node.children) == 0
+
+                # Validate low-level PDF structural binding integrity
+                # A broken structural binding usually results in missing 
target dictionary keys
+                assert pikepdf.Name.Parent in grandchild_node.obj
+                assert grandchild_node.obj.Parent == child_node.obj
+
+
+def test_vacon_low_level_structure_regression(tmp_path):
+    pdf_path = tmp_path / "mock_vacon.pdf"
+    out_path = tmp_path / "out.pdf"
+
+    # 1. Build the PDF "The Hard Way" (Low-level dictionaries)
+    with pikepdf.Pdf.new() as pdf:
+        pdf.add_blank_page()
+
+        # Mirror Vacon's structure:
+        # Item 1 has an Action (/A) instead of a Dest, AND a color array (/C)
+        item1 = pdf.make_indirect(
+            pikepdf.Dictionary(
+                Title="Sisällys",
+                A=pikepdf.Dictionary(S=pikepdf.Name.GoTo, D="TOC"),
+                C=pikepdf.Array([0, 0, 0]),
+            )
+        )
+
+        # Item 2 is a sibling to prove the linked list breaks
+        item2 = pdf.make_indirect(
+            pikepdf.Dictionary(
+                Title="Esipuhe", A=pikepdf.Dictionary(S=pikepdf.Name.GoTo, 
D="AN10025")
+            )
+        )
+
+        # Manually wire the linked list
+        item1.Next = item2
+        item2.Prev = item1
+
+        outlines = pdf.make_indirect(
+            pikepdf.Dictionary(
+                Type=pikepdf.Name.Outlines, First=item1, Last=item2, Count=2
+            )
+        )
+        item1.Parent = outlines
+        item2.Parent = outlines
+
+        pdf.Root.Outlines = outlines
+
+        # Add a NameTree so fixed code can resolve the strings
+        dests = pikepdf.NameTree.new(pdf)
+        dests["TOC"] = pikepdf.Array(
+            [pdf.pages[0].obj, pikepdf.Name.XYZ, None, None, None]
+        )
+        dests["AN10025"] = pikepdf.Array(
+            [pdf.pages[0].obj, pikepdf.Name.XYZ, None, None, None]
+        )
+        pdf.Root.Names = pikepdf.Dictionary(Dests=dests.obj)
+
+        pdf.save(pdf_path)
+
+    # 2. Run rebuild_outlines
+    with pikepdf.open(pdf_path) as pdf_in:
+        with pikepdf.Pdf.new() as pdf_out:
+            pdf_out.add_blank_page()
+            pages_map = [PageRow(nfile=1, npage=1, scale=1.0)]
+
+            rebuild_outlines([pdf_in], pdf_out, pages_map)
+            pdf_out.save(out_path)
+
+    # 3. Assertions
+    with pikepdf.open(out_path) as pdf_res:
+        with pdf_res.open_outline() as res_outline:
+            assert len(res_outline.root) == 2, "Outline tree collapsed!"
+
+            item1_out = res_outline.root[0]
+            item2_out = res_outline.root[1]
+
+            # --- FAILURE 1: Dead Bookmark ---
+            # `main` fails to parse the /A GoTo dictionary, leaving the output 
bookmark dead
+            has_dest = item1_out.destination is not None
+            has_action = pikepdf.Name.A in item1_out.obj
+            assert has_dest or has_action, (
+                "FAIL: Bookmark completely lost its destination/action!"
+            )
+
+            # --- FAILURE 2: Direct Object Corruption ---
+            # `main` overwrites the obj dictionary to copy the Color array, 
forcing a Direct Object
+            assert item1_out.obj.is_indirect, (
+                "FAIL: Bookmark was corrupted into a Direct Object!"
+            )
+
+            # --- FAILURE 3: Broken Pointers ---
+            # The direct object causes the sibling to point back to (0, 0)
+            assert pikepdf.Name.Prev in item2_out.obj
+            try:
+                obj_num = item2_out.obj.Prev.objgen[0]
+            except AttributeError:
+                obj_num = 0
+            assert obj_num != 0, "FAIL: Sibling has a broken /Prev (0, 0) 
pointer!"
-- 
2.56.0

Reply via email to