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
