Honour a page's /Rotate when extracting a scan
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.
This commit is contained in:
+16
-1
@@ -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)
|
||||
|
||||
+26
-4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user