diff --git a/CHANGELOG.md b/CHANGELOG.md index de91b918bc..9e4e103239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default', ## [2.8.9] - Not released yet ### Fixed +* visual gap in rendering subsequent text after `{nb}` page alias when text shaping is enabled - _cf._ [issue #1090](https://github.com/py-pdf/fpdf2/issues/1090) - thanks to @prateek-dagar * `FPDF.write_html()` no longer raises `IndexError: pop from empty list` when a `
` - _cf._ [PR #1917](https://github.com/py-pdf/fpdf2/pull/1917)
## [2.8.8] - 2026-08-09
diff --git a/docs/PageBreaks.md b/docs/PageBreaks.md
index 579c4e292a..6abbd6ac2c 100644
--- a/docs/PageBreaks.md
+++ b/docs/PageBreaks.md
@@ -21,7 +21,6 @@ Simply call `.add_page()`.
The special string `{nb}` will be substituted by the total number of pages on document closure.
This special value can changed by calling [alias_nb_pages()](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.alias_nb_pages).
-!!! warning "This is currently incompatible with [text shaping](./TextShaping.md).
_cf._ [GitHub issue #1090](https://github.com/py-pdf/fpdf2/issues/1090)"
## will_page_break ##
diff --git a/docs/TextShaping.md b/docs/TextShaping.md
index e8c3dde613..5bbf7d4bfd 100644
--- a/docs/TextShaping.md
+++ b/docs/TextShaping.md
@@ -2,7 +2,6 @@
_New in [:octicons-tag-24: 2.7.5](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_
-!!! warning "This is currently incompatible with [the special `{nb}` string](./PageBreaks.md) that inserts the number of pages.
_cf._ [GitHub issue #1090](https://github.com/py-pdf/fpdf2/issues/1090)"
## What is text shaping? ##
Text shaping is a fundamental process in typography and computer typesetting that influences the aesthetics and readability of text in various languages and scripts. It involves the transformation of Unicode text into glyphs, which are then positioned for display or print.
diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py
index b0a639a01c..14ec11d228 100644
--- a/fpdf/fpdf.py
+++ b/fpdf/fpdf.py
@@ -4468,6 +4468,11 @@ def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]:
"Split text into fragments"
if not markdown and not self.text_shaping and not self._fallback_font_ids:
if self.str_alias_nb_pages:
+ dummy_width_string = (
+ "0" * max(1, len(self.str_alias_nb_pages) - 1)
+ if self.str_alias_nb_pages == "{nb}"
+ else "0" * len(self.str_alias_nb_pages)
+ )
for seq, fragment_text in enumerate(
text.split(self.str_alias_nb_pages)
):
@@ -4476,6 +4481,7 @@ def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]:
self.str_alias_nb_pages,
self._get_current_graphics_state(),
self.k,
+ dummy_width_string=dummy_width_string,
)
if fragment_text:
yield Fragment(
@@ -4579,10 +4585,16 @@ def frag() -> Fragment:
)
gstate.strikethrough = in_strikethrough
gstate.underline = in_underline
+ dummy_width_string = (
+ "0" * max(1, len(self.str_alias_nb_pages) - 1)
+ if self.str_alias_nb_pages == "{nb}"
+ else "0" * len(self.str_alias_nb_pages)
+ )
yield TotalPagesSubstitutionFragment(
self.str_alias_nb_pages,
gstate,
self.k,
+ dummy_width_string=dummy_width_string,
)
text = text[len(self.str_alias_nb_pages) :]
continue
diff --git a/fpdf/line_break.py b/fpdf/line_break.py
index bbf290c7d7..d96c55e52a 100644
--- a/fpdf/line_break.py
+++ b/fpdf/line_break.py
@@ -20,7 +20,9 @@
Sequence,
Tuple,
Union,
+ cast,
)
+import warnings
from uuid import uuid4
from fpdf.drawing_primitives import DeviceCMYK, DeviceGray, DeviceRGB
@@ -86,6 +88,16 @@ def __repr__(self) -> str:
f" k={self.k}, link={self.link})"
)
+ def clone(
+ self, characters: Union[list[str], str] = "", link: Optional[int | str] = None
+ ) -> "Fragment":
+ return self.__class__(
+ characters=characters,
+ graphics_state=self.graphics_state,
+ k=self.k,
+ link=link,
+ )
+
@property
def font(self) -> CoreFont | TTFFont:
if TYPE_CHECKING:
@@ -370,13 +382,20 @@ def adjust_pos(pos: float) -> float:
)
char_spacing = self.char_spacing * (self.font_stretching / 100) / self.k
- for ti in self.font.shape_text(
- self.string, self.font_size_pt, self.text_shaping_parameters
+ for i, ti in enumerate(
+ self.font.shape_text(
+ self.string, self.font_size_pt, self.text_shaping_parameters
+ )
):
if ti["mapped_char"] is None: # Missing glyph
continue
char = self.font.escape_text(chr(ti["mapped_char"]))
- if ti["x_offset"] != 0 or ti["y_offset"] != 0:
+ is_first_char = i == 0
+ if (
+ ti["x_offset"] != 0
+ or ti["y_offset"] != 0
+ or (isinstance(self, TotalPagesSubstitutionFragment) and is_first_char)
+ ):
if text:
ret += f"({text}) Tj "
text = ""
@@ -423,9 +442,37 @@ class TotalPagesSubstitutionFragment(Fragment):
output is being produced.
"""
- def __init__(self, *args: Any, **kwargs: Any) -> None:
+ def __init__(
+ self, *args: Any, dummy_width_string: str = "1", **kwargs: Any
+ ) -> None:
super().__init__(*args, **kwargs)
self.uuid = uuid4()
+ self.dummy_width_string = dummy_width_string
+ # Use dummy_width_string for layout phase width calculation if characters are not empty (non-cloned)
+ # and text shaping is active.
+ if self.characters and self.graphics_state.text_shaping:
+ self.characters = [dummy_width_string]
+
+ def clone(
+ self, characters: Union[list[str], str] = "", link: Optional[int | str] = None
+ ) -> "TotalPagesSubstitutionFragment":
+ clone_obj = cast(
+ TotalPagesSubstitutionFragment,
+ super().clone(characters=characters, link=link),
+ )
+ clone_obj.dummy_width_string = self.dummy_width_string
+ return clone_obj
+
+ def get_width(
+ self,
+ start: int = 0,
+ end: Optional[int] = None,
+ chars: Optional[str] = None,
+ initial_cs: bool = True,
+ ) -> float:
+ if chars is None:
+ chars = self.dummy_width_string
+ return super().get_width(start, end, chars, initial_cs)
def get_placeholder_string(self) -> str:
"""
@@ -453,8 +500,48 @@ def render_text_substitution(self, replacement_text: str) -> str:
to render the fragment with the preserved rendering state (stored in `_render_args` and `_render_kwargs`)
and insert the final text in place of the placeholder.
"""
+ alias_name = self.string
self.characters = list(replacement_text)
- return super().render_pdf_text(*self._render_args, **self._render_kwargs)
+
+ dummy_width = self.get_width(chars=self.dummy_width_string)
+ replacement_width = self.get_width(chars=replacement_text)
+
+ if replacement_width > dummy_width:
+ warnings.warn(
+ 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.",
+ UserWarning,
+ )
+
+ shift = 0.0
+ if (
+ self.graphics_state.text_shaping
+ and hasattr(self, "_render_args")
+ and self._render_args
+ ):
+ args = list(self._render_args)
+ if len(args) > 3:
+ # adjust_x is at index 3: frag_ws, current_ws, word_spacing, adjust_x, adjust_y, h
+ shift = (dummy_width - replacement_width) / 2
+ args[3] += shift
+ self._render_args = tuple(args)
+
+ ret = super().render_pdf_text(*self._render_args, **self._render_kwargs)
+
+ if (
+ self.graphics_state.text_shaping
+ and hasattr(self, "_render_args")
+ and self._render_args
+ ):
+ # Reset PDF cursor to the end of reserved space to prevent splitting subsequent text:
+ original_adjust_x = self._render_args[3] - shift
+ end_x = original_adjust_x + dummy_width
+ h = self._render_args[5]
+ pos_y = self._render_args[4]
+ ret += f" 1 0 0 1 {end_x * self.k:.2f} {(h - pos_y) * self.k:.2f} Tm"
+
+ return ret
class TextLine(NamedTuple):
@@ -567,10 +654,8 @@ def add_character(
if not self.fragments:
assert isinstance(original_fragment, Fragment)
self.fragments.append(
- original_fragment.__class__(
+ original_fragment.clone(
characters="",
- graphics_state=original_fragment.graphics_state,
- k=original_fragment.k,
link=url,
)
)
@@ -584,10 +669,8 @@ def add_character(
and url == self.fragments[-1].link
):
self.fragments.append(
- original_fragment.__class__(
+ original_fragment.clone(
characters="",
- graphics_state=original_fragment.graphics_state,
- k=original_fragment.k,
link=url,
)
)
diff --git a/test/alias_in_middle_with_shaping.pdf b/test/alias_in_middle_with_shaping.pdf
new file mode 100644
index 0000000000..f5c4911bc4
Binary files /dev/null and b/test/alias_in_middle_with_shaping.pdf differ
diff --git a/test/alias_in_middle_with_shaping_many_pages.pdf b/test/alias_in_middle_with_shaping_many_pages.pdf
new file mode 100644
index 0000000000..272c3ab7a7
Binary files /dev/null and b/test/alias_in_middle_with_shaping_many_pages.pdf differ
diff --git a/test/alias_in_middle_with_shaping_markdown.pdf b/test/alias_in_middle_with_shaping_markdown.pdf
new file mode 100644
index 0000000000..4983c4a60f
Binary files /dev/null and b/test/alias_in_middle_with_shaping_markdown.pdf differ
diff --git a/test/alias_nb_pages.pdf b/test/alias_nb_pages.pdf
index 6b26988b01..cb128776ff 100644
Binary files a/test/alias_nb_pages.pdf and b/test/alias_nb_pages.pdf differ
diff --git a/test/alias_with_text_shaping.pdf b/test/alias_with_text_shaping.pdf
index 4c9dfcbb4c..895b689bb0 100644
Binary files a/test/alias_with_text_shaping.pdf and b/test/alias_with_text_shaping.pdf differ
diff --git a/test/custom_alias_nb_pages.pdf b/test/custom_alias_nb_pages.pdf
new file mode 100644
index 0000000000..70c10043d2
Binary files /dev/null and b/test/custom_alias_nb_pages.pdf differ
diff --git a/test/fonts/fonts_remap_nb.pdf b/test/fonts/fonts_remap_nb.pdf
index e498d6a79d..1d65bd309d 100644
Binary files a/test/fonts/fonts_remap_nb.pdf and b/test/fonts/fonts_remap_nb.pdf differ
diff --git a/test/outline/toc_no_reset_page_indices.pdf b/test/outline/toc_no_reset_page_indices.pdf
index 6d20ad3973..ee995a9971 100644
Binary files a/test/outline/toc_no_reset_page_indices.pdf and b/test/outline/toc_no_reset_page_indices.pdf differ
diff --git a/test/outline/toc_with_nb_and_footer.pdf b/test/outline/toc_with_nb_and_footer.pdf
index b05521cbbd..975e374a10 100644
Binary files a/test/outline/toc_with_nb_and_footer.pdf and b/test/outline/toc_with_nb_and_footer.pdf differ
diff --git a/test/test_alias.py b/test/test_alias.py
index d43e22a521..6be4a85532 100644
--- a/test/test_alias.py
+++ b/test/test_alias.py
@@ -1,5 +1,6 @@
from pathlib import Path
+import pytest
import fpdf
from test.conftest import assert_pdf_equal
@@ -20,15 +21,12 @@ def test_custom_alias_nb_pages(tmp_path):
pdf = fpdf.FPDF()
pdf.set_font("Times")
alias = "n{}b"
- # Prerequisite to get exactly the same output in the PDF:
- # the default alias and the new one must be of same width:
- assert pdf.get_string_width(pdf.str_alias_nb_pages) == pdf.get_string_width(alias)
pdf.alias_nb_pages(alias)
pdf.add_page()
pdf.cell(0, 10, f"Page {pdf.page_no()}/{alias}", align="C")
pdf.add_page()
pdf.cell(0, 10, f"Page {pdf.page_no()}/{alias}", align="C")
- assert_pdf_equal(pdf, HERE / "alias_nb_pages.pdf", tmp_path)
+ assert_pdf_equal(pdf, HERE / "custom_alias_nb_pages.pdf", tmp_path)
def test_page_label(tmp_path):
@@ -131,3 +129,54 @@ def test_alias_with_shaping(tmp_path):
pdf.write_html("