"""Pure imposition rules used by GreasePress. This module intentionally has no bpy dependency so ordering rules can be checked independently from Blender's scene and UI state. """ from dataclasses import dataclass from typing import List, Optional, Sequence, Tuple PageNumber = Optional[int] @dataclass(frozen=True) class SheetSide: sheet: int side: str left: PageNumber right: PageNumber @dataclass(frozen=True) class BookletPlan: source_page_count: int imposed_page_count: int blank_page_count: int sides: Tuple[SheetSide, ...] @dataclass(frozen=True) class BrochureSide: side: str # OUTSIDE or INSIDE panels: Tuple[PageNumber, ...] widths: Tuple[float, ...] # relative panel widths @dataclass(frozen=True) class BrochurePlan: kind: str required_page_count: int sides: Tuple[BrochureSide, ...] def padded_page_count(page_count: int) -> int: if page_count < 1: raise ValueError("page_count must be at least 1") return ((page_count + 3) // 4) * 4 def _page_or_blank(number: int, source_page_count: int) -> PageNumber: return number if number <= source_page_count else None def impose_booklet(page_count: int) -> BookletPlan: total = padded_page_count(page_count) sheet_count = total // 4 sides: List[SheetSide] = [] for zero_based_sheet in range(sheet_count): sheet = zero_based_sheet + 1 low = 1 + zero_based_sheet * 2 high = total - zero_based_sheet * 2 sides.append(SheetSide(sheet, "FRONT", _page_or_blank(high, page_count), _page_or_blank(low, page_count))) sides.append(SheetSide(sheet, "BACK", _page_or_blank(low + 1, page_count), _page_or_blank(high - 1, page_count))) return BookletPlan(page_count, total, total - page_count, tuple(sides)) def impose_bifold() -> BrochurePlan: return BrochurePlan( "BIFOLD", 4, ( BrochureSide("OUTSIDE", (4, 1), (1.0, 1.0)), BrochureSide("INSIDE", (2, 3), (1.0, 1.0)), ), ) def impose_trifold_accordion() -> BrochurePlan: return BrochurePlan( "TRIFOLD_ACCORDION", 6, ( BrochureSide("OUTSIDE", (6, 1, 2), (1.0, 1.0, 1.0)), BrochureSide("INSIDE", (3, 4, 5), (1.0, 1.0, 1.0)), ), ) def impose_trifold_roll(inset_ratio: float = 0.97) -> BrochurePlan: inset_ratio = min(max(float(inset_ratio), 0.5), 1.0) return BrochurePlan( "TRIFOLD_ROLL", 6, ( BrochureSide("OUTSIDE", (5, 6, 1), (inset_ratio, 1.0, 1.0)), BrochureSide("INSIDE", (2, 3, 4), (1.0, 1.0, inset_ratio)), ), ) def brochure_plan(kind: str, inset_ratio: float = 0.97) -> BrochurePlan: if kind == 'BIFOLD': return impose_bifold() if kind == 'TRIFOLD_ACCORDION': return impose_trifold_accordion() if kind == 'TRIFOLD_ROLL': return impose_trifold_roll(inset_ratio) raise ValueError(f"Unsupported brochure type: {kind}") def page_label(page: PageNumber) -> str: return "Blank" if page is None else str(page) def validate_plan(plan: BookletPlan) -> Sequence[str]: errors: List[str] = [] actual = [p for side in plan.sides for p in (side.left, side.right) if p is not None] expected = list(range(1, plan.source_page_count + 1)) if sorted(actual) != expected: errors.append("The source pages do not appear exactly once") if len(plan.sides) != plan.imposed_page_count // 2: errors.append("Inconsistent number of sides") if plan.imposed_page_count % 4 != 0: errors.append("The imposed total is not divisible by four") return errors def validate_brochure_plan(plan: BrochurePlan, page_count: int) -> Sequence[str]: errors: List[str] = [] if page_count != plan.required_page_count: errors.append(f"This mode requires exactly {plan.required_page_count} pages") actual = [p for side in plan.sides for p in side.panels if p is not None] expected = list(range(1, plan.required_page_count + 1)) if sorted(actual) != expected: errors.append("The panels do not contain each page exactly once") return errors