Using Borb I'm trying to find a way to put text at a certain angle (27.33°). However, as stated in this Stackoverflow question, rotating LayoutElements is not yet possible.
I cannot rotate the page, put my text and rotate it back since my angle is so specific and I also could not figure out how to use the Transformation Matrix to do this. Is there a simple way to achieve this and rotate my text?
disclaimer: I am the author of borb
I have the following code (which attempts to generically rotate any LayoutElement
)
import math
import typing
from decimal import Decimal
from borb.pdf.canvas.geometry.rectangle import Rectangle
from borb.pdf.canvas.layout.layout_element import LayoutElement
def rotate_point(angle_in_degrees: Decimal,
point: typing.Tuple[Decimal, Decimal]) -> typing.Tuple[Decimal, Decimal]:
# convert angle to radians
angle_in_radians: Decimal = Decimal(math.radians(angle_in_degrees))
# perform rotation
x: Decimal = point[0]
y: Decimal = point[1]
x_prime: Decimal = x * Decimal(math.cos(angle_in_radians)) - y * Decimal(math.sin(angle_in_radians))
y_prime: Decimal = x * Decimal(math.sin(angle_in_radians)) + y * Decimal(math.cos(angle_in_radians))
# return
return x_prime, y_prime
def dimensions_of_rotated_rectangle(r: Rectangle,
angle_in_degrees: Decimal) -> Rectangle:
ZERO: Decimal = Decimal(0)
W: Decimal = r.get_width()
H: Decimal = r.get_height()
p0: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(ZERO, ZERO))
p1: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(ZERO, H))
p2: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(W, H))
p3: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(W, ZERO))
# calculate width and height
w_prime: Decimal = max([p0[0], p1[0], p2[0], p3[0]]) - min([p0[0], p1[0], p2[0], p3[0]])
h_prime: Decimal = max([p0[1], p1[1], p2[1], p3[1]]) - min([p0[1], p1[1], p2[1], p3[1]])
# return
return Rectangle(ZERO, ZERO, w_prime, h_prime)
def delta_of_rotated_rectangle(r: Rectangle,
angle_in_degrees: Decimal) -> typing.Tuple[Decimal, Decimal]:
ZERO: Decimal = Decimal(0)
W: Decimal = r.get_width()
H: Decimal = r.get_height()
p0: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(ZERO, ZERO))
p1: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(ZERO, H))
p2: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(W, H))
p3: typing.Tuple[Decimal, Decimal] = rotate_point(angle_in_degrees=angle_in_degrees, point=(W, ZERO))
return (-min([p0[0], p1[0], p2[0], p3[0]]),
-min([p0[1], p1[1], p2[1], p3[1]]))
def largest_inscribed_rectangle(angle_in_degrees: Decimal,
r: Rectangle) -> Rectangle:
max_area: typing.Optional[Decimal] = None
max_w: typing.Optional[Decimal] = None
max_h: typing.Optional[Decimal] = None
for w in range(int(r.get_width() // 2), int(r.get_width()) + 1):
for h in range(int(r.get_height() // 2), int(r.get_height()) + 1):
r2: Rectangle = dimensions_of_rotated_rectangle(Rectangle(Decimal(0),
Decimal(0),
Decimal(w),
Decimal(h)),
angle_in_degrees=angle_in_degrees)
if r2.get_width() > r.get_width():
continue
if r2.get_height() > r.get_height():
continue
area: Decimal = r2.get_width() * r2.get_height()
if max_area is None or area > max_area:
max_area = area
max_w = Decimal(w)
max_h = Decimal(h)
assert max_w is not None
assert max_h is not None
# return
return Rectangle(Decimal(0),
Decimal(0),
max_w,
max_h)
class RotatedLayoutElement(LayoutElement):
def __init__(self, angle_in_degrees: Decimal, layout_element: LayoutElement):
super().__init__()
self._angle_in_degrees: Decimal = angle_in_degrees
self._layout_element: LayoutElement = layout_element
self._prev_content_box: typing.Optional[Rectangle] = None
#
# PRIVATE
#
def _get_content_box(self, available_space: Rectangle) -> Rectangle:
r0: Rectangle = largest_inscribed_rectangle(r=available_space,
angle_in_degrees=self._angle_in_degrees)
self._prev_inner_content_box = self._layout_element.get_layout_box(r0)
r2: Rectangle = dimensions_of_rotated_rectangle(self._prev_inner_content_box, self._angle_in_degrees)
self._prev_content_box = Rectangle(available_space.get_x(),
available_space.get_y() + available_space.get_height() - r2.get_height(),
r2.get_width(),
r2.get_height())
return self._prev_content_box
def _paint_content_box(self, page: "Page", content_box: Rectangle) -> None:
# we are going to do unholy things to the graphics state
# best to store it before the madness begins
page.append_to_content_stream(" q ")
# rotate
page.append_to_content_stream(f"{round(math.cos(math.radians(self._angle_in_degrees)), 2)} "
f"{round(math.sin(math.radians(self._angle_in_degrees)), 2)} "
f"{round(-math.sin(math.radians(self._angle_in_degrees)), 2)} "
f"{round(math.cos(math.radians(self._angle_in_degrees)), 2)} "
f"0 0 cm ")
# translate to counteract translation by rotation
# ensuring the bounding rectangle fits inside the target rectangle
tx, ty = rotate_point(angle_in_degrees=-self._angle_in_degrees,
point=delta_of_rotated_rectangle(self._prev_inner_content_box,
angle_in_degrees=self._angle_in_degrees))
page.append_to_content_stream(f"1 0 0 1 {round(tx, 2)} {round(ty, 2)} cm ")
# translate to point
tx, ty = rotate_point(point=(content_box.get_x(),
content_box.get_y()), angle_in_degrees=-self._angle_in_degrees)
page.append_to_content_stream(f"1 0 0 1 {round(tx, 2)} {round(ty, 2)} cm ")
# paint
self._layout_element.paint(page, Rectangle(Decimal(0),
Decimal(0),
self._prev_inner_content_box.get_width(),
self._prev_inner_content_box.get_height()))
# restore graphics state
page.append_to_content_stream(" Q ")
Copy/paste that, store as rotated_layout_element.py
Now you can use that to do the following:
from decimal import Decimal
from borb.pdf import Document
from borb.pdf import PDF
from borb.pdf import Page
from borb.pdf import PageLayout
from borb.pdf import Paragraph
from borb.pdf import SingleColumnLayout
from borb.pdf import TableUtil
from rotated_layout_element import RotatedLayoutElement
def main():
d: Document = Document()
p: Page = Page()
d.add_page(p)
l: PageLayout = SingleColumnLayout(p)
l.add(Paragraph("27 degrees"))
l.add(
RotatedLayoutElement(
layout_element=TableUtil.from_2d_array([["Lorem", "Ipsum", "Dolor", "Sit", "Amet"],
[1,2,3,4,5],
[4,5,6,7,8]]),
angle_in_degrees=Decimal(27),
)
)
with open("output.pdf", "wb") as fh:
PDF.dumps(fh, d)
if __name__ == "__main__":
main()
Which yields the following PDF: