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 @@ -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)
Expand Down
149 changes: 149 additions & 0 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Any,
BinaryIO,
Callable,
cast,
Iterator,
Literal,
NamedTuple,
Expand Down Expand Up @@ -294,6 +295,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

Expand Down Expand Up @@ -4910,6 +4913,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,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are not passing border. Can you do some tests to make sure borders are rendered when we call multi_cell with a list and border=1?

Also, padding was already embedded (see lines 4898-4904). If we call multi_cell again passing the same margin won't they be doubled if we call multi_cell again inside _render_markdown_list? Can you add tests having a large padding top, one call with a list and another without?

styled_text_fragments = (
self._preload_bidirectional_text(normalized_string, markdown)
if self.text_shaping
Expand Down Expand Up @@ -5050,6 +5072,133 @@ 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:
# 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
)
Comment thread
andersonhc marked this conversation as resolved.
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 = cast("tuple[bool, float]", 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 = cast("tuple[bool, float]", 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,) # type: ignore[assignment]
if output_enum & MethodReturnValue.HEIGHT:
return_value += (total_height,) # type: ignore[assignment]
if len(return_value) == 1:
return return_value[0]
return return_value # type: ignore[return-value]

@check_page
@support_deprecated_txt_arg
def write(
Expand Down
Binary file added test/text/multi_cell_markdown_unordered_list.pdf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
110 changes: 110 additions & 0 deletions test/text/test_multi_cell_markdown.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path

import fpdf
from fpdf.enums import MethodReturnValue
from test.conftest import assert_pdf_equal

import pytest
Expand Down Expand Up @@ -196,6 +197,41 @@ 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)"
Expand All @@ -220,3 +256,77 @@ 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:
assert isinstance(line, str)
stripped = line.lstrip() # pylint: disable=no-member
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]
Loading