Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ul>` or `<ol>` element carries a `line-height` that is not a bare number (_e.g._ `line-height: normal` or `line-height: 1.5em`); such values are now ignored, and the default line height is used, consistently with `<p line-height="x">` - _cf._ [PR #1917](https://github.com/py-pdf/fpdf2/pull/1917)

## [2.8.8] - 2026-08-09
Expand Down
1 change: 0 additions & 1 deletion docs/PageBreaks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).<br>_cf._ [GitHub issue #1090](https://github.com/py-pdf/fpdf2/issues/1090)"

## will_page_break ##

Expand Down
1 change: 0 additions & 1 deletion docs/TextShaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br>_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.
Expand Down
12 changes: 12 additions & 0 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
):
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
105 changes: 94 additions & 11 deletions fpdf/line_break.py
Comment thread
prateek-dagar marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
Sequence,
Tuple,
Union,
cast,
)
import warnings
from uuid import uuid4

from fpdf.drawing_primitives import DeviceCMYK, DeviceGray, DeviceRGB
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
)
Expand All @@ -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,
)
)
Expand Down
Binary file added test/alias_in_middle_with_shaping.pdf
Binary file not shown.
Binary file added test/alias_in_middle_with_shaping_many_pages.pdf
Binary file not shown.
Binary file added test/alias_in_middle_with_shaping_markdown.pdf
Binary file not shown.
Binary file modified test/alias_nb_pages.pdf
Binary file not shown.
Binary file modified test/alias_with_text_shaping.pdf
Binary file not shown.
Binary file added test/custom_alias_nb_pages.pdf
Binary file not shown.
Binary file modified test/fonts/fonts_remap_nb.pdf
Binary file not shown.
Binary file modified test/outline/toc_no_reset_page_indices.pdf
Binary file not shown.
Binary file modified test/outline/toc_with_nb_and_footer.pdf
Binary file not shown.
57 changes: 53 additions & 4 deletions test/test_alias.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pathlib import Path

import pytest
import fpdf
from test.conftest import assert_pdf_equal

Expand All @@ -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):
Expand Down Expand Up @@ -131,3 +129,54 @@ def test_alias_with_shaping(tmp_path):
pdf.write_html("<h1>{nb}</h1>")
pdf.multi_cell(w=pdf.epw, text="Number of pages: {nb}\nAgain:{nb}")
assert_pdf_equal(pdf, HERE / "alias_with_text_shaping.pdf", tmp_path)


def test_alias_in_middle_with_shaping(tmp_path):
pdf = fpdf.FPDF()
pdf.add_font("Quicksand", style="", fname=HERE / "fonts" / "Quicksand-Regular.otf")
pdf.add_page()
pdf.set_font("Quicksand", size=24)
pdf.set_text_shaping(True)
pdf.write(text="Pages {nb} with shaping")
pdf.ln()
pdf.write(text="Pages {nb} with {nb} shaping")
assert_pdf_equal(pdf, HERE / "alias_in_middle_with_shaping.pdf", tmp_path)


def test_alias_in_middle_with_shaping_many_pages(tmp_path):
pdf = fpdf.FPDF()
pdf.add_font("Quicksand", style="", fname=HERE / "fonts" / "Quicksand-Regular.otf")
pdf.set_font("Quicksand", size=24)
pdf.set_text_shaping(True)
for _ in range(14):
pdf.add_page()
pdf.write(text="Pages {nb} with shaping")
pdf.ln()
pdf.write(text="Pages {nb} with {nb} shaping")
assert_pdf_equal(
pdf, HERE / "alias_in_middle_with_shaping_many_pages.pdf", tmp_path
)


def test_alias_in_middle_with_shaping_markdown(tmp_path):
pdf = fpdf.FPDF()
pdf.add_font("Quicksand", style="", fname=HERE / "fonts" / "Quicksand-Regular.otf")
pdf.add_font("Quicksand", style="B", fname=HERE / "fonts" / "Quicksand-Bold.otf")
pdf.add_page()
pdf.set_font("Quicksand", size=24)
pdf.set_text_shaping(True)
pdf.multi_cell(w=pdf.epw, text="**Pages {nb}** with shaping", markdown=True)
assert_pdf_equal(pdf, HERE / "alias_in_middle_with_shaping_markdown.pdf", tmp_path)


def test_alias_overflow_warning():
pdf = fpdf.FPDF()
pdf.add_font("Quicksand", style="", fname=HERE / "fonts" / "Quicksand-Regular.otf")
pdf.set_font("Quicksand", size=24)
pdf.set_text_shaping(True)
pdf.alias_nb_pages("a")
for _ in range(12):
pdf.add_page()
pdf.write(text="Page a")
with pytest.warns(UserWarning, match="is wider than the reserved"):
pdf.output()