diff --git a/fpdf/line_break.py b/fpdf/line_break.py index ab4b88636..0a133ba8c 100644 --- a/fpdf/line_break.py +++ b/fpdf/line_break.py @@ -50,7 +50,7 @@ def __init__( self, characters: Union[list, str], graphics_state: dict, - k: float, + k: Number, link: Optional[Union[int, str]] = None, ): if isinstance(characters, str): @@ -352,11 +352,11 @@ def render_pdf_text_core(self, frag_ws, current_ws): class TextLine(NamedTuple): fragments: tuple - text_width: float + text_width: Number number_of_spaces: int align: Align - height: float - max_width: float + height: Number + max_width: Number trailing_nl: bool = False trailing_form_feed: bool = False @@ -389,7 +389,7 @@ class SpaceHint(NamedTuple): original_character_index: int current_line_fragment_index: int current_line_character_index: int - line_width: float + line_width: Number number_of_spaces: int @@ -398,16 +398,16 @@ class HyphenHint(NamedTuple): original_character_index: int current_line_fragment_index: int current_line_character_index: int - line_width: float + line_width: Number number_of_spaces: int curchar: str - curchar_width: float + curchar_width: Number graphics_state: dict - k: float + k: Number class CurrentLine: - def __init__(self, max_width: float, print_sh: bool = False): + def __init__(self, max_width: Number, print_sh: bool = False): """ Per-line text fragment management for use by MultiLineBreak. Args: @@ -444,12 +444,12 @@ def width(self): def add_character( self, character: str, - character_width: float, + character_width: Number, graphics_state: dict, - k: float, + k: Number, original_fragment_index: int, original_character_index: int, - height: float, + height: Number, url: str = None, ): assert character != NEWLINE @@ -573,12 +573,13 @@ class MultiLineBreak: def __init__( self, fragments: Sequence[Fragment], - max_width: Union[float, callable], + max_width: Union[Number, callable], margins: Sequence[Number], + indent: Number = 0.0, align: Align = Align.L, print_sh: bool = False, wrapmode: WrapMode = WrapMode.WORD, - line_height: float = 1.0, + line_height: Number = 1.0, skip_leading_spaces: bool = False, ): """Accept text as Fragments, to be split into individual lines depending @@ -593,6 +594,7 @@ def __init__( lateral boundaries of the enclosing TextRegion() are not vertical. margins (sequence of floats): The extra clearance that may apply at the beginning and/or end of a line (usually either FPDF.c_margin or 0.0 for each side). + indent (float): How much left edge is moved to the right, shortening the line. align (Align): The horizontal alignment of the current text block. print_sh (bool): If True, a soft-hyphen will be rendered normally, instead of triggering a line break. Default: False @@ -608,6 +610,7 @@ def __init__( self.get_width = max_width else: self.get_width = lambda height: max_width + self.indent = indent self.margins = margins self.align = align self.print_sh = print_sh @@ -629,7 +632,7 @@ def get_line(self): current_font_height = 0 - max_width = self.get_width(current_font_height) + max_width = self.get_width(current_font_height) - self.indent # The full max width will be passed on via TextLine to FPDF._render_styled_text_line(). current_line = CurrentLine(max_width=max_width, print_sh=self.print_sh) # For line wrapping we need to use the reduced width. @@ -659,7 +662,7 @@ def get_line(self): if current_fragment.font_size > current_font_height: current_font_height = current_fragment.font_size # document units - max_width = self.get_width(current_font_height) + max_width = self.get_width(current_font_height) - self.indent current_line.max_width = max_width for margin in self.margins: max_width -= margin diff --git a/fpdf/text_region.py b/fpdf/text_region.py index 658ba6d4c..2f3cfa489 100644 --- a/fpdf/text_region.py +++ b/fpdf/text_region.py @@ -156,8 +156,9 @@ def generate_bullet_frags_and_tl(self, bullet_string: str, bullet_r_margin: floa bullet_fragments, max_width=self._region.get_width, margins=( - self.pdf.c_margin + (self.indent - fragments_width - bullet_r_margin), - self.pdf.c_margin, + self._region.h_margins[0] + + (self.indent - fragments_width - bullet_r_margin), + self._region.h_margins[1], ), align=self.text_align or self._region.text_align or Align.L, wrapmode=self.wrapmode, @@ -182,7 +183,8 @@ def build_lines(self, print_sh) -> List[LineWrapper]: multi_line_break = MultiLineBreak( self._text_fragments, max_width=self._region.get_width, - margins=(self.pdf.c_margin + self.indent, self.pdf.c_margin), + margins=self._region.h_margins, + indent=self.indent, align=self.text_align or self._region.text_align or Align.L, print_sh=print_sh, wrapmode=self.wrapmode, @@ -222,7 +224,7 @@ def __init__( title=None, alt_text=None, ): - self.region = region + self._region = region self.name = name if align: align = Align.coerce(align) @@ -246,7 +248,7 @@ def build_line(self): # We do double duty as a "text line wrapper" here, since all the necessary # information is already in the ImageParagraph object. self.name, self.img, self.info = preload_image( - self.region.pdf.image_cache, self.name + self._region.pdf.image_cache, self.name ) return self @@ -261,11 +263,11 @@ def render(self, col_left, col_width, max_height): if self.height: h = self.height else: - native_h = self.info["h"] / self.region.pdf.k + native_h = self.info["h"] / self._region.pdf.k if self.width: w = self.width else: - native_w = self.info["w"] / self.region.pdf.k + native_w = self.info["w"] / self._region.pdf.k if native_w > col_width or self.fill_width: w = col_width else: @@ -281,7 +283,7 @@ def render(self, col_left, col_width, max_height): elif self.align == Align.C: x += (col_width - w) / 2 if is_svg: - return self.region.pdf._vector_image( + return self._region.pdf._vector_image( name=self.name, svg=self.img, info=self.info, @@ -294,7 +296,7 @@ def render(self, col_left, col_width, max_height): alt_text=self.alt_text, keep_aspect_ratio=self.keep_aspect_ratio, ) - return self.region.pdf._raster_image( + return self._region.pdf._raster_image( name=self.name, img=self.img, info=self.info, @@ -519,7 +521,6 @@ def _render_column_lines(self, text_lines, top, bottom): if ( text_rendered and tl_wrapper.first_line - and not cur_bullet and cur_paragraph.top_margin and self.pdf.y > self.pdf.t_margin ): @@ -631,6 +632,7 @@ def __init__( self.balance = balance total_w = self.extents.right - self.extents.left col_width = (total_w - (ncols - 1) * gutter) / ncols + self.h_margins = (pdf.c_margin, pdf.c_margin) # We calculate the column extents once in advance, and store them for lookup. c_left = self.extents.left self._cols = [Extents(c_left, c_left + col_width)] diff --git a/test/text_region/tcols_bullets_indent.pdf b/test/text_region/tcols_bullets_indent.pdf new file mode 100644 index 000000000..cea176ca9 Binary files /dev/null and b/test/text_region/tcols_bullets_indent.pdf differ diff --git a/test/text_region/test_text_columns.py b/test/text_region/test_text_columns.py index ac540669c..713394636 100644 --- a/test/text_region/test_text_columns.py +++ b/test/text_region/test_text_columns.py @@ -327,3 +327,72 @@ def test_tcols_break_top_margin(tmp_path): # regression test for #1214 ) as par: par.write(text=LOREM_IPSUM) assert_pdf_equal(pdf, HERE / "tcols_break_top_margin.pdf", tmp_path) + + +def test_tcols_bullets_indent(tmp_path): + """Ensure that the top/bottom margins work with indented/bulleted + paragraphs. + Ensure that indented paragraphs have a reduced total width. + """ + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "", 14) + pdf.set_top_margin(50) + pdf.set_auto_page_break(True, 50) + pdf.rect(pdf.l_margin, pdf.t_margin, pdf.epw, pdf.eph) + with pdf.text_columns(text_align="L", ncols=2) as cols: + cols.write("Paragraphs with Top Margin\n\n") + for _ in range(4): + with cols.paragraph( + text_align="J", + top_margin=pdf.font_size * 4, + ) as par: + par.write(text=LOREM_IPSUM[:100]) + for _ in range(5): + with cols.paragraph( + text_align="J", + top_margin=pdf.font_size * 4, + indent=10, + bullet_string="\x95", + ) as par: + par.write(text=LOREM_IPSUM[:100]) + pdf.add_page() + pdf.rect(pdf.l_margin, pdf.t_margin, pdf.epw, pdf.eph) + with pdf.text_columns(text_align="L", ncols=2) as cols: + cols.write("Paragraphs with Bottom Margin\n\n") + for _ in range(4): + with cols.paragraph( + text_align="J", + bottom_margin=pdf.font_size * 4, + ) as par: + par.write(text=LOREM_IPSUM[:100]) + for _ in range(5): + with cols.paragraph( + text_align="J", + bottom_margin=pdf.font_size * 4, + indent=10, + bullet_string="\x95", + ) as par: + par.write(text=LOREM_IPSUM[:100]) + pdf.add_page() + pdf.rect(pdf.l_margin, pdf.t_margin, pdf.epw, pdf.eph) + with pdf.text_columns(text_align="L", ncols=2) as cols: + cols.write("Paragraphs with Top and Bottom Margin\n\n") + for _ in range(4): + with cols.paragraph( + text_align="J", + top_margin=pdf.font_size * 1.5, + bottom_margin=pdf.font_size * 2.5, + ) as par: + par.write(text=LOREM_IPSUM[:100]) + for _ in range(5): + with cols.paragraph( + text_align="J", + top_margin=pdf.font_size * 1.5, + bottom_margin=pdf.font_size * 2.5, + indent=10, + bullet_string="\x95", + ) as par: + par.write(text=LOREM_IPSUM[:100]) + assert_pdf_equal(pdf, HERE / "tcols_bullets_indent.pdf", tmp_path) +