From 8fb382e6656a8ac7ce6693c60ad25e72f3af03c2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:06:38 -0400 Subject: [PATCH 1/6] [mpxpy] Automate PyPI releases; add convert_to_xlsx and include_hyperlinks Add .github/workflows/release.yml: pushing a vX.Y.Z tag from master builds the sdist/wheel, runs the offline unit tests, publishes to PyPI via trusted publishing, and creates a published (non-draft) GitHub release. Guards reject a tag that is not semver, not on master, out of sync with the pyproject version, or not newer than the last release. Add convert_to_xlsx to pdf_new and conversion_new (and to_xlsx_file/ to_xlsx_bytes on Pdf and Conversion), and include_hyperlinks to pdf_new. Bump version to 0.0.23. Triggered by user request to automate mpxpy PyPI releases via GH Actions and add missing API options. --- .github/workflows/release.yml | 84 ++++++++++++++++++++++++++++++ changelog.md | 6 +++ mpxpy/conversion.py | 31 ++++++++++- mpxpy/mathpix_client.py | 18 ++++++- mpxpy/pdf.py | 28 ++++++++++ pyproject.toml | 2 +- tests/test_request_options_unit.py | 18 +++++++ 7 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6140aa0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: release + +# Cut a release by pushing a semver tag from master: +# git tag -a v0.0.23 -m "mpxpy v0.0.23" && git push origin v0.0.23 +# The guards below refuse a tag that is malformed, not on master, out of sync with the +# pyproject.toml version, or not newer than the last one, so a mistaken tag fails before +# anything is published. On success it uploads the sdist and wheel to PyPI and creates a +# published (non-draft) GitHub release. +# +# One-time setup: add this repo as a Trusted Publisher for the mpxpy project on PyPI +# (pypi.org -> the mpxpy project -> Settings -> Publishing -> add a GitHub publisher: +# owner Mathpix, repo mpxpy, workflow release.yml, environment pypi). Trusted publishing +# mints a short-lived OIDC token at publish time, so there is no PyPI token to store. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate the tag + run: | + tag="${GITHUB_REF_NAME}" + echo "Releasing tag: $tag" + if ! echo "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Tag '$tag' is not vX.Y.Z (optionally -prerelease). Not releasing." + exit 1 + fi + if ! git branch -r --contains "$tag" | grep -q 'origin/master'; then + echo "::error::Tag '$tag' is not on master. Release only from master." + exit 1 + fi + version=$(grep -E '^version *= *"' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') + if [ "v$version" != "$tag" ]; then + echo "::error::Tag '$tag' does not match pyproject.toml version '$version'. Bump the version to match." + exit 1 + fi + prev=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^${tag}$" | head -1) + if [ -n "$prev" ]; then + newest=$(printf '%s\n%s\n' "$prev" "$tag" | sort -V | tail -1) + if [ "$newest" != "$tag" ]; then + echo "::error::Tag '$tag' is not newer than the last release '$prev'. Tags must go in order." + exit 1 + fi + echo "Previous release: $prev" + fi + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install + run: | + python -m pip install --upgrade pip build twine + pip install -e ".[dev]" + + - name: Test + run: python -m pytest tests/test_request_options_unit.py tests/test_files_api_unit.py tests/test_webhooks.py + + - name: Build + run: | + rm -rf dist build ./*.egg-info + python -m build + python -m twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes diff --git a/changelog.md b/changelog.md index c310a65..55d2aff 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # mpxpy changelog +## September 22, 2026 + +- Automate PyPI releases. Pushing a `vX.Y.Z` tag from master runs `.github/workflows/release.yml`, which builds the sdist and wheel, publishes to PyPI, and creates a published (non-draft) GitHub release. The tag must be valid semver, sit on master, match the `pyproject.toml` version, and be newer than the last release, or the run fails before anything is published. +- Add `convert_to_xlsx` to `pdf_new` and `conversion_new`, plus `to_xlsx_file`/`to_xlsx_bytes` on `Pdf` and `Conversion`, giving xlsx the same convenience flag as the other formats +- Add `include_hyperlinks` to `pdf_new` to extract PDF link annotations into per-line `links` data + ## August 18, 2026 - Add webhooks support diff --git a/mpxpy/conversion.py b/mpxpy/conversion.py index e11dfad..8564959 100644 --- a/mpxpy/conversion.py +++ b/mpxpy/conversion.py @@ -27,6 +27,7 @@ class Conversion: convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx """ def __init__( self, @@ -42,6 +43,7 @@ def __init__( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, request_options: Optional[Dict[str, Any]] = None, ): """Initialize a Conversion instance. @@ -59,6 +61,7 @@ def __init__( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx Raises: ValidationError: If auth is not provided or conversion_id is empty. @@ -81,6 +84,7 @@ def __init__( self.convert_to_mmd_zip = convert_to_mmd_zip self.convert_to_pptx = convert_to_pptx self.convert_to_html_zip = convert_to_html_zip + self.convert_to_xlsx = convert_to_xlsx self.request_options = request_options or {} def wait_until_complete(self, timeout: int=60): @@ -476,4 +480,29 @@ def to_html_zip_bytes(self) -> bytes: Raises: ConversionIncompleteError: If the conversion is not complete """ - return self.bytes_result(conversion_format='html.zip') \ No newline at end of file + return self.bytes_result(conversion_format='html.zip') + + def to_xlsx_file(self, path: str) -> str: + """Save the processed conversion result to an XLSX file. + + Args: + path: The local file path where the XLSX output will be saved + + Returns: + output_path: The path of the saved XLSX file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='xlsx') + + def to_xlsx_bytes(self) -> bytes: + """Get the processed conversion result in XLSX format as bytes. + + Returns: + bytes: The binary content of the XLSX result + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='xlsx') \ No newline at end of file diff --git a/mpxpy/mathpix_client.py b/mpxpy/mathpix_client.py index 0a1bc83..d08caf1 100644 --- a/mpxpy/mathpix_client.py +++ b/mpxpy/mathpix_client.py @@ -160,7 +160,8 @@ def _reject_reserved_extra_options( 'remove_section_numbering', 'preserve_section_numbering', 'enable_tables_fallback', 'fullwidth_punctuation', 'conversion_formats', 'file_batch_id', 'callback_url', 'callback_headers', 'callback_events', 'disable_itemize', - 'disable_lstlisting', 'include_page_info', 'include_page_breaks', 'conversion_options', + 'disable_lstlisting', 'include_page_info', 'include_page_breaks', 'include_hyperlinks', + 'conversion_options', } _CONVERSION_REQUEST_OPTION_KEYS: Set[str] = {'mmd', 'formats', 'conversion_options'} @@ -450,6 +451,7 @@ def pdf_new( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, improve_mathpix: Optional[bool] = True, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -459,6 +461,7 @@ def pdf_new( disable_lstlisting: Optional[bool] = None, include_page_info: Optional[bool] = None, include_page_breaks: Optional[bool] = None, + include_hyperlinks: Optional[bool] = None, conversion_options: Optional[Dict[str, Any]] = None, extra_options: Optional[Dict[str, Any]] = None, ) -> Pdf: @@ -496,6 +499,7 @@ def pdf_new( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. callback_url: Optional HTTPS URL to receive this request's webhook deliveries. A request without one is not notified at all @@ -505,6 +509,7 @@ def pdf_new( disable_lstlisting: Optional boolean to disable the lstlisting environment for code blocks include_page_info: Optional boolean to include page info in the output include_page_breaks: Optional boolean to include page break markers in the output + include_hyperlinks: Optional boolean to extract PDF link annotations and attach them to line data (adds a per-line "links" field) conversion_options: Optional dict of per-format conversion options (see https://docs.mathpix.com/reference/shared-types#conversion-options) extra_options: Optional dict of unmodeled request options. Modeled request fields are rejected; values are validated server-side @@ -588,6 +593,8 @@ def pdf_new( options["include_page_info"] = include_page_info if include_page_breaks is not None: options["include_page_breaks"] = include_page_breaks + if include_hyperlinks is not None: + options["include_hyperlinks"] = include_hyperlinks if conversion_options is not None: options["conversion_options"] = conversion_options if file_batch_id: @@ -613,6 +620,8 @@ def pdf_new( options["conversion_formats"]['mmd.zip'] = True if convert_to_html_zip: options["conversion_formats"]['html.zip'] = True + if convert_to_xlsx: + options["conversion_formats"]['xlsx'] = True if extra_options: options.update(extra_options) data = { @@ -647,6 +656,7 @@ def pdf_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, @@ -682,6 +692,7 @@ def pdf_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, @@ -765,6 +776,7 @@ def conversion_new( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, conversion_options: Optional[Dict[str, Any]] = None, extra_options: Optional[Dict[str, Any]] = None, ): @@ -782,6 +794,7 @@ def conversion_new( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx conversion_options: Optional dict of per-format conversion options (see https://docs.mathpix.com/reference/shared-types#conversion-options) extra_options: Optional dict of unmodeled request options. Modeled request fields are rejected; values are validated server-side @@ -818,6 +831,8 @@ def conversion_new( options["formats"]['mmd.zip'] = True if convert_to_html_zip: options["formats"]['html.zip'] = True + if convert_to_xlsx: + options["formats"]['xlsx'] = True if conversion_options is not None: options["conversion_options"] = conversion_options if extra_options: @@ -847,6 +862,7 @@ def conversion_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, request_options=self.request_options, ) except Exception as e: diff --git a/mpxpy/pdf.py b/mpxpy/pdf.py index 97d5bd1..8d30b72 100644 --- a/mpxpy/pdf.py +++ b/mpxpy/pdf.py @@ -30,6 +30,7 @@ class Pdf: convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. (Not yet enabled) callback_url: Optional HTTPS URL to receive this request's webhook deliveries. @@ -52,6 +53,7 @@ def __init__( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, improve_mathpix: Optional[bool] = False, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -105,6 +107,7 @@ def __init__( self.convert_to_mmd_zip = convert_to_mmd_zip self.convert_to_pptx = convert_to_pptx self.convert_to_html_zip = convert_to_html_zip + self.convert_to_xlsx = convert_to_xlsx self.improve_mathpix=improve_mathpix self.file_batch_id = file_batch_id self.callback_url = callback_url @@ -591,3 +594,28 @@ def to_html_zip_bytes(self) -> bytes: ConversionIncompleteError: If the conversion is not complete """ return self.bytes_result(conversion_format='html.zip') + + def to_xlsx_file(self, path: str) -> str: + """Save the processed conversion result to an XLSX file. + + Args: + path: The local file path where the XLSX output will be saved + + Returns: + output_path: The path of the saved XLSX file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='xlsx') + + def to_xlsx_bytes(self) -> bytes: + """Get the processed conversion result in XLSX format as bytes. + + Returns: + bytes: The binary content of the XLSX result + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='xlsx') diff --git a/pyproject.toml b/pyproject.toml index c4201bc..d42f6d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "mpxpy" -version = "0.0.22" +version = "0.0.23" description = "Official Mathpix client for Python" readme = "README.md" requires-python = ">=3.8" diff --git a/tests/test_request_options_unit.py b/tests/test_request_options_unit.py index 1295ad0..efc03c6 100644 --- a/tests/test_request_options_unit.py +++ b/tests/test_request_options_unit.py @@ -110,3 +110,21 @@ def test_conversion_new_rejects_modeled_extra_options(client: MathpixClient) -> convert_to_docx=True, extra_options={key: 'override'}, ) + + +def test_pdf_new_sends_convert_to_xlsx_and_include_hyperlinks(client: MathpixClient, tmp_path) -> None: + pdf_path = tmp_path / 'document.pdf' + pdf_path.write_bytes(b'%PDF-1.4') + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'pdf_id': 'pdf-1'} + client.pdf_new(file_path=str(pdf_path), convert_to_xlsx=True, include_hyperlinks=True) + options = json.loads(mock_post.call_args.kwargs['data']['options_json']) + assert options['include_hyperlinks'] is True + assert options['conversion_formats']['xlsx'] is True + + +def test_conversion_new_sends_convert_to_xlsx(client: MathpixClient) -> None: + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'conversion_id': 'conversion-1'} + client.conversion_new(mmd='# Document', convert_to_xlsx=True) + assert mock_post.call_args.kwargs['json']['formats'] == {'xlsx': True} From 09366e9609738c9b1277c8e6ada8f80431e49554 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:07:49 -0400 Subject: [PATCH 2/6] [mpxpy] Remove comments from release workflow --- .github/workflows/release.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6140aa0..2f79bec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,5 @@ name: release -# Cut a release by pushing a semver tag from master: -# git tag -a v0.0.23 -m "mpxpy v0.0.23" && git push origin v0.0.23 -# The guards below refuse a tag that is malformed, not on master, out of sync with the -# pyproject.toml version, or not newer than the last one, so a mistaken tag fails before -# anything is published. On success it uploads the sdist and wheel to PyPI and creates a -# published (non-draft) GitHub release. -# -# One-time setup: add this repo as a Trusted Publisher for the mpxpy project on PyPI -# (pypi.org -> the mpxpy project -> Settings -> Publishing -> add a GitHub publisher: -# owner Mathpix, repo mpxpy, workflow release.yml, environment pypi). Trusted publishing -# mints a short-lived OIDC token at publish time, so there is no PyPI token to store. - on: push: tags: From 28f110d4f8f1e6362ef2a12485ad6a7e11deccb8 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:10:22 -0400 Subject: [PATCH 3/6] [mpxpy] Publish with PYPI_API_TOKEN secret instead of trusted publishing --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f79bec..7c94335 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,10 @@ on: permissions: contents: write - id-token: write jobs: release: runs-on: ubuntu-latest - environment: pypi steps: - uses: actions/checkout@v4 with: @@ -65,6 +63,8 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} - name: Create GitHub release env: From 3c8f8c94b88cc11630cbc65d7f2fc4f0cfbae617 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:15:38 -0400 Subject: [PATCH 4/6] [mpxpy] Derive version from git tag via setuptools-scm --- .github/workflows/release.yml | 5 ----- changelog.md | 2 +- pyproject.toml | 6 ++++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c94335..436187b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,6 @@ jobs: echo "::error::Tag '$tag' is not on master. Release only from master." exit 1 fi - version=$(grep -E '^version *= *"' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - if [ "v$version" != "$tag" ]; then - echo "::error::Tag '$tag' does not match pyproject.toml version '$version'. Bump the version to match." - exit 1 - fi prev=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^${tag}$" | head -1) if [ -n "$prev" ]; then newest=$(printf '%s\n%s\n' "$prev" "$tag" | sort -V | tail -1) diff --git a/changelog.md b/changelog.md index 55d2aff..58bd785 100644 --- a/changelog.md +++ b/changelog.md @@ -2,7 +2,7 @@ ## September 22, 2026 -- Automate PyPI releases. Pushing a `vX.Y.Z` tag from master runs `.github/workflows/release.yml`, which builds the sdist and wheel, publishes to PyPI, and creates a published (non-draft) GitHub release. The tag must be valid semver, sit on master, match the `pyproject.toml` version, and be newer than the last release, or the run fails before anything is published. +- Automate PyPI releases. The package version is now derived from the git tag via setuptools-scm, so there is no version to bump by hand. Pushing a `vX.Y.Z` tag from master runs `.github/workflows/release.yml`, which builds the sdist and wheel, publishes to PyPI, and creates a published (non-draft) GitHub release. The tag must be valid semver, sit on master, and be newer than the last release, or the run fails before anything is published. - Add `convert_to_xlsx` to `pdf_new` and `conversion_new`, plus `to_xlsx_file`/`to_xlsx_bytes` on `Pdf` and `Conversion`, giving xlsx the same convenience flag as the other formats - Add `include_hyperlinks` to `pdf_new` to extract PDF link annotations into per-line `links` data diff --git a/pyproject.toml b/pyproject.toml index d42f6d7..8521f45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ # pyproject.toml [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=61.0", "wheel", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "mpxpy" -version = "0.0.23" +dynamic = ["version"] description = "Official Mathpix client for Python" readme = "README.md" requires-python = ">=3.8" @@ -42,6 +42,8 @@ Repository = "https://github.com/Mathpix/mpxpy" "Bug Tracker" = "https://github.com/Mathpix/mpxpy/issues" [tool.setuptools.packages.find] exclude = ["tests*"] +[tool.setuptools_scm] +fallback_version = "0.0.0" [project.optional-dependencies] dev = [ "pytest>=7.0.0" From b8cdfdbaa0f22f7c8d8b82c2c1389b53c4a9488d Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:19:32 -0400 Subject: [PATCH 5/6] [mpxpy] Add convert_to_mmd_overlay_pdf for the accessible overlay PDF format --- changelog.md | 1 + mpxpy/file.py | 8 ++++++++ mpxpy/mathpix_client.py | 6 ++++++ mpxpy/pdf.py | 30 ++++++++++++++++++++++++++++++ tests/test_request_options_unit.py | 15 +++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/changelog.md b/changelog.md index 58bd785..c963bda 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ - Automate PyPI releases. The package version is now derived from the git tag via setuptools-scm, so there is no version to bump by hand. Pushing a `vX.Y.Z` tag from master runs `.github/workflows/release.yml`, which builds the sdist and wheel, publishes to PyPI, and creates a published (non-draft) GitHub release. The tag must be valid semver, sit on master, and be newer than the last release, or the run fails before anything is published. - Add `convert_to_xlsx` to `pdf_new` and `conversion_new`, plus `to_xlsx_file`/`to_xlsx_bytes` on `Pdf` and `Conversion`, giving xlsx the same convenience flag as the other formats - Add `include_hyperlinks` to `pdf_new` to extract PDF link annotations into per-line `links` data +- Add `convert_to_mmd_overlay_pdf` to `pdf_new` for the new `mmd.overlay.pdf` format (your submitted PDF returned with an invisible, screen-reader-ready text layer, PDF/UA tagged by default; PDF input only), plus `to_mmd_overlay_pdf_file`/`to_mmd_overlay_pdf_bytes` on `Pdf` and `File`. Format options go through `conversion_options["mmd.overlay.pdf"]` ## August 18, 2026 diff --git a/mpxpy/file.py b/mpxpy/file.py index a82832b..1fbaa5f 100644 --- a/mpxpy/file.py +++ b/mpxpy/file.py @@ -410,6 +410,10 @@ def to_html_zip_bytes(self) -> bytes: """Get the processed file result as html.zip bytes.""" return self.bytes_result(conversion_format='html.zip') + def to_mmd_overlay_pdf_bytes(self) -> bytes: + """Get the accessible overlay PDF (your submitted PDF with an invisible text layer) as bytes.""" + return self.bytes_result(conversion_format='mmd.overlay.pdf') + def to_jpg_bytes(self) -> bytes: """Get the processed file result as JPG bytes.""" return self.bytes_result(conversion_format='jpg') @@ -444,6 +448,10 @@ def to_xlsx_file(self, path: str) -> str: """Save the processed file result to an XLSX file at a local path.""" return self.save_file(path=path, conversion_format='xlsx') + def to_mmd_overlay_pdf_file(self, path: str) -> str: + """Save the accessible overlay PDF (your submitted PDF with an invisible text layer) to a local path.""" + return self.save_file(path=path, conversion_format='mmd.overlay.pdf') + def to_pptx_file(self, path: str) -> str: """Save the processed file result to a PPTX file at a local path.""" return self.save_file(path=path, conversion_format='pptx') diff --git a/mpxpy/mathpix_client.py b/mpxpy/mathpix_client.py index d08caf1..cd75119 100644 --- a/mpxpy/mathpix_client.py +++ b/mpxpy/mathpix_client.py @@ -452,6 +452,7 @@ def pdf_new( convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, convert_to_xlsx: Optional[bool] = False, + convert_to_mmd_overlay_pdf: Optional[bool] = False, improve_mathpix: Optional[bool] = True, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -500,6 +501,7 @@ def pdf_new( convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added (PDF/UA tagged by default; PDF input only). Configure via conversion_options["mmd.overlay.pdf"] improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. callback_url: Optional HTTPS URL to receive this request's webhook deliveries. A request without one is not notified at all @@ -622,6 +624,8 @@ def pdf_new( options["conversion_formats"]['html.zip'] = True if convert_to_xlsx: options["conversion_formats"]['xlsx'] = True + if convert_to_mmd_overlay_pdf: + options["conversion_formats"]['mmd.overlay.pdf'] = True if extra_options: options.update(extra_options) data = { @@ -657,6 +661,7 @@ def pdf_new( convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, convert_to_xlsx=convert_to_xlsx, + convert_to_mmd_overlay_pdf=convert_to_mmd_overlay_pdf, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, @@ -693,6 +698,7 @@ def pdf_new( convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, convert_to_xlsx=convert_to_xlsx, + convert_to_mmd_overlay_pdf=convert_to_mmd_overlay_pdf, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, diff --git a/mpxpy/pdf.py b/mpxpy/pdf.py index 8d30b72..39c94ef 100644 --- a/mpxpy/pdf.py +++ b/mpxpy/pdf.py @@ -31,6 +31,7 @@ class Pdf: convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. (Not yet enabled) callback_url: Optional HTTPS URL to receive this request's webhook deliveries. @@ -54,6 +55,7 @@ def __init__( convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, convert_to_xlsx: Optional[bool] = False, + convert_to_mmd_overlay_pdf: Optional[bool] = False, improve_mathpix: Optional[bool] = False, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -78,6 +80,8 @@ def __init__( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. (Not yet enabled) callback_url: Optional HTTPS URL to receive this request's webhook deliveries. @@ -108,6 +112,7 @@ def __init__( self.convert_to_pptx = convert_to_pptx self.convert_to_html_zip = convert_to_html_zip self.convert_to_xlsx = convert_to_xlsx + self.convert_to_mmd_overlay_pdf = convert_to_mmd_overlay_pdf self.improve_mathpix=improve_mathpix self.file_batch_id = file_batch_id self.callback_url = callback_url @@ -619,3 +624,28 @@ def to_xlsx_bytes(self) -> bytes: ConversionIncompleteError: If the conversion is not complete """ return self.bytes_result(conversion_format='xlsx') + + def to_mmd_overlay_pdf_file(self, path: str) -> str: + """Save the accessible overlay PDF (your submitted PDF with an invisible text layer) to a local path. + + Args: + path: The local file path where the overlay PDF will be saved + + Returns: + output_path: The path of the saved PDF file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='mmd.overlay.pdf') + + def to_mmd_overlay_pdf_bytes(self) -> bytes: + """Get the accessible overlay PDF (your submitted PDF with an invisible text layer) as bytes. + + Returns: + bytes: The binary content of the overlay PDF + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='mmd.overlay.pdf') diff --git a/tests/test_request_options_unit.py b/tests/test_request_options_unit.py index efc03c6..5f0e9ac 100644 --- a/tests/test_request_options_unit.py +++ b/tests/test_request_options_unit.py @@ -128,3 +128,18 @@ def test_conversion_new_sends_convert_to_xlsx(client: MathpixClient) -> None: mock_post.return_value.json.return_value = {'conversion_id': 'conversion-1'} client.conversion_new(mmd='# Document', convert_to_xlsx=True) assert mock_post.call_args.kwargs['json']['formats'] == {'xlsx': True} + + +def test_pdf_new_sends_mmd_overlay_pdf_and_options(client: MathpixClient, tmp_path) -> None: + pdf_path = tmp_path / 'document.pdf' + pdf_path.write_bytes(b'%PDF-1.4') + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'pdf_id': 'pdf-1'} + client.pdf_new( + file_path=str(pdf_path), + convert_to_mmd_overlay_pdf=True, + conversion_options={'mmd.overlay.pdf': {'pdf_ua': 2, 'validation_report': True}}, + ) + options = json.loads(mock_post.call_args.kwargs['data']['options_json']) + assert options['conversion_formats']['mmd.overlay.pdf'] is True + assert options['conversion_options'] == {'mmd.overlay.pdf': {'pdf_ua': 2, 'validation_report': True}} From d78c50dc09e7dcd24993192d29fddeba0f247bf1 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 22 Sep 2026 17:22:23 -0400 Subject: [PATCH 6/6] [mpxpy] Add PR CI to run offline unit tests --- .github/workflows/test.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dc2ea60 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +on: + pull_request: + push: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.12'] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Test + run: python -m pytest tests/test_request_options_unit.py tests/test_files_api_unit.py tests/test_webhooks.py