Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
### Added
* support for page number alias substitution with bidirectional / RTL text shaping - _cf._ [issue #1925](https://github.com/py-pdf/fpdf2/issues/1925) - thanks to @prateek-dagar
* `appearance` parameter for [`FPDF.file_attachment_annotation()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.file_attachment_annotation), accepting `FileAttachmentAppearance.HIDDEN` to give the annotation an empty appearance stream so its default icon is not displayed while the file stays embedded and reachable - _cf._ [issue #561](https://github.com/py-pdf/fpdf2/issues/561)
### 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
Expand Down
46 changes: 43 additions & 3 deletions fpdf/bidi.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,24 @@ def level_to_direction(level: int) -> str:


class BidiParagraph:
SENTINEL_CANDIDATES: tuple[str, ...] = (
"\U0001d7ff", # MATHEMATICAL BOLD DIGIT NINE (Bidi Class: EN)
"\U0001d7fe", # MATHEMATICAL BOLD DIGIT EIGHT
"\U0001d7fd", # MATHEMATICAL BOLD DIGIT SEVEN
"\U0001d7fc", # MATHEMATICAL BOLD DIGIT SIX
"\U0001d7fb", # MATHEMATICAL BOLD DIGIT FIVE
"\U0001d7fa", # MATHEMATICAL BOLD DIGIT FOUR
"\U0001d7f9", # MATHEMATICAL BOLD DIGIT THREE
"\U0001d7f8", # MATHEMATICAL BOLD DIGIT TWO
"\U0001d7f7", # MATHEMATICAL BOLD DIGIT ONE
"\U0001d7f6", # MATHEMATICAL BOLD DIGIT ZERO
"\U0001d7ce", # MATHEMATICAL BOLD DIGIT ZERO (alternative)
)

__slots__ = (
"text",
"alias",
"sentinel",
"base_direction",
"debug",
"preserve_bn_chars",
Expand All @@ -550,7 +566,18 @@ def __init__(
base_direction: Optional[TextDirection] = None,
debug: bool = False,
preserve_bn_chars: bool = False,
alias: Optional[str] = None,
) -> None:
self.alias = alias
self.sentinel: Optional[str] = None
if alias and alias in text:
for cand in self.SENTINEL_CANDIDATES:
if cand not in text:
self.sentinel = cand
break
if self.sentinel:
text = text.replace(alias, self.sentinel)

self.text = text
self.base_direction = (
auto_detect_base_direction(self.text, debug)
Expand Down Expand Up @@ -582,7 +609,10 @@ def get_all(self) -> tuple[list[BidiCharacter], tuple[BidiCharacter, ...]]:

def get_reordered_string(self) -> str:
"Used for conformance validation"
return "".join(c.character for c in self.reorder_resolved_levels())
s = "".join(c.character for c in self.reorder_resolved_levels())
if self.alias and self.sentinel:
s = s.replace(self.sentinel, self.alias)
return s

def get_bidi_fragments(self) -> tuple[tuple[str, TextDirection], ...]:
return self.split_bidi_fragments()
Expand Down Expand Up @@ -737,9 +767,14 @@ def split_bidi_fragments(self) -> tuple[tuple[str, TextDirection], ...]:
for c in self.characters:
if c.get_direction_from_level() != current_direction:
if current_fragment:
frag_text = (
current_fragment.replace(self.sentinel, self.alias)
if self.alias and self.sentinel
else current_fragment
)
bidi_fragments.append(
(
current_fragment,
frag_text,
(
TextDirection.RTL
if current_direction == "R"
Expand All @@ -751,9 +786,14 @@ def split_bidi_fragments(self) -> tuple[tuple[str, TextDirection], ...]:
current_direction = c.get_direction_from_level()
current_fragment += c.character
if current_fragment:
frag_text = (
current_fragment.replace(self.sentinel, self.alias)
if self.alias and self.sentinel
else current_fragment
)
bidi_fragments.append(
(
current_fragment,
frag_text,
(
TextDirection.RTL
if current_direction == "R"
Expand Down
30 changes: 26 additions & 4 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4401,23 +4401,37 @@ def _preload_bidirectional_text(
if self.text_shaping["direction"]
else auto_detect_base_direction(text)
)
self.text_shaping["paragraph_direction"] = paragraph_direction

paragraph = BidiParagraph(
text=text,
base_direction=paragraph_direction,
preserve_bn_chars=True,
alias=self.str_alias_nb_pages,
)
directional_segments = paragraph.get_bidi_fragments()
self.text_shaping["paragraph_direction"] = paragraph.base_direction
emphasis = (
"B" in self.font_style,
"I" in self.font_style,
self.strikethrough,
self.underline,
)

fragments: list[Fragment] = []
for bidi_text, bidi_direction in directional_segments:
self.text_shaping["fragment_direction"] = bidi_direction
fragments += self._preload_font_styles(bidi_text, markdown)
styled_frags = self._preload_font_styles(
bidi_text, markdown, _initial_emphasis=emphasis
)
emphasis = getattr(self, "_markdown_emphasis", emphasis)
fragments.extend(styled_frags)
return tuple(fragments)

def _preload_font_styles(
self, text: Optional[str], markdown: bool
self,
text: Optional[str],
markdown: bool,
_initial_emphasis: Optional[tuple[bool, bool, bool, bool]] = None,
) -> Sequence[Fragment]:
"""
When Markdown styling is enabled, we require secondary fonts
Expand All @@ -4434,7 +4448,9 @@ def _preload_font_styles(
prev_font_style += "U"
if self.strikethrough:
prev_font_style += "S"
styled_txt_frags = tuple(self._parse_chars(text, markdown))
styled_txt_frags = tuple(
self._parse_chars(text, markdown, _initial_emphasis=_initial_emphasis)
)
if markdown:
page = self.page
# We set the current to page to zero so that
Expand Down Expand Up @@ -4767,6 +4783,12 @@ def frag() -> Fragment:
escape_run = 0
if txt_frag:
yield frag()
self._markdown_emphasis = (
in_bold,
in_italics,
in_strikethrough,
in_underline,
)

def will_page_break(self, height: float) -> bool:
"""
Expand Down
Binary file added test/alias_in_rtl_text.pdf
Binary file not shown.
Binary file added test/alias_in_rtl_text_custom_alias.pdf
Binary file not shown.
Binary file added test/alias_in_rtl_text_markdown.pdf
Binary file not shown.
Binary file added test/alias_in_rtl_text_rtl_alias.pdf
Binary file not shown.
Binary file added test/alias_in_rtl_text_side_by_side_hyphen.pdf
Binary file not shown.
Binary file not shown.
Binary file added test/alias_in_rtl_text_side_by_side_simple.pdf
Binary file not shown.
Binary file added test/alias_in_rtl_text_side_by_side_slash.pdf
Binary file not shown.
97 changes: 97 additions & 0 deletions test/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,100 @@ def test_alias_overflow_warning():
pdf.write(text="Page a")
with pytest.warns(UserWarning, match="is wider than the reserved"):
pdf.output()


def test_alias_in_rtl_text(tmp_path): # issue #1925
"""Alias must be substituted correctly when surrounded by RTL text."""
pdf = fpdf.FPDF()
pdf.add_font("SBL_Hbrw", fname=HERE / "text_shaping" / "SBL_Hbrw.ttf")
pdf.set_font("SBL_Hbrw", size=18)
pdf.set_text_shaping(True, direction="rtl")
for _ in range(12):
pdf.add_page()
pdf.cell(text="אבג {nb} דהו")

assert_pdf_equal(pdf, HERE / "alias_in_rtl_text.pdf", tmp_path)

Comment thread
andersonhc marked this conversation as resolved.

def test_alias_in_rtl_text_custom_alias(tmp_path): # issue #1925
"""Custom alias must also be substituted correctly with RTL text shaping."""
pdf = fpdf.FPDF()
pdf.add_font("SBL_Hbrw", fname=HERE / "text_shaping" / "SBL_Hbrw.ttf")
pdf.set_font("SBL_Hbrw", size=18)
pdf.set_text_shaping(True, direction="rtl")
pdf.alias_nb_pages("TOTAL")
for _ in range(12):
pdf.add_page()
pdf.cell(text="אבג TOTAL דהו")

assert_pdf_equal(pdf, HERE / "alias_in_rtl_text_custom_alias.pdf", tmp_path)


def test_alias_in_rtl_text_rtl_alias(tmp_path): # issue #1925
"""Custom alias in RTL script."""
pdf = fpdf.FPDF()
pdf.add_font("SBL_Hbrw", fname=HERE / "text_shaping" / "SBL_Hbrw.ttf")
pdf.set_font("SBL_Hbrw", size=18)
pdf.set_text_shaping(True, direction="rtl")
pdf.alias_nb_pages("מספר")
for _ in range(12):
pdf.add_page()
pdf.cell(text="אבג מספר דהו")

assert_pdf_equal(pdf, HERE / "alias_in_rtl_text_rtl_alias.pdf", tmp_path)


def test_alias_in_rtl_text_markdown(tmp_path):
"""Markdown markers in RTL text with page alias."""
pdf = fpdf.FPDF()
pdf.add_font("SBL_Hbrw", fname=HERE / "text_shaping" / "SBL_Hbrw.ttf")
pdf.set_font("SBL_Hbrw", size=18)
pdf.set_text_shaping(True, direction="rtl")
for _ in range(12):
pdf.add_page()
pdf.cell(text="--אבג {nb} דהו--", markdown=True)
pdf.ln()
pdf.cell(text="--אבג {nb} דהו--ABC", markdown=True)
pdf.ln()
pdf.cell(text="--אבג {nb} דהו--אבג", markdown=True)
pdf.ln()
pdf.cell(text="--אבג {nb}-- ABC --דהו--", markdown=True)

assert_pdf_equal(pdf, HERE / "alias_in_rtl_text_markdown.pdf", tmp_path)


@pytest.mark.parametrize(
"alias_pattern,literal_pattern,expected_pdf_file",
[
("אבג {nb} דהו", "אבג 12 דהו", "alias_in_rtl_text_side_by_side_simple.pdf"),
(
"אבג 10/{nb} דהו",
"אבג 10/12 דהו",
"alias_in_rtl_text_side_by_side_slash.pdf",
),
(
"אבג {nb}-10 דהו",
"אבג 12-10 דהו",
"alias_in_rtl_text_side_by_side_hyphen.pdf",
),
(
"אבג ({nb}%) דהו",
"אבג (12%) דהו",
"alias_in_rtl_text_side_by_side_parens_percent.pdf",
),
],
)
def test_alias_and_literal_side_by_side_in_rtl(
tmp_path, alias_pattern, literal_pattern, expected_pdf_file
): # issue #1925
"""Render alias and literal number side-by-side in the same PDF."""
pdf = fpdf.FPDF()
pdf.add_font("SBL_Hbrw", fname=HERE / "text_shaping" / "SBL_Hbrw.ttf")
pdf.set_font("SBL_Hbrw", size=16)
pdf.set_text_shaping(True, direction="rtl")
for _ in range(12):
pdf.add_page()
pdf.cell(w=90, text=alias_pattern, border=1)
pdf.cell(w=90, text=literal_pattern, border=1)

assert_pdf_equal(pdf, HERE / expected_pdf_file, tmp_path)