Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MAINT: Explicitly represent transformation matrix #878

Merged
merged 10 commits into from
May 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to check the results here ... I'm pretty confused by the first translation.

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading