Skip to content
Open
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 @@ -47,6 +47,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
* reject SVG `<use>` cycles and excessive nested expansion to prevent resource exhaustion in `FPDF.image()`
* count SVG `<switch>` 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

Expand Down
122 changes: 98 additions & 24 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4464,7 +4464,70 @@ 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_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.
"""
markers = (
self.MARKDOWN_BOLD_MARKER,
self.MARKDOWN_ITALICS_MARKER,
self.MARKDOWN_STRIKETHROUGH_MARKER,
self.MARKDOWN_UNDERLINE_MARKER,
)
marker_counts = dict.fromkeys(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 = text[index : index + 2]
if marker in marker_counts 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 = text[index : index + 2]
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:
Expand All @@ -4486,10 +4549,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

Expand Down Expand Up @@ -4612,27 +4678,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 "")
Expand Down Expand Up @@ -4815,10 +4886,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
Expand Down
Binary file modified test/text/multi_cell_markdown_dry_run_lines_output_escape.pdf
Binary file not shown.
37 changes: 35 additions & 2 deletions test/text/test_markdown_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,22 @@ 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"

frags = tuple(FPDF()._parse_chars("[x](url)**y**", True))
assert len(frags) == 2
assert "".join(frags[0].characters) == "x"
Expand All @@ -410,3 +421,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"
26 changes: 26 additions & 0 deletions test/text/test_multi_cell_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"]