From 32e29b0a2607e1d355e3c03228dabe1fe569d5a5 Mon Sep 17 00:00:00 2001 From: chaosreload Date: Mon, 13 Apr 2026 14:55:09 +0000 Subject: [PATCH 1/5] feat: support unordered lists in multi_cell(markdown=True) - issue #654 Add support for * / - / + bullet markers in multi_cell() when markdown=True. Inline formatting (**bold**, __italic__, etc.) is supported within list items. Bullet character is U+2022 for TTF fonts and "-" for core fonts. Indentation can be customized via MARKDOWN_BULLET_INDENT class attribute. --- CHANGELOG.md | 1 + fpdf/fpdf.py | 120 ++++++++++++++++++ .../multi_cell_markdown_unordered_list.pdf | Bin 0 -> 1347 bytes ...multi_cell_markdown_unordered_list_ttf.pdf | Bin 0 -> 12814 bytes test/text/test_multi_cell_markdown.py | 37 ++++++ 5 files changed, 158 insertions(+) create mode 100644 test/text/multi_cell_markdown_unordered_list.pdf create mode 100644 test/text/multi_cell_markdown_unordered_list_ttf.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e3b89b5e..97302dd629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default', ## [2.8.8] - Not released yet ### Added * Punjabi (pa) tutorial translation - thanks to @Pawansingh3889 +* support for unordered lists in `multi_cell(markdown=True)`, using `*`, `-` or `+` as bullet markers - _cf._ [issue #654](https://github.com/py-pdf/fpdf2/issues/654) ### Fixed * text rendering when the first text on a page starts with a fallback glyph - _cf._ [issue #1772](https://github.com/py-pdf/fpdf2/issues/1772) * preserve boundary-neutral formatting during bidirectional text preprocessing - _cf._ [issue #1779](https://github.com/py-pdf/fpdf2/issues/1779) diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 64f51583d0..bf8c55af26 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -294,6 +294,8 @@ class FPDF(GraphicsStateMixin, TextRegionMixin): MARKDOWN_LINK_REGEX = re.compile(r"^\[([^][]+)\]\(([^()]+)\)(.*)$", re.DOTALL) MARKDOWN_LINK_COLOR = None MARKDOWN_LINK_UNDERLINE = True + MARKDOWN_BULLET_INDENT = 10 # in mm + MARKDOWN_BULLET_REGEX = re.compile(r"^[ \t]*[*\-+] ", re.MULTILINE) HTML2FPDF_CLASS = HTML2FPDF @@ -4910,6 +4912,25 @@ def multi_cell( # Calculate text length text = self.normalize_text(text) normalized_string = text.replace("\r", "") + + if markdown and self.MARKDOWN_BULLET_REGEX.search(normalized_string): + return self._render_markdown_list( + normalized_string, + w=maximum_allowed_width, + h=h, + align=align, + fill=fill, + link=link, + new_x=new_x, + new_y=new_y, + max_line_height=max_line_height, + print_sh=print_sh, + wrapmode=wrapmode, + output=output, + center=center, + padding=padding, + ) + styled_text_fragments = ( self._preload_bidirectional_text(normalized_string, markdown) if self.text_shaping @@ -5050,6 +5071,105 @@ def multi_cell( return return_value[0] return return_value # type: ignore[return-value] + def _render_markdown_list( + self, + normalized_string: str, + w: float, + h: float, + align: Align, + fill: bool, + link: Optional[int | str], + new_x: XPos, + new_y: YPos, + max_line_height: Optional[float], + print_sh: bool, + wrapmode: WrapMode, + output: str | MethodReturnValue, + center: bool, + padding: Padding, + ) -> "MultiCellResult": + output_enum = MethodReturnValue.coerce(output) + if output_enum & MethodReturnValue.LINES: + raise NotImplementedError( + "output=LINES is not supported for markdown unordered lists" + ) + bullet_char = "\u2022" if self.is_ttf_font else "-" + indent = self.MARKDOWN_BULLET_INDENT + lines = normalized_string.split("\n") + page_break_triggered = False + total_height = 0.0 + + for i, line in enumerate(lines): + is_last = i == len(lines) - 1 + cur_new_x = new_x if is_last else XPos.LEFT + cur_new_y = new_y if is_last else YPos.NEXT + m = self.MARKDOWN_BULLET_REGEX.match(line) + if m: + item_text = line[m.end():] + # Render bullet prefix + bullet_x = self.x + self.cell(w=indent, h=h, text=f" {bullet_char} ", new_x=XPos.RIGHT, new_y=YPos.TOP) + # Render item text indented, with markdown support + result = self.multi_cell( + w=w - indent, + h=h, + text=item_text, + align=align, + fill=fill, + link=link, + markdown=True, + print_sh=print_sh, + new_x=cur_new_x, + new_y=cur_new_y, + max_line_height=max_line_height, + wrapmode=wrapmode, + output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT, + center=center, + padding=padding, + ) + pb, ht = result + if pb: + page_break_triggered = True + total_height += ht + if not is_last: + self.x = bullet_x + else: + if line: + result = self.multi_cell( + w=w, + h=h, + text=line, + align=align, + fill=fill, + link=link, + markdown=True, + print_sh=print_sh, + new_x=cur_new_x, + new_y=cur_new_y, + max_line_height=max_line_height, + wrapmode=wrapmode, + output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT, + center=center, + padding=padding, + ) + pb, ht = result + if pb: + page_break_triggered = True + total_height += ht + else: + # Empty line - just move down + self.ln(h) + total_height += h + + return_value = () + if output_enum & MethodReturnValue.PAGE_BREAK: + return_value += (page_break_triggered,) + if output_enum & MethodReturnValue.HEIGHT: + return_value += (total_height,) + if len(return_value) == 1: + return return_value[0] + return return_value + @check_page @support_deprecated_txt_arg def write( diff --git a/test/text/multi_cell_markdown_unordered_list.pdf b/test/text/multi_cell_markdown_unordered_list.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9d5453a0e8119070a2c51b0536372621658f43dd GIT binary patch literal 1347 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 literal 0 HcmV?d00001 diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index 580eb74d17..efc838de3f 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -196,6 +196,43 @@ def test_multi_cell_markdown_link_dry_run(tmp_path): assert_pdf_equal(pdf, HERE / "multi_cell_markdown_link_dry_run.pdf", tmp_path) +def test_multi_cell_markdown_unordered_list(tmp_path): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + text = ( + "Shopping list:\n" + "* Apples\n" + "- **Bananas**\n" + "+ __Cherries__\n" + "\n" + "End of list." + ) + pdf.multi_cell(w=pdf.epw, text=text, markdown=True) + assert_pdf_equal(pdf, HERE / "multi_cell_markdown_unordered_list.pdf", tmp_path) + + +def test_multi_cell_markdown_unordered_list_ttf(tmp_path): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Roboto", "B", FONTS_DIR / "Roboto-Bold.ttf") + pdf.add_font("Roboto", "I", FONTS_DIR / "Roboto-Italic.ttf") + pdf.set_font("Roboto", size=12) + text = ( + "Shopping list:\n" + "* Apples\n" + "- **Bananas**\n" + "+ __Cherries__\n" + "\n" + "End of list." + ) + pdf.multi_cell(w=pdf.epw, text=text, markdown=True) + assert_pdf_equal( + pdf, HERE / "multi_cell_markdown_unordered_list_ttf.pdf", tmp_path + ) + + def test_multi_cell_markdown_consecutive_links(tmp_path): link1 = "[fpdf2 github](https://github.com/py-pdf/fpdf2)" link2 = "[fpdf2 github Releases](https://github.com/py-pdf/fpdf2/releases)" From 4b272d5126e6592bc94037fd9057d4325bc9168d Mon Sep 17 00:00:00 2001 From: weichao Date: Sun, 19 Apr 2026 13:22:05 +0000 Subject: [PATCH 2/5] fix: address PR review feedback for markdown unordered list support - Fix padding double-application bug in output=LINES fallback path: _render_markdown_list receives w already adjusted for padding, so the inner multi_cell call must use padding=0 - Strengthen test assertions for output=LINES: verify line count, content correctness, and bullet prefix stripping - Add test for output=LINES + padding combination - Remove unrelated formatting-only diff noise (black formatting) --- fpdf/fpdf.py | 36 +++++++++++-- test/text/test_multi_cell_markdown.py | 78 +++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index bf8c55af26..900728a4a9 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -5090,8 +5090,30 @@ def _render_markdown_list( ) -> "MultiCellResult": output_enum = MethodReturnValue.coerce(output) if output_enum & MethodReturnValue.LINES: - raise NotImplementedError( - "output=LINES is not supported for markdown unordered lists" + # Fallback: strip bullet prefixes and render as plain text + plain_lines = [] + for line in normalized_string.split("\n"): + m = self.MARKDOWN_BULLET_REGEX.match(line) + if m: + plain_lines.append(line[m.end() :]) + else: + plain_lines.append(line) + return self.multi_cell( + w=w, + h=h, + text="\n".join(plain_lines), + align=align, + fill=fill, + link=link, + markdown=True, + print_sh=print_sh, + new_x=new_x, + new_y=new_y, + max_line_height=max_line_height, + wrapmode=wrapmode, + output=output, + center=center, + padding=0, # padding already applied by outer multi_cell ) bullet_char = "\u2022" if self.is_ttf_font else "-" indent = self.MARKDOWN_BULLET_INDENT @@ -5105,10 +5127,16 @@ def _render_markdown_list( cur_new_y = new_y if is_last else YPos.NEXT m = self.MARKDOWN_BULLET_REGEX.match(line) if m: - item_text = line[m.end():] + item_text = line[m.end() :] # Render bullet prefix bullet_x = self.x - self.cell(w=indent, h=h, text=f" {bullet_char} ", new_x=XPos.RIGHT, new_y=YPos.TOP) + self.cell( + w=indent, + h=h, + text=f" {bullet_char} ", + new_x=XPos.RIGHT, + new_y=YPos.TOP, + ) # Render item text indented, with markdown support result = self.multi_cell( w=w - indent, diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index efc838de3f..2a70403c3a 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -1,6 +1,7 @@ from pathlib import Path import fpdf +from fpdf.enums import MethodReturnValue from test.conftest import assert_pdf_equal import pytest @@ -228,9 +229,7 @@ def test_multi_cell_markdown_unordered_list_ttf(tmp_path): "End of list." ) pdf.multi_cell(w=pdf.epw, text=text, markdown=True) - assert_pdf_equal( - pdf, HERE / "multi_cell_markdown_unordered_list_ttf.pdf", tmp_path - ) + assert_pdf_equal(pdf, HERE / "multi_cell_markdown_unordered_list_ttf.pdf", tmp_path) def test_multi_cell_markdown_consecutive_links(tmp_path): @@ -257,3 +256,76 @@ def test_multi_cell_markdown_consecutive_links(tmp_path): ) assert len(pdf.pages[pdf.page].annots) == 4 assert_pdf_equal(pdf, HERE / "multi_cell_markdown_consecutive_links.pdf", tmp_path) + + +def test_multi_cell_markdown_unordered_list_border(tmp_path): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + text = "* Apples\n- **Bananas**\n+ __Cherries__" + pdf.multi_cell(w=pdf.epw, text=text, markdown=True, border=1) + assert_pdf_equal( + pdf, HERE / "multi_cell_markdown_unordered_list_border.pdf", tmp_path + ) + + +def test_multi_cell_markdown_unordered_list_fill(tmp_path): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + pdf.set_fill_color(200, 220, 255) + text = "* Apples\n- **Bananas**\n+ __Cherries__" + pdf.multi_cell(w=pdf.epw, text=text, markdown=True, fill=True) + assert_pdf_equal( + pdf, HERE / "multi_cell_markdown_unordered_list_fill.pdf", tmp_path + ) + + +def test_multi_cell_markdown_unordered_list_padding(tmp_path): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + text = "* Apples\n- **Bananas**\n+ __Cherries__" + pdf.multi_cell(w=pdf.epw, text=text, markdown=True, padding=5) + assert_pdf_equal( + pdf, HERE / "multi_cell_markdown_unordered_list_padding.pdf", tmp_path + ) + + +def test_multi_cell_markdown_unordered_list_output_lines(): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + text = "* Apples\n- **Bananas**\n+ __Cherries__" + lines = pdf.multi_cell( + w=pdf.epw, text=text, markdown=True, output=MethodReturnValue.LINES + ) + assert isinstance(lines, list) + assert len(lines) == 3 + assert "Apples" in lines[0] + assert "Bananas" in lines[1] + assert "Cherries" in lines[2] + for line in lines: + stripped = line.lstrip() + assert not stripped.startswith("*") + assert not stripped.startswith("-") + assert not stripped.startswith("+") + + +def test_multi_cell_markdown_unordered_list_output_lines_padding(): + pdf = fpdf.FPDF() + pdf.add_page() + pdf.set_font("Helvetica", size=12) + text = "* Apples\n- **Bananas**\n+ __Cherries__" + lines = pdf.multi_cell( + w=pdf.epw, + text=text, + markdown=True, + output=MethodReturnValue.LINES, + padding=5, + ) + assert isinstance(lines, list) + assert len(lines) == 3 + assert "Apples" in lines[0] + assert "Bananas" in lines[1] + assert "Cherries" in lines[2] From 2e9e40be61f96b8454320e0be678aa1be910a7e5 Mon Sep 17 00:00:00 2001 From: weichao Date: Mon, 20 Apr 2026 06:16:45 +0000 Subject: [PATCH 3/5] fix: add missing test reference PDFs and fix pylint E1101 type error Co-Authored-By: Claude Opus 4.6 (1M context) --- ...multi_cell_markdown_unordered_list_border.pdf | Bin 0 -> 1307 bytes .../multi_cell_markdown_unordered_list_fill.pdf | Bin 0 -> 1350 bytes ...ulti_cell_markdown_unordered_list_padding.pdf | Bin 0 -> 1322 bytes test/text/test_multi_cell_markdown.py | 1 + 4 files changed, 1 insertion(+) create mode 100644 test/text/multi_cell_markdown_unordered_list_border.pdf create mode 100644 test/text/multi_cell_markdown_unordered_list_fill.pdf create mode 100644 test/text/multi_cell_markdown_unordered_list_padding.pdf diff --git a/test/text/multi_cell_markdown_unordered_list_border.pdf b/test/text/multi_cell_markdown_unordered_list_border.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3520e451697b50d7ba3498f168843227d20d63d9 GIT binary patch literal 1307 zcmbtUO=uHA6xM>!jtbI)w1_ZJ1F_J~&Tcl{l~9{(x-{65m;|Z~6*kEv6Wv0XpU4}C&e1DqzFVe^>NjJMhuS| zrr0g6trCcGPK^X$auN_)m2_%|HoHn_vcg?PJDd>NE}wz4?s`V*0LI!eImZ{DU4}dfSlzFX02*ORnLNZNL%V#o z>v7@>t)9b4np9Yo05nTzjW0v2sWK|yK5rL_8jUylU z#Q|x2M^2{&dZ?C^6A+sTH_#06ZV~QQ9Dt^&sL7=@h~{0Iq=DN704mhWywz9PT`>^t zCQF8pS@FLo7)vp)`u|xb4#|=&d+yD;XiZoBzm_BCVz2kot3(3X7)l$(1llT;^~yI5 zN=3ho^uYeRX)IFuKNFgrt+7?gI&_cSae wG)>b@%TA@SrmKcwsYx79V#CrgR^oBnm^}QCRwn}P_`GGPsp}vTnaWGgrlzfC*k&~DUbl&H#%At@W<5ws z2~iP3AUzZWflw(#1Zk#Z5%^R|3Qsd2dx^w%|?lWh%6q%z=ZFHpI{miAQ$AyWKd-vx`Hu>*17Ys;6b(ORG zs=@*IeXjpQFL{0S*14@iV}8khta3nX-uR|;>gI?z>nks9S+{F_`M2q(o0reG?QL1E znZNk)PS5@uGndDYe2p&@FMk>wKKQB!TVuChy!~1HWbxWs4s94ZFx;H%eX!toR`c%p zwug?RlkAMLm1j>&w6z^jEF3YYaHtAvr!Z0D3P53FWM-f{K8lCH_B)=Pz06+cfoLik@Pw`hRVQS!l&3Oe?P!ML5W0q-Krq zkoC*n1juTNQmdc_(}*`>0%M2{AVf{Li%MMGLH!rVK7-k3b*oJ|*_J9u-Nwc!L=6dd zg!XW@nYEb_dn7nVG+e7X+dRK0%c|4 H`Uv<1_0W|Y literal 0 HcmV?d00001 diff --git a/test/text/multi_cell_markdown_unordered_list_padding.pdf b/test/text/multi_cell_markdown_unordered_list_padding.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f23fdd604295ffed0218afb22394c018822d6e20 GIT binary patch literal 1322 zcmbtUO=uHA6vmun4q9m`MHB}l(5lV;B%9rp5Nvj{v|33_1A=LVO?DbrHoLkz(bOXT zK(T_>n_934MNdkx9=rtVr4$Q#7d?3KqMnMh;Gw~p{M01{3p$tC{bt_x-uvEr6HKdG zKNkvv;I|)ZZvh8c=;SYgSPU?VQ?@bW0CUPTJeUiUqs$m!P9wwA6HW!@Sb_+$5aJ~$ zMYxb8lhf?PB?OtYUPK;<#{sepO2_+XQN2)O1*%aLO1m zDH|z{RW8{L*n>2@Ov{O)W8HF!J~uSPKhfJ5+1P`ThD};`$#07xgFL6~7LW%eKP7eU zCr(w>nvP_2IF)_Wt|yeBYVYE48xWFhZ)^=ST-hI?Cdb{q=YG2oD8%u{es=sCr zOmE(M4rT&3`lE|4Q>8CYKdV0jpFYOdX&su;H7g?cQcLI_(i|cVGl;A!2%7~RFiD%F zXxc@{oHgwM+cR4LU`EP$TrV>%HtCN>%_)OVyXfB?^d}t4_*=hxD69HcUmhSYdS^QB*5NMp*7c3{%3Qy4)Q<`^<$kjbJ7k$9*?dMwf0 zmF7M)=2K4gxl%|+(NR%J)4^W#I44JCjt_I3%#wAk&u3O~(Kuis#{zoPSTHIGVF8ZA zEsP@((*^A$_M5Y3IrI1YyNJt1Nb+2anRa?x)U=3+7*k&{A{<%I!_)x@MCiK3F=M2Tg?ik1}O XJKv~R!o#|Y>w?N63xdI9N&~+Ex&(#e literal 0 HcmV?d00001 diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index 2a70403c3a..9c0368243f 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -306,6 +306,7 @@ def test_multi_cell_markdown_unordered_list_output_lines(): assert "Bananas" in lines[1] assert "Cherries" in lines[2] for line in lines: + assert isinstance(line, str) stripped = line.lstrip() assert not stripped.startswith("*") assert not stripped.startswith("-") From 1991824b84d55767a847a9bd99f969f1cffcd5e1 Mon Sep 17 00:00:00 2001 From: weichao Date: Mon, 20 Apr 2026 13:04:41 +0000 Subject: [PATCH 4/5] fix: suppress pylint E1101 false positive on multi_cell LINES result Co-Authored-By: Claude Opus 4.6 (1M context) --- test/text/test_multi_cell_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index 9c0368243f..3d1a9386db 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -307,7 +307,7 @@ def test_multi_cell_markdown_unordered_list_output_lines(): assert "Cherries" in lines[2] for line in lines: assert isinstance(line, str) - stripped = line.lstrip() + stripped = line.lstrip() # pylint: disable=no-member assert not stripped.startswith("*") assert not stripped.startswith("-") assert not stripped.startswith("+") From 482f895b3bcb332b5596b61ae38037ba45e56174 Mon Sep 17 00:00:00 2001 From: weichao Date: Tue, 21 Apr 2026 16:23:26 +0000 Subject: [PATCH 5/5] fix: add cast and type: ignore to satisfy mypy in _render_bullet_list_cell --- fpdf/fpdf.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 900728a4a9..50ab9bc762 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -29,6 +29,7 @@ Any, BinaryIO, Callable, + cast, Iterator, Literal, NamedTuple, @@ -5155,7 +5156,7 @@ def _render_markdown_list( center=center, padding=padding, ) - pb, ht = result + pb, ht = cast("tuple[bool, float]", result) if pb: page_break_triggered = True total_height += ht @@ -5180,7 +5181,7 @@ def _render_markdown_list( center=center, padding=padding, ) - pb, ht = result + pb, ht = cast("tuple[bool, float]", result) if pb: page_break_triggered = True total_height += ht @@ -5191,12 +5192,12 @@ def _render_markdown_list( return_value = () if output_enum & MethodReturnValue.PAGE_BREAK: - return_value += (page_break_triggered,) + return_value += (page_break_triggered,) # type: ignore[assignment] if output_enum & MethodReturnValue.HEIGHT: - return_value += (total_height,) + return_value += (total_height,) # type: ignore[assignment] if len(return_value) == 1: return return_value[0] - return return_value + return return_value # type: ignore[return-value] @check_page @support_deprecated_txt_arg