From 2a22fc469f25fe5a62098c54a47c68ba1b80eccf Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 12:08:45 +0300 Subject: [PATCH] Honour a page's /Rotate when extracting a scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan fed sideways stores its image landscape and sets /Rotate 90 so a viewer turns it upright. Extracting the image by xref — which is how a raster source is read, to keep the scan's native resolution — bypasses that, so every system ran down the page and detection found nothing. Apply the page rotation to the extracted raster. Quarter turns only; nothing produces anything else. --- noteman_slicer/pdf.py | 17 ++++++++++++++++- tests/test_pdf.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/noteman_slicer/pdf.py b/noteman_slicer/pdf.py index 6ec6263..14a7d0e 100644 --- a/noteman_slicer/pdf.py +++ b/noteman_slicer/pdf.py @@ -90,13 +90,28 @@ def page_raster(source: Source, index: int) -> np.ndarray: # Pixmap(doc, xref) rather than decoding extract_image() bytes: # MuPDF handles JBIG2 and CCITT, which no image library will. pix = pymupdf.Pixmap(source.doc, xref) - return _to_gray(pix) + # The embedded image is in its own orientation, not the page's: a + # scanner that fed the sheet sideways stores it landscape and the + # PDF sets /Rotate so viewers turn it upright. Extracting by xref + # bypasses that, so apply it here — otherwise every system runs + # down the page and detection finds nothing. + return _rotate(_to_gray(pix), page.rotation) # A scanned PDF whose page has no embedded image (a blank, or a # cover typeset in vector). Rendering is the only option left. return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY)) +def _rotate(gray: np.ndarray, degrees: int) -> np.ndarray: + """Turn a page raster clockwise by a multiple of 90°, as /Rotate means it. + + ponytail: quarter turns only. A page rotated by anything else would need + resampling, and no scanner produces one. + """ + turns = round(degrees / 90) % 4 + return np.ascontiguousarray(np.rot90(gray, -turns)) if turns else gray + + def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray: if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1: pix = pymupdf.Pixmap(pymupdf.csGRAY, pix) diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 0711c9e..e945422 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -28,15 +28,21 @@ def _vector_pdf(path: Path, pages: int = 2) -> None: doc.save(path) -def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332) -> None: - """Each page is one full-page grayscale image — what a real scan looks like.""" +def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332, rotation: int = 0) -> None: + """Each page is one full-page grayscale image — what a real scan looks like. + + `rotation` reproduces a sheet fed sideways: the image is stored in its own + orientation and /Rotate turns it upright for a viewer. + """ art = np.full((h, w), 255, np.uint8) art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform + art[:60, :60] = 0 # a corner mark, so orientation is checkable pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False) doc = pymupdf.open() for _ in range(pages): - page = doc.new_page(width=A4.width, height=A4.height) + page = doc.new_page(width=A4.width, height=A4.width * h / w) page.insert_image(page.rect, pixmap=pix) + page.set_rotation(rotation) doc.save(path) @@ -64,6 +70,22 @@ def main() -> int: assert page.min() == 0 and page.max() == 255, (page.min(), page.max()) src.close() + # The corner mark sits top-left in an upright scan. + assert page[:60, :60].max() == 0 and page[:60, -60:].min() == 255 + + # A sideways scan comes back upright: the page's /Rotate applies to the + # image extracted by xref, which bypasses it. Okular gets this right and + # the slicer used to not. + sideways = tmp / "sideways.pdf" + _scan_pdf(sideways, pages=1, w=2332, h=1653, rotation=90) + src = open_source(sideways) + assert src.type is SourceType.RASTER + turned = page_raster(src, 0) + assert turned.shape == (2332, 1653), turned.shape + # Turned clockwise, so the mark that was top-left is now top-right. + assert turned[:60, -60:].max() == 0 and turned[:60, :60].min() == 255 + src.close() + # An override must win over detection, and say so. src = open_source(scan, SourceType.VECTOR) assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER @@ -71,7 +93,7 @@ def main() -> int: assert page_raster(src, 0).shape[1] > 4000, "override must force a render" src.close() - for f in (vec, scan): + for f in (vec, scan, sideways): f.unlink() tmp.rmdir() print("ok")