This document outlines the technical implementation plan for adding per-page rotation support to PDFStitcher. The feature will allow users to specify rotation values for individual pages or page ranges using syntax like 1,2,4r90,5-7r180
in the page range field.
✅ COMPLETED - All components of the per-page rotation feature have been successfully implemented.
-
Enhanced Page Range Parser (
utils.py
):- ✅ Added
parse_page_range_with_rotation()
function at line 287 - ✅ Supports syntax like
1,2r90,3-5r180
with regex pattern(\d+)(?:-(\d+))?(?:[rR](\d+))?
- ✅ Validates rotation values (0, 90, 180, 270)
- ✅ Added utility functions:
degrees_to_sw_rotation()
- converts degrees to SW_ROTATION enumget_rotation_matrix()
- returns 2x2 rotation matricesapply_rotation_to_dimensions()
- calculates dimensions after rotation
- ✅ Added
-
ProcessingBase API Updates (
procbase.py
):- ✅ Maintained backward compatibility with existing code
- ✅ Added
page_range_with_rotation
property for full rotation info - ✅ Updated
page_range
property to return list of integers for compatibility - ✅ Modified page validation to handle both dict and int formats
-
PageFilter Rotation Support (
pagefilter.py
):- ✅ Added
_apply_rotation_to_page()
method for applying rotation transformations - ✅ Updated
run()
method to iterate throughpage_range_with_rotation
- ✅ Rotation applied using PDF transformation matrices with proper translation
- ✅ Added
-
PageTiler Per-Page Rotation (
pagetiler.py
):- ✅ Modified
_process_page()
to acceptpage_rotation
parameter - ✅ Updated
_build_pagelist()
to pass per-page rotation - ✅ Modified
_compute_T_matrix()
to use per-page rotation with fallback to global - ✅ Updated
_calc_shift()
to accept rotation parameter
- ✅ Modified
-
CLI Updates (
cli/app.py
):- ✅ Updated help text for
-p/--pages
to show rotation syntax - ✅ Clarified that global
-R/--rotate
is overridden by per-page rotation
- ✅ Updated help text for
-
GUI Updates (
gui/io_tab.py
):- ✅ Added example text: "Add r and degrees to rotate pages. Example: 1-3, 4r90, 5-7r180."
-
Comprehensive Tests (
tests/test_rotation.py
):- ✅ Created 15 tests covering all aspects of the implementation
- ✅ All tests passing successfully
- Per-page rotation takes precedence over global rotation when both are specified
- Backward compatibility maintained - existing code continues to work unchanged
- Rotation values limited to 0, 90, 180, 270 degrees (matching existing constraints)
- Both uppercase 'R' and lowercase 'r' accepted in rotation syntax
- PageTiler: Currently supports rotation (0°, 90°, 180°, 270°) but applies to ALL tiled pages
- PageFilter: No rotation support (simple page selection with margin addition)
- Rotation values: Defined in
SW_ROTATION
enum inpagetiler.py
:- NONE = 0
- CLOCKWISE = 1 (90°)
- COUNTERCLOCKWISE = 2 (270°)
- TURNAROUND = 3 (180°)
- Function:
parse_page_range()
inutils.py:266-283
- Current format:
"1,2,4-7"
returns[1, 2, 4, 5, 6, 7]
- Supports: comma-separated values, hyphenated ranges, repeated pages, out-of-order pages
File: pdfstitcher/utils.py
New Function: parse_page_range_with_rotation(ptext: str = "") -> list[dict]
def parse_page_range_with_rotation(ptext: str = "") -> list[dict]:
"""
Parse page ranges with optional rotation suffixes.
Format: "1,2,4r90,5-7r180"
Returns: [
{"page": 1, "rotation": 0},
{"page": 2, "rotation": 0},
{"page": 4, "rotation": 90},
{"page": 5, "rotation": 180},
{"page": 6, "rotation": 180},
{"page": 7, "rotation": 180}
]
"""
Implementation details:
- Parse rotation suffix using regex:
(\d+)(?:-(\d+))?(?:[rR](\d+))?
- Support both uppercase and lowercase 'r'
- Validate rotation values (0, 90, 180, 270)
- Map CLI values (0, 90, 180, 270) to SW_ROTATION enum values
- Maintain backward compatibility by defaulting to rotation=0
File: pdfstitcher/processing/procbase.py
Current API:
@property
def page_range(self) -> list:
return self._page_range
@page_range.setter
def page_range(self, page_range: Union[str, list]) -> None:
if isinstance(page_range, str):
self._page_range = utils.parse_page_range(page_range)
else:
self._page_range = page_range
New API:
@property
def page_range(self) -> list:
"""Returns list of page numbers for backward compatibility"""
return [p["page"] if isinstance(p, dict) else p for p in self._page_range]
@property
def page_range_with_rotation(self) -> list[dict]:
"""Returns full page range with rotation info"""
return self._page_range
@page_range.setter
def page_range(self, page_range: Union[str, list]) -> None:
if isinstance(page_range, str):
self._page_range = utils.parse_page_range_with_rotation(page_range)
elif isinstance(page_range, list) and all(isinstance(p, int) for p in page_range):
# Convert old format to new format for backward compatibility
self._page_range = [{"page": p, "rotation": 0} for p in page_range]
else:
self._page_range = page_range
File: pdfstitcher/processing/pagefilter.py
Changes to run()
method:
- Import rotation matrix functions from
pagetiler.py
or create utility functions - After adding each page to
out_doc
, apply rotation if specified:for page_info in self.page_range_with_rotation: p = page_info["page"] rotation = page_info["rotation"] # ... existing page addition logic ... if rotation != 0: self._apply_rotation_to_page(self.out_doc.pages[-1], rotation, user_unit)
New method: _apply_rotation_to_page(page, rotation_degrees, user_unit)
- Convert page to XObject using
page.as_form_xobject()
- Apply rotation matrix based on rotation_degrees
- Handle page dimension swapping for 90°/270° rotations
- Update MediaBox and CropBox accordingly
File: pdfstitcher/processing/pagetiler.py
Changes:
-
Modify
_process_page()
to accept per-page rotation:def _process_page(self, page: pikepdf.Page, page_num: int, trim: list = [], page_rotation: int = 0) -> dict:
-
In
run()
method, pass individual page rotation:for page_info in self.page_range_with_rotation: i = page_info["page"] - 1 page_rotation = page_info["rotation"] info = self._process_page( self.in_doc.pages[i], i, trim_amounts, page_rotation )
-
Modify
_compute_T_matrix()
to use per-page rotation instead of global rotation
File: pdfstitcher/cli/app.py
Changes:
- Update help text for
-p/--pages
argument to mention rotation syntax - Add validation for rotation values in page ranges
- Consider deprecating global
-R/--rotate
when using per-page rotation
File: pdfstitcher/gui/io_tab.py
Changes:
- Update tooltip/help text for page range field
- Update example text to show rotation syntax: "1-3, 0, 4r90, 0, 5-10r180"
- Add validation feedback for invalid rotation values
File: pdfstitcher/utils.py
or new pdfstitcher/processing/rotation.py
New utilities:
def degrees_to_sw_rotation(degrees: int) -> SW_ROTATION:
"""Convert degrees (0, 90, 180, 270) to SW_ROTATION enum"""
def get_rotation_matrix(rotation: SW_ROTATION) -> list[float]:
"""Get the 2x2 rotation matrix for given rotation"""
def apply_rotation_to_dimensions(width: float, height: float,
rotation: SW_ROTATION) -> tuple[float, float]:
"""Calculate new dimensions after rotation"""
-
Backward Compatibility: Ensure existing page range strings work unchanged
-
Edge Cases:
- Invalid rotation values (e.g., "1r45")
- Mixed formats (e.g., "1-3r90,4,5r180")
- Repeated pages with different rotations
- Blank pages (page 0) with rotation
-
Integration Tests:
- Test with PageFilter (simple rotation)
- Test with PageTiler (rotation + tiling)
- Test with LayerFilter active
- Test CLI and GUI inputs
- Implement
parse_page_range_with_rotation()
and test thoroughly - Update
ProcessingBase
API with backward compatibility - Add rotation support to
PageFilter
- Modify
PageTiler
for per-page rotation - Update CLI with new syntax support
- Update GUI with examples and validation
- Add comprehensive tests
- The rotation values in CLI (0, 90, 180, 270) need to map to SW_ROTATION enum values
- Consider caching rotation matrices for performance
- Ensure UserUnit scaling is properly handled with rotation
- MediaBox/CropBox updates must account for dimension swapping in 90°/270° rotations