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-5r180with 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_rotationproperty for full rotation info - ✅ Updated
page_rangeproperty 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_rotationparameter - ✅ 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/--pagesto show rotation syntax - ✅ Clarified that global
-R/--rotateis 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_ROTATIONenum 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_rangeNew 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_rangeFile: pdfstitcher/processing/pagefilter.py
Changes to run() method:
- Import rotation matrix functions from
pagetiler.pyor 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/--pagesargument to mention rotation syntax - Add validation for rotation values in page ranges
- Consider deprecating global
-R/--rotatewhen 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
ProcessingBaseAPI with backward compatibility - Add rotation support to
PageFilter - Modify
PageTilerfor 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