diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b33160f7..28acf042d 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 ### 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 diff --git a/fpdf/bidi.py b/fpdf/bidi.py index c793d319b..afb4927a3 100644 --- a/fpdf/bidi.py +++ b/fpdf/bidi.py @@ -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", @@ -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) @@ -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() @@ -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" @@ -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" diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 8a666c25c..28c4da027 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -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 @@ -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 @@ -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: """ diff --git a/test/alias_in_rtl_text.pdf b/test/alias_in_rtl_text.pdf new file mode 100644 index 000000000..d8c996350 Binary files /dev/null and b/test/alias_in_rtl_text.pdf differ diff --git a/test/alias_in_rtl_text_custom_alias.pdf b/test/alias_in_rtl_text_custom_alias.pdf new file mode 100644 index 000000000..a90fa1577 Binary files /dev/null and b/test/alias_in_rtl_text_custom_alias.pdf differ diff --git a/test/alias_in_rtl_text_markdown.pdf b/test/alias_in_rtl_text_markdown.pdf new file mode 100644 index 000000000..96299978b Binary files /dev/null and b/test/alias_in_rtl_text_markdown.pdf differ diff --git a/test/alias_in_rtl_text_rtl_alias.pdf b/test/alias_in_rtl_text_rtl_alias.pdf new file mode 100644 index 000000000..ff8687253 Binary files /dev/null and b/test/alias_in_rtl_text_rtl_alias.pdf differ diff --git a/test/alias_in_rtl_text_side_by_side_hyphen.pdf b/test/alias_in_rtl_text_side_by_side_hyphen.pdf new file mode 100644 index 000000000..1f686c161 Binary files /dev/null and b/test/alias_in_rtl_text_side_by_side_hyphen.pdf differ diff --git a/test/alias_in_rtl_text_side_by_side_parens_percent.pdf b/test/alias_in_rtl_text_side_by_side_parens_percent.pdf new file mode 100644 index 000000000..658e48b92 Binary files /dev/null and b/test/alias_in_rtl_text_side_by_side_parens_percent.pdf differ diff --git a/test/alias_in_rtl_text_side_by_side_simple.pdf b/test/alias_in_rtl_text_side_by_side_simple.pdf new file mode 100644 index 000000000..721151bbb Binary files /dev/null and b/test/alias_in_rtl_text_side_by_side_simple.pdf differ diff --git a/test/alias_in_rtl_text_side_by_side_slash.pdf b/test/alias_in_rtl_text_side_by_side_slash.pdf new file mode 100644 index 000000000..228c92a35 Binary files /dev/null and b/test/alias_in_rtl_text_side_by_side_slash.pdf differ diff --git a/test/test_alias.py b/test/test_alias.py index 6be4a8553..3d0f70cc3 100644 --- a/test/test_alias.py +++ b/test/test_alias.py @@ -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) + + +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)