Skip to content

Commit

Permalink
Fixed FPDF.local_context() style leak during page breaks - Fix #1204
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas-C committed Jun 17, 2024
1 parent 1547d4d commit f387cbe
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 57 deletions.
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,19 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
* feature to identify the Unicode script of the input text and break it into fragments when different scripts are used, improving text shaping results
* [`FPDF.image()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.image): now handles `keep_aspect_ratio` in combination with an enum value provided to `x`
* file names are mentioned in errors when `fpdf2` fails to parse a SVG image
* * feature to adjust spacing before lists via the `HTML2FPDF.list_vertical_margin` attribute
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html): spacing before lists can now be adjusted via the `HTML2FPDF.list_vertical_margin` attribute
### Fixed
* [`FPDF.local_context()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.local_context) used to leak styling during page breaks, when rendering `footer()` & `header()`
* [`fpdf.drawing.DeviceCMYK`](https://py-pdf.github.io/fpdf2/fpdf/drawing.html#fpdf.drawing.DeviceCMYK) objects can now be passed to [`FPDF.set_draw_color()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_draw_color), [`FPDF.set_fill_color()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_fill_color) and [`FPDF.set_text_color()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_text_color) without raising a `ValueError`: [documentation](https://py-pdf.github.io/fpdf2/Text.html#text-formatting).
* individual `/Resources` directories are now properly created for each document page. This change ensures better compliance with the PDF specification but results in a slight increase in the size of PDF documents. You can still use the old behavior by setting `FPDF().single_resources_object = True`
* line size calculation for fragments when text shaping is used
* fixed incoherent indentation of long list entries - _cf._ [issue #1073](https://github.com/py-pdf/fpdf2/issues/1073)
* default values for `top_margin` and `bottom_margin` in `HTML2FPDF._new_paragraph()` calls are now correctly converted into chosen document units.
### Removed
* an obscure and undocumented [feature](https://github.com/py-pdf/fpdf2/issues/1198) of [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html), which used to magically pass local variables as arguments.
### Changed
* Removed an obscure and undocumented [feature](https://github.com/py-pdf/fpdf2/issues/1198) of [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html), which used to magically pass local variables as arguments.
* [`FPDF.table()`](https://py-pdf.github.io/fpdf2/Tables.html) now raises an error when a single row is too high to be rendered on a single page
* `HTML2FPDF.tag_indents` can now be non-integer. Indentation of HTML elements is now independent of font size and bullet strings.
* No spacing controlled by `HTML2FPDF.list_vertical_margin` is created for nested HTML `<li>` elements in contrast with prior respect for `Paragraph.top_margin` when handling `Paragraph`s created when handling `<ul>` and `<ol>` start tags.
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html): `tag_indents` can now be non-integer. Indentation of HTML elements is now independent of font size and bullet strings.

## [2.7.9] - 2024-05-17
### Added
Expand Down
86 changes: 66 additions & 20 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2746,18 +2746,7 @@ def mirror(self, origin, angle):

@check_page
@contextmanager
def local_context(
self,
font_family=None,
font_style=None,
font_size=None,
line_width=None,
draw_color=None,
fill_color=None,
text_color=None,
dash_pattern=None,
**kwargs,
):
def local_context(self, **kwargs):
"""
Creates a local graphics state, which won't affect the surrounding code.
This method must be used as a context manager using `with`:
Expand All @@ -2770,7 +2759,11 @@ def local_context(
allow_transparency
auto_close
blend_mode
char_vpos
char_spacing
dash_pattern
denom_lift
denom_scale
draw_color
fill_color
fill_opacity
Expand All @@ -2780,15 +2773,21 @@ def local_context(
font_stretching
intersection_rule
line_width
nom_lift
nom_scale
paint_rule
stroke_cap_style
stroke_join_style
stroke_miter_limit
stroke_opacity
sub_lift
sub_scale
sup_lift
sup_scale
text_color
text_mode
text_shaping
underline
char_vpos
Args:
**kwargs: key-values settings to set at the beggining of this context.
Expand All @@ -2798,6 +2797,32 @@ def local_context(
"cannot create a local context inside an unbreakable() code block"
)
self._push_local_stack()
self._init_local_context(**kwargs) # write "q" in the output stream
yield
self._out("Q")
self._pop_local_stack()

def _init_local_context(
self,
font_family=None,
font_style=None,
font_size=None,
line_width=None,
draw_color=None,
fill_color=None,
text_color=None,
dash_pattern=None,
**kwargs,
):
"""
This method starts a "q" context in the output stream,
and inserts operators in it to initialize all the PDF settings specified.
"""
if "font_size_pt" in kwargs:
if font_size is not None:
raise ValueError("font_size & font_size_pt cannot be both provided")
font_size = kwargs["font_size_pt"] / self.k
del kwargs["font_size_pt"]
gs = None
for key, value in kwargs.items():
if key in (
Expand All @@ -2815,7 +2840,23 @@ def local_context(
setattr(gs, key, value)
if key == "blend_mode":
self._set_min_pdf_version("1.4")
elif key in ("font_stretching", "text_mode", "underline", "char_vpos"):
elif key in (
"char_vpos",
"char_spacing",
"current_font",
"denom_lift",
"denom_scale",
"font_stretching",
"nom_lift",
"nom_scale",
"sub_lift",
"sub_scale",
"sup_lift",
"sup_scale",
"text_mode",
"text_shaping",
"underline",
):
setattr(self, key, value)
else:
raise ValueError(f"Unsupported setting: {key}")
Expand All @@ -2842,9 +2883,6 @@ def local_context(
self.set_text_color(text_color)
if dash_pattern is not None:
self.set_dash_pattern(**dash_pattern)
yield
self._out("Q")
self._pop_local_stack()

@property
def accept_page_break(self):
Expand Down Expand Up @@ -3537,7 +3575,14 @@ def _perform_page_break_if_need_be(self, h):

def _perform_page_break(self):
x = self.x
gs_stack = []
while self._is_current_graphics_state_nested():
self._out("Q")
gs_stack.append(self._pop_local_stack())
self.add_page(same=True)
for prev_gs in reversed(gs_stack):
self._push_local_stack(prev_gs)
self._init_local_context(**prev_gs)
self.x = x # restore x but not y after drawing header

def _has_next_page(self):
Expand Down Expand Up @@ -3813,9 +3858,7 @@ def multi_cell(
page_break_required = self.will_page_break(h + padding.bottom)
if page_break_required:
page_break_triggered = True
x = self.x
self.add_page(same=True)
self.x = x
self._perform_page_break()
self.y += padding.top

if box_required and (text_line_index == 0 or page_break_required):
Expand Down Expand Up @@ -5070,6 +5113,9 @@ def use_font_face(self, font_face: FontFace):
with pdf.use_font_face(FontFace(emphasis="BOLD", color=255, size_pt=42)):
put_some_text()
Known limitation: in case of a page jump in this local context,
the temporary style may "leak" in the header() & footer().
"""
if not font_face:
yield
Expand Down
73 changes: 40 additions & 33 deletions fpdf/graphics_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,35 +28,44 @@ class GraphicsStateMixin:
DEFAULT_DRAW_COLOR = DeviceGray(0)
DEFAULT_FILL_COLOR = DeviceGray(0)
DEFAULT_TEXT_COLOR = DeviceGray(0)
DEFAULT_STATE = dict(
draw_color=DEFAULT_DRAW_COLOR,
fill_color=DEFAULT_FILL_COLOR,
text_color=DEFAULT_TEXT_COLOR,
underline=False,
font_style="",
font_stretching=100,
char_spacing=0,
font_family="",
font_size_pt=0,
current_font={},
dash_pattern=dict(dash=0, gap=0, phase=0),
line_width=0,
text_mode=TextMode.FILL,
char_vpos=CharVPos.LINE,
sub_scale=0.7,
sup_scale=0.7,
nom_scale=0.75,
denom_scale=0.75,
sub_lift=-0.15,
sup_lift=0.4,
nom_lift=0.2,
denom_lift=0.0,
text_shaping=None,
)

@staticmethod
def _copy_graphics_state(gs):
# "current_font" must be shallow copied
# "text_shaping" must be deep copied (different fragments may have different languages/direction)
# Doing a whole copy and then creating a copy of text_shaping to achieve this result
gs = copy(gs)
if "text_shaping" in gs:
gs["text_shaping"] = copy(gs["text_shaping"])
return gs

def __init__(self, *args, **kwargs):
self.__statestack = [
dict(
draw_color=self.DEFAULT_DRAW_COLOR,
fill_color=self.DEFAULT_FILL_COLOR,
text_color=self.DEFAULT_TEXT_COLOR,
underline=False,
font_style="",
font_stretching=100,
char_spacing=0,
font_family="",
font_size_pt=0,
current_font={},
dash_pattern=dict(dash=0, gap=0, phase=0),
line_width=0,
text_mode=TextMode.FILL,
char_vpos=CharVPos.LINE,
sub_scale=0.7,
sup_scale=0.7,
nom_scale=0.75,
denom_scale=0.75,
sub_lift=-0.15,
sup_lift=0.4,
nom_lift=0.2,
denom_lift=0.0,
text_shaping=None,
),
]
self.__statestack = [self.DEFAULT_STATE]
super().__init__(*args, **kwargs)

def _push_local_stack(self, new=None):
Expand All @@ -69,12 +78,10 @@ def _pop_local_stack(self):
return self.__statestack.pop()

def _get_current_graphics_state(self):
# "current_font" must be shallow copied
# "text_shaping" must be deep copied (different fragments may have different languages/direction)
# Doing a whole copy and then creating a copy of text_shaping to achieve this result
gs = copy(self.__statestack[-1])
gs["text_shaping"] = copy(gs["text_shaping"])
return gs
return self._copy_graphics_state(self.__statestack[-1])

def _is_current_graphics_state_nested(self):
return len(self.__statestack) > 1

@property
def draw_color(self):
Expand Down
Binary file not shown.
Binary file added test/text/header_footer_and_use_font_face.pdf
Binary file not shown.
21 changes: 21 additions & 0 deletions test/text/test_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,27 @@ def test_cell_deprecated_txt_arg():
pdf.cell(txt="Lorem ipsum Ut nostrud irure")


def test_header_footer_and_local_context_font_size(tmp_path): # issue 1204
class PDF(FPDF):
def header(self):
self.cell(text=f"Header {self.page_no()}")
self.ln()

def footer(self):
self.set_y(-15)
self.cell(text=f"Footer {self.page_no()}")

pdf = PDF()
pdf.set_font(family="helvetica", size=12)
pdf.add_page()
with pdf.local_context(font_size=36): # LABEL C
pdf.multi_cell(w=0, text="\n".join(f"Line {i + 1}" for i in range(21)))
assert pdf.font_size_pt == 12
assert_pdf_equal(
pdf, HERE / "header_footer_and_local_context_font_size.pdf", tmp_path
)


@ensure_exec_time_below(seconds=24)
@ensure_rss_memory_below(mib=1)
def test_cell_speed_with_long_text(): # issue #907
Expand Down
28 changes: 28 additions & 0 deletions test/text/test_use_font_face.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pathlib import Path

from fpdf import FPDF, FontFace

from test.conftest import assert_pdf_equal

HERE = Path(__file__).resolve().parent


def test_header_footer_and_use_font_face(tmp_path): # issue 1204
class PDF(FPDF):
def header(self):
with self.use_font_face(FontFace(color="#00ff00", size_pt=12)): # LABEL A
self.cell(text=f"Header {self.page_no()}")
self.ln()

def footer(self):
self.set_y(-15)
with self.use_font_face(FontFace(color="#0000ff", size_pt=12)): # LABEL B
self.cell(text=f"Footer {self.page_no()}")

pdf = PDF()
pdf.set_font(family="helvetica", size=12)
pdf.add_page()
with pdf.use_font_face(FontFace(size_pt=36)): # LABEL C
pdf.multi_cell(w=0, text="\n".join(f"Line {i + 1}" for i in range(21)))
assert pdf.font_size_pt == 12
assert_pdf_equal(pdf, HERE / "header_footer_and_use_font_face.pdf", tmp_path)

0 comments on commit f387cbe

Please sign in to comment.