Skip to content

Commit 3255d36

Browse files
committed
Add Qt auto-rotation for landscape pages and bump to v0.9.4
Extract landscape detection into shared orientation module used by both PDF and Qt devices. Qt window now auto-rotates landscape content rendered on portrait pages (DSC first, CTM heuristic fallback). DPI auto-calculation accounts for post-rotation display height via DSC orientation hint. Window sizing uses wider caps (85%) for landscape pages.
1 parent f4e5180 commit 3255d36

8 files changed

Lines changed: 149 additions & 92 deletions

File tree

postforge/cli_runner.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ def _auto_set_qt_resolution(ctxt: ps.Context) -> None:
4848
except Exception:
4949
return # Can't determine screen size, keep default
5050

51-
max_w = int(screen_w * 0.60)
5251
max_h = int(screen_h * 0.85)
5352

5453
pd = ctxt.gstate.page_device
@@ -58,10 +57,17 @@ def _auto_set_qt_resolution(ctxt: ps.Context) -> None:
5857
if page_w <= 0 or page_h <= 0:
5958
return
6059

61-
# DPI that would make the page fit exactly in the max window
62-
dpi_for_width = max_w * 72.0 / page_w
63-
dpi_for_height = max_h * 72.0 / page_h
64-
dpi = int(min(dpi_for_width, dpi_for_height))
60+
# Determine the post-rotation display height in points. DSC orientation
61+
# tells us before execution whether the page will be auto-rotated —
62+
# same principle as pre-setting PageSize from EPS BoundingBox.
63+
dsc_orient = pd.get(b'DSCOrientation')
64+
if dsc_orient is not None and dsc_orient.val == b'Landscape' and page_w < page_h:
65+
# After 90° rotation the shorter dimension becomes the display height
66+
display_height_pts = min(page_w, page_h)
67+
else:
68+
display_height_pts = page_h
69+
70+
dpi = int(max_h * 72.0 / display_height_pts)
6571

6672
# Clamp to reasonable range
6773
dpi = max(36, min(dpi, 9600))
@@ -273,6 +279,15 @@ def _conditional_paint(ctxt, elem):
273279
page_size.setval([ps.Real(eps_w), ps.Real(eps_h)])
274280
ctxt.gstate.page_device[b"PageSize"] = page_size
275281

282+
# Parse DSC orientation hint for DPI auto-calculation. Landscape
283+
# pages need a higher DPI because the shorter page dimension becomes
284+
# the display height after auto-rotation.
285+
if inputfiles and device == "qt":
286+
dsc = parse_dsc_header(inputfiles[0])
287+
if dsc.orientation:
288+
ctxt.gstate.page_device[b'DSCOrientation'] = \
289+
ps.Name(dsc.orientation.encode('ascii'))
290+
276291
# Override HWResolution if --resolution flag was provided
277292
if args.resolution:
278293
hw_res = ps.Array(ctxt.id)

postforge/core/display_list_builder.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ def add_graphics_operation(self, ctxt: Any, graphics_element: Any) -> None:
6363
# so we don't need to create them here
6464
ctxt.display_list.append(graphics_element)
6565

66+
# Tally CTM x-axis direction for orientation detection.
67+
# Classifies each paint op's CTM to the nearest 90° and increments
68+
# the corresponding counter — fixed 4-int overhead per page.
69+
ctm_arr = ctxt.gstate.CTM.val
70+
a, b = ctm_arr[0].val, ctm_arr[1].val
71+
if a * a + b * b >= 1e-10:
72+
votes = ctxt.display_list.rotation_votes
73+
if abs(a) >= abs(b):
74+
votes[0 if a >= 0 else 2] += 1 # 0° or 180°
75+
else:
76+
votes[3 if b >= 0 else 1] += 1 # 270° or 90°
77+
6678
# Notify Qt device (or other interactive device) to refresh if callback registered
6779
# This enables live rendering in interactive mode
6880
if hasattr(ctxt, 'on_paint_callback') and ctxt.on_paint_callback:

postforge/core/types/graphics.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ def __init__(self, width: int = 0, height: int = 0) -> None:
135135

136136
self.width = width
137137
self.height = height
138+
# CTM rotation vote counters collected during painting ops.
139+
# Each paint operation's CTM x-axis is classified to the nearest
140+
# 90° and tallied here. Used by orientation detection to determine
141+
# content rotation — fixed 4-int cost regardless of document size.
142+
self.rotation_votes: list[int] = [0, 0, 0, 0] # [0°, 90°, 180°, 270°]
138143

139144
"""
140145
This is a list of elements like Paths, Fills, Strokes, etc...
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# PostForge - A PostScript Interpreter
2+
# Copyright (c) 2025-2026 Scott Bowman
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
from __future__ import annotations
6+
7+
"""
8+
Orientation detection for PostScript pages.
9+
10+
Provides a device-agnostic heuristic to detect landscape content rendered
11+
onto portrait pages (e.g., PostScript files that use ``90 rotate``).
12+
"""
13+
14+
15+
def detect_landscape(display_list: list, width_pts: float,
16+
height_pts: float) -> int:
17+
"""Heuristic fallback for landscape detection when DSC is absent.
18+
19+
Reads pre-tallied CTM rotation votes from the display list to determine
20+
the dominant content rotation. Each painting operation's CTM x-axis
21+
direction was classified to the nearest 90° during display list
22+
construction. If the vast majority agree on a non-zero rotation, that
23+
value is returned as the page rotation angle.
24+
25+
Args:
26+
display_list: Display list from the context (with ``rotation_votes``).
27+
width_pts: Page width in points.
28+
height_pts: Page height in points.
29+
30+
Returns:
31+
Rotation angle (0, 90, or 270).
32+
"""
33+
# Only consider portrait pages
34+
if width_pts >= height_pts:
35+
return 0
36+
37+
votes = getattr(display_list, 'rotation_votes', None)
38+
if not votes:
39+
return 0
40+
41+
# votes layout: [0°, 90°, 180°, 270°]
42+
total = sum(votes)
43+
if total < 5:
44+
return 0
45+
46+
angle_map = {0: 0, 1: 90, 2: 180, 3: 270}
47+
best_idx = max(range(4), key=lambda i: votes[i])
48+
best_angle = angle_map[best_idx]
49+
50+
# Only apply rotation for landscape orientations (90/270)
51+
if best_angle in (90, 270) and votes[best_idx] > total * 0.8:
52+
return best_angle
53+
return 0

postforge/devices/pdf/pdf.py

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import os
1919

2020
from ...core import types as ps
21+
from ..common.orientation import detect_landscape
2122
from .font_tracker import FontTracker
2223
from .font_embedder import FontEmbedder
2324
from .cff_font_embedder import CFFEmbedder
@@ -179,7 +180,7 @@ def showpage(ctxt: ps.Context, pd: dict) -> None:
179180
if dsc_orient is not None and dsc_orient.val == b'Landscape':
180181
page_rotate = 90
181182
else:
182-
page_rotate = _detect_landscape(
183+
page_rotate = detect_landscape(
183184
ctxt.display_list, width_pts, height_pts)
184185

185186
# Store page data
@@ -250,54 +251,3 @@ def _is_type42_font(font_dict: ps.Dict) -> bool:
250251
return font_type is not None and font_type.val == 42
251252

252253

253-
def _detect_landscape(display_list: list, width_pts: float,
254-
height_pts: float) -> int:
255-
"""Heuristic fallback for landscape detection when DSC is absent.
256-
257-
Examines CTMs of display list elements to determine the dominant content
258-
rotation. Each element's x-axis direction ``(a, b)`` is classified to
259-
the nearest 90-degree multiple. If the vast majority agree on a non-zero
260-
rotation, that value is returned as the page ``/Rotate``.
261-
262-
Args:
263-
display_list: Display list elements from the context.
264-
width_pts: Page width in PDF points.
265-
height_pts: Page height in PDF points.
266-
267-
Returns:
268-
Rotation angle (0, 90, or 270).
269-
"""
270-
# Only consider portrait pages
271-
if width_pts >= height_pts:
272-
return 0
273-
274-
votes: dict[int, int] = {0: 0, 90: 0, 180: 0, 270: 0}
275-
for item in display_list:
276-
ctm = getattr(item, 'ctm', None)
277-
if ctm is None or len(ctm) < 4:
278-
continue
279-
a, b = ctm[0], ctm[1]
280-
if a * a + b * b < 1e-10:
281-
continue
282-
# Classify x-axis direction to nearest 90°.
283-
# The content stream's initial cm has a negative y-scale (y-flip)
284-
# which mirrors the rotation direction: a PS +90° CCW rotation
285-
# appears as -90° in the rendered PDF. Swap 90↔270 to produce
286-
# the correct /Rotate value that compensates.
287-
if abs(a) >= abs(b):
288-
nearest = 0 if a >= 0 else 180
289-
else:
290-
nearest = 270 if b >= 0 else 90
291-
votes[nearest] += 1
292-
293-
total = sum(votes.values())
294-
if total < 5:
295-
return 0
296-
297-
best = max(votes, key=votes.get)
298-
# Only apply rotation for landscape orientations (90/270)
299-
if best in (90, 270) and votes[best] > total * 0.8:
300-
return best
301-
return 0
302-
303-

postforge/devices/qt/qt.py

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,13 @@ def _get_antialias_mode(pd: dict) -> int:
4747

4848
from ...core import types as ps
4949
from ..common.cairo_renderer import render_display_list
50+
from ..common.orientation import detect_landscape
5051

5152
# Check for PySide6 availability
5253
try:
5354
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget
5455
from PySide6.QtCore import Qt
55-
from PySide6.QtGui import QImage, QPainter, QKeyEvent, QWheelEvent, QMouseEvent, QCursor
56+
from PySide6.QtGui import QImage, QPainter, QKeyEvent, QWheelEvent, QMouseEvent, QCursor, QTransform
5657
PYSIDE6_AVAILABLE = True
5758
except ImportError:
5859
PYSIDE6_AVAILABLE = False
@@ -302,9 +303,9 @@ def _ensure_window(page_width: float | None = None, page_height: float | None =
302303
If the rendered image is smaller than what would fill the default window,
303304
the window shrinks to fit the image at 1:1 pixel size instead of enlarging it.
304305
305-
Width is capped at 60% of screen width and height at 85% of screen height,
306-
so landscape content doesn't produce overly wide windows while portrait
307-
content still uses most of the vertical space.
306+
Portrait pages use 60% width / 85% height caps so windows stay narrow.
307+
Landscape pages (after auto-rotation) use 85% width / 85% height so the
308+
wider content gets adequate horizontal space.
308309
309310
Args:
310311
page_width: Page width (any units, used for aspect ratio)
@@ -327,12 +328,15 @@ def _ensure_window(page_width: float | None = None, page_height: float | None =
327328
screen_width = available.width()
328329
screen_height = available.height()
329330

330-
# Calculate maximum window size — 60% width, 85% height
331-
max_width = int(screen_width * 0.60)
331+
# Landscape pages get more horizontal space; portrait pages stay narrow
332+
if page_width > page_height:
333+
max_width = int(screen_width * 0.85)
334+
else:
335+
max_width = int(screen_width * 0.60)
332336
max_height = int(screen_height * 0.85)
333337

334338
if image_width and image_height:
335-
# Use exact image pixel dimensions, capped at max screen size
339+
# Use image pixel dimensions, capped at max screen size
336340
win_width = image_width
337341
win_height = image_height
338342

@@ -428,6 +432,31 @@ def _wait_for_keypress(ctxt: ps.Context) -> None:
428432
_app.processEvents()
429433

430434

435+
def _detect_page_rotation(pd: dict, display_list: list,
436+
width_pts: float, height_pts: float) -> int:
437+
"""Detect page rotation for landscape content on portrait pages.
438+
439+
Uses DSC ``%%Orientation`` when available (set by cli_runner from DSC
440+
parsing), otherwise falls back to CTM heuristic analysis. Only applies
441+
to portrait pages — landscape MediaBox pages need no rotation.
442+
443+
Args:
444+
pd: Page device dictionary.
445+
display_list: Display list elements from the context.
446+
width_pts: Page width in points.
447+
height_pts: Page height in points.
448+
449+
Returns:
450+
Rotation angle (0, 90, or 270).
451+
"""
452+
if width_pts >= height_pts:
453+
return 0
454+
dsc_orient = pd.get(b'DSCOrientation')
455+
if dsc_orient is not None and dsc_orient.val == b'Landscape':
456+
return 90
457+
return detect_landscape(display_list, width_pts, height_pts)
458+
459+
431460
def _render_to_window(ctxt: ps.Context, pd: dict) -> None:
432461
"""Render the current display list to the Qt window.
433462
@@ -457,43 +486,31 @@ def _render_to_window(ctxt: ps.Context, pd: dict) -> None:
457486
device_w = pd[b"MediaSize"].get(ps.Int(0))[1].val
458487
device_h = pd[b"MediaSize"].get(ps.Int(1))[1].val
459488

460-
# Cap render dimensions to avoid Cairo surface allocation failures
461-
# on screen display. When the device resolution produces surfaces
462-
# larger than this limit, render to a smaller surface and scale the
463-
# Cairo context so the display list (in device coords) fits.
464-
MAX_SURFACE_PIXELS = 16384
465-
downscale = 1.0
466-
render_w = device_w
467-
render_h = device_h
468-
if device_w > MAX_SURFACE_PIXELS or device_h > MAX_SURFACE_PIXELS:
469-
downscale = MAX_SURFACE_PIXELS / max(device_w, device_h)
470-
render_w = int(device_w * downscale)
471-
render_h = int(device_h * downscale)
472-
473-
# Calculate page dimensions in points for window sizing (from actual HWResolution)
489+
# Calculate page dimensions in points (from actual HWResolution)
474490
hw_dpi = pd[b"HWResolution"].get(ps.Int(0))[1].val
475-
scale = hw_dpi / 72.0
476-
_page_width = device_w / scale
477-
_page_height = device_h / scale
478-
479-
# Create Cairo surface at (possibly capped) render resolution
491+
dpi_scale = hw_dpi / 72.0
492+
_page_width = device_w / dpi_scale
493+
_page_height = device_h / dpi_scale
494+
495+
# Detect rotation for landscape content on portrait pages
496+
page_rotate = _detect_page_rotation(
497+
pd, ctxt.display_list, _page_width, _page_height)
498+
499+
# Create Cairo surface at device resolution (DPI was pre-computed by
500+
# _auto_set_qt_resolution to fill ~85% of screen height)
501+
render_w = int(device_w)
502+
render_h = int(device_h)
480503
_surface = cairo.ImageSurface(cairo.FORMAT_RGB24, render_w, render_h)
481504
cc = cairo.Context(_surface)
482505

483506
cc.identity_matrix()
484-
# Scale Cairo context to map device coordinates into the capped surface
485-
if downscale != 1.0:
486-
cc.scale(downscale, downscale)
487507
# Convert PostScript flatness to Cairo tolerance (PS default 1.0 → Cairo default 0.1)
488508
cc.set_tolerance(ctxt.gstate.flatness / 10.0)
489509

490-
# Fill white background (in surface coordinates)
491-
cc.save()
492-
cc.identity_matrix()
510+
# Fill white background
493511
cc.set_source_rgb(1.0, 1.0, 1.0)
494512
cc.rectangle(0, 0, render_w, render_h)
495513
cc.fill()
496-
cc.restore()
497514

498515
cc.set_antialias(_get_antialias_mode(pd))
499516

@@ -503,12 +520,17 @@ def _render_to_window(ctxt: ps.Context, pd: dict) -> None:
503520
# Convert Cairo surface to QImage
504521
_qimage = _cairo_surface_to_qimage(_surface)
505522

523+
# Auto-rotate landscape content rendered on portrait pages
524+
if page_rotate in (90, 270):
525+
_qimage = _qimage.transformed(QTransform().rotate(page_rotate))
526+
_page_width, _page_height = _page_height, _page_width
527+
506528
# Reset view state for new page
507529
_zoom_level = 1.0
508530
_pan_x = 0
509531
_pan_y = 0
510532

511-
# Ensure window exists and update (use page dimensions in points for aspect ratio)
533+
# Ensure window exists and update
512534
_ensure_window(_page_width, _page_height, _qimage.width(), _qimage.height())
513535

514536
_canvas.update()
0 Bytes
Binary file not shown.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "postforge"
7-
version = "0.9.3"
7+
version = "0.9.4"
88
description = "A PostScript interpreter written in Python"
99
authors = [{name = "Scott Bowman", email = "scott@bowmans.org"}]
1010
readme = "README.md"

0 commit comments

Comments
 (0)