diff --git a/pypdf/generic/_appearance_stream.py b/pypdf/generic/_appearance_stream.py index 268a1726d..76b8be9c0 100644 --- a/pypdf/generic/_appearance_stream.py +++ b/pypdf/generic/_appearance_stream.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import IntEnum from io import BytesIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, NamedTuple, cast from .._codecs import fill_from_encoding from .._codecs.core_font_metrics import CORE_FONT_METRICS @@ -70,6 +70,14 @@ class TextAlignment(IntEnum): RIGHT = 2 +class WidthWordGlyphs(NamedTuple): + """A tuple of the unscaled width of a word (unencoded text) and the font-encoded glyphs that represent it.""" + + width: float + word: str + glyphs: str + + class TextStreamAppearance(BaseStreamAppearance): """ A class representing the appearance stream for a text-based form field. @@ -86,10 +94,10 @@ def _scale_text( leading_factor: float, field_width: float, field_height: float, - paragraphs: list[str], + paragraphs: list[list[WidthWordGlyphs]], min_font_size: float, font_size_step: float = 0.2 - ) -> tuple[list[tuple[float, str]], float]: + ) -> tuple[list[WidthWordGlyphs], float]: """ Takes a piece of text and scales it to field_width or field_height, given font_name and font_size. Wraps text where necessary. @@ -100,7 +108,8 @@ def _scale_text( leading_factor: The line distance. field_width: The width of the field in which to fit the text. field_height: The height of the field in which to fit the text. - paragraphs: The text paragraphs to fit with the field. + paragraphs: The list of text paragraphs to fit with the field, where each paragraph is a list + of WidthWordGlyphs tuples of unscaled width, unencoded text, and glyphs (font-encoded text). min_font_size: The minimum font size at which to scale the text. font_size_step: The amount by which to decrement font size per step while scaling. @@ -109,32 +118,40 @@ def _scale_text( and its contents, and the font_size for these lines and lengths. """ wrapped_lines = [] - current_line_words: list[str] = [] + current_line_words: list[WidthWordGlyphs] = [] current_line_width: float = 0 space_width = font.space_width * font_size / 1000 for paragraph in paragraphs: - if not paragraph.strip(): - wrapped_lines.append((0.0, "")) - continue - words = paragraph.split(font.space_char) - for i, word in enumerate(words): - word_width = font.get_text_width(word) * font_size / 1000 + for i, width_word_glyphs in enumerate(paragraph): + word_width = width_word_glyphs.width * font_size test_width = current_line_width + word_width + (space_width if i else 0) if test_width > field_width and current_line_words: - wrapped_lines.append((current_line_width, font.space_char.join(current_line_words))) - current_line_words = [word] + wrapped_lines.append(WidthWordGlyphs( + width=current_line_width, + word=" ".join([word.word for word in current_line_words]), + glyphs=font.space_char.join([word.glyphs for word in current_line_words]) + )) + current_line_words = [width_word_glyphs] current_line_width = word_width elif not current_line_words and word_width > field_width: - wrapped_lines.append((word_width, word)) + wrapped_lines.append(WidthWordGlyphs( + width=word_width, + word=width_word_glyphs.word, + glyphs=width_word_glyphs.glyphs + )) current_line_words = [] current_line_width = 0 else: if current_line_words: current_line_width += space_width - current_line_words.append(word) + current_line_words.append(width_word_glyphs) current_line_width += word_width if current_line_words: - wrapped_lines.append((current_line_width, font.space_char.join(current_line_words))) + wrapped_lines.append(WidthWordGlyphs( + width=current_line_width, + word=" ".join([word.word for word in current_line_words]), + glyphs=font.space_char.join([word.glyphs for word in current_line_words]) + )) current_line_words = [] current_line_width = 0 # Estimate total height. @@ -225,21 +242,33 @@ def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[byt # If font_size is 0, apply the logic for multiline or large-as-possible font if font_size == 0: - min_font_size = 4.0 # The mininum font size + min_font_size = 4.0 # The minimum font size if selection: # Don't wrap text when dealing with a /Ch field, in order to prevent problems is_multiline = False # with matching "selection" with "line" later on. if is_multiline: font_size = DEFAULT_FONT_SIZE_IN_MULTILINE - glyph_paragraphs = [ - _unicode_to_glyph_id(paragraph, reverse_cmap) for paragraph in text.splitlines() - ] + # We create a list of paragraphs, here each paragraph is a list of tuples, where each WidthWordGlyphs + # tuple signifies an unscaled word width, the word itself, and the glyphs that encode it. + paragraphs: list[list[WidthWordGlyphs]] = [] + for line in text.splitlines(): + if not line.strip(): + paragraphs.append([WidthWordGlyphs(width=0.0, word="", glyphs="")]) + continue + line_by_widths_words_glyphs: list[WidthWordGlyphs] = [] + words = line.split(" ") + for word in words: + glyph_word = _unicode_to_glyph_id(word, reverse_cmap) + line_by_widths_words_glyphs.append( + WidthWordGlyphs(width=font.get_text_width(word) / 1000, word=word, glyphs=glyph_word) + ) + paragraphs.append(line_by_widths_words_glyphs) lines, font_size = self._scale_text( font, font_size, leading_factor, field_width, field_height, - glyph_paragraphs, + paragraphs, min_font_size ) else: @@ -248,7 +277,7 @@ def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[byt text_width_unscaled = font.get_text_width(glyphs) / 1000 max_horizontal_size = field_width / (text_width_unscaled or 1) font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1) - lines = [(text_width_unscaled * font_size, glyphs)] + lines = [WidthWordGlyphs(width=text_width_unscaled * font_size, word=text, glyphs=glyphs)] elif is_comb: if max_length and len(text) > max_length: logger_warning( @@ -265,12 +294,16 @@ def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[byt for index, char in enumerate(text): if index < (max_length or len(text)): glyphs = _unicode_to_glyph_id(char, reverse_cmap) - lines.append((font.get_text_width(glyphs) * font_size / 1000, glyphs)) + lines.append( + WidthWordGlyphs(width=font.get_text_width(glyphs) * font_size / 1000, word=char, glyphs=glyphs) + ) else: lines = [] for line in text.splitlines(): glyphs = _unicode_to_glyph_id(line, reverse_cmap) - lines.append((font.get_text_width(glyphs) * font_size / 1000, glyphs)) + lines.append( + WidthWordGlyphs(width=font.get_text_width(glyphs) * font_size / 1000, word=line, glyphs=glyphs) + ) # Set the vertical offset if is_multiline: @@ -285,7 +318,7 @@ def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[byt ).encode() current_x_pos: float = 0 # Initial virtual position within the text object. - for line_number, (line_width, line) in enumerate(lines): + for line_number, (line_width, original_text, line) in enumerate(lines): if selection and line in _unicode_to_glyph_id("".join(selection), reverse_cmap): # Might be improved, but cannot find how to get fill working => replaced with lined box ap_stream += ( @@ -330,11 +363,25 @@ def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[byt # This is the X position where the *current line* will start. current_x_pos = desired_abs_x_start + is_rtl = any( + 0x0590 <= ord(char) <= 0x08FF or + 0xFB1D <= ord(char) <= 0xFDFF or + 0xFE70 <= ord(char) <= 0xFEFF + for char in line + ) encoded_line = _glyph_id_to_bytes(line, encoding_cmap) + if is_rtl: + # Encode input text as UTF-16BE with a BOM, so that the input text is returned + # in the right direction when copying text from the resulting PDF + bom_text = b"\xfe\xff" + original_text.encode("utf-16-be") + hex_original_text = bom_text.hex().upper() + ap_stream += f"/Span << /ActualText <{hex_original_text}> >> BDC\n".encode() if any(len(c) >= 2 for c in encoded_line): ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n" else: ap_stream += b"(" + b"".join(encoded_line) + b") Tj\n" + if is_rtl: + ap_stream += b"EMC\n" ap_stream += b"ET\nQ\nEMC\nQ\n" return ap_stream diff --git a/tests/test_appearance_stream.py b/tests/test_appearance_stream.py index a73eaaaa2..222db039c 100644 --- a/tests/test_appearance_stream.py +++ b/tests/test_appearance_stream.py @@ -66,7 +66,8 @@ def test_scale_text(): layout.rectangle = (0, 0, 160, 360) font_size = 0.0 - text = """Welcome to pypdf + text = """Welcome to pypdf! +أهلاً بكم في pypdf! pypdf is a free and open source pure-python PDF library capable of splitting, merging, cropping, and transforming the pages of PDF files. It can also add custom data, viewing options, and passwords to PDF files. pypdf can retrieve text and metadata from PDFs as well. @@ -79,12 +80,13 @@ def test_scale_text(): ) assert b"12 Tf" in appearance_stream.get_data() assert b"pypdf is a free and open" in appearance_stream.get_data() + assert b"/Span << /ActualText" in appearance_stream.get_data() layout.rectangle = (0, 0, 160, 160) appearance_stream = TextStreamAppearance( layout=layout, text=text, font_size=font_size, is_multiline=is_multiline ) - assert b"9.8 Tf" in appearance_stream.get_data() + assert b"9.6 Tf" in appearance_stream.get_data() layout.rectangle = (0, 0, 160, 12) appearance_stream = TextStreamAppearance( @@ -142,7 +144,10 @@ def test_appearance_stream_rtl(): font_color="0 g", is_multiline=False ) - hex_glyphs_rtl_enabled = re.findall("<(.+?)>", appearance.get_data().decode())[0] + # The regex returns two matches. The first matches the text in /Span << /ActualText <[group 0]> >> BDC + # The second match concerns the encoded text data. + [hex_actual_text, hex_glyphs_rtl_enabled] = re.findall("<([a-zA-Z0-9]+?)> ", appearance.get_data().decode()) + assert bytes.fromhex(hex_actual_text).decode("utf-16-be") == "\ufeff" + test_string assert hex_shaped_test_glyphs == hex_glyphs_rtl_enabled # RTL support disabled @@ -157,7 +162,7 @@ def test_appearance_stream_rtl(): font_color="0 g", is_multiline=False ) - hex_glyphs_rtl_disabled = re.findall("<(.+?)>", appearance.get_data().decode())[0] + [hex_glyphs_rtl_disabled] = re.findall("^<(.+?)>", appearance.get_data().decode(), re.MULTILINE) assert hex_unshaped_test_glyphs == hex_glyphs_rtl_disabled # The hex glyph sequences should be different when RTL support is enabled vs disabled assert hex_glyphs_rtl_enabled != hex_glyphs_rtl_disabled @@ -174,7 +179,9 @@ def test_appearance_stream_rtl(): font_color="0 g", is_multiline=False ) - hex_glyphs_rtl_enabled_fonttools_disabled = re.findall("<(.+?)>", appearance.get_data().decode())[0] + [hex_glyphs_rtl_enabled_fonttools_disabled] = re.findall( + "^<(.+?)>", appearance.get_data().decode(), re.MULTILINE + ) assert hex_shaped_test_glyphs != hex_glyphs_rtl_enabled_fonttools_disabled