Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
_appearance_stream.py590 linesDownload Raw Back to generic
1import re2from dataclasses import dataclass3from enum import IntEnum4from typing import Any, Optional, Union, cast5 6from .._codecs import fill_from_encoding7from .._codecs.core_font_metrics import CORE_FONT_METRICS8from .._font import Font9from .._utils import logger_warning10from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes11from ..generic import (12    DecodedStreamObject,13    DictionaryObject,14    NameObject,15    NumberObject,16    RectangleObject,17)18from ..generic._base import ByteStringObject, TextStringObject, is_null_or_none19 20DEFAULT_FONT_SIZE_IN_MULTILINE = 1221 22 23@dataclass24class BaseStreamConfig:25    """A container representing the basic layout of an appearance stream."""26    rectangle: Union[RectangleObject, tuple[float, float, float, float]] = (0.0, 0.0, 0.0, 0.0)27    border_width: int = 1  # The width of the border in points28    border_style: str = BorderStyles.SOLID29 30 31class BaseStreamAppearance(DecodedStreamObject):32    """A class representing the very base of an appearance stream, that is, a rectangle and a border."""33 34    def __init__(self, layout: Optional[BaseStreamConfig] = None) -> None:35        """36        Takes the appearance stream layout as an argument.37 38        Args:39            layout: The basic layout parameters.40        """41        super().__init__()42        self._layout = layout or BaseStreamConfig()43        self[NameObject("/Type")] = NameObject("/XObject")44        self[NameObject("/Subtype")] = NameObject("/Form")45        self[NameObject("/BBox")] = RectangleObject(self._layout.rectangle)46 47 48class TextAlignment(IntEnum):49    """Defines the alignment options for text within a form field's appearance stream."""50 51    LEFT = 052    CENTER = 153    RIGHT = 254 55 56class TextStreamAppearance(BaseStreamAppearance):57    """58    A class representing the appearance stream for a text-based form field.59 60    This class generates the content stream (the `ap_stream_data`) that dictates61    how text is rendered within a form field's bounding box. It handles properties62    like font, font size, color, multiline text, and text selection highlighting.63    """64 65    def _scale_text(66        self,67        font: Font,68        font_size: float,69        leading_factor: float,70        field_width: float,71        field_height: float,72        text: str,73        min_font_size: float,74        font_size_step: float = 0.275    ) -> tuple[list[tuple[float, str]], float]:76        """77        Takes a piece of text and scales it to field_width or field_height, given font_name78        and font_size. Wraps text where necessary.79 80        Args:81            font: The font to be used.82            font_size: The font size in points.83            leading_factor: The line distance.84            field_width: The width of the field in which to fit the text.85            field_height: The height of the field in which to fit the text.86            text: The text to fit with the field.87            min_font_size: The minimum font size at which to scale the text.88            font_size_step: The amount by which to decrement font size per step while scaling.89 90        Returns:91            The text in the form of list of tuples, each tuple containing the length of a line92            and its contents, and the font_size for these lines and lengths.93        """94        orig_text = text95        paragraphs = text.replace("\n", "\r").split("\r")96        wrapped_lines = []97        current_line_words: list[str] = []98        current_line_width: float = 099        space_width = font.space_width * font_size / 1000100        for paragraph in paragraphs:101            if not paragraph.strip():102                wrapped_lines.append((0.0, ""))103                continue104            words = paragraph.split(" ")105            for i, word in enumerate(words):106                word_width = font.text_width(word) * font_size / 1000107                test_width = current_line_width + word_width + (space_width if i else 0)108                if test_width > field_width and current_line_words:109                    wrapped_lines.append((current_line_width, " ".join(current_line_words)))110                    current_line_words = [word]111                    current_line_width = word_width112                elif not current_line_words and word_width > field_width:113                    wrapped_lines.append((word_width, word))114                    current_line_words = []115                    current_line_width = 0116                else:117                    if current_line_words:118                        current_line_width += space_width119                    current_line_words.append(word)120                    current_line_width += word_width121            if current_line_words:122                wrapped_lines.append((current_line_width, " ".join(current_line_words)))123                current_line_words = []124                current_line_width = 0125        # Estimate total height.126        estimated_total_height = font_size + (len(wrapped_lines) - 1) * leading_factor * font_size127        if estimated_total_height > field_height:128            # Text overflows height; Retry with smaller font size.129            new_font_size = font_size - font_size_step130            if new_font_size >= min_font_size:131                return self._scale_text(132                    font,133                    new_font_size,134                    leading_factor,135                    field_width,136                    field_height,137                    orig_text,138                    min_font_size,139                    font_size_step140                )141        return wrapped_lines, round(font_size, 1)142 143    def _generate_appearance_stream_data(144        self,145        text: str,146        selection: Union[list[str], None],147        font: Font,148        font_glyph_byte_map: Optional[dict[str, bytes]] = None,149        font_name: str = "/Helv",150        font_size: float = 0.0,151        font_color: str = "0 g",152        is_multiline: bool = False,153        alignment: TextAlignment = TextAlignment.LEFT,154        is_comb: bool = False,155        max_length: Optional[int] = None156    ) -> bytes:157        """158        Generates the raw bytes of the PDF appearance stream for a text field.159 160        This private method assembles the PDF content stream operators to draw161        the provided text within the specified rectangle. It handles text positioning,162        font application, color, and special formatting like selected text.163 164        Args:165            text: The text to be rendered in the form field.166            selection: An optional list of strings that should be highlighted as selected.167            font: The font to use.168            font_glyph_byte_map: An optional dictionary mapping characters to their169                byte representation for glyph encoding.170            font_name: The name of the font resource to use (e.g., "/Helv").171            font_size: The font size. If 0, it is automatically calculated172                based on whether the field is multiline or not.173            font_color: The color to apply to the font, represented as a PDF174                graphics state string (e.g., "0 g" for black).175            is_multiline: A boolean indicating if the text field is multiline.176            alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.177            is_comb: Boolean that designates fixed-length fields, where every character178                fills one "cell", such as in a postcode.179            max_length: Used if is_comb is set. The maximum number of characters for a fixed-180                length field.181 182        Returns:183            A byte string containing the PDF content stream data.184 185        """186        rectangle = self._layout.rectangle187        font_glyph_byte_map = font_glyph_byte_map or {}188        if isinstance(rectangle, tuple):189            rectangle = RectangleObject(rectangle)190        leading_factor = (font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / 1000.0191 192        # Set margins based on border width and style, but never less than 1 point193        factor = 2 if self._layout.border_style in {"/B", "/I"} else 1194        margin = max(self._layout.border_width * factor, 1)195        field_height = rectangle.height - 2 * margin196        field_width = rectangle.width - 4 * margin197 198        # If font_size is 0, apply the logic for multiline or large-as-possible font199        if font_size == 0:200            min_font_size = 4.0       # The mininum font size201            if selection:             # Don't wrap text when dealing with a /Ch field, in order to prevent problems202                is_multiline = False  # with matching "selection" with "line" later on.203            if is_multiline:204                font_size = DEFAULT_FONT_SIZE_IN_MULTILINE205                lines, font_size = self._scale_text(206                    font,207                    font_size,208                    leading_factor,209                    field_width,210                    field_height,211                    text,212                    min_font_size213                )214            else:215                max_vertical_size = field_height / leading_factor216                text_width_unscaled = font.text_width(text) / 1000217                max_horizontal_size = field_width / (text_width_unscaled or 1)218                font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1)219                lines = [(text_width_unscaled * font_size, text)]220        elif is_comb:221            if max_length and len(text) > max_length:222                logger_warning (223                    f"Length of text {text} exceeds maximum length ({max_length}) of field, input truncated.",224                    __name__225                )226            # We act as if each character is one line, because we draw it separately later on227            lines = [(228                font.text_width(char) * font_size / 1000,229                char230            ) for index, char in enumerate(text) if index < (max_length or len(text))]231        else:232            lines = [(233                font.text_width(line) * font_size / 1000,234                line235            ) for line in text.replace("\n", "\r").split("\r")]236 237        # Set the vertical offset238        if is_multiline:239            y_offset = rectangle.height + margin - font.font_descriptor.bbox[3] * font_size / 1000.0240        else:241            y_offset = margin + ((field_height - font.font_descriptor.ascent * font_size / 1000) / 2)242        default_appearance = f"{font_name} {font_size} Tf {font_color}"243 244        ap_stream = (245            f"q\n/Tx BMC \nq\n{2 * margin} {margin} {field_width} {field_height} "246            f"re\nW\nBT\n{default_appearance}\n"247        ).encode()248        current_x_pos: float = 0  # Initial virtual position within the text object.249 250        for line_number, (line_width, line) in enumerate(lines):251            if selection and line in selection:252                # Might be improved, but cannot find how to get fill working => replaced with lined box253                ap_stream += (254                    f"1 {y_offset - (line_number * font_size * leading_factor) - 1} "255                    f"{rectangle.width - 2} {font_size + 2} re\n"256                    f"0.5 0.5 0.5 rg s\n{default_appearance}\n"257                ).encode()258 259            # Calculate the desired absolute starting X for the current line260            desired_abs_x_start: float = 0261            if is_comb and max_length:262                # Calculate the width of a cell for one character263                cell_width = rectangle.width / max_length264                # Space from the left edge of the cell to the character's baseline start265                # line_width here is the *actual* character width in points for the single character 'line'266                centering_offset_in_cell = (cell_width - line_width) / 2267                # Absolute start X = (Cell Index, i.e., line_number * Cell Width) + Centering Offset268                desired_abs_x_start = (line_number * cell_width) + centering_offset_in_cell269            elif alignment == TextAlignment.RIGHT:270                desired_abs_x_start = rectangle.width - margin * 2 - line_width271            elif alignment == TextAlignment.CENTER:272                desired_abs_x_start = (rectangle.width - line_width) / 2273            else:  # Left aligned; default274                desired_abs_x_start = margin * 2275            # Calculate x_rel_offset: how much to move from the current_x_pos276            # to reach the desired_abs_x_start.277            x_rel_offset = desired_abs_x_start - current_x_pos278 279            # Y-offset:280            y_rel_offset: float = 0281            if line_number == 0:282                y_rel_offset = y_offset  # Initial vertical position283            elif is_comb:284                y_rel_offset = 0.0  # DO NOT move vertically for subsequent characters285            else:286                y_rel_offset = - font_size * leading_factor  # Move down by line height287 288            # Td is a relative translation (Tx and Ty).289            # It updates the current text position.290            ap_stream += f"{x_rel_offset} {y_rel_offset} Td\n".encode()291            # Update current_x_pos based on the Td operation for the next iteration.292            # This is the X position where the *current line* will start.293            current_x_pos = desired_abs_x_start294 295            encoded_line: list[bytes] = [296                font_glyph_byte_map.get(c, c.encode("utf-16-be")) for c in line297            ]298            if any(len(c) >= 2 for c in encoded_line):299                ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n"300            else:301                ap_stream += b"(" + b"".join(encoded_line) + b") Tj\n"302        ap_stream += b"ET\nQ\nEMC\nQ\n"303        return ap_stream304 305    def __init__(306        self,307        layout: Optional[BaseStreamConfig] = None,308        text: str = "",309        selection: Optional[list[str]] = None,310        font_resource: Optional[DictionaryObject] = None,311        font_name: str = "/Helv",312        font_size: float = 0.0,313        font_color: str = "0 g",314        is_multiline: bool = False,315        alignment: TextAlignment = TextAlignment.LEFT,316        is_comb: bool = False,317        max_length: Optional[int] = None318    ) -> None:319        """320        Initializes a TextStreamAppearance object.321 322        This constructor creates a new PDF stream object configured as an XObject323        of subtype Form. It uses the `_appearance_stream_data` method to generate324        the content for the stream.325 326        Args:327            layout: The basic layout parameters.328            text: The text to be rendered in the form field.329            selection: An optional list of strings that should be highlighted as selected.330            font_resource: An optional variable that represents a PDF font dictionary.331            font_name: The name of the font resource, e.g., "/Helv".332            font_size: The font size. If 0, it's auto-calculated.333            font_color: The font color string.334            is_multiline: A boolean indicating if the text field is multiline.335            alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.336            is_comb: Boolean that designates fixed-length fields, where every character337                fills one "cell", such as in a postcode.338            max_length: Used if is_comb is set. The maximum number of characters for a fixed-339                length field.340 341        """342        super().__init__(layout)343 344        # If a font resource was added, get the font character map345        if font_resource:346            font = Font.from_font_resource(font_resource)347        else:348            logger_warning(f"Font dictionary for {font_name} not found; defaulting to Helvetica.", __name__)349            font_name = "/Helv"350            core_font_metrics = CORE_FONT_METRICS["Helvetica"]351            font = Font(352                name="Helvetica",353                character_map={},354                encoding=dict(zip(range(256), fill_from_encoding("cp1252"))),  # WinAnsiEncoding355                sub_type="Type1",356                font_descriptor=core_font_metrics.font_descriptor,357                character_widths=core_font_metrics.character_widths358            )359            font_resource = font.as_font_resource()360 361        # Check whether the font resource is able to encode the text value.362        encodable = True363        try:364            if isinstance(font.encoding, str):365                text.encode(font.encoding, "surrogatepass")366            else:367                supported_chars = set(font.encoding.values())368                if any(char not in supported_chars for char in text):369                    encodable = False370            # We should add a final check against the character_map (CMap) of the font,371            # but we don't appear to have PDF forms with such fonts, so we skip this for372            # now.373 374        except UnicodeEncodeError:375            encodable = False376 377        if not encodable:378            logger_warning(379                f"Text string '{text}' contains characters not supported by font encoding. "380                "This may result in text corruption. "381                "Consider calling writer.update_page_form_field_values with auto_regenerate=True.",382                __name__383            )384 385        font_glyph_byte_map: dict[str, bytes]386        if isinstance(font.encoding, str):387            font_glyph_byte_map = {388                v: k.encode(font.encoding) for k, v in font.character_map.items()389            }390        else:391            font_glyph_byte_map = {v: bytes((k,)) for k, v in font.encoding.items()}392            font_encoding_rev = {v: bytes((k,)) for k, v in font.encoding.items()}393            for key, value in font.character_map.items():394                font_glyph_byte_map[value] = font_encoding_rev.get(key, key)395 396        ap_stream_data = self._generate_appearance_stream_data(397            text,398            selection,399            font,400            font_glyph_byte_map,401            font_name=font_name,402            font_size=font_size,403            font_color=font_color,404            is_multiline=is_multiline,405            alignment=alignment,406            is_comb=is_comb,407            max_length=max_length408        )409 410        self.set_data(ByteStringObject(ap_stream_data))411        self[NameObject("/Length")] = NumberObject(len(ap_stream_data))412        # Update Resources with font information413        self[NameObject("/Resources")] = DictionaryObject({414            NameObject("/Font"): DictionaryObject({415                NameObject(font_name): getattr(font_resource, "indirect_reference", font_resource)416            })417        })418 419    @staticmethod420    def _find_annotation_font_resource(421            font_name: str,422            annotation: DictionaryObject,423            acro_form: DictionaryObject424        ) -> tuple[str, DictionaryObject]:425        # Try to find a resource dictionary for the font by examining the annotation and, if that fails,426        # the AcroForm resources dictionary427        acro_form_resources: Any = cast(428            DictionaryObject,429            annotation.get_inherited(430                "/DR",431                acro_form.get("/DR", DictionaryObject()),432            ),433        )434        acro_form_font_resources = acro_form_resources.get("/Font", DictionaryObject())435        font_resource = acro_form_font_resources.get(font_name, None)436 437        # Normally, we should have found a font resource by now. However, when a user has provided a specific438        # font name, we may not have found the associated font resource among the AcroForm resources. Also, in439        # case of the 14 Adobe Core fonts, we may be expected to construct a font resource ourselves.440        if is_null_or_none(font_resource):441            if font_name.removeprefix("/") not in CORE_FONT_METRICS:442                # Default to Helvetica if we haven't found a font resource and cannot construct one ourselves.443                logger_warning(f"Font dictionary for {font_name} not found; defaulting to Helvetica.", __name__)444                font_name = "/Helvetica"445            core_font_metrics = CORE_FONT_METRICS[font_name.removeprefix("/")]446            font_resource = Font(447                name=font_name.removeprefix("/"),448                character_map={},449                encoding=dict(zip(range(256), fill_from_encoding("cp1252"))),  # WinAnsiEncoding450                sub_type="Type1",451                font_descriptor=core_font_metrics.font_descriptor,452                character_widths=core_font_metrics.character_widths453            ).as_font_resource()454 455        return font_name, font_resource456 457    @classmethod458    def from_text_annotation(459        cls,460        acro_form: DictionaryObject,  # _root_object[CatalogDictionary.ACRO_FORM])461        field: DictionaryObject,462        annotation: DictionaryObject,463        user_font_name: str = "",464        user_font_size: float = -1,465    ) -> "TextStreamAppearance":466        """467        Creates a TextStreamAppearance object from a text field annotation.468 469        This class method is a factory for creating a `TextStreamAppearance`470        instance by extracting all necessary information (bounding box, font,471        text content, etc.) from the PDF field and annotation dictionaries.472        It respects inheritance for properties like default appearance (`/DA`).473 474        Args:475            acro_form: The root AcroForm dictionary from the PDF catalog.476            field: The field dictionary object.477            annotation: The widget annotation dictionary object associated with the field.478            user_font_name: An optional user-provided font name to override the479                default. Defaults to an empty string.480            user_font_size: An optional user-provided font size to override the481                default. A value of -1 indicates no override.482 483        Returns:484            A new `TextStreamAppearance` instance configured for the given field.485 486        """487        # Calculate rectangle dimensions488        _rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect])489        rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1])))490 491        # Get default appearance dictionary from annotation492        default_appearance = annotation.get_inherited(493            AnnotationDictionaryAttributes.DA,494            acro_form.get(AnnotationDictionaryAttributes.DA, None),495        )496        if not default_appearance:497            # Create a default appearance if none was found in the annotation498            default_appearance = TextStringObject("/Helv 0 Tf 0 g")499        else:500            default_appearance = default_appearance.get_object()501 502        # Retrieve field text and selected values503        field_flags = field.get(FieldDictionaryAttributes.Ff, 0)504        if (505                field.get(FieldDictionaryAttributes.FT, "/Tx") == "/Ch" and506                field_flags & FieldDictionaryAttributes.FfBits.Combo == 0507        ):508            text = "\n".join(annotation.get_inherited(FieldDictionaryAttributes.Opt, []))509            selection = field.get("/V", [])510            if not isinstance(selection, list):511                selection = [selection]512        else:  # /Tx513            text = field.get("/V", "")514            selection = []515 516        # Escape parentheses (PDF 1.7 reference, table 3.2, Literal Strings)517        text = text.replace("\\", "\\\\").replace("(", r"\(").replace(")", r"\)")518 519        # Derive font name, size and color from the default appearance. Also set520        # user-provided font name and font size in the default appearance, if given.521        # For a font name, this presumes that we can find an associated font resource522        # dictionary. Uses the variable font_properties as an intermediate.523        # As per the PDF spec:524        # "At a minimum, the string [that is, default_appearance] shall include a Tf (text525        # font) operator along with its two operands, font and size" (Section 12.7.4.3526        # "Variable text" of the PDF 2.0 specification).527        font_properties = [prop for prop in re.split(r"\s", default_appearance) if prop]528        font_name = font_properties.pop(font_properties.index("Tf") - 2)529        font_size = float(font_properties.pop(font_properties.index("Tf") - 1))530        font_properties.remove("Tf")531        font_color = " ".join(font_properties)532        # Determine the font name to use, prioritizing the user's input533        if user_font_name:534            font_name = user_font_name535        # Determine the font size to use, prioritizing the user's input536        if user_font_size > 0:537            font_size = user_font_size538 539        font_name, font_resource = cls._find_annotation_font_resource(font_name, annotation, acro_form)540 541        # Retrieve formatting information542        is_comb = False543        max_length = None544        if field_flags & FieldDictionaryAttributes.FfBits.Comb:545            is_comb = True546            max_length = annotation.get("/MaxLen")547        is_multiline = False548        if field_flags & FieldDictionaryAttributes.FfBits.Multiline:549            is_multiline = True550        alignment = field.get("/Q", TextAlignment.LEFT)551        border_width = 1552        border_style = BorderStyles.SOLID553        if "/BS" in field:554            border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width)555            border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style)556 557        # Create the TextStreamAppearance instance558        layout = BaseStreamConfig(rectangle=rectangle, border_width=border_width, border_style=border_style)559        new_appearance_stream = cls(560            layout,561            text,562            selection,563            font_resource,564            font_name=font_name,565            font_size=font_size,566            font_color=font_color,567            is_multiline=is_multiline,568            alignment=alignment,569            is_comb=is_comb,570            max_length=max_length571        )572 573        if AnnotationDictionaryAttributes.AP in annotation:574            for key, value in (575                cast(DictionaryObject, annotation[AnnotationDictionaryAttributes.AP]).get("/N", {}).items()576            ):577                if key in {"/BBox", "/Length", "/Subtype", "/Type", "/Filter"}:578                    continue579                # Don't overwrite font resources added by TextAppearanceStream.__init__580                if key == "/Resources":581                    if "/Font" not in value:582                        value.get_object()[NameObject("/Font")] = DictionaryObject()583                    value["/Font"].get_object()[NameObject(font_name)] = getattr(584                        font_resource, "indirect_reference", font_resource585                    )586                else:587                    new_appearance_stream[key] = value588 589        return new_appearance_stream590 
codekingpro/portable-devtools · Team Ai