Skip to content

fix(text_region): preserve font state across render, closes #1804#1823

Open
Pawansingh3889 wants to merge 2 commits into
py-pdf:masterfrom
Pawansingh3889:fix/text-region-font-state-leak
Open

fix(text_region): preserve font state across render, closes #1804#1823
Pawansingh3889 wants to merge 2 commits into
py-pdf:masterfrom
Pawansingh3889:fix/text-region-font-state-leak

Conversation

@Pawansingh3889
Copy link
Copy Markdown

What changed

Fixes #1804 using @andersonhc's narrowed repro from the issue thread.

When a text_columns() / text_region() context exits, ParagraphCollectorMixin.__exit__ calls self.render() after popping the local graphics-state stack. Inside render(), each paragraph temporarily adjusts font_family, font_style, font_size_pt, and current_font to match its own paragraph-level font settings. Because those mutations happen on the already-restored outer state, the last paragraph's font settings leak back onto the calling FPDF instance.

The narrowed minimal repro (from your comment):

pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
cols = pdf.text_columns()
with cols:
    pdf.set_font("Helvetica", size=24)
    with cols.paragraph() as p:
        p.write("Large heading")
    pdf.set_font("Helvetica", size=10)
    with cols.paragraph() as p:
        p.write("Small body text")
pdf.ln()
pdf.cell(text=f"after columns size is {pdf.font_size_pt}")  # was 24, should be 12

Fix

ParagraphCollectorMixin.__exit__ now saves the four font attributes before self.render() and restores them in a try / finally:

saved_font_family = self.pdf.font_family
saved_font_style = self.pdf.font_style
saved_font_size_pt = self.pdf.font_size_pt
saved_current_font = self.pdf.current_font
try:
    self.render()
finally:
    self.pdf.font_family = saved_font_family
    self.pdf.font_style = saved_font_style
    self.pdf.font_size_pt = saved_font_size_pt
    self.pdf.current_font = saved_current_font

Cursor position and other render-produced state are intentionally left alone, since subsequent draws rely on them advancing. I also considered wrapping render() in a full _push_local_stack / _pop_local_stack, but that over-reverted the cursor state and broke seven existing tests. The narrower font-only save/restore keeps every existing snapshot byte-identical.

Test

New test_tcols_font_size_does_not_leak in test/text_region/test_text_columns.py uses the narrowed repro and asserts pdf.font_size_pt is unchanged after the context exits. Passes with the fix, fails without it (24 != 12).

The rest of the test/text_region/ suite passes identically against the fixed branch and against master on my machine. Two tests (test_text_columns_with_text_shaping, test_text_columns_with_shorter_2nd_column) require uharfbuzz which I couldn't install locally — they behave the same way with and without the fix, so not a regression, but I'd appreciate a CI run to confirm.

CHANGELOG

Entry added under ## [2.8.8] - Not released yet### Fixed.

Closes #1804.

@Pawansingh3889
Copy link
Copy Markdown
Author

Pawansingh3889 commented Apr 19, 2026

The one failing job is test (3.10, windows-latest) on test_perfs.py::test_intense_image_rendering — 12.30 s against a 12 s threshold, a Windows-runner timing flake. The source change in this PR only touches ParagraphCollectorMixin.__exit__ in fpdf/text_region.py, no image-rendering path. Every other test-matrix cell passes, including check-reference-pdf-files. Could you kick off a re-run when convenient?

Copy link
Copy Markdown
Collaborator

@andersonhc andersonhc left a comment

Choose a reason for hiding this comment

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

Thank you for the contribution.

Before merging this, I think there is one more context-manager scenario we should cover in this PR.

__exit__() restores font_family, font_style, font_size_pt, and current_font, but it does not restore/invalidate current_font_is_set_on_page. Because of that, after leaving text_columns(), the Python-side font state can say 12pt while the PDF content stream still has the 24pt font selected. The next pdf.cell() may then skip emitting a corrective Tf operator and render with the wrong size.

A regression test that shows this is:

def test_text_columns_restore_page_font_after_context(tmp_path):
    pdf = FPDF()
    pdf.add_page()
    pdf.set_font("Helvetica", size=12)

    with pdf.text_columns() as cols:
        pdf.set_font("Helvetica", size=24)
        cols.write("Large heading")
        pdf.set_font("Helvetica", size=10)
        cols.write("Small body text")

    pdf.cell(text="after columns")
    assert_pdf_equal(
        pdf,
        HERE / "text_columns_restore_page_font_after_context.pdf",
        tmp_path,
    )

@Pawansingh3889 Pawansingh3889 force-pushed the fix/text-region-font-state-leak branch from f145e5f to 497cf45 Compare May 1, 2026 21:07
When a TextColumns / TextRegion context exited, ParagraphCollectorMixin.__exit__
called self.render() after popping the local graphics-state stack. Internally,
render() mutates font_family, font_style, font_size_pt, and current_font as it
draws each paragraph at the paragraph-chosen size. Because those mutations
happened on the restored outer state, the last paragraph's font settings leaked
back onto the calling FPDF instance.

This surfaces most visibly when text_columns() is the first rendered element on
a page: a heading paragraph at size 24 followed by body at size 10 leaves
pdf.font_size_pt equal to 10 after the context, even though the caller never
asked for that. Minimal repro is the narrowed example andersonhc posted on the
issue thread.

Fix: save the four font attributes before the render call and restore them in
a try/finally. Cursor position and other render-produced state are intentionally
left alone; callers rely on them advancing.

Added a regression test test_tcols_font_size_does_not_leak that uses the
narrowed repro and asserts font_size_pt is unchanged after the context.
@Pawansingh3889 Pawansingh3889 force-pushed the fix/text-region-font-state-leak branch from 497cf45 to c84b7fa Compare May 1, 2026 21:18
…ag invalidation

The current_font_is_set_on_page = False addition in the __exit__ finally
block (added to address andersonhc's review feedback) correctly forces a
Tf operator re-emission when text is rendered after a text_columns
context, or when sequential text_columns blocks use different fonts.
That's exactly the bug the review identified: without the flag reset,
the page state still carried the inner paragraph's font selection.

Two existing tests captured the buggy state in their reference PDFs:

- test_tcols_balance: four sequential text_columns blocks with
  different fonts; each exit now invalidates the flag, so the next
  block emits a fresh Tf at its first paragraph.
- test_text_columns_with_shorter_2nd_column: writes "More text after
  columns." outside the text_columns block; that post-context write
  now correctly emits a Tf for the restored Times-14 instead of
  inheriting the inner cols font.

Byte deltas: tcols_balance.pdf -8B, text_columns_with_shorter_2nd_column.pdf
+3B -- consistent with Tf operator reorganization, no other rendering
changes. Visually verified the regenerated PDFs against master.
@Pawansingh3889
Copy link
Copy Markdown
Author

Apologies for the noisy CI on the c84b7fa amend. Two existing tests in test_text_columns.py (test_tcols_balance and test_text_columns_with_shorter_2nd_column) regressed because the current_font_is_set_on_page = False invalidation correctly forces a Tf operator re-emission for text rendered after a text_columns context, exactly the bug you flagged. The old reference PDFs had captured the buggy state where that Tf was missing.

Pushed b46ea413 regenerating both reference PDFs. Byte deltas are tiny (-8B and +3B), consistent with Tf operator reorganization and no other rendering change.

One caveat: test_text_columns_with_shorter_2nd_column uses set_text_shaping(True), so its bytes depend on the local uharfbuzz version. I regenerated it with uharfbuzz 0.54.1 (current latest). If CI's version differs and the hash still mismatches, I'll iterate, or you can regen on your end and I'll rebase.

The original PR description's claim that every existing snapshot stays byte-identical is no longer accurate after this amend; the flag-invalidation legitimately changes those two snapshots. Want me to edit the description, or leave it for the merge commit message to clarify?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

font_size_pt state bleeds across TextRegion boundaries when using ln()

2 participants