diff --git a/docs/api-guide.md b/docs/api-guide.md index b50dcbc..f8b8f0d 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -83,6 +83,8 @@ Use this group to add visible content or remove sensitive content. [add_text_to_pdf][pdfrest.PdfRestClient.add_text_to_pdf], [add_image_to_pdf][pdfrest.PdfRestClient.add_image_to_pdf], [add_shapes_to_pdf][pdfrest.PdfRestClient.add_shapes_to_pdf] + (`add_text_to_pdf` can tag newly added text and identify its natural language + for assistive technology) - Watermarking: [watermark_pdf_with_text][pdfrest.PdfRestClient.watermark_pdf_with_text], [watermark_pdf_with_image][pdfrest.PdfRestClient.watermark_pdf_with_image] diff --git a/examples/README.md b/examples/README.md index b51762c..a827916 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,8 @@ supported interpreter matrix. hosted TDM policy URL in an uploaded PDF. - `examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py` – enlarge a PDF's MediaBox and apply a CropBox inset with typed page selections and margins. +- `examples/add_text/add_tagged_text_to_pdf_example.py` – add an accessible text + overlay with tagging enabled and a BCP 47 language tag. - `examples/add_shapes/add_shapes_to_pdf_example.py` – add a styled rectangle and divider line to a PDF with accessibility tagging enabled. - `examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py` diff --git a/examples/add_text/add_tagged_text_to_pdf_example.py b/examples/add_text/add_tagged_text_to_pdf_example.py new file mode 100644 index 0000000..db20f90 --- /dev/null +++ b/examples/add_text/add_tagged_text_to_pdf_example.py @@ -0,0 +1,66 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Add tagged English text to a PDF for accessible reading and extraction. + +This sample demonstrates how to: + +1. Upload the bundled ``examples/resources/report.pdf`` resource. +2. Describe a text overlay with the typed ``PdfAddTextObject`` contract. +3. Enable tagging and identify the added text with a BCP 47 language tag. +4. Print metadata for the PDF returned by pdfRest. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/add_text/add_tagged_text_to_pdf_example.py``. The input PDF +is included in the repository, so no additional input files are required. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.types import PdfAddTextObject + +RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" + + +def add_tagged_text_to_report() -> None: + """Upload the sample report and add an accessible English heading.""" + load_dotenv() + heading = PdfAddTextObject( + font="Arial Bold", + max_width=468, + opacity=1, + page=1, + rotation=0, + text="Quarterly report", + text_color_rgb=(26, 72, 112), + text_size=24, + x=72, + y=684, + ) + + with PdfRestClient() as client: + uploaded = client.files.create_from_paths([RESOURCE])[0] + response = client.add_text_to_pdf( + uploaded, + text_objects=heading, + tag_enabled=True, + tag_language="en-US", + output="report-with-tagged-text", + ) + + output = response.output_file + print(f"Created {output.name}") + print(f"Output ID: {output.id}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + add_tagged_text_to_report() diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 3608d27..45b8fab 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3434,6 +3434,8 @@ def add_text_to_pdf( file: PdfRestFile | Sequence[PdfRestFile], *, text_objects: PdfAddTextObject | Sequence[PdfAddTextObject], + tag_enabled: bool | None = None, + tag_language: str | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3442,11 +3444,16 @@ def add_text_to_pdf( ) -> PdfRestFileBasedResponse: """Insert one or more text blocks into a PDF. - Places one or more text overlays onto a PDF with font, size, color, opacity, position, and rotation controls. This supports annotations, stamps, and templated labeling workflows. + Places one or more text overlays onto a PDF with font, size, color, + opacity, position, rotation, and accessibility tagging controls. Set + ``tag_enabled=True`` when supplying ``tag_language``. Args: file: Uploaded input file or files as `PdfRestFile` objects. text_objects: Text overlay objects to draw onto the document. + tag_enabled: Enable tagging for newly added text. + tag_language: BCP 47 language tag for newly added tagged text. + Requires ``tag_enabled=True``. output: Output filename prefix used by pdfRest when creating files. extra_query: Additional query parameters merged into the request. extra_headers: Additional HTTP headers merged into the request. @@ -3464,6 +3471,10 @@ def add_text_to_pdf( "files": file, "text_objects": text_objects, } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if tag_language is not None: + payload["tag_language"] = tag_language if output is not None: payload["output"] = output @@ -7101,6 +7112,8 @@ async def add_text_to_pdf( file: PdfRestFile | Sequence[PdfRestFile], *, text_objects: PdfAddTextObject | Sequence[PdfAddTextObject], + tag_enabled: bool | None = None, + tag_language: str | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -7109,11 +7122,16 @@ async def add_text_to_pdf( ) -> PdfRestFileBasedResponse: """Asynchronous variant of [PdfRestClient.add_text_to_pdf][pdfrest.PdfRestClient.add_text_to_pdf]. - Places one or more text overlays onto a PDF with font, size, color, opacity, position, and rotation controls. This supports annotations, stamps, and templated labeling workflows. + Places one or more text overlays onto a PDF with font, size, color, + opacity, position, rotation, and accessibility tagging controls. Set + ``tag_enabled=True`` when supplying ``tag_language``. Args: file: Uploaded input file or files as `PdfRestFile` objects. text_objects: Text overlay objects to draw onto the document. + tag_enabled: Enable tagging for newly added text. + tag_language: BCP 47 language tag for newly added tagged text. + Requires ``tag_enabled=True``. output: Output filename prefix used by pdfRest when creating files. extra_query: Additional query parameters merged into the request. extra_headers: Additional HTTP headers merged into the request. @@ -7131,6 +7149,10 @@ async def add_text_to_pdf( "files": file, "text_objects": text_objects, } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if tag_language is not None: + payload["tag_language"] = tag_language if output is not None: payload["output"] = output diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 83e1c1a..498e558 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -2934,6 +2934,16 @@ def _ensure_single_color_option(self) -> PdfAddTextObjectModel: return self +def _validate_tag_language(value: str) -> str: + if not re.fullmatch(r"[A-Za-z]{2,3}(?:-(?:[A-Za-z]{4}|[A-Za-z]{2}|\d{3}))?", value): + msg = "tag_language must be a valid BCP 47 language tag, such as en-US." + raise ValueError(msg) + return value + + +_TagLanguage = Annotated[str, AfterValidator(_validate_tag_language)] + + class PdfAddTextPayload(BaseModel): """Adapt caller options into a pdfRest-ready add-text request payload.""" @@ -2960,12 +2970,27 @@ class PdfAddTextPayload(BaseModel): BeforeValidator(_ensure_list), PlainSerializer(_serialize_text_objects), ] + tag_enabled: Annotated[ + bool | None, + Field(serialization_alias="tag_enabled", default=None), + ] = None + tag_language: Annotated[ + _TagLanguage | None, + Field(serialization_alias="tag_language", default=None), + ] = None output: Annotated[ str | None, Field(serialization_alias="output", min_length=1, default=None), AfterValidator(_validate_output_prefix), ] = None + @model_validator(mode="after") + def _require_tagging_for_language(self) -> PdfAddTextPayload: + if self.tag_language is not None and self.tag_enabled is not True: + msg = "tag_enabled must be true when tag options are provided." + raise ValueError(msg) + return self + class _PdfAddedShapeBaseModel(BaseModel): """Shared validation and serialization for shapes added to PDFs.""" diff --git a/tests/live/test_live_add_text_to_pdf.py b/tests/live/test_live_add_text_to_pdf.py index 52f2b47..22767c9 100644 --- a/tests/live/test_live_add_text_to_pdf.py +++ b/tests/live/test_live_add_text_to_pdf.py @@ -72,8 +72,11 @@ def test_live_add_text_to_pdf( response = client.add_text_to_pdf( uploaded_pdf_for_text, text_objects=[_default_text_object()], + tag_enabled=True, + tag_language="en-US", output="live-added-text", ) + tagged_info = client.query_pdf_info(response.output_file, queries="tagged") assert response.output_files output_file = response.output_file @@ -82,6 +85,7 @@ def test_live_add_text_to_pdf( assert output_file.size > 0 assert response.warning is None assert uploaded_pdf_for_text.id in response.input_ids + assert tagged_info.tagged is True @pytest.mark.asyncio @@ -97,6 +101,12 @@ async def test_live_async_add_text_to_pdf( response = await client.add_text_to_pdf( uploaded_pdf_for_text, text_objects=[_default_text_object()], + tag_enabled=True, + tag_language="en-US", + ) + tagged_info = await client.query_pdf_info( + response.output_file, + queries="tagged", ) assert response.output_files @@ -105,6 +115,7 @@ async def test_live_async_add_text_to_pdf( assert output_file.size > 0 assert response.warning is None assert uploaded_pdf_for_text.id in response.input_ids + assert tagged_info.tagged is True def test_live_add_text_to_pdf_invalid_page( diff --git a/tests/test_add_text_to_pdf.py b/tests/test_add_text_to_pdf.py index 9731354..9905e7b 100644 --- a/tests/test_add_text_to_pdf.py +++ b/tests/test_add_text_to_pdf.py @@ -10,6 +10,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfAddTextPayload from .graphics_test_helpers import ( ASYNC_API_KEY, @@ -62,6 +63,97 @@ def _serialize_text_object_for_request( return serialized +@pytest.mark.parametrize( + ("tag_language", "expected_language"), + [ + pytest.param("en", "en", id="language"), + pytest.param("EN-us", "EN-us", id="region"), + pytest.param("zh-hant", "zh-hant", id="script"), + pytest.param("es-419", "es-419", id="numeric-region"), + pytest.param("eng-US", "eng-US", id="alpha3-language"), + pytest.param("zz", "zz", id="service-compatible-language"), + pytest.param("en-AB", "en-AB", id="service-compatible-region"), + ], +) +def test_add_text_payload_serializes_tag_controls( + tag_language: str, + expected_language: str, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + text_object = make_text_object() + + payload = PdfAddTextPayload.model_validate( + { + "files": pdf_file, + "text_objects": text_object, + "tag_enabled": True, + "tag_language": tag_language, + "output": "tagged-text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert payload == { + "id": str(pdf_file.id), + "text_objects": to_json( + [_serialize_text_object_for_request(text_object)] + ).decode(), + "tag_enabled": True, + "tag_language": expected_language, + "output": "tagged-text", + } + + +def test_add_text_payload_omits_unspecified_tag_controls() -> None: + payload = PdfAddTextPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "text_objects": make_text_object(), + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert "tag_enabled" not in payload + assert "tag_language" not in payload + + +def test_add_text_payload_omits_explicit_none_tag_controls() -> None: + payload = PdfAddTextPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "text_objects": make_text_object(), + "tag_enabled": None, + "tag_language": None, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert "tag_enabled" not in payload + assert "tag_language" not in payload + + +@pytest.mark.parametrize( + "tag_language", + [ + pytest.param("", id="empty"), + pytest.param("e", id="short-language"), + pytest.param("english", id="long-language"), + pytest.param("en_US", id="underscore"), + pytest.param("en-Latn-US", id="multiple-subtags"), + ], +) +def test_add_text_payload_rejects_invalid_tag_language(tag_language: str) -> None: + with pytest.raises( + ValidationError, + match="tag_language must be a valid BCP 47 language tag", + ): + PdfAddTextPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "text_objects": make_text_object(), + "tag_enabled": True, + "tag_language": tag_language, + } + ) + + def test_add_text_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) pdf_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -73,18 +165,17 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/pdf-with-added-text": seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) - assert payload["id"] == str(pdf_file.id) - assert payload["output"] == "with-text" - assert ( - payload["text_objects"] - == to_json( + assert payload == { + "id": str(pdf_file.id), + "text_objects": to_json( [ _serialize_text_object_for_request( make_text_object(is_right_to_left=True) ) ] - ).decode() - ) + ).decode(), + "output": "with-text", + } return httpx.Response( 200, json={ @@ -136,6 +227,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.headers["X-Debug"] == "1" payload = json.loads(request.content.decode("utf-8")) assert payload["rotation"] == 15 + assert payload["tag_enabled"] is True + assert payload["tag_language"] == "es-419" assert ( payload["text_objects"] == to_json( @@ -169,6 +262,8 @@ def handler(request: httpx.Request) -> httpx.Response: response = client.add_text_to_pdf( pdf_file, text_objects=[make_text_object(rotation=15)], + tag_enabled=True, + tag_language="es-419", extra_query={"trace": "true"}, extra_headers={"X-Debug": "1"}, extra_body={ @@ -192,6 +287,57 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.25) +def test_add_text_to_pdf_requires_tagging_for_language( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=re.escape("tag_enabled must be true when tag options are provided."), + ), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=make_text_object(), + tag_language="en-US", + ) + + +def test_add_text_to_pdf_rejects_invalid_tag_language( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match="tag_language must be a valid BCP 47 language tag", + ), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=make_text_object(), + tag_enabled=True, + tag_language="en-Latn-US", + ) + + def test_add_text_to_pdf_requires_color(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -348,18 +494,17 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/pdf-with-added-text": seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) - assert payload["id"] == str(pdf_file.id) - assert payload["output"] == "async-with-text" - assert ( - payload["text_objects"] - == to_json( + assert payload == { + "id": str(pdf_file.id), + "text_objects": to_json( [ _serialize_text_object_for_request( make_text_object(page="all", is_right_to_left=True) ) ] - ).decode() - ) + ).decode(), + "output": "async-with-text", + } return httpx.Response( 200, json={ @@ -408,6 +553,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.headers["X-Test"] == "async" payload = json.loads(request.content.decode("utf-8")) assert payload["text_size"] == 18 + assert payload["tag_enabled"] is True + assert payload["tag_language"] == "es-419" assert ( payload["text_objects"] == to_json( @@ -441,6 +588,8 @@ def handler(request: httpx.Request) -> httpx.Response: response = await client.add_text_to_pdf( pdf_file, text_objects=[make_text_object(text_size=18)], + tag_enabled=True, + tag_language="es-419", extra_query={"trace": "true"}, extra_headers={"X-Test": "async"}, extra_body={ @@ -463,6 +612,56 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(1.0) +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_requires_tagging_for_language( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=re.escape("tag_enabled must be true when tag options are provided."), + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=make_text_object(), + tag_enabled=False, + tag_language="en-US", + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_rejects_invalid_tag_language( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match="tag_language must be a valid BCP 47 language tag", + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=make_text_object(), + tag_enabled=True, + tag_language="en_US", + ) + + @pytest.mark.asyncio async def test_async_add_text_to_pdf_invalid_cmyk_range( monkeypatch: pytest.MonkeyPatch,