Skip to content

Commit 856ae9c

Browse files
ShivamSharma43clauderomanlutzCopilot
authored
FIX Include module colors, layout and injection items in QRCodeConverter and PDFConverter identifiers (#3245)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd31d51f-1a2d-4441-8e25-50992348d470
1 parent 7534bda commit 856ae9c

4 files changed

Lines changed: 170 additions & 2 deletions

File tree

‎pyrit/converter/pdf_converter.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import ast
55
import hashlib
6+
import json
67
import time
78
from io import BytesIO
89
from pathlib import Path
@@ -23,6 +24,9 @@
2324
_GENERATED_TEXT_COLOR: tuple[int, int, int] = (0, 0, 0)
2425
_INJECTED_TEXT_COLOR: tuple[int, int, int] = (255, 255, 255)
2526

27+
# Default row height in mm for generated PDFs. The identifier leaves the default out, so default hashes don't move.
28+
_DEFAULT_ROW_HEIGHT = 10
29+
2630

2731
class PDFConverter(Converter):
2832
"""
@@ -52,7 +56,7 @@ def __init__(
5256
page_width: int = 210,
5357
page_height: int = 297,
5458
column_width: int = 0,
55-
row_height: int = 10,
59+
row_height: int = _DEFAULT_ROW_HEIGHT,
5660
existing_pdf: Path | None = None,
5761
injection_items: list[dict[str, Any]] | None = None,
5862
) -> None:
@@ -71,7 +75,8 @@ def __init__(
7175
column_width (int): Width of each column in the PDF. Defaults to 0 (full page width).
7276
row_height (int): Height of each row in the PDF. Defaults to 10.
7377
existing_pdf (Path | None, optional): Path to an existing PDF file. Defaults to None.
74-
injection_items (list[Dict] | None, optional): A list of injection items for modifying an existing PDF.
78+
injection_items (list[dict[str, Any]] | None, optional): Injection items for modifying an existing PDF.
79+
Identifier values must be JSON-serializable or UTF-8 bytes, which are identified as their decoded text.
7580
7681
Raises:
7782
ValueError: If the font color is invalid or the injection items are not provided as a list of dictionaries.
@@ -130,6 +135,14 @@ def _build_identifier(self) -> ComponentIdentifier:
130135
if self._existing_pdf_path:
131136
existing_pdf_path = str(self._existing_pdf_path)
132137

138+
injection_items_hash = None
139+
if self._injection_items:
140+
injection_items_hash = hashlib.sha256(
141+
json.dumps(self._injection_items, sort_keys=True, default=self._serialize_injection_value).encode(
142+
"utf-8"
143+
)
144+
).hexdigest()[:16]
145+
133146
return self._create_identifier(
134147
params={
135148
"font_type": self._font_type,
@@ -139,6 +152,11 @@ def _build_identifier(self) -> ComponentIdentifier:
139152
"prompt_template_hash": template_hash,
140153
"existing_pdf_path": existing_pdf_path,
141154
"font_color": list(self._font_color) if self._font_color is not None else None,
155+
# Layout and injection settings join the identity only when they are set, so the
156+
# default identity keeps its existing hash.
157+
"column_width": self._column_width or None,
158+
"row_height": self._row_height if self._row_height != _DEFAULT_ROW_HEIGHT else None,
159+
"injection_items_hash": injection_items_hash,
142160
}
143161
)
144162

@@ -461,3 +479,22 @@ async def _serialize_pdf_async(self, pdf_bytes: bytes, content: str) -> DataType
461479
)
462480
await pdf_serializer.save_data_async(pdf_bytes, output_filename=output_filename)
463481
return pdf_serializer
482+
483+
@staticmethod
484+
def _serialize_injection_value(value: object) -> str:
485+
"""
486+
Normalize byte text consistently with ReportLab for identifier serialization.
487+
488+
Args:
489+
value (object): A value not handled by the JSON encoder.
490+
491+
Returns:
492+
str: The decoded UTF-8 text.
493+
494+
Raises:
495+
TypeError: If the value is not bytes.
496+
UnicodeDecodeError: If the bytes are not valid UTF-8.
497+
"""
498+
if isinstance(value, bytes):
499+
return value.decode("utf-8")
500+
raise TypeError(f"Unsupported injection item value type: {type(value).__name__}")

‎pyrit/converter/qr_code_converter.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,22 @@ def _build_identifier(self) -> ComponentIdentifier:
6767
Returns:
6868
ComponentIdentifier: The identifier for this converter.
6969
"""
70+
# The data, finder, and border colors default to the dark or light color. They join the
71+
# identity only when they differ from it, so equivalent configurations, including the
72+
# default one, keep their existing hash.
7073
return self._create_identifier(
7174
params={
7275
"scale": self._scale,
7376
"border": self._border,
7477
"dark_color": self._dark_color,
7578
"light_color": self._light_color,
79+
"data_dark_color": self._data_dark_color if self._data_dark_color != self._dark_color else None,
80+
"data_light_color": self._data_light_color if self._data_light_color != self._light_color else None,
81+
"finder_dark_color": self._finder_dark_color if self._finder_dark_color != self._dark_color else None,
82+
"finder_light_color": (
83+
self._finder_light_color if self._finder_light_color != self._light_color else None
84+
),
85+
"border_color": self._border_color if self._border_color != self._light_color else None,
7686
}
7787
)
7888

‎tests/unit/converter/test_pdf_converter.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pyrit.converter import ConverterResult, PDFConverter
1717
from pyrit.memory import DataTypeSerializer
1818
from pyrit.models import SeedPrompt
19+
from pyrit.registry import ConverterRegistry
1920

2021

2122
@pytest.mark.usefixtures("patch_central_database")
@@ -648,6 +649,84 @@ def test_identifier_distinguishes_font_color() -> None:
648649
assert len({default.hash, red.hash, white.hash}) == 3
649650

650651

652+
@pytest.mark.usefixtures("patch_central_database")
653+
def test_identifier_distinguishes_layout() -> None:
654+
default = PDFConverter().get_identifier()
655+
narrow = PDFConverter(column_width=80).get_identifier()
656+
tall = PDFConverter(row_height=20).get_identifier()
657+
auto_height = PDFConverter(row_height=0).get_identifier()
658+
659+
assert "column_width" not in default.params
660+
assert "row_height" not in default.params
661+
assert narrow.params["column_width"] == 80
662+
assert tall.params["row_height"] == 20
663+
assert auto_height.params["row_height"] == 0
664+
assert len({default.hash, narrow.hash, tall.hash, auto_height.hash}) == 4
665+
666+
667+
@pytest.mark.usefixtures("patch_central_database")
668+
def test_identifier_distinguishes_injection_items(mock_pdf_path: Path) -> None:
669+
def injecting(text: str) -> PDFConverter:
670+
return PDFConverter(existing_pdf=mock_pdf_path, injection_items=[{"page": 0, "x": 50, "y": 700, "text": text}])
671+
672+
first = injecting("first payload")
673+
second = injecting("second payload")
674+
675+
assert "injection_items_hash" not in PDFConverter(existing_pdf=mock_pdf_path).get_identifier().params
676+
assert first.get_identifier().hash != second.get_identifier().hash
677+
assert injecting("first payload").get_identifier().hash == first.get_identifier().hash
678+
679+
# Both configurations get their own default registry name instead of colliding.
680+
instances = ConverterRegistry().instances
681+
instances.register(first)
682+
instances.register(second)
683+
assert instances.get(second.get_identifier().unique_name) is second
684+
685+
686+
@pytest.mark.usefixtures("patch_central_database")
687+
@pytest.mark.parametrize("text", ["hello", "caf\u00e9"])
688+
async def test_identifier_normalizes_injected_byte_text_async(*, mock_pdf_path: Path, text: str) -> None:
689+
byte_text = text.encode("utf-8")
690+
converters = [
691+
PDFConverter(existing_pdf=mock_pdf_path, injection_items=[{"text": value}])
692+
for value in (byte_text, text, str(byte_text))
693+
]
694+
identifiers = [converter.get_identifier() for converter in converters]
695+
696+
assert identifiers[0].hash == identifiers[1].hash
697+
assert identifiers[0].hash != identifiers[2].hash
698+
699+
instances = ConverterRegistry().instances
700+
instances.register(converters[0])
701+
instances.register(converters[2])
702+
assert instances.get(identifiers[2].unique_name) is converters[2]
703+
704+
for converter, expected in zip(converters, (text, text, str(byte_text)), strict=True):
705+
result = await converter.convert_async(prompt="")
706+
assert PdfReader(result.output_text).pages[0].extract_text() == f"Page 1 content\n{expected}\n"
707+
708+
709+
@pytest.mark.usefixtures("patch_central_database")
710+
def test_identifier_rejects_unsupported_injection_values() -> None:
711+
converter = PDFConverter(injection_items=[{"text": object()}])
712+
713+
with pytest.raises(TypeError, match="Unsupported injection item value type: object"):
714+
converter.get_identifier()
715+
716+
717+
@pytest.mark.usefixtures("patch_central_database")
718+
def test_identifier_rejects_invalid_utf8_injection_text() -> None:
719+
converter = PDFConverter(injection_items=[{"text": b"\xff"}])
720+
721+
with pytest.raises(UnicodeDecodeError):
722+
converter.get_identifier()
723+
724+
725+
@pytest.mark.usefixtures("patch_central_database")
726+
def test_identifier_keeps_default_hash() -> None:
727+
assert PDFConverter().get_identifier().hash == "a05b4cf1482efac3adbc73fd088af40021f1ec3e8f08da575101410595aacf96"
728+
729+
651730
@pytest.mark.usefixtures("patch_central_database")
652731
@pytest.mark.parametrize("font_color", [(256, 0, 0), (0, 0), [0, 0, 0]])
653732
def test_init_rejects_invalid_font_color(font_color: Any) -> None:

‎tests/unit/converter/test_qr_code_converter.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from pyrit.converter import QRCodeConverter
1414
from pyrit.memory import DataTypeSerializer
15+
from pyrit.registry import ConverterRegistry
1516

1617

1718
def test_qr_code_converter_initialization():
@@ -98,3 +99,44 @@ def test_text_image_converter_input_supported():
9899
converter = QRCodeConverter()
99100
assert converter.input_supported("text") is True
100101
assert converter.input_supported("image_path") is False
102+
103+
104+
@pytest.mark.parametrize(
105+
("color_param", "converter"),
106+
[
107+
pytest.param("data_dark_color", QRCodeConverter(data_dark_color=(255, 0, 0)), id="data_dark_color"),
108+
pytest.param("data_light_color", QRCodeConverter(data_light_color=(255, 0, 0)), id="data_light_color"),
109+
pytest.param("finder_dark_color", QRCodeConverter(finder_dark_color=(255, 0, 0)), id="finder_dark_color"),
110+
pytest.param("finder_light_color", QRCodeConverter(finder_light_color=(255, 0, 0)), id="finder_light_color"),
111+
pytest.param("border_color", QRCodeConverter(border_color=(255, 0, 0)), id="border_color"),
112+
],
113+
)
114+
def test_qr_code_identifier_distinguishes_module_colors(color_param: str, converter: QRCodeConverter) -> None:
115+
default = QRCodeConverter()
116+
assert converter.get_identifier().params[color_param] == [255, 0, 0]
117+
assert converter.get_identifier().hash != default.get_identifier().hash
118+
119+
# Both configurations get their own default registry name instead of colliding.
120+
instances = ConverterRegistry().instances
121+
instances.register(default)
122+
instances.register(converter)
123+
assert instances.get(converter.get_identifier().unique_name) is converter
124+
125+
126+
def test_qr_code_identifier_ignores_colors_equal_to_their_fallback() -> None:
127+
dark, light = (10, 20, 30), (200, 210, 220)
128+
implicit = QRCodeConverter(dark_color=dark, light_color=light)
129+
explicit = QRCodeConverter(
130+
dark_color=dark,
131+
light_color=light,
132+
data_dark_color=dark,
133+
data_light_color=light,
134+
finder_dark_color=dark,
135+
finder_light_color=light,
136+
border_color=light,
137+
)
138+
assert explicit.get_identifier().hash == implicit.get_identifier().hash
139+
140+
141+
def test_qr_code_identifier_keeps_default_hash() -> None:
142+
assert QRCodeConverter().get_identifier().hash == "4466678813b329b72a65ac7f1985ffbf60e1f29da83af759b59720eea274bbe4"

0 commit comments

Comments
 (0)