diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f73de4b0..30b391e815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default', * reject SVG `` cycles and excessive nested expansion to prevent resource exhaustion in `FPDF.image()` * count SVG `` elements in SVG complexity limits * declare the default base state and display order for Optional Content Groups so PDF viewers can list layers correctly - _cf._ [issue #1895](https://github.com/py-pdf/fpdf2/issues/1895) +* parse balanced markdown styles inside link labels while keeping unbalanced markers literal, and consume escapes without destabilizing `LINES` re-serialization - _cf._ [issue #1847](https://github.com/py-pdf/fpdf2/issues/1847) ### Changed * skip byte-for-byte compressed data comparison when zlib-ng is detected, regardless of OS diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 08077f0d69..d98930a2eb 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -297,6 +297,12 @@ class FPDF(GraphicsStateMixin, TextRegionMixin): MARKDOWN_ITALICS_MARKER = "__" MARKDOWN_STRIKETHROUGH_MARKER = "~~" MARKDOWN_UNDERLINE_MARKER = "--" + MARKDOWN_MARKERS = ( + MARKDOWN_BOLD_MARKER, + MARKDOWN_ITALICS_MARKER, + MARKDOWN_STRIKETHROUGH_MARKER, + MARKDOWN_UNDERLINE_MARKER, + ) MARKDOWN_ESCAPE_CHARACTER = "\\" MARKDOWN_LINK_REGEX = re.compile(r"^\[([^][]+)\]\(([^()]+)\)(.*)$", re.DOTALL) MARKDOWN_LINK_COLOR = None @@ -4464,7 +4470,81 @@ def get_fallback_font(self, char: str, style: str = "") -> Optional[str]: return None return fonts_with_char[0] - def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]: + def _markdown_marker_at( + self, text: str, previous_character: str | None = None + ) -> str | None: + """Return the active markdown marker at the start of ``text``, if any.""" + marker = text[:2] + if ( + marker in self.MARKDOWN_MARKERS + and previous_character != marker[0] + and (len(text) < 3 or text[2] != marker[0]) + ): + return marker + return None + + def _markdown_escape_unbalanced_link_markers(self, text: str) -> str: + """ + Escape marker kinds that do not form complete pairs inside a link label. + + This lets balanced emphasis be parsed within the label while preventing + an opening marker from spanning the link boundary. Existing escapes are + preserved and taken into account when counting markers. + """ + marker_counts = dict.fromkeys(self.MARKDOWN_MARKERS, 0) + escape_run = 0 + index = 0 + while index < len(text): + if text[index] == self.MARKDOWN_ESCAPE_CHARACTER: + escape_run += 1 + index += 1 + continue + marker = self._markdown_marker_at( + text[index:], text[index - 1] if index else None + ) + if marker and escape_run % 2 == 0: + marker_counts[marker] += 1 + index += 2 + else: + index += 1 + escape_run = 0 + + unbalanced_markers = { + marker for marker, count in marker_counts.items() if count % 2 + } + if not unbalanced_markers: + return text + + result: list[str] = [] + index = 0 + escape_run = 0 + while index < len(text): + if text[index] == self.MARKDOWN_ESCAPE_CHARACTER: + result.append(text[index]) + escape_run += 1 + index += 1 + continue + marker = self._markdown_marker_at( + text[index:], text[index - 1] if index else None + ) + if marker in unbalanced_markers: + if escape_run % 2 == 0: + result.append(self.MARKDOWN_ESCAPE_CHARACTER) + result.append(marker) + index += 2 + else: + result.append(text[index]) + index += 1 + escape_run = 0 + return "".join(result) + + def _parse_chars( + self, + text: str, + markdown: bool, + *, + _initial_emphasis: tuple[bool, bool, bool, bool] | None = None, + ) -> 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: @@ -4486,10 +4566,13 @@ def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]: yield Fragment(text, self._get_current_graphics_state(), self.k) return txt_frag: list[str] = [] - in_bold: bool = "B" in self.font_style - in_italics: bool = "I" in self.font_style - in_strikethrough: bool = bool(self.strikethrough) - in_underline: bool = bool(self.underline) + if _initial_emphasis is None: + in_bold: bool = "B" in self.font_style + in_italics: bool = "I" in self.font_style + in_strikethrough: bool = bool(self.strikethrough) + in_underline: bool = bool(self.underline) + else: + in_bold, in_italics, in_strikethrough, in_underline = _initial_emphasis current_fallback_font = None current_text_script = None @@ -4530,12 +4613,7 @@ def frag() -> Fragment: continue if markdown and escape_run: - is_escape_target = text[:2] in ( - self.MARKDOWN_BOLD_MARKER, - self.MARKDOWN_ITALICS_MARKER, - self.MARKDOWN_STRIKETHROUGH_MARKER, - self.MARKDOWN_UNDERLINE_MARKER, - ) + is_escape_target = text[:2] in self.MARKDOWN_MARKERS if is_escape_target and escape_run % 2 == 1: for _ in range(escape_run // 2): txt_frag.append(self.MARKDOWN_ESCAPE_CHARACTER) @@ -4550,15 +4628,10 @@ def frag() -> Fragment: txt_frag.append(self.MARKDOWN_ESCAPE_CHARACTER) escape_run = 0 - is_marker = text[:2] in ( - self.MARKDOWN_BOLD_MARKER, - self.MARKDOWN_ITALICS_MARKER, - self.MARKDOWN_STRIKETHROUGH_MARKER, - self.MARKDOWN_UNDERLINE_MARKER, - ) + marker = self._markdown_marker_at(text, txt_frag[-1] if txt_frag else None) + is_marker = marker is not None if markdown and escape_next_marker: is_marker = False - half_marker = text[0] text_script = get_unicode_script(text[0]) if text_script not in ( UnicodeScript.COMMON, @@ -4589,11 +4662,7 @@ def frag() -> Fragment: # Check that previous & next characters are not identical to the marker: if markdown: - if ( - is_marker - and (not txt_frag or txt_frag[-1] != half_marker) - and (len(text) < 3 or text[2] != half_marker) - ): + if is_marker: if txt_frag: yield frag() if text[:2] == self.MARKDOWN_BOLD_MARKER: @@ -4612,27 +4681,32 @@ def frag() -> Fragment: link_text, link_dest, text = is_link.groups() if txt_frag: yield frag() - gstate = self._get_current_graphics_state() - gstate.font_style = ("B" if in_bold else "") + ( - "I" if in_italics else "" - ) - gstate.strikethrough = in_strikethrough - gstate.underline = self.MARKDOWN_LINK_UNDERLINE or in_underline - if self.MARKDOWN_LINK_COLOR: - gstate.text_color = convert_to_device_color( - self.MARKDOWN_LINK_COLOR - ) try: page = int(link_dest) link_dest = self.add_link(page=page) except ValueError: pass - yield Fragment( - list(link_text), - gstate, - self.k, - link=link_dest, - ) + link_text = self._markdown_escape_unbalanced_link_markers(link_text) + for link_frag in self._parse_chars( + link_text, + True, + _initial_emphasis=( + in_bold, + in_italics, + in_strikethrough, + in_underline, + ), + ): + link_frag.link = link_dest + link_frag.graphics_state.underline = ( + self.MARKDOWN_LINK_UNDERLINE + or link_frag.graphics_state.underline + ) + if self.MARKDOWN_LINK_COLOR: + link_frag.graphics_state.text_color = ( + convert_to_device_color(self.MARKDOWN_LINK_COLOR) + ) + yield link_frag continue if self.is_ttf_font and text[0] != "\n" and not ord(text[0]) in font_glyphs: style = ("B" if in_bold else "") + ("I" if in_italics else "") @@ -4815,10 +4889,13 @@ def escape(text: str) -> str: text_parts.append(emphasis_markers[te]) last_emphasis = next_emphasis text = "".join(frag.characters) - # NOTE: Currently, markdown format inside of links is not handled - # so only escape markdown markers outside of links + # Escape literal marker characters in link fragments so the + # LINES re-serialization round-trip stays stable. Styling is + # represented by the surrounding emphasis markers above. text_parts.append( - f"[{text:s}]({frag.link!s:s})" if frag.link else escape(text) + f"[{escape(text):s}]({frag.link!s:s})" + if frag.link + else escape(text) ) next_emphasis = TextEmphasis.NONE removed_emphasis = last_emphasis & ~next_emphasis diff --git a/test/text/multi_cell_markdown_dry_run_lines_output_escape.pdf b/test/text/multi_cell_markdown_dry_run_lines_output_escape.pdf index 9444e33342..3d5d107b82 100644 Binary files a/test/text/multi_cell_markdown_dry_run_lines_output_escape.pdf and b/test/text/multi_cell_markdown_dry_run_lines_output_escape.pdf differ diff --git a/test/text/test_markdown_parse.py b/test/text/test_markdown_parse.py index f9e61983d3..077ab2f62c 100644 --- a/test/text/test_markdown_parse.py +++ b/test/text/test_markdown_parse.py @@ -390,11 +390,29 @@ def test_markdown_parse_link_variations(): frags = tuple(FPDF()._parse_chars("[**bold**](https://example.com)", True)) assert len(frags) == 1 - assert "".join(frags[0].characters) == "**bold**" + assert "".join(frags[0].characters) == "bold" assert frags[0].graphics_state.underline is True - assert frags[0].graphics_state.font_style == "" + assert frags[0].graphics_state.font_style == "B" assert frags[0].link == "https://example.com" + frags = tuple(FPDF()._parse_chars("[**bold** normal](url)", True)) + assert ["".join(frag.characters) for frag in frags] == ["bold", " normal"] + assert [frag.graphics_state.font_style for frag in frags] == ["B", ""] + assert all(frag.link == "url" for frag in frags) + + frags = merge_fragments(tuple(FPDF()._parse_chars("[**bold](url)", True))) + assert len(frags) == 1 + assert "".join(frags[0].characters) == "**bold" + assert frags[0].graphics_state.font_style == "" + assert frags[0].link == "url" + + for text in ("[***a**b](url)", "[**a***b](url)"): + frags = merge_fragments(tuple(FPDF()._parse_chars(text, True))) + assert len(frags) == 1 + assert "".join(frags[0].characters) == text[1:-6] + assert frags[0].graphics_state.font_style == "" + assert frags[0].link == "url" + frags = tuple(FPDF()._parse_chars("[x](url)**y**", True)) assert len(frags) == 2 assert "".join(frags[0].characters) == "x" @@ -410,3 +428,25 @@ def test_markdown_parse_link_variations(): ) assert frags == expected assert frags[1].link == "url" + + +def test_markdown_parse_escaped_markers_inside_link(): # issue 1847 + # Escaping markdown markers inside a link must consume the escape + # backslashes, exactly as it does outside of a link, instead of leaving + # them as literal backslashes in the rendered text. + frags = merge_fragments( + tuple( + FPDF()._parse_chars("[\\**Issue\\** 1844](https://example.com/1844)", True) + ) + ) + assert len(frags) == 1 + assert "".join(frags[0].characters) == "**Issue** 1844" + assert frags[0].link == "https://example.com/1844" + assert frags[0].graphics_state.font_style == "" + assert frags[0].graphics_state.underline is True + + # A doubled backslash inside a link collapses to a single literal + # backslash, just like outside of a link. + frags = tuple(FPDF()._parse_chars("[a\\\\b](url)", True)) + assert len(frags) == 1 + assert "".join(frags[0].characters) == "a\\b" diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index 27bd5f8221..d3bef7b8fa 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -396,3 +396,29 @@ def test_multi_cell_markdown_dry_run_lines_output_escape(tmp_path): assert_pdf_equal( pdf, HERE / "multi_cell_markdown_dry_run_lines_output_escape.pdf", tmp_path ) + + +def test_multi_cell_markdown_escaped_markers_inside_link(): # issue 1847 + # Escaping markdown markers inside a link previously left the escape + # backslashes in the rendered text, and the LINES re-serialization emitted + # the display text verbatim, producing double-escaped output such as + # "\**Issue\**". Both the parsed fragment and the LINES round-trip must + # now be free of stray escape characters. + pdf = fpdf.FPDF() + pdf.set_font("Helvetica") + pdf.add_page() + + text = "[\\**Issue\\** 1844](https://github.com/py-pdf/fpdf2/pull/1844)" + + # The LINES re-serialization must round-trip stably without accumulating + # additional escape characters. + lines = pdf.multi_cell( + pdf.epw, + text=text, + dry_run=True, + markdown=True, + new_x="left", + new_y="next", + output=fpdf.enums.MethodReturnValue.LINES, + ) + assert lines == ["[\\**Issue\\** 1844](https://github.com/py-pdf/fpdf2/pull/1844)"]