Skip to content

Commit

Permalink
MAINT: Explicitly represent transformation matrix (#878)
Browse files Browse the repository at this point in the history
This allows us to have a simpler user interface for page transformations
  • Loading branch information
MartinThoma committed May 19, 2022
1 parent 4429066 commit 5703b61
Show file tree
Hide file tree
Showing 16 changed files with 228 additions and 108 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Expand Up @@ -36,6 +36,7 @@ repos:
rev: 22.3.0
hooks:
- id: black
args: [--target-version, py36]
# - repo: https://github.com/asottile/pyupgrade
# rev: v2.31.1
# hooks:
Expand Down
2 changes: 2 additions & 0 deletions PyPDF2/__init__.py
@@ -1,4 +1,5 @@
from ._merger import PdfFileMerger
from ._page import Transformation
from ._reader import PdfFileReader
from ._version import __version__
from ._writer import PdfFileWriter
Expand All @@ -10,6 +11,7 @@
"PageRange",
"PaperSize",
"parse_filename_page_ranges",
"Transformation",
"PdfFileMerger",
"PdfFileReader",
"PdfFileWriter",
Expand Down
208 changes: 116 additions & 92 deletions PyPDF2/_page.py
Expand Up @@ -30,17 +30,7 @@
import math
import uuid
from decimal import Decimal
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
Union,
cast,
)
from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union, cast

from .constants import PageAttributes as PG
from .constants import Ressources as RES
Expand All @@ -57,7 +47,12 @@
RectangleObject,
TextStringObject,
)
from .utils import b_, matrixMultiply
from .utils import (
CompressedTransformationMatrix,
TransformationMatrixType,
b_,
matrixMultiply,
)


def getRectangle(self: Any, name: str, defaults: Iterable[str]) -> RectangleObject:
Expand Down Expand Up @@ -94,6 +89,88 @@ def createRectangleAccessor(name: str, fallback: Iterable[str]) -> property:
)


class Transformation:
"""
Specify a 2D transformation.
The transformation between two coordinate systems is represented by a 3-by-3
transformation matrix written as follows:
a b 0
c d 0
e f 1
Because a transformation matrix has only six elements that can be changed,
it is usually specified in PDF as the six-element array [ a b c d e f ].
Coordinate transformations are expressed as matrix multiplications:
a b 0
[ x′ y′ 1 ] = [ x y 1 ] × c d 0
e f 1
Usage
-----
>>> from PyPDF2 import Transformation
>>> op = Transformation().scale(sx=2, sy=3).translate(tx=10, ty=20)
>>> page.mergeTransformedPage(page2, op)
"""

# 9.5.4 Coordinate Systems for 3D
# 4.2.2 Common Transformations
def __init__(self, ctm: CompressedTransformationMatrix = (1, 0, 0, 1, 0, 0)):
self.ctm = ctm

@property
def matrix(self) -> TransformationMatrixType:
return (
(self.ctm[0], self.ctm[1], 0),
(self.ctm[2], self.ctm[3], 0),
(self.ctm[4], self.ctm[5], 1),
)

@staticmethod
def compress(matrix: TransformationMatrixType) -> CompressedTransformationMatrix:
return (
matrix[0][0],
matrix[0][1],
matrix[1][0],
matrix[1][1],
matrix[0][2],
matrix[1][2],
)

def translate(self, tx: float = 0, ty: float = 0) -> "Transformation":
m = self.ctm
return Transformation(ctm=(m[0], m[1], m[2], m[3], m[4] + tx, m[5] + ty))

def scale(
self, sx: Optional[float] = None, sy: Optional[float] = None
) -> "Transformation":
if sx is None and sy is None:
raise ValueError("Either sx or sy must be specified")
if sx is None:
sx = sy
if sy is None:
sy = sx
assert sx is not None
assert sy is not None
op: TransformationMatrixType = ((sx, 0, 0), (0, sy, 0), (0, 0, 1))
ctm = Transformation.compress(matrixMultiply(self.matrix, op))
return Transformation(ctm)

def rotate(self, rotation: float) -> "Transformation":
rotation = math.radians(rotation)
op: TransformationMatrixType = (
(math.cos(rotation), math.sin(rotation), 0),
(-math.sin(rotation), math.cos(rotation), 0),
(0, 0, 1),
)
ctm = Transformation.compress(matrixMultiply(self.matrix, op))
return Transformation(ctm)

def __repr__(self) -> str:
return f"Transformation(ctm={self.ctm})"


class PageObject(DictionaryObject):
"""
PageObject represents a single page within a PDF file.
Expand Down Expand Up @@ -245,7 +322,7 @@ def _pushPopGS(contents: Any, pdf: Any) -> ContentStream: # PdfFileReader

@staticmethod
def _addTransformationMatrix(
contents: Any, pdf: Any, ctm: Iterable[float]
contents: Any, pdf: Any, ctm: CompressedTransformationMatrix
) -> ContentStream: # PdfFileReader
# adds transformation matrix at the beginning of the given
# contents stream.
Expand Down Expand Up @@ -298,7 +375,7 @@ def _mergePage(
self,
page2: "PageObject",
page2transformation: Optional[Callable[[Any], ContentStream]] = None,
ctm: Optional[Iterable[float]] = None,
ctm: Optional[CompressedTransformationMatrix] = None,
expand: bool = False,
) -> None:
# First we work on merging the resource dictionaries. This allows us
Expand Down Expand Up @@ -396,7 +473,7 @@ def _mergePage(
page2.mediaBox.getLowerRight_y().as_numeric(),
]
if ctm is not None:
ctm = [float(x) for x in ctm]
ctm = tuple(float(x) for x in ctm) # type: ignore[assignment]
new_x = [
ctm[0] * corners2[i] + ctm[2] * corners2[i + 1] + ctm[4]
for i in range(0, 8, 2)
Expand Down Expand Up @@ -424,7 +501,10 @@ def _mergePage(
self[NameObject(PG.ANNOTS)] = new_annots

def mergeTransformedPage(
self, page2: "PageObject", ctm: Iterable[float], expand: bool = False
self,
page2: "PageObject",
ctm: Union[CompressedTransformationMatrix, Transformation],
expand: bool = False,
) -> None:
"""
mergeTransformedPage is similar to mergePage, but a transformation
Expand All @@ -437,10 +517,13 @@ def mergeTransformedPage(
:param bool expand: Whether the page should be expanded to fit the dimensions
of the page to be merged.
"""
if isinstance(ctm, Transformation):
ctm = ctm.ctm
ctm = cast(CompressedTransformationMatrix, ctm)
self._mergePage(
page2,
lambda page2Content: PageObject._addTransformationMatrix(
page2Content, page2.pdf, ctm
page2Content, page2.pdf, ctm # type: ignore[arg-type]
),
ctm,
expand,
Expand All @@ -459,8 +542,8 @@ def mergeScaledPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""
# CTM to scale : [ sx 0 0 sy 0 0 ]
self.mergeTransformedPage(page2, [scale, 0, 0, scale, 0, 0], expand)
op = Transformation().scale(scale, scale)
self.mergeTransformedPage(page2, op, expand)

def mergeRotatedPage(
self, page2: "PageObject", rotation: float, expand: bool = False
Expand All @@ -475,19 +558,8 @@ def mergeRotatedPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""
rotation = math.radians(rotation)
self.mergeTransformedPage(
page2,
[
math.cos(rotation),
math.sin(rotation),
-math.sin(rotation),
math.cos(rotation),
0,
0,
],
expand,
)
op = Transformation().rotate(rotation)
self.mergeTransformedPage(page2, op, expand)

def mergeTranslatedPage(
self, page2: "PageObject", tx: float, ty: float, expand: bool = False
Expand All @@ -503,7 +575,8 @@ def mergeTranslatedPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""
self.mergeTransformedPage(page2, [1, 0, 0, 1, tx, ty], expand)
op = Transformation().translate(tx, ty)
self.mergeTransformedPage(page2, op, expand)

def mergeRotatedTranslatedPage(
self,
Expand All @@ -525,23 +598,8 @@ def mergeRotatedTranslatedPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""

translation: List[List[float]] = [[1, 0, 0], [0, 1, 0], [-tx, -ty, 1]]
rotation = math.radians(rotation)
rotating: List[List[float]] = [
[math.cos(rotation), math.sin(rotation), 0],
[-math.sin(rotation), math.cos(rotation), 0],
[0, 0, 1],
]
rtranslation: List[List[float]] = [[1, 0, 0], [0, 1, 0], [tx, ty, 1]]
ctm = matrixMultiply(translation, rotating)
ctm = matrixMultiply(ctm, rtranslation)

return self.mergeTransformedPage(
page2,
[ctm[0][0], ctm[0][1], ctm[1][0], ctm[1][1], ctm[2][0], ctm[2][1]],
expand,
)
op = Transformation().translate(-tx, -ty).rotate(rotation).translate(tx, ty)
return self.mergeTransformedPage(page2, op, expand)

def mergeRotatedScaledPage(
self, page2: "PageObject", rotation: float, scale: float, expand: bool = False
Expand All @@ -557,20 +615,8 @@ def mergeRotatedScaledPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""
rotation = math.radians(rotation)
rotating: List[List[float]] = [
[math.cos(rotation), math.sin(rotation), 0],
[-math.sin(rotation), math.cos(rotation), 0],
[0, 0, 1],
]
scaling: List[List[float]] = [[scale, 0, 0], [0, scale, 0], [0, 0, 1]]
ctm = matrixMultiply(rotating, scaling)

self.mergeTransformedPage(
page2,
[ctm[0][0], ctm[0][1], ctm[1][0], ctm[1][1], ctm[2][0], ctm[2][1]],
expand,
)
op = Transformation().rotate(rotation).scale(scale, scale)
self.mergeTransformedPage(page2, op, expand)

def mergeScaledTranslatedPage(
self,
Expand All @@ -592,16 +638,8 @@ def mergeScaledTranslatedPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""

translation: List[List[float]] = [[1, 0, 0], [0, 1, 0], [tx, ty, 1]]
scaling: List[List[float]] = [[scale, 0, 0], [0, scale, 0], [0, 0, 1]]
ctm = matrixMultiply(scaling, translation)

return self.mergeTransformedPage(
page2,
[ctm[0][0], ctm[0][1], ctm[1][0], ctm[1][1], ctm[2][0], ctm[2][1]],
expand,
)
op = Transformation().scale(scale, scale).translate(tx, ty)
return self.mergeTransformedPage(page2, op, expand)

def mergeRotatedScaledTranslatedPage(
self,
Expand All @@ -626,24 +664,10 @@ def mergeRotatedScaledTranslatedPage(
:param bool expand: Whether the page should be expanded to fit the
dimensions of the page to be merged.
"""
translation: List[List[float]] = [[1, 0, 0], [0, 1, 0], [tx, ty, 1]]
rotation = math.radians(rotation)
rotating: List[List[float]] = [
[math.cos(rotation), math.sin(rotation), 0],
[-math.sin(rotation), math.cos(rotation), 0],
[0, 0, 1],
]
scaling: List[List[float]] = [[scale, 0, 0], [0, scale, 0], [0, 0, 1]]
ctm = matrixMultiply(rotating, scaling)
ctm = matrixMultiply(ctm, translation)

self.mergeTransformedPage(
page2,
[ctm[0][0], ctm[0][1], ctm[1][0], ctm[1][1], ctm[2][0], ctm[2][1]],
expand,
)
op = Transformation().rotate(rotation).scale(scale, scale).translate(tx, ty)
self.mergeTransformedPage(page2, op, expand)

def addTransformation(self, ctm: List[float]) -> None:
def addTransformation(self, ctm: CompressedTransformationMatrix) -> None:
"""
Apply a transformation matrix to the page.
Expand All @@ -666,7 +690,7 @@ def scale(self, sx: float, sy: float) -> None:
:param float sx: The scaling factor on horizontal axis.
:param float sy: The scaling factor on vertical axis.
"""
self.addTransformation([sx, 0, 0, sy, 0, 0])
self.addTransformation((sx, 0, 0, sy, 0, 0))
self.mediaBox = RectangleObject(
(
float(self.mediaBox.getLowerLeft_x()) * sx,
Expand Down
8 changes: 4 additions & 4 deletions PyPDF2/filters.py
Expand Up @@ -32,9 +32,9 @@

import math
import struct
from io import StringIO,BytesIO
from typing import Any, Dict, Optional, Tuple, Union
import zlib
from io import BytesIO, StringIO
from typing import Any, Dict, Optional, Tuple, Union

from .generic import ArrayObject, DictionaryObject, NameObject

Expand All @@ -47,12 +47,12 @@
from .constants import ColorSpaces
from .constants import FilterTypeAbbreviations as FTA
from .constants import FilterTypes as FT
from .constants import GraphicsStateParameters as G
from .constants import ImageAttributes as IA
from .constants import LzwFilterParameters as LZW
from .constants import StreamAttributes as SA
from .constants import GraphicsStateParameters as G
from .errors import PdfReadError, PdfStreamError
from .utils import b_,ord_, paethPredictor
from .utils import b_, ord_, paethPredictor


def decompress(data: bytes) -> bytes:
Expand Down
2 changes: 0 additions & 2 deletions PyPDF2/generic.py
Expand Up @@ -44,7 +44,6 @@
from .constants import StreamAttributes as SA
from .constants import TypArguments as TA
from .constants import TypFitArguments as TF

from .errors import (
STREAM_TRUNCATED_PREMATURELY,
PdfReadError,
Expand Down Expand Up @@ -1340,7 +1339,6 @@ def __init__(
self[NameObject("/Page")] = page
self[NameObject("/Type")] = typ


# from table 8.2 of the PDF 1.7 reference.
if typ == "/XYZ":
(
Expand Down

0 comments on commit 5703b61

Please sign in to comment.