Skip to content

Fix visual gap after {nb} page alias when text shaping is enabled (#1090) - #1894

Open
prateek-dagar wants to merge 5 commits into
py-pdf:masterfrom
prateek-dagar:fix-issue-1090
Open

Fix visual gap after {nb} page alias when text shaping is enabled (#1090)#1894
prateek-dagar wants to merge 5 commits into
py-pdf:masterfrom
prateek-dagar:fix-issue-1090

Conversation

@prateek-dagar

@prateek-dagar prateek-dagar commented Jul 21, 2026

Copy link
Copy Markdown

Fixes #1090

This PR resolves the issue where the special {nb} (or a custom) page number alias in the middle of a line causes a visual gap/overlap when text shaping is enabled.

To fix this, the layout width of the TotalPagesSubstitutionFragment is calculated using an estimated page number length (str(self.page_no())) during the layout phase only when text shaping is enabled, and falls back to the alias string under standard rendering to maintain 100% backwards compatibility.

Checklist:

  • A unit test is covering the code added / modified by this PR
  • In case of a new feature, docstrings have been added, with also some documentation in the docs/ folder
  • A mention of the change is present in CHANGELOG.md
  • This PR is ready to be merged

By submitting this pull request, I confirm that my contribution is made under the terms of the GNU LGPL 3.0 license.

@andersonhc andersonhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Using "0" * len(self.str_alias_nb_pages) for shaped layout seems safer than page_no(), because layout should not depend on the current page number when output later substitutes pages_count.

Could we also add a regression test with >= 10 pages and {nb} in the middle of shaped text? The current new test is one page, so it does not catch page 1 reserving "1" and later rendering "10" or "12".

Separately, the new top-level split on the alias seems to regress markdown parsing across {nb}. For example, **A {nb} B** parses with bold only before the alias. It may be better to keep alias handling inside the existing parser loop so markdown state is preserved.

Comment thread fpdf/fpdf.py Outdated
self._get_current_graphics_state(),
self.k,
dummy_width_string=(
str(self.page_no())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't feel using only the length of the current page number for the placeholder spacing is the best choice.
For page "1" we're saving very little space for the total page number if the document has more than 10 pages.
The non-shaping version saves the space of the alias string -default {nb} which is 4 characters.
What about something like "0" * len(self.str_alias_nb_pages)"?

@prateek-dagar

Copy link
Copy Markdown
Author

@andersonhc I tested with a 12-page document. Using "0" * len(self.str_alias_nb_pages) (which is "0000" for "{nb}") still did not work as expected because actual page count "12" is shorter.

Since we cannot know the total page count at the start, I propose two solutions:

  1. Zero Padding: Pad the final page number with leading zeros (e.g. "0012") to match the 4-character alias width.
  2. Explicit Expected Digits: Add a parameter to alias_nb_pages to let users pass expected digits (e.g., expected_digits=2 or dummy_width_string="00").
    We can also do a hybrid: fall back to zero padding if expected_digits is not provided. What do you think is best?

@andersonhc

Copy link
Copy Markdown
Collaborator
  1. Explicit Expected Digits: Add a parameter to alias_nb_pages to let users pass expected digits (e.g., expected_digits=2 or dummy_width_string="00").

The alias itself is already a not-so-explicit expected digits. For non-shaping we just save the space it would take to render {nb} as placeholder spacing, and we recommended using a custom long alias for bigger documents so we save more space.

I will do some more tests soon and get back to you on a viable solution. It should be a uniform spacing in all pages because it's going to be replaced by the same number on all pages - that's why I don't want to use current page number on the spacing.

@andersonhc

Copy link
Copy Markdown
Collaborator

Hi @prateek-dagar ,

I did the following test:

from pathlib import Path
from fpdf import FPDF

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

pdf = FPDF()
pdf.add_font(family="DejaVu", fname=HERE / ".." /"fonts"/"DejaVuSans.ttf")
pdf.set_text_shaping(True)
pdf.set_font("DejaVu", "", 20)

for _ in range(100):
    pdf.add_page()
    pdf.write(text="Hello {nb} world")

pdf.output(HERE / "test-pr1894.pdf")

On page 1 I had:
image

While on page 100 I had:
image

I still believe we should have the alias to reserve a constant space for all pages because all of them will be replaced by the same number.

It's a matter of tweaking what dummy_width_string value should be, and:

  • it must be a constant value for all pages
  • it must be based on the alias length, so we can use a bigger alias for documents where we expect more pages.

I found "0" * max(1, len(self.str_alias_nb_pages) - 1) to be the best compromise, but I'm open to hearing other opinions.

There is one big improvement that we could do - and I totally understand if you think it's out of scope for this PR:

Change TotalPagesSubstitutionFragment to save the reserved width (basically the result of get_width() with the dummy text at current font and size), then at rendering time you can calculate the replacement width and:

  • add half the difference of replacement width - reserved width to make the replacement centered on the reserved space
  • issue an warning to the user if the replacement width exceeds reserved width, suggesting to use a bigger alias

@prateek-dagar

Copy link
Copy Markdown
Author

Hi @andersonhc ,

Thanks for the detailed feedback and suggestions!

I really like the idea of saving the reserved width at layout time to auto-center the replacement text and issuing a warning if the final page count exceeds that width. I will try to implement this centering, warning, and fix the markdown regression will let you as soon as i am done with it or face any challenge implementing it.

Regarding the "0" * max(1, len(self.str_alias_nb_pages) - 1) formula:
I think this might break things or cause unexpected spacing for users who have defined custom aliases of a specific length (e.g. if they set "00" to reserve exactly 2 characters, this formula would only reserve 1).

To prevent this, my plan is to, apply the 3-character default ("000") only when the default "{nb}" alias is used.
Keep the dummy width string matching the exact length of the alias ("0" * len(alias)) when a custom alias is provided so we don't break custom layouts.

What you think about this.?

@andersonhc

Copy link
Copy Markdown
Collaborator

What you think about this.?

I agree with your approach for {nb} vs custom aliases.

I'm excited to see the progress here. This is shaping up to a much better feature for users. Let me know if you run into any questions.

@prateek-dagar

Copy link
Copy Markdown
Author

Hi @andersonhc ,

I have updated the implementation and pushed the latest commits. Here is a summary of the fixes:

  • The default {nb} now reserves a 3-character space ("000") and centers the page number inside it during rendering.
  • Overrode get_width() and clone() on the substitution fragment to ensure the layout remains stable during line-wrapping.
  • Cursor & Positioning Fixes:Fixed a bug where a word attached directly to the alias (e.g. {nb}shaping) had its first letter split off (rendering as 12s shaping or 12s haping) due to text cursor offsets. We forced positioning on the first character and reset the cursor at the end of the substitution to resolve this.
  • Testing: Added new test cases (14-page flow, markdown, overflow warnings), updated all reference PDFs, and verified that the entire local test suite passes successfully.

You can test the word-splitting case directly with this snippet:

from fpdf import FPDF

pdf = FPDF()
# Add a shaped font (e.g. Quicksand)
pdf.add_font("Quicksand", style="", fname="test/fonts/Quicksand-Regular.otf")
pdf.set_font("Quicksand", size=24)
pdf.set_text_shaping(True)

for _ in range(12):
    pdf.add_page()
    # Prior to this fix, this would render split first letters: "12s haping"
    pdf.write(text="Pages {nb} with {nb}shaping")
    pdf.ln()

pdf.output("test_output.pdf")

Please review the changes when you have a moment. Let me know if you see any issues or have further feedback!

@andersonhc andersonhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I really like the centering. It looks much better already.

Comment thread fpdf/line_break.py
Comment on lines +511 to +513
f"Replacement text '{replacement_text}' width ({replacement_width}) "
f"exceeds reserved dummy width ({dummy_width}) for alias '{alias_name}'. "
"Consider using a longer alias name to reserve more space.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
f"Replacement text '{replacement_text}' width ({replacement_width}) "
f"exceeds reserved dummy width ({dummy_width}) for alias '{alias_name}'. "
"Consider using a longer alias name to reserve more space.",
f"The total page count '{replacement_text}' is wider than the reserved "
f"alias width for '{alias_name}'. Use a longer alias with "
"alias_nb_pages() to reserve more space.",

"reserved dummy width" is implementation-specific and might be confusing to users

Comment thread fpdf/line_break.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found one remaining edge case where the line breaker can split a single page-count substitution fragment across multiple lines, which then registers multiple substitutions for one {nb} and renders the total page count multiple times.
A minimal reproduction is to remove spaces (or other normal word-break opportunities) and force character-level breaking around the alias:

from pathlib import Path
from fpdf import FPDF

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

pdf = FPDF()
pdf.add_font(family="DejaVu", fname=HERE / ".." /"fpdf2"/"test"/"fonts"/"DejaVuSans.ttf")
pdf.set_text_shaping(True)
pdf.set_font("DejaVu", "", 20)

for _ in range(5):
    pdf.add_page()
    pdf.multi_cell(w=10, text="Hello{nb}world")

pdf.output(HERE / "test-pr1894.pdf")

The underlying issue is that the dummy text for the substitution fragment, e.g. "000", is currently treated as independently wrappable characters. If the line breaker is forced into that fragment, it can clone/register one TotalPagesSubstitutionFragment per dummy character.

I think we need to make page-count substitution fragments atomic in the line-breaking path. In practice, add_character() / the surrounding line-break logic should never split a TotalPagesSubstitutionFragment; it should either place the whole substitution fragment on the current line, move it as a whole to the next line, or raise if the reserved width cannot fit on an empty line.

Comment thread fpdf/line_break.py

shift = 0.0
if (
self.graphics_state.text_shaping

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd be OK not applying the center not only to text shaping, but to everything (If you're OK replacing all the reference PDFs, of course 😄 )

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.

{nb} breaks if text shaping is turned on with certain fonts

2 participants