diff --git a/docs/api-guide.md b/docs/api-guide.md index 2a59449..6e459ef 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -58,7 +58,10 @@ Use this group when users need metadata or text intelligence. [translate_pdf_text][pdfrest.PdfRestClient.translate_pdf_text], [translate_pdf_text_to_file][pdfrest.PdfRestClient.translate_pdf_text_to_file] - Markdown output: - [convert_to_markdown][pdfrest.PdfRestClient.convert_to_markdown] + [convert_to_markdown_inline][pdfrest.PdfRestClient.convert_to_markdown_inline] + returns generated Markdown directly, while + [convert_to_markdown][pdfrest.PdfRestClient.convert_to_markdown] returns a + downloadable Markdown file ## Compose, split, merge, and package diff --git a/examples/README.md b/examples/README.md index a4f56f2..e35251d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,8 @@ supported interpreter matrix. - `examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py` – convert Markdown, plain text, JSON, XML, and CSV documents to PDF with format-specific options. +- `examples/convert_to_markdown/convert_pdf_to_markdown_example.py` – convert a + PDF to inline Markdown and a downloadable Markdown file. - `examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py` – convert PostScript to PDF with an uploaded custom `.joboptions` profile. - `examples/convert_to_image/convert_password_protected_pdf_to_png_example.py` – diff --git a/examples/convert_to_markdown/convert_pdf_to_markdown_example.py b/examples/convert_to_markdown/convert_pdf_to_markdown_example.py new file mode 100644 index 0000000..3f054f6 --- /dev/null +++ b/examples/convert_to_markdown/convert_pdf_to_markdown_example.py @@ -0,0 +1,38 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert a PDF to inline Markdown and a downloadable Markdown file. + +This example loads ``PDFREST_API_KEY`` (optionally from ``.env``), uploads the +checked-in ``examples/resources/report.pdf`` fixture, requests inline Markdown, +then requests the file-output workflow and prints both results. + +Run from the repository root with: +``uv run examples/convert_to_markdown/convert_pdf_to_markdown_example.py``. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient + +load_dotenv() + +resource = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" + +with PdfRestClient(api_key=os.environ["PDFREST_API_KEY"]) as client: + source_pdf = client.files.create_from_paths([resource])[0] + inline_response = client.convert_to_markdown_inline(source_pdf) + file_response = client.convert_to_markdown( + source_pdf, + output="report-markdown", + ) + +print("Inline Markdown preview:") +print(inline_response.markdown[:500]) +print(f"Created file: {file_response.output_file.name}") +print(f"MIME type: {file_response.output_file.type}") +print(f"URL: {file_response.output_file.url}") diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index dd1e6cb..2ea5083 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -61,6 +61,7 @@ translate_httpx_error, ) from .models import ( + ConvertToMarkdownResponse, ExtractedTextDocument, PdfRestDeletionResponse, PdfRestErrorResponse, @@ -87,6 +88,7 @@ ConvertPdfToPostscriptPayload, ConvertPlainTextToPdfPayload, ConvertPostscriptToPdfPayload, + ConvertToMarkdownInlinePayload, ConvertToMarkdownPayload, ConvertUrlToPdfPayload, ConvertXmlToPdfPayload, @@ -2957,7 +2959,9 @@ def convert_to_markdown( ) -> PdfRestFileBasedResponse: """Convert a PDF to Markdown and return a file-based response. - Converts document content into Markdown and supports either inline text output or file output. This is useful when preparing PDFs for LLM/RAG indexing and markdown-centric tooling. + Converts document content into a downloadable Markdown file. Use + [convert_to_markdown_inline][pdfrest.PdfRestClient.convert_to_markdown_inline] + when the generated Markdown should be returned directly in the response. Args: file: Uploaded input file or files as `PdfRestFile` objects. @@ -2996,6 +3000,59 @@ def convert_to_markdown( timeout=timeout, ) + def convert_to_markdown_inline( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | None = None, + page_break_comments: bool = False, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> ConvertToMarkdownResponse: + """Convert a PDF to Markdown and return the content inline. + + Args: + file: Uploaded PDF as a `PdfRestFile` object. + pages: Page selection to constrain processing to specific pages. + page_break_comments: When true, inserts page-break marker comments + between source pages. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Generated Markdown and the input resource ID. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "output_type": "json", + "page_break_comments": page_break_comments, + } + if pages is not None: + payload["pages"] = pages + + validated_payload = ConvertToMarkdownInlinePayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/markdown", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return ConvertToMarkdownResponse.model_validate(raw_payload) + def ocr_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -6729,7 +6786,9 @@ async def convert_to_markdown( ) -> PdfRestFileBasedResponse: """Asynchronous variant of [PdfRestClient.convert_to_markdown][pdfrest.PdfRestClient.convert_to_markdown]. - Converts document content into Markdown and supports either inline text output or file output. This is useful when preparing PDFs for LLM/RAG indexing and markdown-centric tooling. + Converts document content into a downloadable Markdown file. Use + [convert_to_markdown_inline][pdfrest.AsyncPdfRestClient.convert_to_markdown_inline] + when the generated Markdown should be returned directly in the response. Args: file: Uploaded input file or files as `PdfRestFile` objects. @@ -6768,6 +6827,46 @@ async def convert_to_markdown( timeout=timeout, ) + async def convert_to_markdown_inline( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | None = None, + page_break_comments: bool = False, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> ConvertToMarkdownResponse: + """Asynchronously convert a PDF to Markdown returned inline. + + See + [PdfRestClient.convert_to_markdown_inline][pdfrest.PdfRestClient.convert_to_markdown_inline] + for argument and response details. + """ + payload: dict[str, Any] = { + "files": file, + "output_type": "json", + "page_break_comments": page_break_comments, + } + if pages is not None: + payload["pages"] = pages + + validated_payload = ConvertToMarkdownInlinePayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/markdown", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return ConvertToMarkdownResponse.model_validate(raw_payload) + async def ocr_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 62ab2fc..482512b 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,6 +1,7 @@ """Public model exports for the `pdfrest.models` package.""" from .public import ( + ConvertToMarkdownResponse, ExtractedTextDocument, ExtractedTextFullText, ExtractedTextFullTextPage, @@ -27,6 +28,7 @@ ) __all__ = [ + "ConvertToMarkdownResponse", "ExtractTextResponse", "ExtractedTextDocument", "ExtractedTextFullText", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 0d0f834..c6307a2 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -877,8 +877,8 @@ class ExtractTextPayload(BaseModel): ] = None -class ConvertToMarkdownPayload(BaseModel): - """Adapt caller options into a pdfRest-ready markdown conversion payload.""" +class _BaseConvertToMarkdownPayload(BaseModel): + """Adapt shared caller options into a pdfRest-ready Markdown payload.""" files: Annotated[ list[PdfRestFile], @@ -902,14 +902,27 @@ class ConvertToMarkdownPayload(BaseModel): BeforeValidator(_int_to_string), PlainSerializer(_serialize_page_ranges), ] = None - output_type: Annotated[ - SummaryOutputType, Field(serialization_alias="output_type", default="json") - ] = "json" page_break_comments: Annotated[ Literal["on", "off"] | None, Field(serialization_alias="page_break_comments", default=None), BeforeValidator(_bool_to_on_off), ] = None + + +class ConvertToMarkdownInlinePayload(_BaseConvertToMarkdownPayload): + """Validate a PDF-to-Markdown request that returns inline content.""" + + output_type: Annotated[ + Literal["json"], Field(serialization_alias="output_type", default="json") + ] = "json" + + +class ConvertToMarkdownPayload(_BaseConvertToMarkdownPayload): + """Validate a PDF-to-Markdown request that returns a file.""" + + output_type: Annotated[ + Literal["file"], Field(serialization_alias="output_type", default="file") + ] = "file" output: Annotated[ str | None, Field(serialization_alias="output", min_length=1, default=None), diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 5fbc33b..326e6b0 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -28,6 +28,7 @@ ) __all__ = ( + "ConvertToMarkdownResponse", "ExtractTextResponse", "ExtractedTextDocument", "ExtractedTextFullText", @@ -866,18 +867,15 @@ class ExtractedTextDocument(BaseModel): class ConvertToMarkdownResponse(BaseModel): - """Response returned by the markdown conversion tool.""" + """Inline Markdown returned by PDF-to-Markdown conversion.""" model_config = ConfigDict(extra="allow") markdown: Annotated[ - str | None, - Field( - description="Inline markdown content when output_type is json.", - default=None, - ), - ] = None - """Inline markdown content when output_type is json.""" + str, + Field(description="Markdown content generated from the input PDF."), + ] + """Markdown content generated from the input PDF.""" input_id: Annotated[ PdfRestFileID, @@ -888,34 +886,6 @@ class ConvertToMarkdownResponse(BaseModel): ] """The id of the input file.""" - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - """Download URL for file output.""" - - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output when output_type is file.", - default=None, - ), - ] = None - """The id of the generated output when output_type is file.""" - - warning: Annotated[ - str | None, - Field(description="A warning that was generated during markdown conversion."), - ] = None - """A warning that was generated during markdown conversion.""" - class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index 760e179..fb5dff9 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -3,7 +3,7 @@ import pytest from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient -from pdfrest.models import PdfRestFileBasedResponse +from pdfrest.models import ConvertToMarkdownResponse, PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -53,6 +53,98 @@ async def test_live_async_convert_to_markdown_success( assert response.input_id == uploaded.id +def test_live_convert_to_markdown_inline( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("20-pages.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.convert_to_markdown_inline( + uploaded, + pages=[1, 2], + page_break_comments=True, + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert [ + line.strip() for line in response.markdown.splitlines() if line.strip() + ] == [ + "", + "1", + "", + "2", + ] + assert response.input_id == uploaded.id + + +@pytest.mark.asyncio +async def test_live_async_convert_to_markdown_inline( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("20-pages.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.convert_to_markdown_inline( + uploaded, + pages=[1, 2], + page_break_comments=True, + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert [ + line.strip() for line in response.markdown.splitlines() if line.strip() + ] == [ + "", + "1", + "", + "2", + ] + assert response.input_id == uploaded.id + + +def test_live_convert_to_markdown_inline_invalid_page_break_comments( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError, match=r"(?i)page break comments"): + client.convert_to_markdown_inline( + uploaded, + extra_body={"page_break_comments": "maybe"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_markdown_inline_invalid_page_break_comments( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError, match=r"(?i)page break comments"): + await client.convert_to_markdown_inline( + uploaded, + extra_body={"page_break_comments": "maybe"}, + ) + + def test_live_convert_to_markdown_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py index e9acd80..471bfb5 100644 --- a/tests/test_convert_to_markdown.py +++ b/tests/test_convert_to_markdown.py @@ -8,13 +8,22 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import ( + ConvertToMarkdownResponse, PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID, ) -from pdfrest.models._internal import ConvertToMarkdownPayload +from pdfrest.models._internal import ( + ConvertToMarkdownInlinePayload, + ConvertToMarkdownPayload, +) -from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) def _make_markdown_file(file_id: str, name: str = "markdown.md") -> PdfRestFile: @@ -66,6 +75,37 @@ def test_convert_to_markdown_payload_invalid_page_break_comments() -> None: ) +def test_convert_to_markdown_inline_payload_serializes_exact_request() -> None: + input_file = make_pdf_file(PdfRestFileID.generate(1)) + + payload = ConvertToMarkdownInlinePayload.model_validate( + { + "files": input_file, + "pages": [1, "3-last"], + "page_break_comments": True, + "output_type": "json", + } + ) + + assert payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) == { + "id": str(input_file.id), + "pages": "1,3-last", + "page_break_comments": "on", + "output_type": "json", + } + + +def test_convert_to_markdown_inline_payload_rejects_file_output() -> None: + input_file = make_pdf_file(PdfRestFileID.generate(1)) + + with pytest.raises(ValidationError, match="Input should be 'json'"): + ConvertToMarkdownInlinePayload.model_validate( + {"files": input_file, "output_type": "file"} + ) + + @pytest.mark.asyncio async def test_async_convert_to_markdown_payload_rejects_non_pdf() -> None: file_id = str(PdfRestFileID.generate()) @@ -408,3 +448,194 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1, "get": 1} assert isinstance(response, PdfRestFileBasedResponse) assert len(response.output_files) == 1 + + +def test_convert_to_markdown_inline_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + markdown = "# Simple Report\n\nInline Markdown content." + expected_payload = ConvertToMarkdownInlinePayload.model_validate( + { + "files": input_file, + "pages": ["1-last"], + "page_break_comments": True, + "output_type": "json", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/markdown" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "inline-sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == {**expected_payload, "debug": True} + return httpx.Response( + 200, + json={"markdown": markdown, "inputId": str(input_file.id)}, + ) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_markdown_inline( + input_file, + pages=["1-last"], + page_break_comments=True, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "inline-sync"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert response.markdown == markdown + assert response.input_id == input_file.id + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_inline_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + markdown = "# Simple Report\n\nAsync inline Markdown content." + expected_payload = ConvertToMarkdownInlinePayload.model_validate( + { + "files": input_file, + "pages": ["1-last"], + "page_break_comments": False, + "output_type": "json", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/markdown" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "inline-async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == {**expected_payload, "debug": True} + return httpx.Response( + 200, + json={"markdown": markdown, "inputId": str(input_file.id)}, + ) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_markdown_inline( + input_file, + pages=["1-last"], + page_break_comments=False, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "inline-async"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert response.markdown == markdown + assert response.input_id == input_file.id + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +def test_convert_to_markdown_inline_rejects_multiple_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport( + lambda request: pytest.fail( + f"Unexpected request {request.method} {request.url}" + ) + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="List should have at most 1 item"), + ): + client.convert_to_markdown_inline([input_file, input_file]) + + +def test_convert_to_markdown_inline_rejects_non_pdf( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(1), + "example.png", + "image/png", + ) + ) + transport = httpx.MockTransport( + lambda request: pytest.fail( + f"Unexpected request {request.method} {request.url}" + ) + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.convert_to_markdown_inline(png_file) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_inline_rejects_multiple_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + transport = httpx.MockTransport( + lambda request: pytest.fail( + f"Unexpected request {request.method} {request.url}" + ) + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="List should have at most 1 item"): + await client.convert_to_markdown_inline([input_file, input_file]) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_inline_rejects_non_pdf( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(2), + "example.png", + "image/png", + ) + ) + transport = httpx.MockTransport( + lambda request: pytest.fail( + f"Unexpected request {request.method} {request.url}" + ) + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.convert_to_markdown_inline(png_file)