From 4129dfae714e1b2af176abe6bfa2e7a5ca5f809a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 7 Jul 2026 11:18:24 +0200 Subject: [PATCH 01/11] forge: keep script_env {CC}/{CXX}/{AR} template vars in sync with the re-pointed toolchain build.py re-points cc/cxx/ar/strip/ranlib to the installed NDK (NDK_HOME) when the sysconfigdata-baked paths are absent on the host, and stores those in env[]. But script_vars is built as {**env, **sysconfig_data, ...}, so **sysconfig_data re-shadows CC/CXX/AR with the original embedded-NDK paths. On the Android 3.14 support tree those are absolute paths into a vendored NDK that isn't present in CI, so any recipe that passes {CC}/{CXX}/{AR} into a sub-make gets a non-existent compiler ('clang: not found', make Error 127). Re-assert the env compiler values into script_vars after the merge so the template vars match the toolchain forge actually exports. No-op where sysconfigdata paths are already valid (iOS, Android 3.12). (cherry picked from commit f436a94a2384cae002c56c63a146822386db0321) --- src/forge/build.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/forge/build.py b/src/forge/build.py index 4eec7731..7f3bad99 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -572,6 +572,18 @@ def compile_env(self, **kwargs) -> dict[str, str]: "sysconfigdata_name": self.cross_venv.sysconfigdata_name, } + # `**sysconfig_data` above re-shadows the compiler/binutils keys with the + # values python-build baked into `_sysconfigdata`, which on some support + # trees (e.g. Android 3.14) are absolute paths into an embedded NDK that + # isn't present on this host. `env` already re-pointed those to the real + # installed toolchain (see the NDK_HOME fix-up above), so re-assert the + # env values here — otherwise a recipe that references `{CC}`/`{CXX}`/... + # in `script_env` (e.g. to hand a cross compiler to a sub-`make`) would + # receive the stale embedded path and fail with "clang: not found". + for _tool in ("CC", "CXX", "AR", "RANLIB", "STRIP"): + if _tool in env: + script_vars[_tool] = env[_tool] + # Set up any additional environment variables needed in the script environment. for key, value in self.package.meta["build"]["script_env"].items(): if key in ["LDFLAGS", "CFLAGS", "CPPFLAGS"]: From 74054a90f764b26d66c25936ecfea606ad107948 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 03:50:06 +0200 Subject: [PATCH 02/11] recipe: pymupdf 1.27.2.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a recipe for [PyMuPDF](https://pymupdf.readthedocs.io/) 1.27.2.3 — MuPDF behind a Python API: open PDF/XPS/EPUB/CBZ/images, render pages to bitmaps, extract and search text, and compose documents. Requested in [flet#3400](https://github.com/flet-dev/flet/discussions/3400) (15 reactions, still open and unanswered). ## Recipe shape Self-contained, and deliberately not a `flet-libmupdf` + consumer chain: PyMuPDF downloads and builds its own MuPDF, then generates the C++ wrapper and the SWIG layer from *those* headers, so an externally-built MuPDF adds a compile without removing a step. A `flet-libmupdf` recipe was written and proven green before that became clear; it is not part of this branch. The hard part is the wrapper codegen, which runs on the build host under crossenv's cross-python. That interpreter reports the target — `platform.system()` is `Android`/`iOS` — so every upstream `== 'Linux'` / `startswith('darwin')` test picks the wrong branch, and libclang is handed no sysroot, so MuPDF's generator falls back to hardcoded 64-bit type sizes. `crossenv-codegen.patch` covers that, the iOS install-name wiring for serious-python #223, and the link flags pipcl drops; both patches explain themselves in their preambles. Also included: the `src/forge/build.py` fix that keeps `script_env`'s `{CC}`/`{CXX}`/`{AR}` in step with the toolchain forge actually exports. `**sysconfig_data` re-shadowed them with paths into an embedded NDK that isn't present in CI, which any recipe passing `{CC}` to a sub-make would trip over — this one does. ## Changes since the earlier prototype - **ZXing is no longer compiled in.** MuPDF's wrapper script appends `barcode=yes` after the recipe's own make arguments and the last assignment wins, so `barcode=no` in `meta.yaml` never took effect. No PyMuPDF release exposes a barcode API and `barcode.c` keeps a raising stub, so the patch flips that token: ~16 MB of object code, 2 MB off `libmupdf`, 1.2 MB off the wheel, no reachable functionality lost. - **`_extra` gets the iOS deployment target.** pipcl links it from its own flag list and never reads `$LDFLAGS`, so it carried a legacy `LC_VERSION_MIN_IPHONEOS 7.0` while the other three libraries had `LC_BUILD_VERSION` / `minos 13.0`. Same cause as the Android 16 KB alignment flag, fixed the same way. - **`pipcl` is pinned.** PyMuPDF requires it unpinned; it is the build backend *and* the linker, the patch monkeypatches it, and it shipped twelve releases in four months. - **The libclang `-target` is now mandatory**, not best-effort — unset, it silently produced host type sizes for a 32-bit parse. - **Tests: 2 → 14**, and both patches gained the explanatory preamble the repo convention asks for. Stays on 1.27.2.3 rather than 1.28.2: 1.28 rewrote `setup.py` around pipcl's API (five of eight hunks reject), removed `PYMUPDF_SETUP_FLAVOUR` so the dev headers would ship unconditionally, and vendors `cmark-gfm`, an unproven C dependency for these five slices. Worth doing as its own change. ## Validation All six slices build locally. On-device 12/12 on an Android arm64 emulator and on an iOS 18.6 simulator — covering rendering to real pixels, the compiled-in base-14 fonts, PNG encoding, search geometry and page surgery, not just import. ## Consumer notes PyMuPDF lets a Flet app render a PDF page to bytes an `ft.Image` can display, pull its text back out with coordinates, and build documents — all on device, with no upload. Two things to know before adding it: iOS needs Flet 0.86+ (the wheel depends on serious-python's dylib relocation), and PyMuPDF does not support concurrent use, so renders belong on one thread behind a lock. Full guidance, the size and feature tables, and the platform notes are in [`recipes/pymupdf/README.md`](recipes/pymupdf/README.md); a runnable app is in [`recipes/pymupdf/examples/render-and-read`](recipes/pymupdf/examples/render-and-read). --- recipes/pymupdf/README.md | 282 +++++++++++++++ .../examples/render-and-read/.gitignore | 7 + .../examples/render-and-read/README.md | 52 +++ .../examples/render-and-read/pyproject.toml | 16 + .../examples/render-and-read/src/main.py | 325 +++++++++++++++++ recipes/pymupdf/meta.yaml | 125 +++++++ .../pymupdf/patches/crossenv-codegen.patch | 257 ++++++++++++++ .../pymupdf/patches/ios-dylib-preload.patch | 66 ++++ recipes/pymupdf/test_pymupdf.py | 39 -- recipes/pymupdf/tests/test_pymupdf.py | 332 ++++++++++++++++++ 10 files changed, 1462 insertions(+), 39 deletions(-) create mode 100644 recipes/pymupdf/README.md create mode 100644 recipes/pymupdf/examples/render-and-read/.gitignore create mode 100644 recipes/pymupdf/examples/render-and-read/README.md create mode 100644 recipes/pymupdf/examples/render-and-read/pyproject.toml create mode 100644 recipes/pymupdf/examples/render-and-read/src/main.py create mode 100644 recipes/pymupdf/meta.yaml create mode 100644 recipes/pymupdf/patches/crossenv-codegen.patch create mode 100644 recipes/pymupdf/patches/ios-dylib-preload.patch delete mode 100644 recipes/pymupdf/test_pymupdf.py create mode 100644 recipes/pymupdf/tests/test_pymupdf.py diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md new file mode 100644 index 00000000..56e986b5 --- /dev/null +++ b/recipes/pymupdf/README.md @@ -0,0 +1,282 @@ +# pymupdf + +[`pymupdf`](https://pymupdf.readthedocs.io/) is the Python binding for +[MuPDF](https://mupdf.com/), and it is the reason a phone can do anything useful with a PDF +without sending it somewhere. It opens PDF, XPS, EPUB, CBZ and image files; renders any page +to a bitmap at any scale; pulls the text back out with coordinates; and writes documents from +scratch. On mobile that matters twice over — the file never leaves the device, and rendering +a page locally is the difference between a viewer and a download button. + +**The wheel is self-contained: four native libraries ship inside it.** MuPDF itself +(`libmupdf`), its C++ wrapper (`libmupdfcpp`), the SWIG module over that wrapper (`_mupdf`) +and PyMuPDF's own accelerator (`_extra`). There is no companion `flet-lib*` package to add — +but the four have to find each other at load time, and how that works differs between the +platforms, so it is described under [Android notes](#android-notes) and +[iOS notes](#ios-notes) rather than here. + +Import it as `pymupdf`. The historical `fitz` name is still shipped as a separate top-level +module and still works, which matters because most PyMuPDF code you will find in the wild +opens with `import fitz`. + +## Install + +```toml +# pyproject.toml +dependencies = [ + "flet", + "pymupdf", +] +``` + +Nothing else to configure on Android: one extra wheel comes along and needs no entry of its +own, `flet-libcpp-shared`, the NDK C++ runtime that MuPDF's C++ wrapper links against. On +iOS there is no such dependency — the system `/usr/lib/libc++.1.dylib` covers it. + +**iOS needs Flet 0.86 or newer.** The iOS wheel relies on serious-python 4.2.1 (PR #223) +relocating its bundled libraries into framework bundles, and on the marker files that leaves +behind; on an older Flet the libraries land somewhere the loader will not look and the app +dies at `import pymupdf` with `Library not loaded: @rpath/libmupdf.dylib`. Android has no +such floor. + +No [`[tool.flet.android] extract_packages`](https://flet.dev/docs/publish/android/#extract-packages) +entry is needed. Under Flet 0.86 Android ships site-packages as a compressed archive, which +breaks any package that opens a bundled data file by path — pymupdf has none. The only +non-code file in the wheel is an empty `py.typed`. + +Builds for all three Android ABIs Flet targets (arm64-v8a, armeabi-v7a, x86_64) and for iOS +device and simulator, on Python 3.12, 3.13 and 3.14. + +## Storage + +Most of the time you want no file at all. A document can be opened from a `bytes` object and +written back to one, and a rendered page goes straight into a Flet control: + +```python +doc = pymupdf.open(stream=blob, filetype="pdf") # no path +png = doc[0].get_pixmap(dpi=144).tobytes("png") +image.src = png # ft.Image.src takes bytes +``` + +When a document does belong on disk, put it in Flet's app storage — the working directory is +not a durable location on either platform: + +```python +import os + +data = os.getenv("FLET_APP_STORAGE_DATA", ".") # survives restarts and updates +doc.save(os.path.join(data, "report.pdf")) +``` + +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data) +is for documents the user expects to keep; +[`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) +is for anything you can regenerate, such as a cache of rendered page images, and may be +cleared between launches. A PDF shipped with the app is an asset: put it under `src/assets/` +and read it from +[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). + +[`doc.save(path, incremental=True)`](https://pymupdf.readthedocs.io/en/latest/document.html#Document.save) +needs the document to have been opened from that same path, so it is only available for +files you own on disk — not for the `stream=` case. + +## Examples + +See runnable Flet apps in [`examples/`](examples): + +- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it at a + zoom you choose, and highlights search hits on the rendered page. + +## Threading + +**PyMuPDF does not support concurrent use, and it will not tell you when you break the +rule.** Upstream is unambiguous — *"PyMuPDF does not support multithreaded use, even with +Python's newer free-threading mode"* — and the package calls MuPDF's +`reinit_singlethreaded()` at import, which switches off the locking MuPDF would otherwise +use. Two overlapping calls do not raise; they corrupt state, and on a phone that surfaces as +a native crash with no Python traceback. + +None of the four libraries starts a thread of its own: no extension in either wheel +references `pthread_create`, or any OpenMP symbol. So all the concurrency is whatever your +app introduces. + +Rendering is genuinely slow enough to need a thread — a full page at high zoom is several +megapixels — and MuPDF releases the GIL while it works, so +[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) really +does keep the UI live. But `run_thread` submits to a thread *pool*, so two handlers started +close together will run inside MuPDF at the same time. Serialise them yourself: + +```python +MUPDF = threading.Lock() + +def work(): + with MUPDF: + png = DOC[index].get_pixmap(dpi=dpi).tobytes("png") + sheet.src = png + page.update() # auto-update does not reach background threads +``` + +Disabling the button that starts the work is not a substitute — it cannot catch a tap already +in flight. Note also that exceptions raised inside `run_thread` are swallowed, so wrap the +body if you want to see a `pymupdf.FileDataError` rather than a screen that never updates. +If you need real parallelism, upstream's answer is multiprocessing with one document per +process, which is not available to you here. + +## Android notes + +The four libraries are installed as `jniLibs` and resolve each other by `DT_NEEDED` name at +`dlopen` time, which is why this recipe builds MuPDF with unversioned sonames: an APK only +accepts bare `lib*.so`, so a stock `libmupdf.so.27.2` soname would leave `_mupdf` asking for +a file that cannot be packaged. What ships is `libmupdf.so`, and the dependency entries +naming it match. + +`libmupdfcpp`, `_mupdf` and `libmupdf` all link `libc++_shared.so`, which is the +`flet-libcpp-shared` dependency in [Install](#install); Android does not provide the NDK C++ +runtime itself. Every `PT_LOAD` segment is 16 KB-aligned, so the wheels load on Android 15 +devices with 16 KB pages. + +| | arm64-v8a | armeabi-v7a | x86_64 | +| --- | --- | --- | --- | +| `libmupdf.so` | 55.4 MB | 52.7 MB | 56.0 MB | +| `_mupdf` | 12.3 MB | 11.3 MB | 12.4 MB | +| `libmupdfcpp.so` | 1.9 MB | 1.5 MB | 2.0 MB | +| `_extra` | 0.2 MB | 0.2 MB | 0.2 MB | +| **wheel / unpacked** | **40.8 / 73 MB** | **40.2 / 69 MB** | **41.3 / 74 MB** | + +## iOS notes + +All four binaries are `MH_DYLIB`, which is what `flet build ipa` requires — a CMake-style +`MH_BUNDLE` fails at link rather than at import. + +Their inter-dependencies are the interesting part. serious-python relocates each bundled +binary into its own framework bundle, but rewrites only the extension modules' own +install-ids: a `.dylib`'s id, and every dependency entry in every file, is left as it was. +So this recipe points them at the framework paths at build time, and `pymupdf/__init__.py` +loads `libmupdf` and then `libmupdfcpp` with `RTLD_GLOBAL` before importing `_extra` — which +lets dyld satisfy each `@rpath` reference from an image that is already in memory. That +preload is why the [Flet floor](#install) exists. It is inert on Android and on desktop. + +There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib/libc++.1.dylib`. + +| | device arm64 | simulator arm64 | simulator x86_64 | +| --- | --- | --- | --- | +| `libmupdf.dylib` | 54.3 MB | 54.9 MB | 55.0 MB | +| `_mupdf.so` | 12.9 MB | 13.0 MB | 12.9 MB | +| `libmupdfcpp.dylib` | 1.8 MB | 1.8 MB | 1.8 MB | +| `_extra.so` | 0.2 MB | 0.2 MB | 0.2 MB | +| **wheel / unpacked** | **40.3 / 73 MB** | **40.9 / 73 MB** | **41.0 / 73 MB** | + +## Things to know + +- **Fonts are compiled into the library, and that is most of the wheel.** MuPDF turns its + bundled fonts into C arrays at build time, so text renders on a device that has no + PostScript fonts and no fontconfig — including scripts a PDF did not embed a font for. + This build keeps the whole set: the base-14 faces, 159 Noto families, `DroidSansFallback` + and `SourceHanSerif` for CJK, Arabic, Tibetan and emoji. It is also why `libmupdf` here is + 55 MB against 31 MB in the same-version wheel PyPI ships for macOS, which excludes most of + the Noto set. If your PDFs embed their own fonts — most produced by real software do — you + are paying for a fallback you will not use, but the choice is made at build time and + cannot be changed from an app. +- **The base-14 faces are Latin-1 only.** `page.insert_text(..., fontname="helv")` with an em + dash, a curly quote or any non-Latin-1 character silently rasterises it as `?`. There is no + exception; the string you read back with `get_text` is not what you see. Use + [`insert_htmlbox`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_htmlbox), + which lays text out through MuPDF's HTML engine and picks a font that has the glyph, or + embed a font of your own with + [`insert_font`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_font). +- **There is no OCR.** MuPDF is built without Tesseract, so + [`page.get_textpage_ocr()`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_textpage_ocr) + and everything downstream of it fails at runtime. A scanned PDF is a page of images to this + build: it renders perfectly and extracts no text. Tesseract would bring its own language + data files as well as the engine, which is not something to add by accident. +- **There is no signature support.** MuPDF is built without libcrypto, so PKCS#7 signing and + signature *verification* are unavailable. Encryption is unaffected — the standard security + handler is MuPDF's own code, so opening a password-protected PDF with + `pymupdf.open(path)` then `doc.authenticate(password)` works, as does saving with + `encryption=` and owner/user passwords. +- **Also absent:** barcode generation and decoding (upstream exposes no Python API for it at + this version, and the ZXing library it would need is ~2 MB), and the `curl`, `X11` and + `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet app. +- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG.** + `page.get_pixmap(dpi=...)` returns raw RGB samples, and they grow with the square of the + scale: a text-filled A4 page is 1.4 MB at 72 dpi, 5.7 MB at 144 and **24.9 MB at 300**, + where the PNG `tobytes("png")` produces is 14 KB, 248 KB and 522 KB. Only the PNG crosses + into Flet. Render at the scale you will actually display, drop the pixmap as soon as you + have the bytes, and set + [`gapless_playback=True`](https://flet.dev/docs/controls/image/) on the `ft.Image` or it + blanks between frames. +- **Size.** The wheel is about 41 MB and unpacks to 69–74 MB depending on the slice, nearly + all of it `libmupdf`. There is no test suite or header directory to trim with + `[tool.flet.cleanup]` — the library *is* the package. What you can do is ship fewer copies: + on Android, `split_per_abi` or a `target_arch` narrowed to the ABIs you support. +- **`flet run` on your desktop uses PyPI's wheel, not this one.** That build has a different + font set and different compiled-in features, so a desktop run proves your code and not the + device build. `pymupdf.TOOLS.fitz_config` reports what the wheel actually has, and it + differs between the two. + +## Build notes (maintainers) + +Both patches carry their own explanation in a preamble, and every `meta.yaml` setting is +justified in a comment beside it; what follows is what neither file records. + +**Shape.** This is a single self-contained recipe, not the `flet-libmupdf` native library +plus consumer that the chain-recipe pattern would suggest — and a working `flet-libmupdf` +was in fact built and then abandoned. PyMuPDF's build does not consume an external MuPDF in +any useful way: it downloads its own copy, and then generates the C++ wrapper *and* the SWIG +layer from those exact headers, so a separately-built MuPDF only duplicates the compile +without removing a step. Everything the recipe does is therefore aimed at the one upstream +build, through `MUPDF_MAKE` and a patch. + +**Why the codegen is the hard part.** PyMuPDF parses MuPDF's headers with libclang and +generates a C++ wrapper, on the build host, under crossenv's cross-python. That interpreter +reports the *target* — `platform.system()` is `Android` or `iOS` — which matches no branch +upstream has, and libclang is given no sysroot, so the generator falls back to hardcoded +64-bit type sizes. That is the whole reason the patch exists, and why the recipe cannot be +reduced to environment variables. + +**pipcl is pinned in `requirements.build`, and that pin is load-bearing.** PyMuPDF asks for a +bare `pipcl`, which is both the build backend and the linker for `_mupdf`/`_extra`, and the +patch monkeypatches one of its functions. It shipped twelve releases in four months. Raise +the pin deliberately, with a build, rather than letting it float. + +**1.28 is a separate project, not a bump.** PyMuPDF 1.28 rewrote `setup.py` around `pipcl`'s +API — five of the eight hunks reject — and removed `PYMUPDF_SETUP_FLAVOUR` entirely, so the +dev headers and static library this recipe drops would ship unconditionally and need a new +hunk to suppress. MuPDF 1.28 also vendors `cmark-gfm`, an unproven C dependency for these +five slices. The MuPDF-script surgery, by contrast, applies unchanged. Do it as its own +change with its own CI run. + +What to re-verify on a bump, in rough order of how quietly it can go wrong: + +- **That barcode is still off.** `MUPDF_MAKE` says `barcode=no`, and that setting alone does + nothing: MuPDF's wrapper script appends `barcode=yes` after it and make lets the last + command-line assignment win, so the patch has to rewrite that token too. If either half is + lost the build stays green and ZXing quietly returns. Check `strings libmupdf.so | grep + ZXing` is empty. +- **The sonames, on Android.** They must be unversioned. A change in how `SO_VERSION=` is + handled upstream produces a wheel that builds, packages and then fails to `dlopen` on + device — the first symptom is an on-device test failure, not a build error. +- **`_extra` on both platforms.** It is the one library pipcl links from its own flag list, + ignoring everything forge exports, so it is where dropped link flags show up: 16 KB + `PT_LOAD` alignment on Android, and `LC_BUILD_VERSION` with a sane `minos` rather than a + legacy `LC_VERSION_MIN_IPHONEOS` on iOS. Both are re-added by the patch and both are easy + to lose. +- **Mach-O filetype `MH_DYLIB` on all three iOS slices**, and that the preload block still + sits above the `from . import extra` line it is meant to precede — an upstream reshuffle of + `src/__init__.py` moves the import without failing the patch. +- **The compiled-out feature list**, read out of the built library rather than off the + `MUPDF_MAKE` flags. The barcode case above is precisely why: a flag in the recipe is not + evidence about the wheel. +- **Whether `extract_packages` is still unnecessary.** It holds only while nothing in the + package opens a bundled file by path. A new data file upstream flips it, and the symptom is + an import failure on Android only. +- **The font set**, which is the size story and the [Things to know](#things-to-know) claim + about non-Latin text. If shrinking the wheel ever becomes the priority, MuPDF's `TOFU` + family of defines is the lever — `tofu=yes` drops the Noto fonts and keeps CJK, which is + roughly what upstream's own desktop wheels do — but it changes what renders on a device + and has not been tested here. +- **All sizes and counts.** Re-measure from the built wheels; do not scale. + +The tests cover import through both names, page composition, rendering to real pixels, the +base-14 fonts, PNG encoding, search geometry, structured text, image round-trip, page +surgery and a save/reopen through the filesystem. They do not cover encrypted documents, the +non-PDF input formats, or `insert_htmlbox`. diff --git a/recipes/pymupdf/examples/render-and-read/.gitignore b/recipes/pymupdf/examples/render-and-read/.gitignore new file mode 100644 index 00000000..429a8307 --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/.gitignore @@ -0,0 +1,7 @@ +.venv/ +.flet/ +build/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +uv.lock diff --git a/recipes/pymupdf/examples/render-and-read/README.md b/recipes/pymupdf/examples/render-and-read/README.md new file mode 100644 index 00000000..586fc0ac --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/README.md @@ -0,0 +1,52 @@ +# pymupdf render and read + +A three-page PDF, built in memory when the app starts, then shown one page at a time as a +rendered image. Page through it, drag the zoom from 1× to 4× and watch the caption report +the pixel count and the milliseconds it took, and type a word into the search field to see +every occurrence highlighted in yellow on the page. + +What it demonstrates: + +- **Rasterising a page**, which is the thing you ship PyMuPDF to a phone for. + [`page.get_pixmap(matrix=...)`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_pixmap) + renders through MuPDF and + [`pixmap.tobytes("png")`](https://pymupdf.readthedocs.io/en/latest/pixmap.html#Pixmap.tobytes) + encodes the result, which + [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src) accepts as bytes — + no temp file, no base64. +- **That the fonts are inside the wheel.** Page 1 sets the same sentence in four of the + base-14 faces. Nothing loads a font file; a phone has no PostScript fonts and no + fontconfig, and the glyphs still draw because MuPDF compiles them into the library. +- **Vector, not pixels.** Page 2 is a bar chart, a Bézier and three primitives written with + page operators, so raising the zoom produces real detail rather than a bigger blur. The + same slider on a page of scanned images would only enlarge them. +- **Text that survives the render.** + [`page.search_for`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.search_for) + returns a rectangle per hit, in page points; the app turns each into a + [highlight annotation](https://pymupdf.readthedocs.io/en/latest/page.html#Page.add_highlight_annot), + renders, then deletes the annotations again so the document is unchanged between renders. + Hit coordinates do not move when you zoom — only the renderer's scale does. +- **Compute off the UI thread** — every render runs in + [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) with a + spinner up, driven from the slider's `on_change_end` so one gesture means one + rasterisation, and the handler ends with the explicit + [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) that a background + thread needs. + +The document is generated rather than bundled, so the example ships no asset — and +composing a PDF is itself half of what PyMuPDF does. + +## Try it + +[Build](https://flet.dev/docs/publish/) the app, then install it on a device or emulator/simulator: + +```bash +# Android +uv run flet build apk + +# iOS +uv run flet build ipa + +# iOS-Simulator +uv run flet build ios-simulator +``` diff --git a/recipes/pymupdf/examples/render-and-read/pyproject.toml b/recipes/pymupdf/examples/render-and-read/pyproject.toml new file mode 100644 index 00000000..3bc98a0f --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "pymupdf-render-and-read" +version = "1.0.0" +description = "Builds a PDF with PyMuPDF, renders it to an image and searches its text." +requires-python = ">=3.10" + +dependencies = [ + "flet==0.86.5", + "pymupdf==1.27.2.3", +] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.app] +path = "src" diff --git a/recipes/pymupdf/examples/render-and-read/src/main.py b/recipes/pymupdf/examples/render-and-read/src/main.py new file mode 100644 index 00000000..f713bb9e --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -0,0 +1,325 @@ +"""Build a PDF in memory, rasterise it with MuPDF, and read its text back.""" + +import threading +import time + +import flet as ft +import pymupdf + +# PyMuPDF does not support multithreaded use, and calls reinit_singlethreaded() at +# import. page.run_thread hands work to a thread *pool*, so two renders started +# close together would otherwise overlap inside MuPDF. Serialise them here: the +# renders are milliseconds, so queueing behind the lock costs nothing. +MUPDF = threading.Lock() + +PAGE_W, PAGE_H = 400.0, 520.0 +INK = (0.11, 0.12, 0.16) +MUTED = (0.42, 0.45, 0.52) +ACCENT = (0.15, 0.39, 0.92) +RULE = (0.88, 0.89, 0.92) + +# The base-14 PDF fonts are Latin-1, so the document text stays ASCII: an em dash +# passed to insert_text comes out of the rasteriser as a "?" glyph. +FACES = ( + ("helv", "Helvetica"), + ("tiro", "Times Roman"), + ("cour", "Courier"), + ("hebo", "Helvetica Bold"), +) +SAMPLE = "Sphinx of black quartz, judge my vow 0123456789" +BARS = ((34, "Jan"), (58, "Feb"), (47, "Mar"), (72, "Apr"), (65, "May"), (88, "Jun")) +TITLES = ("Typography", "Vector graphics", "Text") + + +def banner(page, number, title): + """Draw the coloured title bar shared by every page.""" + page.draw_rect(pymupdf.Rect(0, 0, PAGE_W, 48), color=None, fill=ACCENT) + page.insert_text((26, 31), title, fontname="hebo", fontsize=15, color=(1, 1, 1)) + page.insert_text( + (PAGE_W - 48, 31), f"{number} / 3", fontname="helv", fontsize=9, color=(1, 1, 1) + ) + + +def typography_page(doc): + """A page per base-14 face, which is what proves the fonts are in the wheel. + + Nothing here loads a font file. MuPDF compiles the standard faces into the + library at build time, so every sample below is drawn from glyphs that + ship inside `libmupdf` rather than from anything on the device. + """ + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 1, TITLES[0]) + y = 96 + for fontname, label in FACES: + page.insert_text((26, y), label, fontname=fontname, fontsize=14, color=INK) + page.insert_text( + (26, y + 18), SAMPLE, fontname=fontname, fontsize=8.5, color=MUTED + ) + page.draw_line( + pymupdf.Point(26, y + 32), + pymupdf.Point(PAGE_W - 26, y + 32), + color=RULE, + width=0.6, + ) + y += 58 + page.insert_textbox( + pymupdf.Rect(26, y + 10, PAGE_W - 26, PAGE_H - 20), + "These are four of the base-14 PDF faces. A phone carries no PostScript " + "fonts and no fontconfig, so every glyph above came out of the library " + "itself. Zoom in: they stay sharp, because the page stores outlines and " + "the rasteriser fills them at whatever scale you ask for.", + fontname="helv", + fontsize=8.5, + color=MUTED, + lineheight=1.45, + ) + + +def vector_page(doc): + """A bar chart and some primitives, drawn with page operators rather than pixels. + + The point of the page is what the zoom slider does to it: these shapes are + stored as coordinates, so raising the scale produces genuinely more detail + instead of a larger blur. + """ + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 2, TITLES[1]) + page.insert_text( + (26, 76), + "Bars, curves and strokes are page operators.", + fontname="helv", + fontsize=8.5, + color=MUTED, + ) + base = 330.0 + for index, (value, label) in enumerate(BARS): + x = 34 + index * 56 + page.draw_rect( + pymupdf.Rect(x, base - value * 2.1, x + 36, base), color=None, fill=ACCENT + ) + page.insert_text( + (x + 8, base - value * 2.1 - 6), + str(value), + fontname="helv", + fontsize=7.5, + color=MUTED, + ) + page.insert_text( + (x + 8, base + 15), label, fontname="helv", fontsize=7.5, color=MUTED + ) + page.draw_line( + pymupdf.Point(26, base), pymupdf.Point(PAGE_W - 26, base), color=INK, width=0.9 + ) + + # A Shape batches drawing commands into one page operator run; the curve and + # the three primitives below it exist to give the zoom something to sharpen. + shape = page.new_shape() + shape.draw_bezier( + pymupdf.Point(34, 400), + pymupdf.Point(140, 372), + pymupdf.Point(250, 428), + pymupdf.Point(PAGE_W - 34, 390), + ) + shape.finish(color=ACCENT, width=1.6, closePath=False) + shape.commit() + page.draw_circle(pymupdf.Point(62, 470), 16, color=INK, width=1) + page.draw_rect(pymupdf.Rect(108, 454, 140, 486), color=INK, width=1) + page.draw_line(pymupdf.Point(168, 486), pymupdf.Point(200, 454), color=INK, width=1) + + +def text_page(doc): + """A prose page, so that search and extraction have something to find.""" + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 3, TITLES[2]) + page.insert_textbox( + pymupdf.Rect(26, 76, PAGE_W - 26, PAGE_H - 20), + "The words on this page are text objects, not pixels. The same page that " + "rasterises into the image above can be read back with get_text, and " + "search_for returns a rectangle for every hit, which is how the yellow " + "highlight gets placed.\n\n" + "Type a word into the search field to see it marked on the page. Try " + "quartz, or rectangle, or MuPDF.\n\n" + "Because glyphs carry their own coordinates, extraction is unaffected by " + "the zoom. The rectangle of a hit is measured in points on the page; only " + "the renderer decides how many pixels a point becomes.", + fontname="helv", + fontsize=10, + color=INK, + lineheight=1.5, + ) + + +def build_document(): + """Assemble the three-page document the app renders. + + The example generates its own PDF rather than shipping one so that it stays + a single directory with no bundled asset, and so that composing a document + is itself part of what gets demonstrated. + """ + doc = pymupdf.open() + typography_page(doc) + vector_page(doc) + text_page(doc) + return doc + + +DOC = build_document() + + +def render(index, zoom, term): + """Rasterise one page at `zoom`, highlighting `term`, and return PNG bytes. + + Hits are marked with real highlight annotations and deleted again once the + pixmap exists, which keeps the document itself unchanged between renders -- + the alternative, drawing rectangles onto the page, would accumulate. Pixmaps + render with annotations included by default, so no extra flag is needed. + """ + with MUPDF: + page = DOC[index] + hits = page.search_for(term) if term else [] + annotations = [page.add_highlight_annot(rect) for rect in hits] + + started = time.perf_counter() + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom)) + png = pixmap.tobytes("png") + elapsed = time.perf_counter() - started + + # Read the dimensions and drop the pixmap before releasing the lock: every + # attribute on it is a call back into MuPDF, and a full page at 4x is tens + # of megabytes of samples that nothing needs once the PNG exists. + size = (pixmap.width, pixmap.height) + del pixmap + + for annotation in annotations: + page.delete_annot(annotation) + + return png, len(hits), size, elapsed + + +def main(page: ft.Page): + """Show one rendered page at a time, with page navigation, zoom and search. + + Rendering is pushed to a background thread: at the top of the zoom range a + page is several megapixels, and doing that on the UI thread would stall the + slider mid-drag. + """ + state = {"index": 0, "zoom": 2.0, "term": ""} + + def redraw(): + """Kick off a render for the current state, with the spinner up.""" + spinner.visible = True + page.update() + page.run_thread(work) + + def work(): + """Render on a background thread, then refill the image and the caption.""" + png, hits, (width, height), elapsed = render( + state["index"], state["zoom"], state["term"] + ) + sheet.src = png + position.value = ( + f"{state['index'] + 1} / {DOC.page_count} · {TITLES[state['index']]}" + ) + found.value = ( + "" if not state["term"] else f"{hits} hit{'' if hits == 1 else 's'}" + ) + stats.value = ( + f"{width}x{height} px at {state['zoom']:.1f}x in {elapsed * 1e3:.0f} ms" + ) + spinner.visible = False + page.update() # auto-update does not reach background threads + + def step(delta): + """Return a handler that moves `delta` pages, clamped to the document.""" + + def handler(e): + state["index"] = max(0, min(DOC.page_count - 1, state["index"] + delta)) + redraw() + + return handler + + def on_zoom(e): + """Re-render at the slider's scale once the finger lifts. + + on_change_end rather than on_change: a drag emits a value per pixel, and + each one would queue a full-page rasterisation. + """ + state["zoom"] = e.control.value + redraw() + + def on_search(e): + """Re-render with the new search term highlighted.""" + state["term"] = e.control.value.strip() + redraw() + + page.appbar = ft.AppBar(title=ft.Text("Render and read"), center_title=True) + page.add( + ft.SafeArea( + expand=True, + content=ft.Column( + controls=[ + ft.Text( + f"pymupdf {pymupdf.__version__} · MuPDF {pymupdf.mupdf_version}", + size=11, + ), + ft.TextField( + label="Search this page", + dense=True, + on_submit=on_search, + on_blur=on_search, + ), + ft.Container( + expand=True, + alignment=ft.Alignment.CENTER, + content=( + # src is required, and the first render fills it in; + # gapless_playback stops the control blanking between + # renders, since each one is a different byte string. + sheet := ft.Image( + src=b"", fit=ft.BoxFit.CONTAIN, gapless_playback=True + ) + ), + ), + ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[ + ft.IconButton(ft.Icons.CHEVRON_LEFT, on_click=step(-1)), + ft.Column( + spacing=0, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + position := ft.Text(size=12), + found := ft.Text(size=11, color=ft.Colors.PRIMARY), + ], + ), + ft.IconButton(ft.Icons.CHEVRON_RIGHT, on_click=step(1)), + ], + ), + ft.Row( + controls=[ + ft.Text("zoom", size=11), + ft.Slider( + min=1.0, + max=4.0, + value=2.0, + divisions=6, + label="{value}x", + expand=True, + on_change_end=on_zoom, + ), + spinner := ft.ProgressRing( + width=14, height=14, visible=False + ), + ] + ), + stats := ft.Text(size=11), + ] + ), + ) + ) + + redraw() + + +if __name__ == "__main__": + ft.run(main) diff --git a/recipes/pymupdf/meta.yaml b/recipes/pymupdf/meta.yaml new file mode 100644 index 00000000..5184df58 --- /dev/null +++ b/recipes/pymupdf/meta.yaml @@ -0,0 +1,125 @@ +package: + name: pymupdf + version: 1.27.2.3 + +patches: + - crossenv-codegen.patch + - ios-dylib-preload.patch + +requirements: + build: + # PyMuPDF's build (setup.py -> pipcl -> mupdf/scripts/mupdfwrap.py) needs SWIG + # to wrap MuPDF's C++ API, and python clang bindings (libclang) to parse the + # MuPDF headers when generating the C++ wrapper. Both run on the host. + - swig + - libclang + - setuptools + # PyMuPDF's pyproject.toml asks for a bare `pipcl`, so the build would otherwise + # float on whatever version PyPI serves that day (12 releases in the four months + # to 2026-07). pipcl is the build backend AND the linker for _mupdf/_extra, and + # crossenv-codegen.patch monkeypatches pipcl.darwin, so an upstream refactor + # breaks the build with no change on our side. Installed first, which satisfies + # the unpinned requirement. Re-test and raise deliberately. + - pipcl 12 +# {% if sdk == 'android' %} + host: + # The MuPDF C++ wrapper (libmupdfcpp.so) + _mupdf.so are C++; on Android they + # link libc++_shared.so, which the device runtime doesn't provide unless bundled. + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + +build: + number: 0 + script_env: + # The libclang wrapper-codegen parse needs the real per-arch target triple so + # MuPDF's manual size_t/int typedefs (rewritten to clang __*_TYPE__ builtins + # by the patch) resolve to the actual sizes — size_t is unsigned long on + # 64-bit but unsigned int on 32-bit (x86 / armeabi-v7a). Android uses forge's + # HOST_TRIPLET (aarch64-linux-android, i686-linux-android, ...); iOS builds + # the apple triple from the arch (the simulator/device suffix doesn't affect + # type sizes, so a bare -apple-ios is enough for the parse). +# {% if sdk == 'android' %} + MOBILE_FORGE_CLANG_TARGET: "{HOST_TRIPLET}" +# {% else %} + MOBILE_FORGE_CLANG_TARGET: "{{ arch }}-apple-ios" +# {% endif %} + # PyMuPDF's pipcl links _extra.so via base_linker(), which uses $LD when set + # and otherwise falls back to the host `c++` (Apple ld then rejects the GNU + # `-z` flags in forge's LDFLAGS). forge exports CC/CXX but not LD, so point + # LD at the cross C++ compiler. (MuPDF's own _mupdf.so already uses $CXX.) + LD: "{CXX}" + # One self-contained wheel: PyMuPDF python + _extra ext ('p') and the MuPDF + # shared libraries ('b'). Drop 'd' (build-time dev headers/libs) — not needed + # at runtime. + PYMUPDF_SETUP_FLAVOUR: pb + # PyMuPDF builds its bundled MuPDF 1.27.2 itself. mupdfwrap.py builds the C + # library by running the `make` token taken verbatim from $MUPDF_MAKE + # (scripts/wrap/__main__.py:_get_m_command), appending its own make args + # (build=release shared=yes OUT=... libs ...). We inject the cross toolchain + # and Makefile knobs here as make *command-line* vars, which override + # MuPDF Makerules' `CC = xcrun cc` defaults. HAVE_OBJCOPY=no keeps the + # portable hexdump font codegen (proven in flet-libmupdf); host-lib-dragging + # optionals are disabled. SO_VERSION= builds libmupdf UNVERSIONED (soname + # libmupdf.so, no libmupdf.so.X.Y): Android resolves DT_NEEDED by name and + # APK jniLibs only accept bare lib*.so, so a versioned soname would make + # on-device dlopen of libmupdf.so.27.2 fail. Pairs with USE_SONAME=no. + # OS gates the platform conventions: Android => OS=Linux (ELF .so, needs the + # NDK liblog for __android_log_print); iOS => OS=Darwin (Mach-O .dylib, + # -dead_strip). The host is macOS either way, so we always override the + # toolchain off Makerules' xcrun defaults. + # barcode=no is stated here for the record but does NOT take effect on its own: + # mupdfwrap appends its own `barcode=yes` after this string and make lets the + # last command-line assignment win, so crossenv-codegen.patch flips that token + # too. Keep the two in step — dropping either one puts ZXing back in the wheel. +# {% if sdk == 'android' %} + MUPDF_MAKE: >- + make OS=Linux CC={CC} CXX={CXX} AR={AR} RANLIB={RANLIB} SO_VERSION= + HAVE_OBJCOPY=no HAVE_LIBCRYPTO=no HAVE_GLUT=no HAVE_X11=no HAVE_CURL=no + barcode=no tesseract=no + XCFLAGS="{CFLAGS} {CPPFLAGS} -fPIC" XCXXFLAGS="-std=c++14" + XLDFLAGS="{LDFLAGS}" XLIBS="-llog" +# {% else %} + MUPDF_MAKE: >- + make OS=Darwin CC={CC} CXX={CXX} AR={AR} RANLIB={RANLIB} SO_VERSION= + HAVE_OBJCOPY=no HAVE_LIBCRYPTO=no HAVE_GLUT=no HAVE_X11=no HAVE_CURL=no + barcode=no tesseract=no + XCFLAGS="{CFLAGS} {CPPFLAGS} -fPIC" XCXXFLAGS="-std=c++14" + XLDFLAGS="{LDFLAGS}" +# {% endif %} + # Make MuPDF's wrapper build use UNVERSIONED sonames too (get_so_version() + # returns '' when USE_SONAME=no): libmupdfcpp.so / _mupdf.so reference and + # are built as bare lib*.so, matching SO_VERSION= above and what ships in + # the APK. Without this the _mupdf/_extra DT_NEEDED stay libmupdf.so.27.2. + USE_SONAME: "no" + # Don't build MuPDF with Tesseract OCR: a heavy dependency, and it would also need + # language data files at runtime that the wheel has nowhere to put. + PYMUPDF_SETUP_MUPDF_TESSERACT: "0" + # pipcl (both MuPDF's and PyMuPDF's) shells out to `python3.12-config` for the + # Python include/link flags of _mupdf.so / _extra.so. Android's support tree + # ships a python3.12-config script (runs via bash on the host, reports the + # target paths). The iOS Python.xcframework ships NO python-config, so there + # we synthesize one from the crossenv cross-python's own sysconfig: emit the + # target include dir for --includes and nothing for --ldflags (iOS extensions + # link with -undefined dynamic_lookup, so no libpython on the link line). +# {% if sdk == 'android' %} + PIPCL_PYTHON_CONFIG: bash {HOST_PYTHON_HOME}/bin/python{py_version_short}-config +# {% else %} + PIPCL_PYTHON_CONFIG: "{CROSS_VENV_PYTHON} -c \"import sys,sysconfig; print('-I'+sysconfig.get_path('include') if '--includes' in sys.argv else '')\"" +# {% endif %} + # Android link flags appended to every link step (C++ wrapper / _mupdf / _extra, + # and the MuPDF C lib via make's env LDFLAGS): + # -llog: the NDK auto-defines __ANDROID__, activating MuPDF's + # __android_log_print path, which lives in the NDK `liblog`. + # -lpython3.x: forge links with -Wl,--no-undefined, so the SWIG/_extra + # Python C-API symbols (PyExc_*, PyErr_*, ...) must resolve at link time. + # Android needs the extension to link libpython. forge's base -L uses + # sysconfigdata's `prefix/lib`, which on the 3.14 support tree is a dead + # python-build build path (libpython3.14.so isn't there), so also add the + # real on-disk python lib dir {HOST_PYTHON_HOME}/lib + # (=install/android//python-/lib) — otherwise MuPDF's libmupdf.so + # link fails `ld.lld: unable to find library -lpython3.14`. Harmless on 3.12 + # (libpython is in both dirs). Applied via XLDFLAGS to the MuPDF C-lib links + # too; libmupdf.so doesn't need libpython but a resolvable -l is a no-op. +# {% if sdk == 'android' %} + LDFLAGS: -llog -L{HOST_PYTHON_HOME}/lib -lpython{py_version_short} +# {% endif %} diff --git a/recipes/pymupdf/patches/crossenv-codegen.patch b/recipes/pymupdf/patches/crossenv-codegen.patch new file mode 100644 index 00000000..f8b18f06 --- /dev/null +++ b/recipes/pymupdf/patches/crossenv-codegen.patch @@ -0,0 +1,257 @@ +Make PyMuPDF's build survive a cross-compile, and wire its four native libraries +up so they resolve on device. + +PyMuPDF builds MuPDF itself and then generates a C++ wrapper for it with SWIG and +libclang. Both of those steps run on the build host but under crossenv's +cross-python, which reports the TARGET: platform.system() is 'Android' or 'iOS' +and sys.platform is 'android'/'ios'. Nothing upstream expects that, so every +`== 'Linux'` / `== 'Darwin'` / `startswith('darwin')` test picks the wrong branch. +The edits fall into four groups. + +Host-side codegen. clang.cindex derives the libclang filename from +platform.system(), so it hunts libclang.so while the host wheel ships +libclang.dylib -- symlink one to the other. MuPDF's own scripts (state.py, +jlib.py) get 'Android'/'iOS' folded into their linux/macos predicates, since +state_.macos alone decides the .dylib suffix and the install_name_tool calls. +cpp.py hands libclang a translation unit with no system headers, and skips its +own size_t/int8_t..uint64_t/FILE/va_list typedefs on anything it does not +recognise -- without them the MuPDF structs parse incompletely and the generator +dies in class_add_iterator, so force both blocks to emit. Those typedefs hardcode +64-bit macOS sizes, which produces a wrapper that will not compile on +armeabi-v7a, so rewrite them to clang's __SIZE_TYPE__-style builtins and pass +-target so they resolve against the real ABI. Finally, MuPDF decides whether to +link libmupdf into _mupdf.so by testing whether the build directory name starts +with 'shared-'; forge's is '-...-shared-...', so the test has to be a +containment check or the link fails under -Wl,--no-undefined. + +Wheel size. MuPDF's wrapper script appends `barcode=yes` to the make command +after the recipe's own arguments, and a later make command-line assignment wins, +so the recipe cannot turn ZXing off from meta.yaml. Neither PyMuPDF 1.27 nor 1.28 +exposes any barcode API, and barcode.c keeps a stub that raises when the feature +is compiled out, so flipping that token drops ~16 MB of object code and no +reachable functionality. + +iOS delivery. serious-python (PR #223) relocates each bundled binary into its own +..framework, but rewrites only the .so modules' install-id -- a +.dylib's id and every dependency load command are left alone. So _extra and +_mupdf keep asking for @rpath/libmupdf.dylib, which now resolves to nothing, and +dyld aborts before Python starts. Point the ids and the dependency entries at the +framework paths at build time, and rename libmupdfcpp.so to .dylib so #223 leaves +its id alone too. (ios-dylib-preload.patch is the runtime half of this.) + +Link flags pipcl drops. pipcl links _extra.so from its own flag list and never +reads $LDFLAGS, so nothing forge sets reaches that one library while the other +three get it through MuPDF's make. Two things have to be put back by hand. On +Android it is the 16 KB max-page-size flag, without which forge's alignment check +rejects the wheel rather than fixing it. On iOS it is the deployment target: +with none on the link line the linker writes a legacy LC_VERSION_MIN_IPHONEOS of +7.0, where libmupdf, libmupdfcpp and _mupdf all carry a proper LC_BUILD_VERSION +with minos 13.0. Both values are taken from the environment forge already +exports, so neither can drift away from the rest of the build. + +The mupdf edits are applied by a helper that asserts its pattern is present and +skips work already done: the source tree is shared across the arches of one +invocation, so a second pass must be a no-op rather than an error. All of it is +gated on CROSS_VENV_SDK, which forge exports on every host -- Android builds on +Linux and iOS on macOS, so a host-OS gate would silently skip CI. + +--- a/setup.py ++++ b/setup.py +@@ -561,7 +561,7 @@ + linux = sys.platform.startswith( 'linux') or 'gnu' in sys.platform + openbsd = sys.platform.startswith( 'openbsd') + freebsd = sys.platform.startswith( 'freebsd') +-darwin = sys.platform.startswith( 'darwin') ++darwin = sys.platform.startswith( 'darwin') or sys.platform == 'ios' # mobile-forge: iOS is Darwin/Mach-O + windows = platform.system() == 'Windows' or platform.system().startswith('CYGWIN') + msys2 = platform.system().startswith('MSYS_NT-') + +@@ -577,6 +577,20 @@ + pipcl.py `build_fn()` callback. + ''' + #pipcl.show_sysconfig() ++ ++ # mobile-forge: crossenv cross-python reports the target ('Android'/'iOS') so ++ # clang.cindex hunts libclang.so while a macOS wheel ships libclang.dylib. ++ # Codegen parses on the host; expose it under the expected name (no-op on Linux). ++ try: ++ import clang.cindex as _mf_cc ++ _mf_nat = os.path.join(os.path.dirname(_mf_cc.__file__), 'native') ++ _mf_dylib = os.path.join(_mf_nat, 'libclang.dylib') ++ _mf_so = os.path.join(_mf_nat, 'libclang.so') ++ if os.path.exists(_mf_dylib) and not os.path.exists(_mf_so): ++ os.symlink('libclang.dylib', _mf_so) ++ log(f'mobile-forge: symlinked {_mf_so} -> libclang.dylib') ++ except Exception as _mf_e: ++ log(f'mobile-forge: libclang crossenv shim skipped: {_mf_e}') + + if PYMUPDF_SETUP_DUMMY == '1': + log(f'{PYMUPDF_SETUP_DUMMY=} Building dummy wheel with no files.') +@@ -585,6 +599,76 @@ + # Download MuPDF. + # + mupdf_local, mupdf_location = get_mupdf() ++ ++ # mobile-forge: crossenv reports platform.system()=='Android'/'iOS', matching ++ # neither 'Linux' nor 'Darwin' in MuPDF's scripts. Gate on CROSS_VENV_SDK so it ++ # applies on both macOS (iOS) and Linux (Android CI) hosts. Idempotent (shared src). ++ if mupdf_local and os.environ.get('CROSS_VENV_SDK'): ++ import re as _mf_re ++ _w = os.path.join(mupdf_local, 'scripts', 'wrap') ++ def _mf_edit(p, subs, regex=False): ++ with open(p) as _f: ++ _t = _f.read() ++ for _o, _n in subs: ++ if _n in _t: ++ continue ++ if regex: ++ _t2 = _mf_re.sub(_o, _n, _t) ++ else: ++ assert _o in _t, f'mobile-forge: pattern not found in {p}: {_o!r}' ++ _t2 = _t.replace(_o, _n, 1) ++ assert _t2 != _t, f'mobile-forge: no change for {_o!r} in {p}' ++ _t = _t2 ++ with open(p, 'w') as _f: ++ _f.write(_t) ++ try: ++ _mf_edit(os.path.join(_w, 'state.py'), [ ++ ("self.linux = self.os_name == 'Linux'", ++ "self.linux = self.os_name in ('Linux', 'Android')"), ++ ("self.macos = self.os_name == 'Darwin'", ++ "self.macos = self.os_name in ('Darwin', 'iOS')")]) ++ _mf_edit(os.path.join(mupdf_local, 'scripts', 'jlib.py'), [ ++ ("darwin = (platform.system() == 'Darwin')", ++ "darwin = (platform.system() in ('Darwin', 'iOS'))")]) ++ _mf_edit(os.path.join(_w, 'cpp.py'), [ ++ ('if state.state_.linux or state.state_.macos:', ++ 'if state.state_.linux or state.state_.macos or 1:'), ++ ('if state.state_.macos:', ++ 'if state.state_.macos or 1:')]) ++ _mf_edit(os.path.join(_w, 'cpp.py'), [ ++ (r'typedef unsigned long(\s+)size_t;', r'typedef __SIZE_TYPE__ size_t;'), ++ (r'typedef signed char(\s+)int8_t;', r'typedef __INT8_TYPE__ int8_t;'), ++ (r'typedef short(\s+)int16_t;', r'typedef __INT16_TYPE__ int16_t;'), ++ (r'typedef int(\s+)int32_t;', r'typedef __INT32_TYPE__ int32_t;'), ++ (r'typedef long long(\s+)int64_t;', r'typedef __INT64_TYPE__ int64_t;'), ++ (r'typedef unsigned char(\s+)uint8_t;', r'typedef __UINT8_TYPE__ uint8_t;'), ++ (r'typedef unsigned short(\s+)uint16_t;', r'typedef __UINT16_TYPE__ uint16_t;'), ++ (r'typedef unsigned int(\s+)uint32_t;', r'typedef __UINT32_TYPE__ uint32_t;'), ++ (r'typedef unsigned long long(\s+)uint64_t;', r'typedef __UINT64_TYPE__ uint64_t;'), ++ ], regex=True) ++ _mf_edit(os.path.join(_w, 'cpp.py'), [ ++ ("'-D', 'FZ_FUNCTION=',", ++ "'-D', 'FZ_FUNCTION=', '-target', os.environ['MOBILE_FORGE_CLANG_TARGET'],")]) ++ _mf_edit(os.path.join(_w, '__main__.py'), [ ++ ("os.path.basename( build_dirs.dir_so).startswith( 'shared-')", ++ "('shared-' in os.path.basename( build_dirs.dir_so))")]) ++ # mobile-forge: mupdfwrap appends its own make args AFTER $MUPDF_MAKE, and ++ # make lets the last command-line assignment win -- so the recipe's ++ # barcode=no is undone here. No PyMuPDF release exposes a barcode API, and ++ # barcode.c keeps a raising stub when the feature is compiled out, so this ++ # drops ~16MB of ZXing C++ that nothing can reach. ++ _mf_edit(os.path.join(_w, '__main__.py'), [ ++ ("verbose=yes barcode=yes", ++ "verbose=yes barcode=no")]) ++ import pipcl as _mf_pipcl ++ if not getattr(_mf_pipcl, '_mf_ios_darwin', False): ++ _mf_darwin_orig = _mf_pipcl.darwin ++ _mf_pipcl.darwin = lambda: _mf_darwin_orig() or sys.platform == 'ios' ++ _mf_pipcl._mf_ios_darwin = True ++ log('mobile-forge: patched MuPDF state/jlib/cpp/__main__ + pipcl.darwin') ++ except Exception as _mf_e2: ++ log(f'mobile-forge: mupdf crossenv shim FAILED: {_mf_e2}') ++ raise + if mupdf_local: + mupdf_version_tuple = get_mupdf_version(mupdf_local) + # else we cannot determine version this way and do not use it +@@ -641,6 +725,42 @@ + log(f'Not building extension.') + path_so_leaf = None + ++ # mobile-forge (iOS): serious-python's relocation (#223) moves each bundled ++ # site-packages .so/.dylib to `..framework/.`, but rewrites ++ # only the *.so* modules' own install-id to that path -- a .dylib's id and ALL ++ # dependency load commands are left untouched. So a dep referenced as ++ # @rpath/libmupdf.dylib resolves to /Frameworks/libmupdf.dylib (absent) and ++ # dyld aborts at launch. Point the MuPDF dylib ids + the ext modules' deps at the ++ # framework paths here instead (validated on iOS-sim: import fitz OK, PDF read PASS). ++ if sys.platform == 'ios' and mupdf_build_dir: ++ import subprocess as _mf_sp ++ _mf_fw = lambda s: f'@rpath/pymupdf.{s}.framework/pymupdf.{s}' ++ _mf_core = f'{mupdf_build_dir}/libmupdf.dylib' ++ if os.path.exists(_mf_core): ++ _mf_sp.run(['install_name_tool', '-id', _mf_fw('libmupdf'), _mf_core], check=True) ++ _mf_cpp_so = f'{mupdf_build_dir}/libmupdfcpp.so' ++ _mf_cpp = f'{mupdf_build_dir}/libmupdfcpp.dylib' ++ if os.path.exists(_mf_cpp_so): # rename C++ wrapper .so->.dylib so #223 keeps its id ++ os.rename(_mf_cpp_so, _mf_cpp) ++ _mf_sp.run(['install_name_tool', '-id', _mf_fw('libmupdfcpp'), _mf_cpp], check=True) ++ _mf_deps = [f'{mupdf_build_dir}/_mupdf.so', _mf_cpp] ++ if path_so_leaf: ++ _mf_deps.append(f'{g_root}/src/build/{path_so_leaf}') ++ _mf_present = [p for p in _mf_deps if os.path.exists(p)] ++ for _mf_d in _mf_present: ++ _mf_sp.run(['install_name_tool', '-change', '@rpath/libmupdf.dylib', _mf_fw('libmupdf'), _mf_d], check=False) ++ for _mf_o in ('@rpath/libmupdfcpp.so', '@rpath/libmupdfcpp.dylib'): ++ _mf_sp.run(['install_name_tool', '-change', _mf_o, _mf_fw('libmupdfcpp'), _mf_d], check=False) ++ # install_name_tool exits 0 when the name it was asked to change is absent, so ++ # a drifted load command would leave the wheel green and abort dyld on device ++ # an hour later. Assert the post-condition instead of trusting the calls. ++ for _mf_d in _mf_present + [_mf_core]: ++ _mf_out = _mf_sp.run(['otool', '-L', _mf_d], capture_output=True, text=True).stdout ++ _mf_bad = [ln.split()[0] for ln in _mf_out.splitlines()[1:] ++ if ln.split() and ln.split()[0] in ( ++ '@rpath/libmupdf.dylib', '@rpath/libmupdfcpp.so', ++ '@rpath/libmupdfcpp.dylib')] ++ assert not _mf_bad, f'mobile-forge: unrewritten MuPDF deps in {_mf_d}: {_mf_bad}' + # Generate list of (from, to) items to return to pipcl. What we add depends + # on PYMUPDF_SETUP_FLAVOUR. + # +@@ -688,7 +808,7 @@ + add('d', f'{mupdf_build_dir2}/libmuthreads.lib', f'{to_dir_d}/lib/') + elif darwin: + add('p', f'{mupdf_build_dir}/_mupdf.so', to_dir) +- add('b', f'{mupdf_build_dir}/libmupdfcpp.so', to_dir) ++ add('b', f'{mupdf_build_dir}/libmupdfcpp.' + ('dylib' if sys.platform == 'ios' else 'so'), to_dir) + add('b', f'{mupdf_build_dir}/libmupdf.dylib', to_dir) + add('d', f'{mupdf_build_dir}/libmupdf-threads.a', f'{to_dir_d}/lib/') + elif pyodide: +@@ -1099,6 +1219,20 @@ + (compiler_extra, linker_extra, includes, defines, optimise, debug, libpaths, libs, libraries) \ + = _extension_flags( mupdf_local, mupdf_build_dir, build_type) + log(f'_build_extension(): {g_py_limited_api=} {defines=}') ++ if os.environ.get('CROSS_VENV_SDK') == 'android': ++ # forge requires Android .so to be 16 KB page-aligned; PyMuPDF pipcl's link of ++ # _extra.so uses its own flags (not forge's LDFLAGS), so add it here. ++ linker_extra += ' -Wl,-z,max-page-size=16384' ++ elif str(os.environ.get('CROSS_VENV_SDK', '')).startswith('iphone'): ++ # Same cause on iOS: with no deployment target on the link line the linker ++ # emits a legacy LC_VERSION_MIN_IPHONEOS 7.0 instead of the LC_BUILD_VERSION ++ # the other three libraries carry. Reuse whatever forge put in CFLAGS so the ++ # value cannot drift from the rest of the build. ++ import re as _mf_re2 ++ _mf_vm = _mf_re2.search(r'-m(?:ios|ios-simulator)-version-min=[\d.]+', ++ os.environ.get('CFLAGS', '')) ++ if _mf_vm: ++ linker_extra += ' ' + _mf_vm.group(0) + if mupdf_local: + includes = ( + f'{mupdf_local}/platform/c++/include', +@@ -1446,7 +1580,7 @@ + ret.append(libclang) + elif openbsd: + print(f'OpenBSD: libclang not available via pip; assuming `pkg_add py3-llvm`.') +- elif darwin and platform_release_tuple() < (18,): ++ elif darwin and sys.platform != 'ios' and platform_release_tuple() < (18,): + # There are still of problems when building on old macos. + ret.append('libclang==14.0.6') + else: +@@ -1457,7 +1591,7 @@ + print(f'OpenBSD: pip install of swig does not build; assuming `pkg_add swig`.') + elif PYMUPDF_SETUP_SWIG: + pass +- elif darwin and python_version_tuple < (3, 13): ++ elif darwin and sys.platform != 'ios' and python_version_tuple < (3, 13): + # Latest swig-4.4.1 gives director errors on macos with python<3.13. + ret.append('swig==4.3.1') + else: diff --git a/recipes/pymupdf/patches/ios-dylib-preload.patch b/recipes/pymupdf/patches/ios-dylib-preload.patch new file mode 100644 index 00000000..7303b92a --- /dev/null +++ b/recipes/pymupdf/patches/ios-dylib-preload.patch @@ -0,0 +1,66 @@ +iOS: preload the MuPDF dylibs before the extension modules import them. + +_extra and _mupdf name libmupdf and libmupdfcpp through @rpath. serious-python +(PR #223, released in 4.2.1) relocates every bundled binary into its own +framework bundle, and nothing on the extension modules' rpath points at where the +dylibs land -- so dyld cannot resolve them, and because CPython's import +machinery is what loads _extra, the failure is an ImportError at first use rather +than something a later shim could repair. + +Loading the two dylibs with RTLD_GLOBAL first sidesteps the search entirely: +dyld binds each @rpath reference by matching an already-loaded image's +install-id, which crossenv-codegen.patch has pointed at the framework path. Order +matters -- libmupdfcpp depends on libmupdf. + +The lookup tries a plain sibling .dylib first, then falls back to reading the +'.fwork' marker serious-python leaves behind and walking up the parent +directories to find the framework it names. Failures are swallowed: on Android +the DT_NEEDED entries resolve by basename from jniLibs and on desktop the wheel +is unpatched upstream, so on both this block is dead weight that must not be able +to break the import. + +--- a/src/__init__.py ++++ b/src/__init__.py +@@ -29,6 +29,42 @@ + import warnings + import weakref + import zipfile ++ ++# mobile-forge (iOS): _extra.so / _mupdf.so link libmupdf.dylib + libmupdfcpp.dylib ++# via @rpath, but flet relocates the extension modules into per-module *.framework ++# bundles while those dependency dylibs ship as plain files in this package dir, ++# and nothing on the ext modules' rpath resolves them. Preload the two dylibs ++# (RTLD_GLOBAL, dependency order libmupdf -> libmupdfcpp) so dyld binds each @rpath ++# reference by the already-loaded image's install-id. Follow a '.fwork' marker ++# when a serious_python build (>=4.2.1, PR #223) relocated the dylib into a framework. ++# No-op on Android (DT_NEEDED resolves by basename from jniLibs) and on desktop. ++try: ++ import ctypes as _mf_ct ++ _mf_dir = os.path.dirname(os.path.abspath(__file__)) ++ for _mf_b in ('libmupdf', 'libmupdfcpp'): ++ _mf_dl = os.path.join(_mf_dir, _mf_b + '.dylib') ++ if os.path.exists(_mf_dl): ++ try: ++ _mf_ct.CDLL(_mf_dl, mode=_mf_ct.RTLD_GLOBAL) ++ except OSError: ++ pass ++ continue ++ _mf_fw = os.path.join(_mf_dir, _mf_b + '.fwork') ++ if not os.path.exists(_mf_fw): ++ continue ++ _mf_rel = open(_mf_fw).read().strip() ++ _mf_p = _mf_dir ++ for _mf_i in range(12): ++ _mf_p = os.path.dirname(_mf_p) ++ _mf_fb = os.path.join(_mf_p, _mf_rel) ++ if os.path.exists(_mf_fb): ++ try: ++ _mf_ct.CDLL(_mf_fb, mode=_mf_ct.RTLD_GLOBAL) ++ except OSError: ++ pass ++ break ++except Exception: ++ pass + + from . import extra + diff --git a/recipes/pymupdf/test_pymupdf.py b/recipes/pymupdf/test_pymupdf.py deleted file mode 100644 index 224909f8..00000000 --- a/recipes/pymupdf/test_pymupdf.py +++ /dev/null @@ -1,39 +0,0 @@ -def test_open_and_read(tmp_path): - """PyMuPDF wraps the MuPDF C library. Create a one-page PDF in memory - then re-open it and read the text back.""" - import fitz # PyMuPDF - - # Create a fresh document with one page containing known text. - src = fitz.open() - page = src.new_page() - page.insert_text((72, 72), "Hello mobile-forge") - pdf_bytes = src.tobytes() - src.close() - - # Re-open from bytes and read the text back. - dst = fitz.open(stream=pdf_bytes, filetype="pdf") - assert dst.page_count == 1 - text = dst[0].get_text() - dst.close() - - assert "Hello mobile-forge" in text - - -def test_metadata(): - """Document.metadata is a Python wrapper around MuPDF's - pdf_dict_get_inheritable — confirms basic dict roundtrip.""" - import fitz - - doc = fitz.open() - doc.new_page() - doc.set_metadata({"title": "test", "author": "ci"}) - - blob = doc.tobytes() - doc.close() - - rt = fitz.open(stream=blob, filetype="pdf") - md = rt.metadata - rt.close() - - assert md["title"] == "test" - assert md["author"] == "ci" diff --git a/recipes/pymupdf/tests/test_pymupdf.py b/recipes/pymupdf/tests/test_pymupdf.py new file mode 100644 index 00000000..0caea6f6 --- /dev/null +++ b/recipes/pymupdf/tests/test_pymupdf.py @@ -0,0 +1,332 @@ +"""On-device tests for PyMuPDF. + +The wheel ships four interdependent native libraries — libmupdf, libmupdfcpp, +_mupdf (the SWIG wrapper over MuPDF's C++ API) and _extra — so the first thing +these tests prove is that all four resolve and load. Everything after that +exercises a layer that a cross-compiled MuPDF can plausibly get wrong: the +rasterizer, the base-14 fonts compiled into the library, the image codecs, and +the PDF writer. +""" + +import pymupdf + + +def render(page, dpi=72): + """Rasterize a page and return (pixmap, count of non-white pixels). + + The count is what separates "MuPDF rendered something" from "MuPDF returned + a correctly-sized blank" — a missing font or a broken rasterizer produces + the latter, and only the pixel data tells them apart. + """ + pix = page.get_pixmap(dpi=dpi) + samples = pix.samples + ink = sum(1 for i in range(0, len(samples), pix.n) if samples[i] != 0xFF) + return pix, ink + + +def test_import_names(): + """Both `pymupdf` and the legacy `fitz` alias import and are the same build. + + Most code in the wild still says `import fitz`, so the alias package has to + survive packaging; it is a separate top-level module, not a re-export. + """ + import fitz + + assert fitz.__name__ == "fitz" + assert fitz.Document is pymupdf.Document + # pymupdf.mupdf is the SWIG wrapper over MuPDF's C++ API; reaching it means + # _mupdf and libmupdfcpp loaded, not just _extra. + from pymupdf import mupdf + + assert mupdf.FZ_VERSION + + +def test_open_and_read(): + """Create a one-page PDF in memory, re-open it and read the text back.""" + src = pymupdf.open() + page = src.new_page() + page.insert_text((72, 72), "Hello mobile-forge") + pdf_bytes = src.tobytes() + src.close() + + dst = pymupdf.open(stream=pdf_bytes, filetype="pdf") + assert dst.page_count == 1 + text = dst[0].get_text() + dst.close() + + assert "Hello mobile-forge" in text + + +def test_metadata(): + """Document metadata survives a write/re-read round trip.""" + doc = pymupdf.open() + doc.new_page() + doc.set_metadata({"title": "test", "author": "ci"}) + blob = doc.tobytes() + doc.close() + + rt = pymupdf.open(stream=blob, filetype="pdf") + md = rt.metadata + rt.close() + + assert md["title"] == "test" + assert md["author"] == "ci" + + +def test_render_page_to_pixels(): + """Rendering produces real pixels, at the size and depth asked for. + + This is the test that proves the MuPDF rasterizer was cross-compiled into + something that runs: `get_pixmap` walks the display list and writes RGB + samples. A page with a filled rectangle on it must come back with ink. + """ + doc = pymupdf.open() + page = doc.new_page(width=200, height=100) + page.draw_rect(pymupdf.Rect(20, 20, 180, 80), color=(0, 0, 0), fill=(0, 0, 0)) + + pix, ink = render(page) + doc.close() + + assert (pix.width, pix.height) == (200, 100) + assert pix.n == 3 # RGB, no alpha + assert len(pix.samples) == 200 * 100 * 3 + # The rectangle covers 160x60 = 9600 px; allow for antialiasing at the edges. + assert ink > 9000 + + +def test_base14_fonts_are_built_in(): + """Text renders with no font files on disk — the base-14 set is compiled in. + + MuPDF turns its bundled fonts into C arrays at build time, so a phone with + no fontconfig and no /usr/share/fonts still draws glyphs. If that codegen + were skipped, this page would rasterize blank while `get_text` still + reported the string, so the assertion has to be about pixels. + """ + doc = pymupdf.open() + page = doc.new_page(width=200, height=60) + page.insert_text((10, 40), "Hamburgefonstiv", fontname="helv", fontsize=18) + + _, ink = render(page) + doc.close() + + assert ink > 200 + + +def test_font_variants_differ(): + """The base-14 faces are distinct fonts, not one face under many names. + + Same string, same size, three of the standard PDF faces: serif, sans and a + fixed-pitch face. Their glyph coverage differs, so the rendered ink differs + — which is only true if each name resolved to its own compiled-in font. + """ + inks = {} + for fontname in ("helv", "tiro", "cour"): + doc = pymupdf.open() + page = doc.new_page(width=240, height=60) + page.insert_text((10, 40), "Hamburgefonstiv", fontname=fontname, fontsize=18) + _, inks[fontname] = render(page) + doc.close() + + assert all(v > 200 for v in inks.values()) + assert len(set(inks.values())) == 3 + + +def test_pixmap_to_png(): + """A rendered page encodes to PNG, which is how it reaches a Flet control. + + Exercises MuPDF's bundled zlib and PNG writer. The magic number is checked + rather than the length, because a truncated or headerless blob would still + have a plausible size. + """ + doc = pymupdf.open() + page = doc.new_page(width=120, height=60) + page.draw_circle(pymupdf.Point(60, 30), 25, color=(1, 0, 0), fill=(1, 0, 0)) + png = page.get_pixmap(dpi=72).tobytes("png") + doc.close() + + assert png[:8] == b"\x89PNG\r\n\x1a\n" + # IHDR carries the dimensions as big-endian uint32s at a fixed offset. + assert int.from_bytes(png[16:20], "big") == 120 + assert int.from_bytes(png[20:24], "big") == 60 + + +def test_search_returns_geometry(): + """`search_for` locates a string and returns its rectangle on the page. + + Text search runs over the same structured-text extraction the renderer + builds, so a hit with sane coordinates shows the text pipeline agrees with + the layout the page was written with. + """ + doc = pymupdf.open() + page = doc.new_page(width=300, height=200) + page.insert_text((50, 100), "findable", fontsize=14) + blob = doc.tobytes() + doc.close() + + rt = pymupdf.open(stream=blob, filetype="pdf") + hits = rt[0].search_for("findable") + misses = rt[0].search_for("absent") + rt.close() + + assert len(hits) == 1 + rect = hits[0] + assert 40 < rect.x0 < 60 + assert 80 < rect.y0 < 105 + assert rect.width > 10 and rect.height > 5 + assert misses == [] + + +def test_structured_text_dict(): + """`get_text("dict")` returns the block/line/span tree with font details. + + The dict form is what apps use to lay text out themselves, and it reaches + further into MuPDF's stext machinery than the plain-string form. + """ + doc = pymupdf.open() + page = doc.new_page() + page.insert_text((72, 72), "structured", fontname="helv", fontsize=11) + blob = doc.tobytes() + doc.close() + + rt = pymupdf.open(stream=blob, filetype="pdf") + span = rt[0].get_text("dict")["blocks"][0]["lines"][0]["spans"][0] + rt.close() + + assert span["text"] == "structured" + assert span["size"] == 11 + assert "Helvetica" in span["font"] + + +def test_image_roundtrip(): + """A PNG image embeds into a page and comes back out through the extractor. + + Covers the image codecs MuPDF was built with: the pixmap is encoded to PNG, + inserted, then recovered via `extract_image` after the PDF writer has + stored it. + """ + src = pymupdf.open() + src_page = src.new_page(width=40, height=40) + src_page.draw_rect(pymupdf.Rect(0, 0, 40, 40), color=(0, 0, 1), fill=(0, 0, 1)) + png = src_page.get_pixmap(dpi=72).tobytes("png") + src.close() + + doc = pymupdf.open() + page = doc.new_page() + page.insert_image(pymupdf.Rect(50, 50, 150, 150), stream=png) + blob = doc.tobytes() + doc.close() + + rt = pymupdf.open(stream=blob, filetype="pdf") + xref = rt[0].get_images()[0][0] + image = rt.extract_image(xref) + rt.close() + + assert image["width"] == 40 and image["height"] == 40 + assert image["image"][:4] in (b"\x89PNG", b"\xff\xd8\xff\xe0") + + +def test_page_manipulation(): + """Pages can be added, copied between documents, deleted and reordered. + + Document surgery goes through the PDF object graph rather than the + renderer, so it is a separate code path from everything above. + """ + doc = pymupdf.open() + for i in range(3): + doc.new_page().insert_text((72, 72), f"page {i}") + + other = pymupdf.open() + other.insert_pdf(doc) + assert other.page_count == 3 + + other.delete_page(1) + assert other.page_count == 2 + assert "page 2" in other[1].get_text() + + other.move_page(1, 0) + assert "page 2" in other[0].get_text() + + doc.close() + other.close() + + +def test_non_pdf_formats(): + """Images and comic archives open as documents, not just PDFs. + + MuPDF treats every input format as a document, and the handlers for them are + compile-time options — so this is the check that the build kept more than the + PDF one. A CBZ is a zip of images, which makes it constructible here without + committing a fixture. + """ + import io + import zipfile + + doc = pymupdf.open() + page = doc.new_page(width=60, height=40) + page.draw_rect(pymupdf.Rect(0, 0, 60, 40), color=None, fill=(0, 0.4, 1)) + png = page.get_pixmap(dpi=72).tobytes("png") + doc.close() + + image = pymupdf.open(stream=png, filetype="png") + assert image.page_count == 1 + assert image[0].rect.width == 60 + assert image[0].get_pixmap(dpi=72).width == 60 + image.close() + + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("001.png", png) + zf.writestr("002.png", png) + comic = pymupdf.open(stream=archive.getvalue(), filetype="cbz") + assert comic.page_count == 2 + comic.close() + + # The remaining handlers are reported rather than exercised: epub and xps need + # a fixture big enough to be worth committing, and this is what the build says. + config = pymupdf.TOOLS.fitz_config + assert all(config[name] for name in ("pdf", "img", "cbz", "epub", "xps", "svg")) + + +def test_encryption_roundtrip(): + """A password-protected PDF can be written and opened again. + + The standard security handler is MuPDF's own code rather than libcrypto, which + this build leaves out — so encryption survives while signing does not, and that + distinction is worth pinning down. + """ + doc = pymupdf.open() + doc.new_page().insert_text((72, 72), "classified") + blob = doc.tobytes( + encryption=pymupdf.PDF_ENCRYPT_AES_256, + owner_pw="owner", + user_pw="user", + ) + doc.close() + + locked = pymupdf.open(stream=blob, filetype="pdf") + assert locked.needs_pass + assert locked.authenticate("user") + assert "classified" in locked[0].get_text() + locked.close() + + +def test_write_and_reopen_from_disk(tmp_path): + """A document saves to a real file and reopens from that path. + + Apps write PDFs into Flet's app-storage directories, so the path-based + save/open pair matters as much as the in-memory one — and it is the only + test here that touches the filesystem. + """ + target = tmp_path / "written.pdf" + + doc = pymupdf.open() + doc.new_page().insert_text((72, 72), "from disk") + doc.save(str(target)) + doc.close() + + assert target.stat().st_size > 0 + + rt = pymupdf.open(str(target)) + assert rt.page_count == 1 + assert "from disk" in rt[0].get_text() + rt.close() From 379f752924a988eacd858ffead2beef483e72978 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 03:50:06 +0200 Subject: [PATCH 03/11] skills: pymupdf findings (mobile_test_pythons=ALL, appended make args, pipcl pin) [skip ci] Three things this recipe cost a cycle to learn: - forge-ci said never to dispatch `mobile_test_pythons=ALL`. That was true under flet 0.85's cp312-only packager, and stopped being true on 2026-07-14; the pinned PYTHON_BUILD_RELEASE has carried the dc76612 fix since 20260730, so a plain dispatch can now test every leg on device. - local-recipe-testing's loop used `uvx --with flet-cli`, which resolves flet-cli 0.85.2 and rejects `--python-version` outright. Replaced with the form CI uses. - forge-error-catalogue gains three build-time entries: a sub-make flag that never takes effect because upstream appends its own arguments after yours; the one extension whose build backend ignores $LDFLAGS (Android 16 KB alignment, iOS deployment target); and an unpinned build backend in [build-system] requires breaking a build with no change on your side. --- .claude/skills/forge-ci/SKILL.md | 15 ++++- .../references/failure-catalogue.md | 66 +++++++++++++++++++ .claude/skills/local-recipe-testing/SKILL.md | 10 +-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index 027bad6d..07aa58c9 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -53,8 +53,17 @@ Key structural facts: canonical (first-listed, i.e. 3.12) leg**. On the 3.13/3.14 legs they are filtered out of the package list entirely. - **Mobile tests run only on the legs listed in `mobile_test_pythons`** - (default `3.12` — and per hard experience, 3.12 is the only leg whose mobile - tests pass on this fork; never dispatch `mobile_test_pythons=ALL`). + (default `3.12`). `ALL` used to be structurally impossible — flet 0.85's + packager bundled its own CPython 3.12 and could only consume cp312 wheels, so + 3.13/3.14 died at `No matching distribution`. **That was lifted on + 2026-07-14**: under flet 0.86's version-specific packager plus a python-build + containing dc76612 (`_pyrepl` pruning + the mimalloc seccomp `open()` fix), + 3.13 and 3.14 pass genuinely on both platforms. The pinned + `PYTHON_BUILD_RELEASE` in `setup.sh` has been new enough since **20260730** + (dc76612 landed 2026-07-12), so a plain dispatch no longer needs + `python_build_run_id` — check that pin before trusting this. On an older pin, + pass a `python_build_run_id` whose run has the fix, or the stale release takes + 3.13/3.14 red again. - The mobile test bumps local wheels' build tag to `9999` in `dist-test/` so pip prefers them over same-version wheels already published on pypi.flet.dev. @@ -218,7 +227,7 @@ the log. | `packages` | `"name:"` entries, comma-separated; `:` suffix means default version. `ALL` expands to every recipe | | `prebuild_recipes` | comma-separated, **ordered**, built per-job before packages | | `python_versions` | defaults to all three; narrow for a quick re-run (e.g. `3.12.13`) | -| `mobile_test_pythons` | default `3.12` — leave it; never `ALL` on this fork. Pass `""` to build wheels WITHOUT the on-device test (e.g. when the test can't pass yet because the fix lives in unreleased serious_python — you'll test locally) | +| `mobile_test_pythons` | default `3.12`. `ALL` is valid again since the 20260730 python-build pin (see "How a run is shaped") — use it when you want every leg tested on device, and expect the run to take proportionally longer. Pass `""` to build wheels WITHOUT the on-device test (e.g. when the test can't pass yet because the fix lives in unreleased serious_python — you'll test locally) | | `archs` | default `android,iOS` | | `python_build_run_id` | a `flet-dev/python-build` Actions run-id whose artifacts to use instead of the pinned release; empty → the hardcoded FALLBACK in `build-wheels-version.yml` (grep `PYTHON_BUILD_RUN_ID: ${{ … || '' }}`). Bump that fallback to ship an unreleased python-build fix to every job | diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 99a85066..f24c75d9 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -1140,6 +1140,72 @@ A real recipe break reproduces on rerun and names a compiler/CMake error. --- +### A recipe flag in `MUPDF_MAKE` / any sub-`make` command line provably has no effect + +**Cause:** the upstream build script appends **its own** make arguments *after* the +string it took from your env var, and `make` lets the **last** command-line +assignment win. PyMuPDF's `mupdfwrap.py` does exactly this — it honours +`$MUPDF_MAKE` verbatim and then appends +`' HAVE_GLUT=no HAVE_PTHREAD=yes verbose=yes barcode=yes'`, so the recipe's +`barcode=no` was silently overridden and ~16 MB of ZXing C++ shipped in every +wheel for a feature PyMuPDF exposes no Python API for. + +**Fix:** don't trust the flag — **verify the outcome in the built artifact** +(`strings libmupdf.so | grep -c ZXing`), then patch the appending line itself. +The recipe already patches the upstream scripts, so this is one more idempotent +`_mf_edit`: `"verbose=yes barcode=yes"` → `"verbose=yes barcode=no"`. Keep the +`meta.yaml` flag as well and say in a comment that the two must stay in step. +Before flipping a feature off, check the C source has a **stub** rather than +dropping the symbol — MuPDF's `barcode.c` keeps `fz_new_barcode_pixmap` under +`#if !FZ_ENABLE_BARCODE` and throws, so the generated C++ wrapper still links. +(From `recipes/pymupdf`.) The general tell: a `meta.yaml` flag that a build-log +grep shows on the command line *and* whose effect is absent from the binary. + +--- + +### Only one extension in the wheel is missing a link flag forge exports (16 KB alignment, iOS deployment target) + +**Cause:** the package links that one library with a build backend that composes +its own link line and never reads `$LDFLAGS`. PyMuPDF's `pipcl` builds +`_extra.so` from `linker_command / general_flags / libpaths / libs / +linker_extra / pythonflags.ldflags / rpath_flag` — forge's `LDFLAGS` is not in +that list, while the other three libraries get everything through MuPDF's own +`make`. Two different symptoms, one cause: + +- Android — forge's `_check_elf_alignment` *raises* on the 4 KB `PT_LOAD`, + failing the wheel. +- iOS — no failure at all. The linker just writes a legacy + `LC_VERSION_MIN_IPHONEOS 7.0` where the siblings carry `LC_BUILD_VERSION` / + `minos 13.0`, which only shows up in `otool -l`. + +**Fix:** re-add the flags inside the backend, keyed off `CROSS_VENV_SDK`, and +source the values from the environment forge already exports so they cannot +drift (`-Wl,-z,max-page-size=16384` on android; on iOS, re-use the +`-mios-version-min=…` token parsed out of `$CFLAGS`). **Diagnostic:** compare +the load commands of every native file in the wheel against each other — +`llvm-readelf -l` / `otool -l | grep -A3 LC_BUILD_VERSION` — an odd one out is +the one its build backend linked. (From `recipes/pymupdf`.) + +--- + +### The build breaks with no change on your side (unpinned build backend) + +**Cause:** the package's `[build-system] requires` names a build backend with +**no version bound**, and forge installs `pyproject["build-system"]["requires"]` +as-is, so every build resolves whatever PyPI serves that day. PyMuPDF requires a +bare `pipcl` — which is simultaneously its PEP 517 backend and the linker for +`_mupdf`/`_extra`, and which the recipe monkeypatches — and pipcl shipped twelve +releases in the four months to 2026-07. + +**Fix:** pin it in `requirements.build` (`- pipcl 12`). `install_requirements` +runs before the pyproject requires are installed and targets the same build env, +and a bare `pipcl` requirement is then already satisfied, so the pin wins with no +patching. Raise it deliberately, with a build. **Check for this whenever a recipe +patches or monkeypatches anything in its build backend** — that is the case where +upstream drift becomes your build break. (From `recipes/pymupdf`.) + +--- + ## Runtime failures (on device/emulator/simulator) ### Flet 0.86 changed Android packaging — `sitepackages.zip` + jniLibs relocation (the umbrella behind a whole class of "worked under 0.85, fails now" on-device failures) diff --git a/.claude/skills/local-recipe-testing/SKILL.md b/.claude/skills/local-recipe-testing/SKILL.md index 68c57839..288b4f29 100644 --- a/.claude/skills/local-recipe-testing/SKILL.md +++ b/.claude/skills/local-recipe-testing/SKILL.md @@ -42,8 +42,8 @@ cp dist/-*-android_24_arm64_v8a.whl /tmp/rt_dist/ # forge's dist/ whee rm -rf tests/recipe-tester/build/site-packages tests/recipe-tester/build/.hash cd tests/recipe-tester PIP_FIND_LINKS=/tmp/rt_dist \ - uvx --prerelease allow --default-index https://pypi.flet.dev --index https://pypi.org/simple \ - --from flet-cli flet build apk --arch arm64-v8a --yes --python-version 3.12 + uvx --prerelease allow --with 'flet-cli' --with 'flet' \ + flet build apk --arch arm64-v8a --yes --python-version 3.12 cd "$REPO" # 4. Boot the rootable AVD (gotcha #4/#5), install, launch @@ -75,8 +75,8 @@ forge iphoneos:arm64 ; forge iphonesimulator:arm64 ; forge iph rm -rf tests/recipe-tester/build/site-packages tests/recipe-tester/build/.hash cd tests/recipe-tester PIP_FIND_LINKS="$(realpath ../../dist)" \ - uvx --prerelease allow --default-index https://pypi.flet.dev --index https://pypi.org/simple \ - --from flet-cli flet build ios-simulator --yes --python-version 3.12 # 0.86 pin — gotcha #13 + uvx --prerelease allow --with 'flet-cli' --with 'flet' \ + flet build ios-simulator --yes --python-version 3.12 # 0.86 pin — gotcha #13 # 3. Boot any available iPhone sim, install, launch — ALWAYS by explicit UDID # (gotcha #11: `booted` is ambiguous the moment two sims are booted) @@ -138,7 +138,7 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul 12. **Verify the staged tests + the on-device test COUNT — staging can fail silently.** `stage_recipe.sh` wipes and re-stages `recipe_tests/`; if the invocation ever fails without you noticing (a scripted loop with a bad variable — zsh does NOT word-split unquoted `$VAR` like bash, so a `for r in $RECIPES`-style loop can pass the whole list as ONE argument), the PREVIOUS recipe's tests are still staged and run happily, reporting "N passed" for the wrong package. Two cheap checks after staging: `ls tests/recipe-tester/recipe_tests/` shows YOUR test files, and the "N passed" in console.log matches your recipe's test count. (Bit during the h5py→keras loop: the same 4 stale h5py tests "passed" three times.) **Stronger still — verify the built APK's CONTENTS, not just `recipe_tests/`:** a build that *fails* can leave a STALE `build/apk/recipe-tester.apk` that installs the wrong app entirely. `unzip -l build/apk/recipe-tester.apk` should show your recipe's test `.py` inside `app.zip` AND (for a native recipe) `lib//lib*.so` for its libs. Caught an opaque run that silently installed a stale pysodium APK and reported "2 passed" for the wrong package. When in doubt nuke `build/apk` too, not just `build/site-packages`. -13. **Flet >=0.86** — plain `flet build` gets the 0.86+ packaging model; the old `uvx --prerelease allow --default-index https://pypi.flet.dev …` incantation is obsolete (harmless, but stop cargo-culting it). 0.86 ships site-packages as `sitepackages.zip` and relocates native `.so` to jniLibs — a whole class of on-device loader/data-file failures lives there (`forge-error-catalogue` § the `sitepackages.zip` class). **New default trap replacing the old one: 0.86.5's `flet build` bundles Python 3.14 by DEFAULT** — an end-user-default build resolves **cp314** wheels (verified: flet-cv2-example APK shipped `libpython3.14.so` + the cp314 opencv wheel). The loop's explicit `--python-version 3.12` still works and matches `setup.sh 3.12.13`-built recipe wheels; just know that "what users get by default" is now cp314, so a recipe published only for cp312 is invisible to a default build. +13. **Flet >=0.86 — but `uvx --with flet-cli` alone does NOT get you 0.86.** It resolved **flet-cli 0.85.2** (2026-08-19), whose `flet build` has no `--python-version` flag at all and dies with `unrecognized arguments: --python-version`. Use the form CI uses, which pulls the runtime alongside the CLI so the pair resolves to 0.86.x: `uvx --prerelease allow --with 'flet-cli' --with 'flet' flet build apk|ios-simulator --yes --python-version 3.12` (`.github/workflows/build-wheels-version.yml`). The old `--default-index https://pypi.flet.dev` incantation is separately obsolete. 0.86 ships site-packages as `sitepackages.zip` and relocates native `.so` to jniLibs — a whole class of on-device loader/data-file failures lives there (`forge-error-catalogue` § the `sitepackages.zip` class). **New default trap replacing the old one: 0.86.5's `flet build` bundles Python 3.14 by DEFAULT** — an end-user-default build resolves **cp314** wheels (verified: flet-cv2-example APK shipped `libpython3.14.so` + the cp314 opencv wheel). The loop's explicit `--python-version 3.12` still works and matches `setup.sh 3.12.13`-built recipe wheels; just know that "what users get by default" is now cp314, so a recipe published only for cp312 is invisible to a default build. ## Model assets & test-only deps From 93d80423e0b9e07704cd3ea2122c5927ea88ddda Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 04:05:58 +0200 Subject: [PATCH 04/11] =?UTF-8?q?recipe:=20pymupdf=20docs=20=E2=80=94=20qu?= =?UTF-8?q?ote=20the=20real=20error=20strings,=20correct=20measurements=20?= =?UTF-8?q?[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from checking the claims against the built wheels rather than the recipe flags: - The OCR and barcode bullets now quote what MuPDF actually answers -- `OCR Disabled in this build` and `Barcode functionality not included`, both verified present in the shipped libmupdf on each platform. - Pixmap memory was understated. Re-measured on a text-filled A4: 1.4 MB of samples at 72 dpi, 5.7 at 144, 24.9 at 300, against PNGs of 14 KB / 248 KB / 522 KB. It grows with the square of the scale, which is the point. - Added the Python-layer comparison against the same-version desktop wheel: the same 13 files, nine byte-identical, and the four that differ are the patched __init__, build metadata and the two SWIG-generated layers. That is the evidence for "upstream's documentation applies unchanged". - The tests paragraph listed coverage that predates the non-PDF-format and encryption tests, and now says what is genuinely untested. - Spelling normalised to the repo's -ise. --- recipes/pymupdf/README.md | 27 +++++++++++++++++++-------- recipes/pymupdf/tests/test_pymupdf.py | 10 +++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md index 56e986b5..adc8448c 100644 --- a/recipes/pymupdf/README.md +++ b/recipes/pymupdf/README.md @@ -185,17 +185,21 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib [`insert_font`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_font). - **There is no OCR.** MuPDF is built without Tesseract, so [`page.get_textpage_ocr()`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_textpage_ocr) - and everything downstream of it fails at runtime. A scanned PDF is a page of images to this - build: it renders perfectly and extracts no text. Tesseract would bring its own language - data files as well as the engine, which is not something to add by accident. + and anything else that builds an OCR device raises `OCR Disabled in this build`. It fails + loudly rather than returning nothing, which is the good case — but a scanned PDF is a page + of images to this build: it renders perfectly and extracts no text. Tesseract would bring + its own language data files as well as the engine, which is not something to add by + accident. - **There is no signature support.** MuPDF is built without libcrypto, so PKCS#7 signing and signature *verification* are unavailable. Encryption is unaffected — the standard security handler is MuPDF's own code, so opening a password-protected PDF with `pymupdf.open(path)` then `doc.authenticate(password)` works, as does saving with `encryption=` and owner/user passwords. -- **Also absent:** barcode generation and decoding (upstream exposes no Python API for it at - this version, and the ZXing library it would need is ~2 MB), and the `curl`, `X11` and - `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet app. +- **Also absent:** barcode generation and decoding — MuPDF's own entry points answer + `Barcode functionality not included`, though PyMuPDF exposes no Python API for them at this + version anyway, which is why the ~2 MB ZXing library is left out. Likewise the `curl`, + `X11` and `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet + app. - **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG.** `page.get_pixmap(dpi=...)` returns raw RGB samples, and they grow with the square of the scale: a text-filled A4 page is 1.4 MB at 72 dpi, 5.7 MB at 144 and **24.9 MB at 300**, @@ -208,6 +212,11 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib all of it `libmupdf`. There is no test suite or header directory to trim with `[tool.flet.cleanup]` — the library *is* the package. What you can do is ship fewer copies: on Android, `split_per_abi` or a `target_arch` narrowed to the ABIs you support. +- **The Python API is upstream's, unchanged**, so upstream's documentation and the answers + you find online apply as written. The wheel ships the same 13 Python files as the + same-version desktop wheel, nine of them byte-identical; the four that differ are + `__init__.py` (the iOS preload described above), `_build.py` (build metadata) and the two + SWIG-generated layers, which are regenerated per target by construction. - **`flet run` on your desktop uses PyPI's wheel, not this one.** That build has a different font set and different compiled-in features, so a desktop run proves your code and not the device build. `pymupdf.TOOLS.fitz_config` reports what the wheel actually has, and it @@ -278,5 +287,7 @@ What to re-verify on a bump, in rough order of how quietly it can go wrong: The tests cover import through both names, page composition, rendering to real pixels, the base-14 fonts, PNG encoding, search geometry, structured text, image round-trip, page -surgery and a save/reopen through the filesystem. They do not cover encrypted documents, the -non-PDF input formats, or `insert_htmlbox`. +surgery, an encrypted round-trip, PNG and CBZ input, and a save/reopen through the +filesystem. What they do not touch: `insert_htmlbox` and the HTML engine behind it, EPUB and +XPS input (both only asserted through `fitz_config`), and any of the compiled-out features — +the absence of OCR and signing is checked by reading the built library, not on device. diff --git a/recipes/pymupdf/tests/test_pymupdf.py b/recipes/pymupdf/tests/test_pymupdf.py index 0caea6f6..3a0a5bad 100644 --- a/recipes/pymupdf/tests/test_pymupdf.py +++ b/recipes/pymupdf/tests/test_pymupdf.py @@ -4,7 +4,7 @@ _mupdf (the SWIG wrapper over MuPDF's C++ API) and _extra — so the first thing these tests prove is that all four resolve and load. Everything after that exercises a layer that a cross-compiled MuPDF can plausibly get wrong: the -rasterizer, the base-14 fonts compiled into the library, the image codecs, and +rasteriser, the base-14 fonts compiled into the library, the image codecs, and the PDF writer. """ @@ -12,10 +12,10 @@ def render(page, dpi=72): - """Rasterize a page and return (pixmap, count of non-white pixels). + """Rasterise a page and return (pixmap, count of non-white pixels). The count is what separates "MuPDF rendered something" from "MuPDF returned - a correctly-sized blank" — a missing font or a broken rasterizer produces + a correctly-sized blank" — a missing font or a broken rasteriser produces the latter, and only the pixel data tells them apart. """ pix = page.get_pixmap(dpi=dpi) @@ -76,7 +76,7 @@ def test_metadata(): def test_render_page_to_pixels(): """Rendering produces real pixels, at the size and depth asked for. - This is the test that proves the MuPDF rasterizer was cross-compiled into + This is the test that proves the MuPDF rasteriser was cross-compiled into something that runs: `get_pixmap` walks the display list and writes RGB samples. A page with a filled rectangle on it must come back with ink. """ @@ -99,7 +99,7 @@ def test_base14_fonts_are_built_in(): MuPDF turns its bundled fonts into C arrays at build time, so a phone with no fontconfig and no /usr/share/fonts still draws glyphs. If that codegen - were skipped, this page would rasterize blank while `get_text` still + were skipped, this page would rasterise blank while `get_text` still reported the string, so the assertion has to be about pixels. """ doc = pymupdf.open() From 3b659ade8015c1d03d906951916db605f275d01f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 04:16:41 +0200 Subject: [PATCH 05/11] pymupdf example: stop the keyboard autocorrecting the search query [skip ci] Driving the example on an iOS simulator turned a typed `quartz` into `Quarts` before it ever reached search_for, which then reported no hits on a page that plainly contains the word. search_for matches the literal string, so the field now sets autocorrect=False, enable_suggestions=False and capitalization=NONE. Verified on both platforms afterwards: 4 hits, highlighted. --- recipes/pymupdf/examples/render-and-read/src/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/recipes/pymupdf/examples/render-and-read/src/main.py b/recipes/pymupdf/examples/render-and-read/src/main.py index f713bb9e..115a532b 100644 --- a/recipes/pymupdf/examples/render-and-read/src/main.py +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -262,9 +262,15 @@ def on_search(e): f"pymupdf {pymupdf.__version__} · MuPDF {pymupdf.mupdf_version}", size=11, ), + # search_for matches the literal string, so the phone keyboard + # must not "help": autocorrect turned quartz into Quarts on a + # simulator, which searches for a word the page does not contain. ft.TextField( label="Search this page", dense=True, + autocorrect=False, + enable_suggestions=False, + capitalization=ft.TextCapitalization.NONE, on_submit=on_search, on_blur=on_search, ), From 34a4455728a21b29f3ecf19fbe676f5a76d93b3e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 04:45:17 +0200 Subject: [PATCH 06/11] pymupdf example: drop the zoom slider [skip ci] The slider changed the render scale but not the displayed size: the image sits in a BoxFit.CONTAIN container, so every scale was drawn into the same box. Above the 2x default each step was also downscaled back to the same ~770 physical pixels, so the control did nothing you could see. Making it work would have meant sizing the image in logical pixels, scrolling it in both axes, and tracking page.width through an on_resize handler -- a lot of apparatus for a point that does not land on a phone screen. Removed instead, at the maintainer's call. The framing had leaked well past the widget: page 1 of the generated PDF invited you to "Zoom in", page 3 explained that extraction is unaffected by zoom, and vector_page's docstring opened by naming the slider. All of that is now written without it -- the vector page still makes its point, that shapes are page operators and the renderer decides how many pixels each becomes. Rendering is a fixed RENDER_SCALE, and the caption reports what it cost. --- recipes/pymupdf/README.md | 8 +-- .../examples/render-and-read/README.md | 20 +++--- .../examples/render-and-read/src/main.py | 69 +++++++------------ 3 files changed, 39 insertions(+), 58 deletions(-) diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md index adc8448c..b3bb1428 100644 --- a/recipes/pymupdf/README.md +++ b/recipes/pymupdf/README.md @@ -83,8 +83,8 @@ files you own on disk — not for the `stream=` case. See runnable Flet apps in [`examples/`](examples): -- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it at a - zoom you choose, and highlights search hits on the rendered page. +- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it to + an image, and highlights search hits on the rendered page. ## Threading @@ -99,8 +99,8 @@ None of the four libraries starts a thread of its own: no extension in either wh references `pthread_create`, or any OpenMP symbol. So all the concurrency is whatever your app introduces. -Rendering is genuinely slow enough to need a thread — a full page at high zoom is several -megapixels — and MuPDF releases the GIL while it works, so +Rendering is genuinely slow enough to need a thread — a full page at a useful scale is +several megapixels — and MuPDF releases the GIL while it works, so [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) really does keep the UI live. But `run_thread` submits to a thread *pool*, so two handlers started close together will run inside MuPDF at the same time. Serialise them yourself: diff --git a/recipes/pymupdf/examples/render-and-read/README.md b/recipes/pymupdf/examples/render-and-read/README.md index 586fc0ac..72597cbb 100644 --- a/recipes/pymupdf/examples/render-and-read/README.md +++ b/recipes/pymupdf/examples/render-and-read/README.md @@ -1,9 +1,9 @@ # pymupdf render and read A three-page PDF, built in memory when the app starts, then shown one page at a time as a -rendered image. Page through it, drag the zoom from 1× to 4× and watch the caption report -the pixel count and the milliseconds it took, and type a word into the search field to see -every occurrence highlighted in yellow on the page. +rendered image. Page through it, and type a word into the search field to see every +occurrence highlighted in yellow on the page. The caption reports how many pixels MuPDF +produced and how long it took. What it demonstrates: @@ -17,21 +17,21 @@ What it demonstrates: - **That the fonts are inside the wheel.** Page 1 sets the same sentence in four of the base-14 faces. Nothing loads a font file; a phone has no PostScript fonts and no fontconfig, and the glyphs still draw because MuPDF compiles them into the library. -- **Vector, not pixels.** Page 2 is a bar chart, a Bézier and three primitives written with - page operators, so raising the zoom produces real detail rather than a bigger blur. The - same slider on a page of scanned images would only enlarge them. +- **Vector, not pixels.** Page 2 is a bar chart, a Bézier and three primitives written as + page operators rather than an image, so the renderer decides how many pixels each one + becomes. Ask for a larger pixmap and you get more detail, not a bigger blur. - **Text that survives the render.** [`page.search_for`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.search_for) returns a rectangle per hit, in page points; the app turns each into a [highlight annotation](https://pymupdf.readthedocs.io/en/latest/page.html#Page.add_highlight_annot), renders, then deletes the annotations again so the document is unchanged between renders. - Hit coordinates do not move when you zoom — only the renderer's scale does. + A hit is measured in page points, independent of the scale it is drawn at. - **Compute off the UI thread** — every render runs in [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) with a - spinner up, driven from the slider's `on_change_end` so one gesture means one - rasterisation, and the handler ends with the explicit + spinner up, and the handler ends with the explicit [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) that a background - thread needs. + thread needs. A module-level lock serialises the renders, because PyMuPDF does not support + concurrent use and `run_thread` hands work to a pool. The document is generated rather than bundled, so the example ships no asset — and composing a PDF is itself half of what PyMuPDF does. diff --git a/recipes/pymupdf/examples/render-and-read/src/main.py b/recipes/pymupdf/examples/render-and-read/src/main.py index 115a532b..0baf5f72 100644 --- a/recipes/pymupdf/examples/render-and-read/src/main.py +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -13,6 +13,10 @@ MUPDF = threading.Lock() PAGE_W, PAGE_H = 400.0, 520.0 +# Real pixels asked of MuPDF per point of page. Phones draw at 2-3x and Flet +# reports no device ratio, so this is a fixed compromise: crisp on a phone at +# 800x1040 px, and only ~2.4 MB of samples per render. +RENDER_SCALE = 2.0 INK = (0.11, 0.12, 0.16) MUTED = (0.42, 0.45, 0.52) ACCENT = (0.15, 0.39, 0.92) @@ -66,8 +70,8 @@ def typography_page(doc): pymupdf.Rect(26, y + 10, PAGE_W - 26, PAGE_H - 20), "These are four of the base-14 PDF faces. A phone carries no PostScript " "fonts and no fontconfig, so every glyph above came out of the library " - "itself. Zoom in: they stay sharp, because the page stores outlines and " - "the rasteriser fills them at whatever scale you ask for.", + "itself. The page stores outlines rather than pixels, so the rasteriser " + "fills them at whatever size it is asked for.", fontname="helv", fontsize=8.5, color=MUTED, @@ -78,9 +82,8 @@ def typography_page(doc): def vector_page(doc): """A bar chart and some primitives, drawn with page operators rather than pixels. - The point of the page is what the zoom slider does to it: these shapes are - stored as coordinates, so raising the scale produces genuinely more detail - instead of a larger blur. + Everything here is stored as coordinates rather than pixels, so it is the + rasteriser that decides how many of them each shape becomes. """ page = doc.new_page(width=PAGE_W, height=PAGE_H) banner(page, 2, TITLES[1]) @@ -111,8 +114,7 @@ def vector_page(doc): pymupdf.Point(26, base), pymupdf.Point(PAGE_W - 26, base), color=INK, width=0.9 ) - # A Shape batches drawing commands into one page operator run; the curve and - # the three primitives below it exist to give the zoom something to sharpen. + # A Shape batches drawing commands into a single page operator run. shape = page.new_shape() shape.draw_bezier( pymupdf.Point(34, 400), @@ -139,9 +141,9 @@ def text_page(doc): "highlight gets placed.\n\n" "Type a word into the search field to see it marked on the page. Try " "quartz, or rectangle, or MuPDF.\n\n" - "Because glyphs carry their own coordinates, extraction is unaffected by " - "the zoom. The rectangle of a hit is measured in points on the page; only " - "the renderer decides how many pixels a point becomes.", + "Because glyphs carry their own coordinates, a hit is measured in points " + "on the page, and stays put however many pixels the renderer decides a " + "point should become.", fontname="helv", fontsize=10, color=INK, @@ -166,8 +168,8 @@ def build_document(): DOC = build_document() -def render(index, zoom, term): - """Rasterise one page at `zoom`, highlighting `term`, and return PNG bytes. +def render(index, term): + """Rasterise one page, highlighting `term`, and return PNG bytes. Hits are marked with real highlight annotations and deleted again once the pixmap exists, which keeps the document itself unchanged between renders -- @@ -180,13 +182,13 @@ def render(index, zoom, term): annotations = [page.add_highlight_annot(rect) for rect in hits] started = time.perf_counter() - pixmap = page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom)) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(RENDER_SCALE, RENDER_SCALE)) png = pixmap.tobytes("png") elapsed = time.perf_counter() - started # Read the dimensions and drop the pixmap before releasing the lock: every - # attribute on it is a call back into MuPDF, and a full page at 4x is tens - # of megabytes of samples that nothing needs once the PNG exists. + # attribute on it is a call back into MuPDF, and the samples are megabytes + # that nothing needs once the PNG exists. size = (pixmap.width, pixmap.height) del pixmap @@ -197,13 +199,12 @@ def render(index, zoom, term): def main(page: ft.Page): - """Show one rendered page at a time, with page navigation, zoom and search. + """Show one rendered page at a time, with page navigation and search. - Rendering is pushed to a background thread: at the top of the zoom range a - page is several megapixels, and doing that on the UI thread would stall the - slider mid-drag. + Rendering is pushed to a background thread: a page is a few megabytes of + samples, and doing that on the UI thread would freeze it mid-tap. """ - state = {"index": 0, "zoom": 2.0, "term": ""} + state = {"index": 0, "term": ""} def redraw(): """Kick off a render for the current state, with the spinner up.""" @@ -213,9 +214,7 @@ def redraw(): def work(): """Render on a background thread, then refill the image and the caption.""" - png, hits, (width, height), elapsed = render( - state["index"], state["zoom"], state["term"] - ) + png, hits, (width, height), elapsed = render(state["index"], state["term"]) sheet.src = png position.value = ( f"{state['index'] + 1} / {DOC.page_count} · {TITLES[state['index']]}" @@ -224,7 +223,8 @@ def work(): "" if not state["term"] else f"{hits} hit{'' if hits == 1 else 's'}" ) stats.value = ( - f"{width}x{height} px at {state['zoom']:.1f}x in {elapsed * 1e3:.0f} ms" + f"rasterised {width}x{height} px in {elapsed * 1e3:.0f} ms · " + f"{len(png) / 1024:.0f} KB PNG" ) spinner.visible = False page.update() # auto-update does not reach background threads @@ -238,15 +238,6 @@ def handler(e): return handler - def on_zoom(e): - """Re-render at the slider's scale once the finger lifts. - - on_change_end rather than on_change: a drag emits a value per pixel, and - each one would queue a full-page rasterisation. - """ - state["zoom"] = e.control.value - redraw() - def on_search(e): """Re-render with the new search term highlighted.""" state["term"] = e.control.value.strip() @@ -303,22 +294,12 @@ def on_search(e): ), ft.Row( controls=[ - ft.Text("zoom", size=11), - ft.Slider( - min=1.0, - max=4.0, - value=2.0, - divisions=6, - label="{value}x", - expand=True, - on_change_end=on_zoom, - ), + stats := ft.Text(size=11, expand=True), spinner := ft.ProgressRing( width=14, height=14, visible=False ), ] ), - stats := ft.Text(size=11), ] ), ) From e64cc42370c82622a970a2a71f39ba6670b2ec22 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 14:50:20 +0200 Subject: [PATCH 07/11] forge: let the effective environment win for every script_env template var Follow-up to the {CC}/{CXX}/{AR} fix, from review. Re-asserting five keys after the merge treated the symptom I had hit; the same shadowing applies to every variable forge adjusts. sysconfig_data was merged last, so a recipe template got python-build's build-time constant instead of the value forge exports -- the compiler paths, and equally the CFLAGS/CPPFLAGS/LDFLAGS forge extends with SDK, sysroot and opt/lib search paths. Ordering env last states the rule once and stops the allowlist growing the next time forge adjusts something sysconfig also defines. Measured on pymupdf/android arm64-v8a, diffing the expansions against the previous build: XCFLAGS is unchanged (forge's include paths were already in sysconfigdata's copy) and XLDFLAGS gains exactly one entry -- -L.../site-packages/opt/lib, the path a flet-lib* host dep installs into. Nothing is lost from either. That gap was latent rather than biting: no recipe in the tree hands {LDFLAGS} to a sub-make and links a flet-lib*, and pymupdf passes XLDFLAGS explicitly, which is why every slice built green without it. Also moves scheme_paths above sysconfig_data. That is a no-op -- get_paths() and get_config_vars() share no keys -- but it keeps the dict ordered from least to most specific. Beyond the compiler and flags keys, the only variable whose source changes is ANDROID_API_LEVEL, which 13 recipes interpolate into CMake arguments. sysconfig holds int 24 and forge sets str(sdk_version), which cross.py pins to "24", so both render identically. The comment now says what is actually true: script_vars is forge's environment, not the recipe's. The loop below appends to env's LDFLAGS/CFLAGS/CPPFLAGS while processing script_env, and script_vars is a snapshot taken before that, so a recipe that both sets one of those and refers to it as {...} sees the value without its own additions. pymupdf relies on exactly that. --- src/forge/build.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/forge/build.py b/src/forge/build.py index 7f3bad99..38a20a65 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -565,25 +565,29 @@ def compile_env(self, **kwargs) -> dict[str, str]: env["ANDROID_API_LEVEL"] = str(self.cross_venv.sdk_version) env["HOST_TRIPLET"] = self.cross_venv.platform_triplet + # `env` wins. Everything forge has adjusted above — the compiler and + # binutils re-pointed at the installed NDK, and the CFLAGS/CPPFLAGS/LDFLAGS + # it extended with SDK, sysroot and `opt/lib` search paths — also exists as + # a build-time constant in `_sysconfigdata`, so merging sysconfig last would + # hand recipe templates the stale value. That is how `{CC}` came to expand + # to a path inside an embedded NDK that is not present on this host + # ("clang: not found" from a sub-make on the Android 3.14 tree), and how + # `{LDFLAGS}` came to omit the `opt/lib` a `flet-lib*` host dep installs + # into. Ordering it this way states the rule once instead of maintaining a + # list of keys to re-assert. + # + # This is forge's environment, not the recipe's: the loop below *appends* + # to env's LDFLAGS/CFLAGS/CPPFLAGS as it processes `script_env`, and + # `script_vars` is a snapshot taken before that. A recipe that both sets + # one of those and refers to it as `{...}` elsewhere therefore sees the + # value without its own additions. script_vars = { - **env, - **self.cross_venv.scheme_paths, **self.cross_venv.sysconfig_data, + **self.cross_venv.scheme_paths, + **env, "sysconfigdata_name": self.cross_venv.sysconfigdata_name, } - # `**sysconfig_data` above re-shadows the compiler/binutils keys with the - # values python-build baked into `_sysconfigdata`, which on some support - # trees (e.g. Android 3.14) are absolute paths into an embedded NDK that - # isn't present on this host. `env` already re-pointed those to the real - # installed toolchain (see the NDK_HOME fix-up above), so re-assert the - # env values here — otherwise a recipe that references `{CC}`/`{CXX}`/... - # in `script_env` (e.g. to hand a cross compiler to a sub-`make`) would - # receive the stale embedded path and fail with "clang: not found". - for _tool in ("CC", "CXX", "AR", "RANLIB", "STRIP"): - if _tool in env: - script_vars[_tool] = env[_tool] - # Set up any additional environment variables needed in the script environment. for key, value in self.package.meta["build"]["script_env"].items(): if key in ["LDFLAGS", "CFLAGS", "CPPFLAGS"]: From 240fc512ea4ff0364e37a808bf0e0c62ccf93320 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 20 Aug 2026 16:39:51 +0200 Subject: [PATCH 08/11] improve --- .claude/skills/forge-ci/SKILL.md | 15 +- .claude/skills/local-recipe-testing/SKILL.md | 2 - recipes/pymupdf/README.md | 68 +++-- .../examples/render-and-read/README.md | 6 +- .../examples/render-and-read/src/document.py | 198 +++++++++++++++ .../examples/render-and-read/src/main.py | 236 ++---------------- recipes/pymupdf/meta.yaml | 56 ++--- 7 files changed, 280 insertions(+), 301 deletions(-) create mode 100644 recipes/pymupdf/examples/render-and-read/src/document.py diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index 07aa58c9..d7f530cd 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -52,18 +52,7 @@ Key structural facts: wheels that are identical on every Python leg, so they build **only on the canonical (first-listed, i.e. 3.12) leg**. On the 3.13/3.14 legs they are filtered out of the package list entirely. -- **Mobile tests run only on the legs listed in `mobile_test_pythons`** - (default `3.12`). `ALL` used to be structurally impossible — flet 0.85's - packager bundled its own CPython 3.12 and could only consume cp312 wheels, so - 3.13/3.14 died at `No matching distribution`. **That was lifted on - 2026-07-14**: under flet 0.86's version-specific packager plus a python-build - containing dc76612 (`_pyrepl` pruning + the mimalloc seccomp `open()` fix), - 3.13 and 3.14 pass genuinely on both platforms. The pinned - `PYTHON_BUILD_RELEASE` in `setup.sh` has been new enough since **20260730** - (dc76612 landed 2026-07-12), so a plain dispatch no longer needs - `python_build_run_id` — check that pin before trusting this. On an older pin, - pass a `python_build_run_id` whose run has the fix, or the stale release takes - 3.13/3.14 red again. +- Mobile tests run only on the legs listed in `mobile_test_pythons` (default: `3.12`). - The mobile test bumps local wheels' build tag to `9999` in `dist-test/` so pip prefers them over same-version wheels already published on pypi.flet.dev. @@ -227,7 +216,7 @@ the log. | `packages` | `"name:"` entries, comma-separated; `:` suffix means default version. `ALL` expands to every recipe | | `prebuild_recipes` | comma-separated, **ordered**, built per-job before packages | | `python_versions` | defaults to all three; narrow for a quick re-run (e.g. `3.12.13`) | -| `mobile_test_pythons` | default `3.12`. `ALL` is valid again since the 20260730 python-build pin (see "How a run is shaped") — use it when you want every leg tested on device, and expect the run to take proportionally longer. Pass `""` to build wheels WITHOUT the on-device test (e.g. when the test can't pass yet because the fix lives in unreleased serious_python — you'll test locally) | +| `mobile_test_pythons` | default `3.12`. | | `archs` | default `android,iOS` | | `python_build_run_id` | a `flet-dev/python-build` Actions run-id whose artifacts to use instead of the pinned release; empty → the hardcoded FALLBACK in `build-wheels-version.yml` (grep `PYTHON_BUILD_RUN_ID: ${{ … || '' }}`). Bump that fallback to ship an unreleased python-build fix to every job | diff --git a/.claude/skills/local-recipe-testing/SKILL.md b/.claude/skills/local-recipe-testing/SKILL.md index 288b4f29..e0c23c6d 100644 --- a/.claude/skills/local-recipe-testing/SKILL.md +++ b/.claude/skills/local-recipe-testing/SKILL.md @@ -138,8 +138,6 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul 12. **Verify the staged tests + the on-device test COUNT — staging can fail silently.** `stage_recipe.sh` wipes and re-stages `recipe_tests/`; if the invocation ever fails without you noticing (a scripted loop with a bad variable — zsh does NOT word-split unquoted `$VAR` like bash, so a `for r in $RECIPES`-style loop can pass the whole list as ONE argument), the PREVIOUS recipe's tests are still staged and run happily, reporting "N passed" for the wrong package. Two cheap checks after staging: `ls tests/recipe-tester/recipe_tests/` shows YOUR test files, and the "N passed" in console.log matches your recipe's test count. (Bit during the h5py→keras loop: the same 4 stale h5py tests "passed" three times.) **Stronger still — verify the built APK's CONTENTS, not just `recipe_tests/`:** a build that *fails* can leave a STALE `build/apk/recipe-tester.apk` that installs the wrong app entirely. `unzip -l build/apk/recipe-tester.apk` should show your recipe's test `.py` inside `app.zip` AND (for a native recipe) `lib//lib*.so` for its libs. Caught an opaque run that silently installed a stale pysodium APK and reported "2 passed" for the wrong package. When in doubt nuke `build/apk` too, not just `build/site-packages`. -13. **Flet >=0.86 — but `uvx --with flet-cli` alone does NOT get you 0.86.** It resolved **flet-cli 0.85.2** (2026-08-19), whose `flet build` has no `--python-version` flag at all and dies with `unrecognized arguments: --python-version`. Use the form CI uses, which pulls the runtime alongside the CLI so the pair resolves to 0.86.x: `uvx --prerelease allow --with 'flet-cli' --with 'flet' flet build apk|ios-simulator --yes --python-version 3.12` (`.github/workflows/build-wheels-version.yml`). The old `--default-index https://pypi.flet.dev` incantation is separately obsolete. 0.86 ships site-packages as `sitepackages.zip` and relocates native `.so` to jniLibs — a whole class of on-device loader/data-file failures lives there (`forge-error-catalogue` § the `sitepackages.zip` class). **New default trap replacing the old one: 0.86.5's `flet build` bundles Python 3.14 by DEFAULT** — an end-user-default build resolves **cp314** wheels (verified: flet-cv2-example APK shipped `libpython3.14.so` + the cp314 opencv wheel). The loop's explicit `--python-version 3.12` still works and matches `setup.sh 3.12.13`-built recipe wheels; just know that "what users get by default" is now cp314, so a recipe published only for cp312 is invisible to a default build. - ## Model assets & test-only deps `stage_recipe.sh` copies **every** file in `recipes//tests/` into the app (`cp -r tests/. recipe_tests/`), so a model dropped next to the test file becomes an app asset. Two tiers: diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md index b3bb1428..9c25fc01 100644 --- a/recipes/pymupdf/README.md +++ b/recipes/pymupdf/README.md @@ -7,10 +7,10 @@ to a bitmap at any scale; pulls the text back out with coordinates; and writes d scratch. On mobile that matters twice over — the file never leaves the device, and rendering a page locally is the difference between a viewer and a download button. -**The wheel is self-contained: four native libraries ship inside it.** MuPDF itself +The wheel is self-contained: four native libraries ship inside it, i.e., MuPDF itself (`libmupdf`), its C++ wrapper (`libmupdfcpp`), the SWIG module over that wrapper (`_mupdf`) -and PyMuPDF's own accelerator (`_extra`). There is no companion `flet-lib*` package to add — -but the four have to find each other at load time, and how that works differs between the +and PyMuPDF's own accelerator (`_extra`). They have to find each other at load time, +and how that works differs between the platforms, so it is described under [Android notes](#android-notes) and [iOS notes](#ios-notes) rather than here. @@ -28,20 +28,12 @@ dependencies = [ ] ``` -Nothing else to configure on Android: one extra wheel comes along and needs no entry of its -own, `flet-libcpp-shared`, the NDK C++ runtime that MuPDF's C++ wrapper links against. On -iOS there is no such dependency — the system `/usr/lib/libc++.1.dylib` covers it. - -**iOS needs Flet 0.86 or newer.** The iOS wheel relies on serious-python 4.2.1 (PR #223) +**iOS needs Flet 0.86 or newer:** The iOS wheel relies on +[serious-python](https://github.com/flet-dev/serious-python) 4.2.1 +([PR #223](https://github.com/flet-dev/serious-python/pull/223)) relocating its bundled libraries into framework bundles, and on the marker files that leaves behind; on an older Flet the libraries land somewhere the loader will not look and the app -dies at `import pymupdf` with `Library not loaded: @rpath/libmupdf.dylib`. Android has no -such floor. - -No [`[tool.flet.android] extract_packages`](https://flet.dev/docs/publish/android/#extract-packages) -entry is needed. Under Flet 0.86 Android ships site-packages as a compressed archive, which -breaks any package that opens a bundled data file by path — pymupdf has none. The only -non-code file in the wheel is an empty `py.typed`. +dies at `import pymupdf` with `Library not loaded: @rpath/libmupdf.dylib`. Builds for all three Android ABIs Flet targets (arm64-v8a, armeabi-v7a, x86_64) and for iOS device and simulator, on Python 3.12, 3.13 and 3.14. @@ -49,12 +41,13 @@ device and simulator, on Python 3.12, 3.13 and 3.14. ## Storage Most of the time you want no file at all. A document can be opened from a `bytes` object and -written back to one, and a rendered page goes straight into a Flet control: +written back to one, and a rendered page goes straight into a Flet control like [`Image`](https://flet.dev/docs/controls/image) +which supports `bytes` as source: ```python doc = pymupdf.open(stream=blob, filetype="pdf") # no path png = doc[0].get_pixmap(dpi=144).tobytes("png") -image.src = png # ft.Image.src takes bytes +ft.Image(src=png) ``` When a document does belong on disk, put it in Flet's app storage — the working directory is @@ -71,8 +64,8 @@ doc.save(os.path.join(data, "report.pdf")) is for documents the user expects to keep; [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) is for anything you can regenerate, such as a cache of rendered page images, and may be -cleared between launches. A PDF shipped with the app is an asset: put it under `src/assets/` -and read it from +cleared between launches. A PDF shipped with the app is an asset: put it in your +[assets directory](https://flet.dev/docs/cookbook/assets) and read it later on using [`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). [`doc.save(path, incremental=True)`](https://pymupdf.readthedocs.io/en/latest/document.html#Document.save) @@ -118,8 +111,15 @@ def work(): Disabling the button that starts the work is not a substitute — it cannot catch a tap already in flight. Note also that exceptions raised inside `run_thread` are swallowed, so wrap the body if you want to see a `pymupdf.FileDataError` rather than a screen that never updates. -If you need real parallelism, upstream's answer is multiprocessing with one document per -process, which is not available to you here. +If you need real parallelism, upstream's answer is +[multiprocessing](https://flet.dev/docs/cookbook/multiprocessing) with one document per +process — which mobile rules out, since neither platform lets an app spawn children. +[Subinterpreters](https://flet.dev/docs/cookbook/subinterpreters), the in-process +alternative on Python 3.14, do not help either: PyMuPDF's extensions use single-phase +init, so importing it inside one fails with `ImportError: module _extra does not support +loading in subinterpreters`, and every entry point — `pymupdf`, `fitz`, `pymupdf.mupdf` — +trips the same check. That is upstream's to change, not this recipe's; it fails the same +way on desktop. On device, one thread behind the lock is the whole story. ## Android notes @@ -129,8 +129,8 @@ accepts bare `lib*.so`, so a stock `libmupdf.so.27.2` soname would leave `_mupdf a file that cannot be packaged. What ships is `libmupdf.so`, and the dependency entries naming it match. -`libmupdfcpp`, `_mupdf` and `libmupdf` all link `libc++_shared.so`, which is the -`flet-libcpp-shared` dependency in [Install](#install); Android does not provide the NDK C++ +`libmupdfcpp`, `_mupdf` and `libmupdf` all link `libc++_shared.so` (from +`flet-libcpp-shared` dependency); Android does not provide the NDK C++ runtime itself. Every `PT_LOAD` segment is 16 KB-aligned, so the wheels load on Android 15 devices with 16 KB pages. @@ -155,8 +155,6 @@ loads `libmupdf` and then `libmupdfcpp` with `RTLD_GLOBAL` before importing `_ex lets dyld satisfy each `@rpath` reference from an image that is already in memory. That preload is why the [Flet floor](#install) exists. It is inert on Android and on desktop. -There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib/libc++.1.dylib`. - | | device arm64 | simulator arm64 | simulator x86_64 | | --- | --- | --- | --- | | `libmupdf.dylib` | 54.3 MB | 54.9 MB | 55.0 MB | @@ -167,7 +165,7 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib ## Things to know -- **Fonts are compiled into the library, and that is most of the wheel.** MuPDF turns its +- **Fonts are compiled into the library, and that is most of the wheel:** MuPDF turns its bundled fonts into C arrays at build time, so text renders on a device that has no PostScript fonts and no fontconfig — including scripts a PDF did not embed a font for. This build keeps the whole set: the base-14 faces, 159 Noto families, `DroidSansFallback` @@ -176,21 +174,21 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib the Noto set. If your PDFs embed their own fonts — most produced by real software do — you are paying for a fallback you will not use, but the choice is made at build time and cannot be changed from an app. -- **The base-14 faces are Latin-1 only.** `page.insert_text(..., fontname="helv")` with an em +- **The base-14 faces are Latin-1 only:** `page.insert_text(..., fontname="helv")` with an em dash, a curly quote or any non-Latin-1 character silently rasterises it as `?`. There is no exception; the string you read back with `get_text` is not what you see. Use [`insert_htmlbox`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_htmlbox), which lays text out through MuPDF's HTML engine and picks a font that has the glyph, or embed a font of your own with [`insert_font`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_font). -- **There is no OCR.** MuPDF is built without Tesseract, so +- **There is no OCR:** MuPDF is built without Tesseract, so [`page.get_textpage_ocr()`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_textpage_ocr) and anything else that builds an OCR device raises `OCR Disabled in this build`. It fails loudly rather than returning nothing, which is the good case — but a scanned PDF is a page of images to this build: it renders perfectly and extracts no text. Tesseract would bring its own language data files as well as the engine, which is not something to add by accident. -- **There is no signature support.** MuPDF is built without libcrypto, so PKCS#7 signing and +- **There is no signature support:** MuPDF is built without libcrypto, so PKCS#7 signing and signature *verification* are unavailable. Encryption is unaffected — the standard security handler is MuPDF's own code, so opening a password-protected PDF with `pymupdf.open(path)` then `doc.authenticate(password)` works, as does saving with @@ -200,7 +198,7 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib version anyway, which is why the ~2 MB ZXing library is left out. Likewise the `curl`, `X11` and `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet app. -- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG.** +- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG:** `page.get_pixmap(dpi=...)` returns raw RGB samples, and they grow with the square of the scale: a text-filled A4 page is 1.4 MB at 72 dpi, 5.7 MB at 144 and **24.9 MB at 300**, where the PNG `tobytes("png")` produces is 14 KB, 248 KB and 522 KB. Only the PNG crosses @@ -208,7 +206,7 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib have the bytes, and set [`gapless_playback=True`](https://flet.dev/docs/controls/image/) on the `ft.Image` or it blanks between frames. -- **Size.** The wheel is about 41 MB and unpacks to 69–74 MB depending on the slice, nearly +- **Size:** The wheel is about 41 MB and unpacks to 69–74 MB depending on the slice, nearly all of it `libmupdf`. There is no test suite or header directory to trim with `[tool.flet.cleanup]` — the library *is* the package. What you can do is ship fewer copies: on Android, `split_per_abi` or a `target_arch` narrowed to the ABIs you support. @@ -217,7 +215,7 @@ There is no `libc++_shared` equivalent: the extensions link the system `/usr/lib same-version desktop wheel, nine of them byte-identical; the four that differ are `__init__.py` (the iOS preload described above), `_build.py` (build metadata) and the two SWIG-generated layers, which are regenerated per target by construction. -- **`flet run` on your desktop uses PyPI's wheel, not this one.** That build has a different +- **`flet run` on your desktop uses PyPI's wheel, not this one:** That build has a different font set and different compiled-in features, so a desktop run proves your code and not the device build. `pymupdf.TOOLS.fitz_config` reports what the wheel actually has, and it differs between the two. @@ -256,7 +254,7 @@ change with its own CI run. What to re-verify on a bump, in rough order of how quietly it can go wrong: -- **That barcode is still off.** `MUPDF_MAKE` says `barcode=no`, and that setting alone does +- **That barcode is still off:** `MUPDF_MAKE` says `barcode=no`, and that setting alone does nothing: MuPDF's wrapper script appends `barcode=yes` after it and make lets the last command-line assignment win, so the patch has to rewrite that token too. If either half is lost the build stays green and ZXing quietly returns. Check `strings libmupdf.so | grep @@ -264,7 +262,7 @@ What to re-verify on a bump, in rough order of how quietly it can go wrong: - **The sonames, on Android.** They must be unversioned. A change in how `SO_VERSION=` is handled upstream produces a wheel that builds, packages and then fails to `dlopen` on device — the first symptom is an on-device test failure, not a build error. -- **`_extra` on both platforms.** It is the one library pipcl links from its own flag list, +- **`_extra` on both platforms:** It is the one library pipcl links from its own flag list, ignoring everything forge exports, so it is where dropped link flags show up: 16 KB `PT_LOAD` alignment on Android, and `LC_BUILD_VERSION` with a sane `minos` rather than a legacy `LC_VERSION_MIN_IPHONEOS` on iOS. Both are re-added by the patch and both are easy @@ -275,7 +273,7 @@ What to re-verify on a bump, in rough order of how quietly it can go wrong: - **The compiled-out feature list**, read out of the built library rather than off the `MUPDF_MAKE` flags. The barcode case above is precisely why: a flag in the recipe is not evidence about the wheel. -- **Whether `extract_packages` is still unnecessary.** It holds only while nothing in the +- **Whether `extract_packages` is necessary:** It holds only if something in the package opens a bundled file by path. A new data file upstream flips it, and the symptom is an import failure on Android only. - **The font set**, which is the size story and the [Things to know](#things-to-know) claim diff --git a/recipes/pymupdf/examples/render-and-read/README.md b/recipes/pymupdf/examples/render-and-read/README.md index 72597cbb..7153c406 100644 --- a/recipes/pymupdf/examples/render-and-read/README.md +++ b/recipes/pymupdf/examples/render-and-read/README.md @@ -3,7 +3,7 @@ A three-page PDF, built in memory when the app starts, then shown one page at a time as a rendered image. Page through it, and type a word into the search field to see every occurrence highlighted in yellow on the page. The caption reports how many pixels MuPDF -produced and how long it took. +produced, how long it took, and how big the PNG came out. What it demonstrates: @@ -14,13 +14,13 @@ What it demonstrates: encodes the result, which [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src) accepts as bytes — no temp file, no base64. -- **That the fonts are inside the wheel.** Page 1 sets the same sentence in four of the +- **That the fonts are inside the wheel.:** Page 1 sets the same sentence in four of the base-14 faces. Nothing loads a font file; a phone has no PostScript fonts and no fontconfig, and the glyphs still draw because MuPDF compiles them into the library. - **Vector, not pixels.** Page 2 is a bar chart, a Bézier and three primitives written as page operators rather than an image, so the renderer decides how many pixels each one becomes. Ask for a larger pixmap and you get more detail, not a bigger blur. -- **Text that survives the render.** +- **Text that survives the render:** [`page.search_for`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.search_for) returns a rectangle per hit, in page points; the app turns each into a [highlight annotation](https://pymupdf.readthedocs.io/en/latest/page.html#Page.add_highlight_annot), diff --git a/recipes/pymupdf/examples/render-and-read/src/document.py b/recipes/pymupdf/examples/render-and-read/src/document.py new file mode 100644 index 00000000..627fc20d --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/src/document.py @@ -0,0 +1,198 @@ +import threading +import time + +import pymupdf + +PAGE_W, PAGE_H = 400.0, 520.0 +# Real pixels asked of MuPDF per point of page. Phones draw at 2-3x and Flet +# reports no device ratio, so this is a fixed compromise: crisp on a phone at +# 800x1040 px, and only ~2.4 MB of samples per render. +RENDER_SCALE = 2.0 +INK = (0.11, 0.12, 0.16) +MUTED = (0.42, 0.45, 0.52) +ACCENT = (0.15, 0.39, 0.92) +RULE = (0.88, 0.89, 0.92) + +# The base-14 PDF fonts are Latin-1, so the document text stays ASCII: an em dash +# passed to insert_text comes out of the rasteriser as a "?" glyph. +FACES = ( + ("helv", "Helvetica"), + ("tiro", "Times Roman"), + ("cour", "Courier"), + ("hebo", "Helvetica Bold"), +) +SAMPLE = "Sphinx of black quartz, judge my vow 0123456789" +BARS = ((34, "Jan"), (58, "Feb"), (47, "Mar"), (72, "Apr"), (65, "May"), (88, "Jun")) +TITLES = ("Typography", "Vector graphics", "Text") + + +VERSIONS = f"pymupdf {pymupdf.__version__} · MuPDF {pymupdf.mupdf_version}" + +# PyMuPDF does not support multithreaded use, and calls reinit_singlethreaded() at +# import. page.run_thread hands work to a thread *pool*, so two renders started +# close together would otherwise overlap inside MuPDF. Serialise them here: the +# renders are milliseconds, so queueing behind the lock costs nothing. +_LOCK = threading.Lock() + + +def banner(page, number, title): + """Draw the coloured title bar shared by every page.""" + page.draw_rect(pymupdf.Rect(0, 0, PAGE_W, 48), color=None, fill=ACCENT) + page.insert_text((26, 31), title, fontname="hebo", fontsize=15, color=(1, 1, 1)) + page.insert_text( + (PAGE_W - 48, 31), f"{number} / 3", fontname="helv", fontsize=9, color=(1, 1, 1) + ) + + +def typography_page(doc): + """A page per base-14 face, which is what proves the fonts are in the wheel. + + Nothing here loads a font file. MuPDF compiles the standard faces into the + library at build time, so every sample below is drawn from glyphs that + ship inside `libmupdf` rather than from anything on the device. + """ + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 1, TITLES[0]) + y = 96 + for fontname, label in FACES: + page.insert_text((26, y), label, fontname=fontname, fontsize=14, color=INK) + page.insert_text( + (26, y + 18), SAMPLE, fontname=fontname, fontsize=8.5, color=MUTED + ) + page.draw_line( + pymupdf.Point(26, y + 32), + pymupdf.Point(PAGE_W - 26, y + 32), + color=RULE, + width=0.6, + ) + y += 58 + page.insert_textbox( + pymupdf.Rect(26, y + 10, PAGE_W - 26, PAGE_H - 20), + "These are four of the base-14 PDF faces. A phone carries no PostScript " + "fonts and no fontconfig, so every glyph above came out of the library " + "itself. The page stores outlines rather than pixels, so the rasteriser " + "fills them at whatever size it is asked for.", + fontname="helv", + fontsize=8.5, + color=MUTED, + lineheight=1.45, + ) + + +def vector_page(doc): + """A bar chart and some primitives, drawn with page operators rather than pixels. + + Everything here is stored as coordinates rather than pixels, so it is the + rasteriser that decides how many of them each shape becomes. + """ + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 2, TITLES[1]) + page.insert_text( + (26, 76), + "Bars, curves and strokes are page operators.", + fontname="helv", + fontsize=8.5, + color=MUTED, + ) + base = 330.0 + for index, (value, label) in enumerate(BARS): + x = 34 + index * 56 + page.draw_rect( + pymupdf.Rect(x, base - value * 2.1, x + 36, base), color=None, fill=ACCENT + ) + page.insert_text( + (x + 8, base - value * 2.1 - 6), + str(value), + fontname="helv", + fontsize=7.5, + color=MUTED, + ) + page.insert_text( + (x + 8, base + 15), label, fontname="helv", fontsize=7.5, color=MUTED + ) + page.draw_line( + pymupdf.Point(26, base), pymupdf.Point(PAGE_W - 26, base), color=INK, width=0.9 + ) + + # A Shape batches drawing commands into a single page operator run. + shape = page.new_shape() + shape.draw_bezier( + pymupdf.Point(34, 400), + pymupdf.Point(140, 372), + pymupdf.Point(250, 428), + pymupdf.Point(PAGE_W - 34, 390), + ) + shape.finish(color=ACCENT, width=1.6, closePath=False) + shape.commit() + page.draw_circle(pymupdf.Point(62, 470), 16, color=INK, width=1) + page.draw_rect(pymupdf.Rect(108, 454, 140, 486), color=INK, width=1) + page.draw_line(pymupdf.Point(168, 486), pymupdf.Point(200, 454), color=INK, width=1) + + +def text_page(doc): + """A prose page, so that search and extraction have something to find.""" + page = doc.new_page(width=PAGE_W, height=PAGE_H) + banner(page, 3, TITLES[2]) + page.insert_textbox( + pymupdf.Rect(26, 76, PAGE_W - 26, PAGE_H - 20), + "The words on this page are text objects, not pixels. The same page that " + "rasterises into the image above can be read back with get_text, and " + "search_for returns a rectangle for every hit, which is how the yellow " + "highlight gets placed.\n\n" + "Type a word into the search field to see it marked on the page. Try " + "quartz, or rectangle, or MuPDF.\n\n" + "Because glyphs carry their own coordinates, a hit is measured in points " + "on the page, and stays put however many pixels the renderer decides a " + "point should become.", + fontname="helv", + fontsize=10, + color=INK, + lineheight=1.5, + ) + + +def build_document(): + """Assemble the three-page document the app renders. + + The example generates its own PDF rather than shipping one so that it stays + a single directory with no bundled asset, and so that composing a document + is itself part of what gets demonstrated. + """ + doc = pymupdf.open() + typography_page(doc) + vector_page(doc) + text_page(doc) + return doc + + +DOC = build_document() + + +def render(index, term): + """Rasterise one page, highlighting `term`, and return PNG bytes. + + Hits are marked with real highlight annotations and deleted again once the + pixmap exists, which keeps the document itself unchanged between renders -- + the alternative, drawing rectangles onto the page, would accumulate. Pixmaps + render with annotations included by default, so no extra flag is needed. + """ + with _LOCK: + page = DOC[index] + hits = page.search_for(term) if term else [] + annotations = [page.add_highlight_annot(rect) for rect in hits] + + started = time.perf_counter() + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(RENDER_SCALE, RENDER_SCALE)) + png = pixmap.tobytes("png") + elapsed = time.perf_counter() - started + + # Read the dimensions and drop the pixmap before releasing the lock: every + # attribute on it is a call back into MuPDF, and the samples are megabytes + # that nothing needs once the PNG exists. + size = (pixmap.width, pixmap.height) + del pixmap + + for annotation in annotations: + page.delete_annot(annotation) + + return png, len(hits), size, elapsed diff --git a/recipes/pymupdf/examples/render-and-read/src/main.py b/recipes/pymupdf/examples/render-and-read/src/main.py index 0baf5f72..12645b22 100644 --- a/recipes/pymupdf/examples/render-and-read/src/main.py +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -1,209 +1,9 @@ -"""Build a PDF in memory, rasterise it with MuPDF, and read its text back.""" - -import threading -import time - import flet as ft -import pymupdf - -# PyMuPDF does not support multithreaded use, and calls reinit_singlethreaded() at -# import. page.run_thread hands work to a thread *pool*, so two renders started -# close together would otherwise overlap inside MuPDF. Serialise them here: the -# renders are milliseconds, so queueing behind the lock costs nothing. -MUPDF = threading.Lock() - -PAGE_W, PAGE_H = 400.0, 520.0 -# Real pixels asked of MuPDF per point of page. Phones draw at 2-3x and Flet -# reports no device ratio, so this is a fixed compromise: crisp on a phone at -# 800x1040 px, and only ~2.4 MB of samples per render. -RENDER_SCALE = 2.0 -INK = (0.11, 0.12, 0.16) -MUTED = (0.42, 0.45, 0.52) -ACCENT = (0.15, 0.39, 0.92) -RULE = (0.88, 0.89, 0.92) - -# The base-14 PDF fonts are Latin-1, so the document text stays ASCII: an em dash -# passed to insert_text comes out of the rasteriser as a "?" glyph. -FACES = ( - ("helv", "Helvetica"), - ("tiro", "Times Roman"), - ("cour", "Courier"), - ("hebo", "Helvetica Bold"), -) -SAMPLE = "Sphinx of black quartz, judge my vow 0123456789" -BARS = ((34, "Jan"), (58, "Feb"), (47, "Mar"), (72, "Apr"), (65, "May"), (88, "Jun")) -TITLES = ("Typography", "Vector graphics", "Text") - -def banner(page, number, title): - """Draw the coloured title bar shared by every page.""" - page.draw_rect(pymupdf.Rect(0, 0, PAGE_W, 48), color=None, fill=ACCENT) - page.insert_text((26, 31), title, fontname="hebo", fontsize=15, color=(1, 1, 1)) - page.insert_text( - (PAGE_W - 48, 31), f"{number} / 3", fontname="helv", fontsize=9, color=(1, 1, 1) - ) - - -def typography_page(doc): - """A page per base-14 face, which is what proves the fonts are in the wheel. - - Nothing here loads a font file. MuPDF compiles the standard faces into the - library at build time, so every sample below is drawn from glyphs that - ship inside `libmupdf` rather than from anything on the device. - """ - page = doc.new_page(width=PAGE_W, height=PAGE_H) - banner(page, 1, TITLES[0]) - y = 96 - for fontname, label in FACES: - page.insert_text((26, y), label, fontname=fontname, fontsize=14, color=INK) - page.insert_text( - (26, y + 18), SAMPLE, fontname=fontname, fontsize=8.5, color=MUTED - ) - page.draw_line( - pymupdf.Point(26, y + 32), - pymupdf.Point(PAGE_W - 26, y + 32), - color=RULE, - width=0.6, - ) - y += 58 - page.insert_textbox( - pymupdf.Rect(26, y + 10, PAGE_W - 26, PAGE_H - 20), - "These are four of the base-14 PDF faces. A phone carries no PostScript " - "fonts and no fontconfig, so every glyph above came out of the library " - "itself. The page stores outlines rather than pixels, so the rasteriser " - "fills them at whatever size it is asked for.", - fontname="helv", - fontsize=8.5, - color=MUTED, - lineheight=1.45, - ) - - -def vector_page(doc): - """A bar chart and some primitives, drawn with page operators rather than pixels. - - Everything here is stored as coordinates rather than pixels, so it is the - rasteriser that decides how many of them each shape becomes. - """ - page = doc.new_page(width=PAGE_W, height=PAGE_H) - banner(page, 2, TITLES[1]) - page.insert_text( - (26, 76), - "Bars, curves and strokes are page operators.", - fontname="helv", - fontsize=8.5, - color=MUTED, - ) - base = 330.0 - for index, (value, label) in enumerate(BARS): - x = 34 + index * 56 - page.draw_rect( - pymupdf.Rect(x, base - value * 2.1, x + 36, base), color=None, fill=ACCENT - ) - page.insert_text( - (x + 8, base - value * 2.1 - 6), - str(value), - fontname="helv", - fontsize=7.5, - color=MUTED, - ) - page.insert_text( - (x + 8, base + 15), label, fontname="helv", fontsize=7.5, color=MUTED - ) - page.draw_line( - pymupdf.Point(26, base), pymupdf.Point(PAGE_W - 26, base), color=INK, width=0.9 - ) - - # A Shape batches drawing commands into a single page operator run. - shape = page.new_shape() - shape.draw_bezier( - pymupdf.Point(34, 400), - pymupdf.Point(140, 372), - pymupdf.Point(250, 428), - pymupdf.Point(PAGE_W - 34, 390), - ) - shape.finish(color=ACCENT, width=1.6, closePath=False) - shape.commit() - page.draw_circle(pymupdf.Point(62, 470), 16, color=INK, width=1) - page.draw_rect(pymupdf.Rect(108, 454, 140, 486), color=INK, width=1) - page.draw_line(pymupdf.Point(168, 486), pymupdf.Point(200, 454), color=INK, width=1) - - -def text_page(doc): - """A prose page, so that search and extraction have something to find.""" - page = doc.new_page(width=PAGE_W, height=PAGE_H) - banner(page, 3, TITLES[2]) - page.insert_textbox( - pymupdf.Rect(26, 76, PAGE_W - 26, PAGE_H - 20), - "The words on this page are text objects, not pixels. The same page that " - "rasterises into the image above can be read back with get_text, and " - "search_for returns a rectangle for every hit, which is how the yellow " - "highlight gets placed.\n\n" - "Type a word into the search field to see it marked on the page. Try " - "quartz, or rectangle, or MuPDF.\n\n" - "Because glyphs carry their own coordinates, a hit is measured in points " - "on the page, and stays put however many pixels the renderer decides a " - "point should become.", - fontname="helv", - fontsize=10, - color=INK, - lineheight=1.5, - ) - - -def build_document(): - """Assemble the three-page document the app renders. - - The example generates its own PDF rather than shipping one so that it stays - a single directory with no bundled asset, and so that composing a document - is itself part of what gets demonstrated. - """ - doc = pymupdf.open() - typography_page(doc) - vector_page(doc) - text_page(doc) - return doc - - -DOC = build_document() - - -def render(index, term): - """Rasterise one page, highlighting `term`, and return PNG bytes. - - Hits are marked with real highlight annotations and deleted again once the - pixmap exists, which keeps the document itself unchanged between renders -- - the alternative, drawing rectangles onto the page, would accumulate. Pixmaps - render with annotations included by default, so no extra flag is needed. - """ - with MUPDF: - page = DOC[index] - hits = page.search_for(term) if term else [] - annotations = [page.add_highlight_annot(rect) for rect in hits] - - started = time.perf_counter() - pixmap = page.get_pixmap(matrix=pymupdf.Matrix(RENDER_SCALE, RENDER_SCALE)) - png = pixmap.tobytes("png") - elapsed = time.perf_counter() - started - - # Read the dimensions and drop the pixmap before releasing the lock: every - # attribute on it is a call back into MuPDF, and the samples are megabytes - # that nothing needs once the PNG exists. - size = (pixmap.width, pixmap.height) - del pixmap - - for annotation in annotations: - page.delete_annot(annotation) - - return png, len(hits), size, elapsed +from document import TITLES, VERSIONS, render def main(page: ft.Page): - """Show one rendered page at a time, with page navigation and search. - - Rendering is pushed to a background thread: a page is a few megabytes of - samples, and doing that on the UI thread would freeze it mid-tap. - """ state = {"index": 0, "term": ""} def redraw(): @@ -217,7 +17,7 @@ def work(): png, hits, (width, height), elapsed = render(state["index"], state["term"]) sheet.src = png position.value = ( - f"{state['index'] + 1} / {DOC.page_count} · {TITLES[state['index']]}" + f"{state['index'] + 1} / {len(TITLES)} · {TITLES[state['index']]}" ) found.value = ( "" if not state["term"] else f"{hits} hit{'' if hits == 1 else 's'}" @@ -229,14 +29,10 @@ def work(): spinner.visible = False page.update() # auto-update does not reach background threads - def step(delta): - """Return a handler that moves `delta` pages, clamped to the document.""" - - def handler(e): - state["index"] = max(0, min(DOC.page_count - 1, state["index"] + delta)) - redraw() - - return handler + def go(delta): + """Move `delta` pages, clamped to the ends of the document.""" + state["index"] = max(0, min(len(TITLES) - 1, state["index"] + delta)) + redraw() def on_search(e): """Re-render with the new search term highlighted.""" @@ -249,13 +45,7 @@ def on_search(e): expand=True, content=ft.Column( controls=[ - ft.Text( - f"pymupdf {pymupdf.__version__} · MuPDF {pymupdf.mupdf_version}", - size=11, - ), - # search_for matches the literal string, so the phone keyboard - # must not "help": autocorrect turned quartz into Quarts on a - # simulator, which searches for a word the page does not contain. + ft.Text(VERSIONS, size=11), ft.TextField( label="Search this page", dense=True, @@ -273,14 +63,18 @@ def on_search(e): # gapless_playback stops the control blanking between # renders, since each one is a different byte string. sheet := ft.Image( - src=b"", fit=ft.BoxFit.CONTAIN, gapless_playback=True + src=b"", + fit=ft.BoxFit.CONTAIN, + gapless_playback=True, ) ), ), ft.Row( alignment=ft.MainAxisAlignment.SPACE_BETWEEN, controls=[ - ft.IconButton(ft.Icons.CHEVRON_LEFT, on_click=step(-1)), + ft.IconButton( + ft.Icons.CHEVRON_LEFT, on_click=lambda: go(-1) + ), ft.Column( spacing=0, horizontal_alignment=ft.CrossAxisAlignment.CENTER, @@ -289,7 +83,9 @@ def on_search(e): found := ft.Text(size=11, color=ft.Colors.PRIMARY), ], ), - ft.IconButton(ft.Icons.CHEVRON_RIGHT, on_click=step(1)), + ft.IconButton( + ft.Icons.CHEVRON_RIGHT, on_click=lambda: go(1) + ), ], ), ft.Row( diff --git a/recipes/pymupdf/meta.yaml b/recipes/pymupdf/meta.yaml index 5184df58..1b0f107a 100644 --- a/recipes/pymupdf/meta.yaml +++ b/recipes/pymupdf/meta.yaml @@ -1,35 +1,9 @@ package: name: pymupdf - version: 1.27.2.3 - -patches: - - crossenv-codegen.patch - - ios-dylib-preload.patch - -requirements: - build: - # PyMuPDF's build (setup.py -> pipcl -> mupdf/scripts/mupdfwrap.py) needs SWIG - # to wrap MuPDF's C++ API, and python clang bindings (libclang) to parse the - # MuPDF headers when generating the C++ wrapper. Both run on the host. - - swig - - libclang - - setuptools - # PyMuPDF's pyproject.toml asks for a bare `pipcl`, so the build would otherwise - # float on whatever version PyPI serves that day (12 releases in the four months - # to 2026-07). pipcl is the build backend AND the linker for _mupdf/_extra, and - # crossenv-codegen.patch monkeypatches pipcl.darwin, so an upstream refactor - # breaks the build with no change on our side. Installed first, which satisfies - # the unpinned requirement. Re-test and raise deliberately. - - pipcl 12 -# {% if sdk == 'android' %} - host: - # The MuPDF C++ wrapper (libmupdfcpp.so) + _mupdf.so are C++; on Android they - # link libc++_shared.so, which the device runtime doesn't provide unless bundled. - - flet-libcpp-shared >=27.2.12479018 -# {% endif %} + version: "1.27.2.3" build: - number: 0 + number: 1 script_env: # The libclang wrapper-codegen parse needs the real per-arch target triple so # MuPDF's manual size_t/int typedefs (rewritten to clang __*_TYPE__ builtins @@ -123,3 +97,29 @@ build: # {% if sdk == 'android' %} LDFLAGS: -llog -L{HOST_PYTHON_HOME}/lib -lpython{py_version_short} # {% endif %} + +requirements: + build: + # PyMuPDF's build (setup.py -> pipcl -> mupdf/scripts/mupdfwrap.py) needs SWIG + # to wrap MuPDF's C++ API, and python clang bindings (libclang) to parse the + # MuPDF headers when generating the C++ wrapper. Both run on the host. + - swig + - libclang + - setuptools + # PyMuPDF's pyproject.toml asks for a bare `pipcl`, so the build would otherwise + # float on whatever version PyPI serves that day (12 releases in the four months + # to 2026-07). pipcl is the build backend AND the linker for _mupdf/_extra, and + # crossenv-codegen.patch monkeypatches pipcl.darwin, so an upstream refactor + # breaks the build with no change on our side. Installed first, which satisfies + # the unpinned requirement. Re-test and raise deliberately. + - pipcl 12 +# {% if sdk == 'android' %} + host: + # The MuPDF C++ wrapper (libmupdfcpp.so) + _mupdf.so are C++; on Android they + # link libc++_shared.so, which the device runtime doesn't provide unless bundled. + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + +patches: + - crossenv-codegen.patch + - ios-dylib-preload.patch From 84f97f00735740ffde4f6e13ca313cff65a26baf Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 20 Aug 2026 17:53:33 +0200 Subject: [PATCH 09/11] add opencv docs --- recipes/opencv-python/README.md | 287 ++++++++++++++++++ .../examples/shape-finder/.gitignore | 7 + .../examples/shape-finder/README.md | 52 ++++ .../examples/shape-finder/pyproject.toml | 20 ++ .../examples/shape-finder/src/main.py | 201 ++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 recipes/opencv-python/README.md create mode 100644 recipes/opencv-python/examples/shape-finder/.gitignore create mode 100644 recipes/opencv-python/examples/shape-finder/README.md create mode 100644 recipes/opencv-python/examples/shape-finder/pyproject.toml create mode 100644 recipes/opencv-python/examples/shape-finder/src/main.py diff --git a/recipes/opencv-python/README.md b/recipes/opencv-python/README.md new file mode 100644 index 00000000..10275526 --- /dev/null +++ b/recipes/opencv-python/README.md @@ -0,0 +1,287 @@ +# opencv-python + +[`opencv-python`](https://github.com/opencv/opencv-python) is the `cv2` binding for +[OpenCV](https://opencv.org/): image filtering and geometry, contours and shape analysis, +feature detectors, camera calibration, stitching, optical flow, and a +[`dnn`](https://docs.opencv.org/5.x/main_modules/dnn.html) module that runs ONNX models. +On mobile it is what lets a camera frame be measured, corrected or classified *on the +device* — the whole library is compiled into the wheel, so nothing is uploaded and nothing +needs a network. It is the most-upvoted package request Flet has +([flet#3200](https://github.com/flet-dev/flet/discussions/3200)). + +## Install + +```toml +# pyproject.toml +dependencies = [ + "flet", + "opencv-python", +] + +[tool.flet.android] +extract_packages = ["cv2"] +``` + +**Pick exactly one of the three distributions.** `opencv-python`, +[`opencv-contrib-python`](../opencv-contrib-python) and +[`opencv-python-headless`](../opencv-python-headless) all install a top-level package +called `cv2`, so two of them in one environment silently overwrite each other's files and +you end up running whichever landed last — upstream's own +[warning](https://github.com/opencv/opencv-python#installation-and-usage), and it applies +here unchanged. Which one: + +- **`opencv-python`** unless you have a reason otherwise. It is the whole of OpenCV's main + tree — `core`, `imgproc`, `imgcodecs`, `features`, `flann`, `calib`, `geometry`, + `stereo`, `objdetect`, `photo`, `ptcloud`, `stitching`, `video`, `videoio` and `dnn`. +- **`opencv-contrib-python`** is a strict superset, adding thirty-seven further modules — + `face`, `tracking`, `ximgproc`, `xphoto`, `optflow`, `img_hash`, `wechat_qrcode`, `text`, + `bgsegm`, `dnn_superres`, `gapi` among them — for 20.5 MB of wheel against 13.8 MB on + Android arm64. The one that catches people out is **`cv2.ml`** (`SVM`, `KNearest`, + `RTrees`, `ANN_MLP`): OpenCV 5 moved the `ml` module into contrib, so `cv2.ml` raises + `AttributeError` on the base wheel where OpenCV 4 had it. Take contrib for a named + module you need, not by default. +- **`opencv-python-headless`** exists so that a pin someone else wrote — albumentations + and most OCR stacks require `opencv-python-headless` by name — resolves to something. + It saves you nothing here: on mobile it is *the same build*. Its wheel has the identical + file list, an extension the same size to within 5 KB, and a `getBuildInformation()` that + differs from `opencv-python`'s in one line — the CI machine's kernel version — because + there is no GUI backend in either to leave out (see + [Things to know](#things-to-know)). + +Nothing else is required. `numpy` comes along automatically, and with it +`flet-libcpp-shared` on Android — that dependency belongs to numpy, not to cv2, whose own +extension links libc++ statically and declares nothing beyond `numpy>=2`. + +The `extract_packages` entry above is the configuration these wheels are tested in, and it +costs you nothing but a little disk. Be clear about what it is not, though: it is **not** +what makes `import cv2` work. The loader in these wheels resolves the native extension +directly and reads no file out of the package directory, so cv2 imports and every image +operation runs whether the package is extracted or left inside Android's zipped +site-packages. Nor does it bring back `cv2.Mat` or `cv2.typing` — those are gone for an +unrelated reason, described in [Things to know](#things-to-know). + +**Leave `[tool.flet.compile]` alone for mobile.** You will find advice to set +`packages = false`; that is a desktop fix. The stock PyPI wheel's loader `exec()`s a +`config.py` at import time, so compiling packages to `.pyc` and stripping the sources +breaks it with `ImportError: OpenCV loader: missing configuration file: ['config.py']`. +The loader in *these* wheels never reads that file, and the recipe's own on-device tests +run with packages compiled. If you build a desktop or web target from the same project, +scope the workaround to that target rather than turning compilation off everywhere: + +```toml +[tool.flet.macos.compile] +packages = false +``` + +Builds for all three Android ABIs Flet targets (arm64-v8a, armeabi-v7a, x86_64) and for +iOS device and both simulator slices, on Python 3.12, 3.13 and 3.14. + +## Storage + +[`imread`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imread) and +[`imwrite`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imwrite) take ordinary +filesystem paths, so anything the app writes belongs in +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data) +— the app-private directory that is never auto-deleted and is included in backups: + +```python +out_path = os.path.join(os.getenv("FLET_APP_STORAGE_DATA", "."), "capture.png") +cv2.imwrite(out_path, frame) +``` + +Use [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) +for a frame you re-derive on demand and +[`FLET_APP_STORAGE_CACHE`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_cache) +for something you can afford to lose. Images you ship with the app are assets, not storage, +and belong in `src/assets/`. + +Most of the time you want no file at all: +[`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) and +[`imdecode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imdecode) move whole +images between `numpy` arrays and `bytes` in memory, which is also how a result reaches the +screen — see [Things to know](#things-to-know). + +## Examples + +See runnable Flet apps in [`examples/`](examples): + +- [`shape-finder`](examples/shape-finder) — segments shapes out of a noisy scene and shows the annotated frame. + +## Threading + +**OpenCV is genuinely multi-threaded here**, which sets it apart from most of the numerical +wheels on this index. Android builds with a pthreads parallel framework, iOS with Grand +Central Dispatch, and everything routed through OpenCV's `parallel_for_` — resizes, warps, +filters, `dnn` inference, most of `imgproc` — spreads across the phone's cores by itself. +[`cv2.setNumThreads(n)`](https://docs.opencv.org/5.x/main_modules/core_utils.html#setnumthreads) +caps that, `cv2.setNumThreads(0)` makes it serial, and the `OPENCV_FOR_THREADS_NUM` +environment variable does the same thing before the first call. + +[`cv2.getNumThreads()`](https://docs.opencv.org/5.x/main_modules/core_utils.html#getnumthreads) +does not tell you what you set on the GCD backend. Measured on macOS, which uses the same +backend as iOS: after `setNumThreads(1)`, `setNumThreads(2)` and `setNumThreads(8)` it kept +returning the core count, while the wall-clock time of a large `GaussianBlur` moved by more +than 4× — so the setting takes effect and the getter is not evidence of it. Time the call +rather than reading the number back. + +None of that helps the UI thread. A pipeline over a full-resolution camera frame will +freeze the UI wherever it runs, so push it to +[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) and end +the handler with an explicit +[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) — auto-update does +not reach background threads. OpenCV itself imposes no thread rules on you: arrays and +results move between threads freely, and there is no handle to serialise. Two threads +writing into the same `numpy` array is your problem, not something OpenCV will detect. + +## Android notes + +The Android build carries two image formats the iOS one does not: **TIFF** (libtiff 4.7.1) +and **JPEG 2000** (OpenJPEG 2.5.3). Both platforms have JPEG (libjpeg-turbo), PNG, WebP, +GIF, HDR, PXM, PFM and Sun raster; neither has AVIF or OpenEXR. So a `.tiff` or `.jp2` +round-trip that passes on an emulator will fail on an iPhone — see +[iOS notes](#ios-notes). + +`dnn` has kernels here that iOS does not. OpenCV 5's vendored MLAS (NEON SGEMM and SGEMV on +arm64) is compiled into the Android wheel and disabled in the iOS one, and Android +additionally gets the Carotene HAL for a set of `imgproc` operations. Both are transparent: +the same call returns the same answer on either platform, and only the time it takes moves. + +The **NDK Camera and MediaNDK video backends are compiled in** — `ANDROID_NATIVE` is a +registered `videoio` backend and the extension links `libcamera2ndk.so` and +`libmediandk.so`. That is a statement about the binary, not a working camera: nothing in +this recipe opens one, the app would additionally need the `CAMERA` +[permission](https://flet.dev/docs/publish/android/#permissions), and +[`VideoCapture`](https://docs.opencv.org/5.x/main_modules/videoio.html) inside a Flet app is +untested here. Treat it as worth trying, not as supported — the route that is known to work +is [`flet-camera`](https://pypi.org/project/flet-camera/) to acquire frames and cv2 to +process them. + +## iOS notes + +**No TIFF, no JPEG 2000.** Writing one raises — +`cv2.error: (-2:Unspecified error) could not find a writer for the specified extension` +from `imwrite`, and `could not find encoder for the specified extension` from `imencode` — +while *reading* one fails silently: `cv2.imdecode` returns `None` for a format it has no +decoder for, exactly as it does for a corrupt buffer, so check the return value rather than +relying on an exception. JPEG, PNG and WebP cover everything else. The build also has no +OpenEXR and no AVIF, which matches Android. + +The vendored MLAS kernels are **off** on iOS: their object files do not survive into the +iOS framework binary, and `import cv2` failed at `dlopen` with an undefined `MlasGemmBatch` +until they were disabled. `dnn` falls back to OpenCV's built-in SGEMM, which gives the same +answer on the same model; how much throughput that costs has not been measured here. +Apple's Accelerate framework is linked in, as are UIKit, CoreGraphics and QuartzCore. + +`videoio` registers the AVFoundation backend and the build reports `iOS capture: YES`; as +on Android, that is what the binary contains and not a tested path. + +## Things to know + +- **There is no GUI, so `cv2.imshow` raises.** Both builds report `GUI: NONE`, and every + [highgui](https://docs.opencv.org/5.x/main_modules/highgui.html) entry point — + [`imshow`](https://docs.opencv.org/5.x/main_modules/highgui.html#imshow), `waitKey`, + `namedWindow`, the trackbars — fails with + `cv2.error: (-213:The function/feature is not implemented) The function is not + implemented. Rebuild the library with Windows, GTK+ 2.x or Cocoa support`. The + replacement is one line, because + [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src) accepts `bytes` as + well as a path: + + ```python + view.src = cv2.imencode(".jpg", frame)[1].tobytes() + ``` + + Set [`gapless_playback=True`](https://flet.dev/docs/controls/image/#flet.Image.gapless_playback) + so the control does not blank between frames. For a continuous stream, encoding every + frame is the wrong shape — + [`ft.RawImage`](https://flet.dev/docs/controls/rawimage/#rawimage-vs-image) takes raw RGBA + over a dedicated channel and paces itself. +- **On device, `cv2` is the extension module, not the package.** OpenCV's compiled bindings + insist on being loaded under the exact top-level name `cv2`, and the loader in these + wheels does that by loading the relocated native extension as `cv2` — which, because + OpenCV uses single-phase module init, replaces the `cv2` package in `sys.modules` with + the extension itself. Everything the C++ side defines is present and normal: + `cv2.__version__`, every function and constant, and the native submodules `cv2.dnn`, + `cv2.aruco`, `cv2.utils`, `cv2.videoio_registry`. What is gone is the handful of + pure-Python submodules the desktop wheel merges in afterwards — **`cv2.Mat`, + `cv2.typing`, `cv2.data`, `cv2.mat_wrapper` and `cv2.misc` do not exist**, and + `import cv2.typing` fails with + `ModuleNotFoundError: No module named 'cv2.typing'; 'cv2' is not a package`. That matters + if one of your dependencies does `from cv2.typing import MatLike` outside a + `TYPE_CHECKING` block; annotate with `numpy.ndarray` in your own code and the question + does not arise. No `extract_packages` setting changes this. +- **No FFmpeg, so no video files.** The desktop wheel bundles 99 shared libraries — + the whole of FFmpeg, OpenEXR, Tesseract, SDL2 — and the mobile wheels bundle none of + them; that is the entire difference in the file list between the two, everything else is + statically linked in. `cv2.VideoCapture("clip.mp4")` therefore has no FFmpeg to fall back + on. Android's MediaNDK backend can in principle decode what the OS decodes, iOS has + AVFoundation, and neither has been exercised by this recipe. Still images are the + supported path. +- **Haar cascades are gone, and not because of this build.** OpenCV 5 removed + `cv2.CascadeClassifier` upstream; the symbol is absent from the desktop wheel of the same + version too, and no cascade XML ships in `cv2/data/` on any platform, so + `cv2.data.haarcascades` (where the module exists at all) points at an empty directory. Use + [`cv2.FaceDetectorYN`](https://docs.opencv.org/5.x/main_modules/objdetect.html) with a + YuNet ONNX model bundled in `src/assets/` — smaller and considerably more accurate — or + `cv2.QRCodeDetector` and `cv2.barcode` for codes. ArUco did *not* move to contrib: it is + in `objdetect` now, so `cv2.aruco` is in this wheel. +- **Size.** The wheels are 12–17 MB and unpack to 24–43 MB depending on architecture + (Android arm64-v8a: 13.8 MB and 33.9 MB; armeabi-v7a: 12.2 MB and 23.9 MB; x86_64: + 16.7 MB and 42.9 MB; iOS arm64: 13.3 MB and 38.8 MB). Essentially all of that is the + single `cv2` extension — 33.0 MB of the Android arm64 total, 37.8 MB of the iOS one — so + there is nothing to trim with `[tool.flet.cleanup]`: no test suite, no data files, and + the 451 KB of `.pyi` type stubs are stripped during packaging anyway. Building only the + ABIs you ship is the lever that exists; see + [target architectures](https://flet.dev/docs/publish/android/#supported-target-architectures). + +## Build notes (maintainers) + +Each patch carries its rationale at the top of the file and each build flag is justified in +`meta.yaml` next to the flag, so this section is what neither of those records. + +The recipe builds OpenCV's *own* CMake tree with the python bindings forced back on +(upstream disables them for `ANDROID` and `APPLE_FRAMEWORK`, which is what most of the +patch undoes), rather than going the PEP 517 shim route used for packages with no usable +sdist. `opencv-python`'s sdist drives scikit-build with a `CMAKE_ARGS` handoff, and that +handoff is the whole integration — which is why the same recipe shape is copy-pasted across +all three distributions, with the flavour selected only by the package name and, on iOS +contrib, one extra `BUILD_opencv_rgbd=OFF`. **Keep the three `meta.yaml` files in step**: a +fix applied to one and not the others produces three wheels claiming the same OpenCV +version with different contents, and nothing in CI compares them. + +`extract_packages: [cv2]` is retained deliberately even though it is, for this version, a +no-op: the loader's extra-submodule pass it was added for cannot succeed regardless (the +package module is no longer in `sys.modules` by the time it runs). It costs nothing, it is +what the on-device tests exercise, and if upstream ever moves to multi-phase module init +the pass starts working again and the entry becomes load-bearing without anyone touching it. + +What to re-verify on a bump, in rough order of how quietly it can go wrong: + +- **That `cv2` is still the extension module rather than the package**, since a good deal of + [Things to know](#things-to-know) hangs off it. It follows from `PyModule_Create2` in the + binary — single-phase init, so `module_from_spec` registers the extension in `sys.modules` + under `cv2` and the package module is dropped. The current tests do not pin it; the + quickest check is `cv2.__spec__.loader` on device, or `hasattr(cv2, "typing")` being + `False`. If a release switches to multi-phase init the behaviour flips silently and the + bullet needs rewriting, not updating. +- **The two platforms' codec lists.** No TIFF and no JPEG 2000 on iOS is read out of + `getBuildInformation()` in the shipped binary, and it is a consequence of which 3rdparty + libraries the iOS configure step found, not of anything the recipe sets — so it can move + either way on a bump without a build failure. Re-extract the build information from both + `.so` files and diff the `Media I/O` blocks before repeating the claim, and do the same + for `Video I/O` (`MEDIANDK`/`NDK Camera` on Android, `AVFoundation` on iOS) and for the + `GUI: NONE` line the `imshow` bullet rests on. +- **The module list, and with it the contrib boundary.** `cv2.ml` living in contrib and + ArUco living in `objdetect` are OpenCV 5 facts, not permanent ones. The + `OpenCV modules: To be built` line of each build differs between the three flavours and + is the cheapest way to regenerate the Install section's comparison. +- **Headless being identical to the main build on mobile.** It holds only while there is no + GUI backend to disable in the first place. If a future toolchain gives the Android or iOS + build a working `highgui`, headless stops being a synonym and both the Install section + and the `imshow` bullet change together. +- **The desktop-wheel comparison.** That the mobile wheels differ from the PyPI wheel of the + same version in exactly the 99 bundled `.dylibs` and nothing else is what backs the + "no FFmpeg" bullet. Re-run that diff; a new pure-Python file upstream would also change + it. +- **The sizes** are measured per architecture from the built wheels. Re-measure, do not + scale. diff --git a/recipes/opencv-python/examples/shape-finder/.gitignore b/recipes/opencv-python/examples/shape-finder/.gitignore new file mode 100644 index 00000000..429a8307 --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/.gitignore @@ -0,0 +1,7 @@ +.venv/ +.flet/ +build/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +uv.lock diff --git a/recipes/opencv-python/examples/shape-finder/README.md b/recipes/opencv-python/examples/shape-finder/README.md new file mode 100644 index 00000000..d43404ee --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/README.md @@ -0,0 +1,52 @@ +# cv2 shape finder + +Nine shapes are drawn on a grid, then buried under Gaussian noise you control with a +slider. [OpenCV](https://opencv.org/) segments them back out, names each one from its +contour, and the annotated picture comes back on screen as JPEG bytes. + +What it demonstrates: + +- **Showing an OpenCV result without a GUI backend** — the mobile wheels have none, so + `cv2.imshow` raises. The frame is encoded with + [`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) and + handed straight to [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src), + which accepts `bytes` as well as a path. JPEG rather than PNG because the buffer + crosses the Flet transport on every run — at the top of the slider a PNG of the same + frame is about four times larger. +- **A real segmentation pipeline in one handler** — + [`cvtColor`](https://docs.opencv.org/5.x/main_modules/imgproc_color_conversions.html#cvtcolor), + an Otsu [`threshold`](https://docs.opencv.org/5.x/main_modules/imgproc_misc.html#threshold) + that picks its own cut point, + [`findContours`](https://docs.opencv.org/5.x/main_modules/imgproc_shape.html#findcontours), + and [`approxPolyDP`](https://docs.opencv.org/5.x/main_modules/geometry_shape.html#approxpolydp), + whose vertex count is what names the shape. All compiled native code in the wheel, + identical on Android and iOS. +- **Compute off the UI thread** — the pipeline runs in + [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) with + the button disabled and a spinner up, and the handler ends with the explicit + [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) that a + background thread needs. The slider fires on + [`on_change_end`](https://flet.dev/docs/controls/slider/#flet.Slider.on_change_end), not + `on_change`, so one drag runs the pipeline once instead of once per pixel travelled. +- **Which stage actually survives noise** — the table reports the number of contours + found *before* the minimum-area filter alongside the final counts. + +Push the slider up and that contour count runs from nine into five figures while the +shape counts hold: it is the area filter, not the threshold, doing the work. Push it all +the way and the labels finally slip — noise roughens the outlines until `approxPolyDP` +reads a circle as a four-sided polygon. + +## Try it + +[Build](https://flet.dev/docs/publish/) the app, then install it on a device or emulator/simulator: + +```bash +# Android +uv run flet build apk + +# iOS +uv run flet build ipa + +# iOS-Simulator +uv run flet build ios-simulator +``` diff --git a/recipes/opencv-python/examples/shape-finder/pyproject.toml b/recipes/opencv-python/examples/shape-finder/pyproject.toml new file mode 100644 index 00000000..865dbde2 --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "opencv-shape-finder" +version = "1.0.0" +description = "Segments shapes out of a noisy scene with OpenCV and shows the result." +requires-python = ">=3.11" + +dependencies = [ + "flet==0.86.5", + "opencv-python==5.0.0.93", + "numpy==2.4.6", +] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.app] +path = "src" + +[tool.flet.android] +extract_packages = ["cv2"] diff --git a/recipes/opencv-python/examples/shape-finder/src/main.py b/recipes/opencv-python/examples/shape-finder/src/main.py new file mode 100644 index 00000000..5bc0cc15 --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/src/main.py @@ -0,0 +1,201 @@ +import time + +import cv2 +import flet as ft +import numpy as np + +SIZE = 480 +CELL = SIZE // 3 +KINDS = ("triangle", "rectangle", "circle") +MIN_AREA = 900 + + +def scene(rng): + """Draw one random shape per cell of a 3x3 grid and report what was placed. + + The grid is what makes the counts meaningful: findContours with RETR_EXTERNAL + returns one contour per connected blob, so two shapes allowed to touch would come + back as a single contour and no count could ever match. + """ + canvas = np.full((SIZE, SIZE, 3), 20, np.uint8) + placed = dict.fromkeys(KINDS, 0) + for row_index in range(3): + for col_index in range(3): + kind = KINDS[int(rng.integers(0, len(KINDS)))] + cx = col_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) + cy = row_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) + r = int(rng.integers(38, 56)) + colour = tuple(int(c) for c in rng.integers(120, 255, 3)) + if kind == "circle": + cv2.circle(canvas, (cx, cy), r, colour, -1) + elif kind == "rectangle": + cv2.rectangle(canvas, (cx - r, cy - r), (cx + r, cy + r), colour, -1) + else: + corners = np.array( + [[cx, cy - r], [cx - r, cy + r], [cx + r, cy + r]], np.int32 + ) + cv2.fillPoly(canvas, [corners], colour) + placed[kind] += 1 + return canvas, placed + + +def analyse(canvas, noise): + """Bury the scene in noise, segment the shapes back out, and label each one. + + Four compiled OpenCV stages in one call — a colour conversion, an Otsu threshold + that picks its own cut point, contour extraction, and a polygon approximation whose + vertex count names the shape. Returns the annotated picture, the counts by kind, the + number of contours found *before* the area filter, and the milliseconds spent. + + That raw contour count is the interesting number: it is what noise inflates, from + nine into the thousands, while the area filter keeps the answer at nine. + """ + rng = np.random.default_rng() + noisy = canvas.astype(np.int16) + rng.normal(0, noise, canvas.shape) + noisy = np.clip(noisy, 0, 255).astype(np.uint8) + + started = time.perf_counter() + gray = cv2.cvtColor(noisy, cv2.COLOR_BGR2GRAY) + _, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + found = dict.fromkeys(KINDS, 0) + for contour in contours: + if cv2.contourArea(contour) < MIN_AREA: + continue + # 3% of the perimeter is loose enough to collapse a noisy edge into one + # straight side, and tight enough to leave a circle with far more than four. + corners = cv2.approxPolyDP(contour, 0.03 * cv2.arcLength(contour, True), True) + kind = {3: "triangle", 4: "rectangle"}.get(len(corners), "circle") + found[kind] += 1 + cv2.drawContours(noisy, [contour], -1, (255, 255, 255), 2) + top = contour[contour[:, :, 1].argmin()][0] + cv2.putText( + noisy, + kind, + (int(top[0]) - 26, int(top[1]) - 8), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (255, 255, 255), + 1, + cv2.LINE_AA, + ) + return noisy, found, len(contours), (time.perf_counter() - started) * 1000 + + +def jpeg(image): + """Encode a BGR array as JPEG bytes, which is what ft.Image.src takes directly. + + JPEG rather than PNG because this buffer crosses the Flet transport on every run, + and at the top of the noise slider a PNG of the same frame is about four times + larger. + """ + _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 80]) + return buffer.tobytes() + + +def row(label, *cells): + """One line of the results table: a label, then a column per value.""" + return ft.Row( + controls=[ft.Text(label, expand=3), *(ft.Text(c, expand=2) for c in cells)] + ) + + +def main(page: ft.Page): + """Show a noise slider over a scene OpenCV has to segment, and the counts it got. + + The picture reaches the screen as JPEG bytes rather than through a window: there is + no GUI backend in the mobile wheels, so cv2.imshow raises, and ft.Image.src taking + bytes directly is what replaces it. + """ + canvas, placed = scene(np.random.default_rng()) + + def redraw(): + """Draw a fresh set of shapes, then segment the new scene.""" + nonlocal canvas, placed + canvas, placed = scene(np.random.default_rng()) + segment() + + def segment(): + """Lock the controls and hand the pipeline to a background thread.""" + button.disabled = True + spinner.visible = True + page.update() + page.run_thread(compute) + + def compute(): + """Segment at the slider's noise level and put the annotated frame on screen. + + The body of the thread segment() starts. Push the noise up and the contour + count runs into five figures while the total still comes back as nine: it is + the minimum-area filter, not the threshold, that survives a ruined picture. + At the very top of the slider the per-kind labels do slip, because noise + roughens an outline until approxPolyDP reads a circle as four-sided. + """ + annotated, found, contours, elapsed = analyse(canvas, noise.value) + frame = jpeg(annotated) + view.src = frame + results.controls = [ + row("", "placed", "found"), + ft.Divider(height=1), + *(row(kind, placed[kind], found[kind]) for kind in KINDS), + ft.Divider(height=1), + row("contours before filter", contours), + row("segmented in", f"{elapsed:.0f} ms"), + row("jpeg sent to ft.Image", f"{len(frame) / 1024:.0f} KB"), + ] + button.disabled = False + spinner.visible = False + page.update() # auto-update does not reach background threads + + page.appbar = ft.AppBar(title=ft.Text("cv2 shape finder"), center_title=True) + page.add( + ft.SafeArea( + expand=True, + content=ft.Column( + scroll=ft.ScrollMode.AUTO, + controls=[ + ft.Text(f"OpenCV {cv2.__version__} — {SIZE}×{SIZE} scene", size=12), + view := ft.Image( + src=jpeg(canvas), + fit=ft.BoxFit.CONTAIN, + border_radius=8, + gapless_playback=True, + ), + ft.Text("Noise added before segmentation", size=12), + noise := ft.Slider( + min=0, + max=150, + value=30, + divisions=10, + round=0, + label="σ {value}", + # on_change would re-run the whole pipeline for every pixel the + # thumb travels; on_change_end runs it once, on release. + on_change_end=segment, + ), + ft.Row( + controls=[ + button := ft.Button( + "New scene", + icon=ft.Icons.SHUFFLE, + on_click=redraw, + ), + spinner := ft.ProgressRing( + width=20, + height=20, + visible=False, + ), + ] + ), + results := ft.Column(spacing=4), + ], + ), + ) + ) + + segment() + + +if __name__ == "__main__": + ft.run(main) From bdff95181e0e25ae5e8c1a052e4df780457929a9 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 20 Aug 2026 18:39:37 +0200 Subject: [PATCH 10/11] docs: update README with supported targets and architecture details --- .claude/skills/forge-ci/SKILL.md | 19 +++ recipes/opencv-python/README.md | 146 ++++++++++-------- .../examples/shape-finder/README.md | 14 +- .../examples/shape-finder/src/main.py | 111 +------------ .../examples/shape-finder/src/shapes.py | 95 ++++++++++++ recipes/pymupdf/README.md | 40 +++-- .../examples/render-and-read/src/main.py | 1 - 7 files changed, 233 insertions(+), 193 deletions(-) create mode 100644 recipes/opencv-python/examples/shape-finder/src/shapes.py diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index d7f530cd..b6c55135 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -206,9 +206,28 @@ Seen repeatedly on this fork; all are safe to retry once: it's not transient — see the `forge-error-catalogue` skill (`User for pypi.flet.dev:` entry). +- **`Execution failed for task ':serious_python_android:downloadDistArchive_'`** + → `groovy.json.JsonException: Unable to determine the current character … + The current character read is '\0' … index number 255` in the **"Stage + tests + build recipe-tester APK"** step. serious_python's Gradle download + task got a truncated/corrupt body where JSON was expected. Nothing to do + with the recipe. It is **easy to misread as a recipe failure** because the + step name mentions the recipe and the flet output has no `##[error]` of its + own — the log just stops and cleanup terminates orphan java/adb processes. + TELLS that it is this: all wheels built (`Successfully built -…whl` + for every ABI) and the `wheels-*` artifact exists for the red leg, the + failure is *after* the wheel phase, and `Gradle task assembleRelease failed + with exit code 1` is the only error. Find the root cause by grepping the + extracted log for `What went wrong` — not for `##[error]`/`Error building + Flet`, which only match the generic tail. Rerun clears it. + Real failures reproduce on rerun. Don't retry more than once without reading the log. +Note `PKG_VERSION` being empty in `stage_recipe.sh ''` is **not** a +fault: it is derived from the *dispatch input* (`packages="pkg:"` → empty), +not from `meta.yaml`, so a trailing colon always stages an unpinned dep. + ## Dispatch inputs quick reference | Input | Notes | diff --git a/recipes/opencv-python/README.md b/recipes/opencv-python/README.md index 10275526..3ac92954 100644 --- a/recipes/opencv-python/README.md +++ b/recipes/opencv-python/README.md @@ -6,13 +6,27 @@ feature detectors, camera calibration, stitching, optical flow, and a [`dnn`](https://docs.opencv.org/5.x/main_modules/dnn.html) module that runs ONNX models. On mobile it is what lets a camera frame be measured, corrected or classified *on the device* — the whole library is compiled into the wheel, so nothing is uploaded and nothing -needs a network. It is the most-upvoted package request Flet has -([flet#3200](https://github.com/flet-dev/flet/discussions/3200)). +needs a network. + +## Supported targets + +| Platform | Architectures | +| -------- | ------------- | +| [Android](https://flet.dev/docs/publish/android/#supported-target-architectures) | `arm64-v8a`, `armeabi-v7a`, `x86_64` | +| [iOS device](https://flet.dev/docs/publish/ios/#flet-build-ipa) | `arm64` | +| [iOS simulator](https://flet.dev/docs/publish/ios/#flet-build-ios-simulator) | `arm64`, `x86_64` | + +Built for Python 3.12, 3.13 and 3.14. + +This page describes opencv-python 5.0.0.93. Other published versions can differ in both +Python and architecture coverage — [the index listing](https://pypi.flet.dev/opencv-python/) +is the record of every wheel that actually exists. ## Install +Add it to your `pyproject.toml`: + ```toml -# pyproject.toml dependencies = [ "flet", "opencv-python", @@ -22,7 +36,7 @@ dependencies = [ extract_packages = ["cv2"] ``` -**Pick exactly one of the three distributions.** `opencv-python`, +**Pick exactly one of the three distributions:** `opencv-python`, [`opencv-contrib-python`](../opencv-contrib-python) and [`opencv-python-headless`](../opencv-python-headless) all install a top-level package called `cv2`, so two of them in one environment silently overwrite each other's files and @@ -36,7 +50,7 @@ here unchanged. Which one: - **`opencv-contrib-python`** is a strict superset, adding thirty-seven further modules — `face`, `tracking`, `ximgproc`, `xphoto`, `optflow`, `img_hash`, `wechat_qrcode`, `text`, `bgsegm`, `dnn_superres`, `gapi` among them — for 20.5 MB of wheel against 13.8 MB on - Android arm64. The one that catches people out is **`cv2.ml`** (`SVM`, `KNearest`, + Android arm64. The one that catches people out is `cv2.ml` (`SVM`, `KNearest`, `RTrees`, `ANN_MLP`): OpenCV 5 moved the `ml` module into contrib, so `cv2.ml` raises `AttributeError` on the base wheel where OpenCV 4 had it. Take contrib for a named module you need, not by default. @@ -48,33 +62,38 @@ here unchanged. Which one: there is no GUI backend in either to leave out (see [Things to know](#things-to-know)). -Nothing else is required. `numpy` comes along automatically, and with it -`flet-libcpp-shared` on Android — that dependency belongs to numpy, not to cv2, whose own -extension links libc++ statically and declares nothing beyond `numpy>=2`. - -The `extract_packages` entry above is the configuration these wheels are tested in, and it -costs you nothing but a little disk. Be clear about what it is not, though: it is **not** -what makes `import cv2` work. The loader in these wheels resolves the native extension -directly and reads no file out of the package directory, so cv2 imports and every image -operation runs whether the package is extracted or left inside Android's zipped -site-packages. Nor does it bring back `cv2.Mat` or `cv2.typing` — those are gone for an -unrelated reason, described in [Things to know](#things-to-know). - -**Leave `[tool.flet.compile]` alone for mobile.** You will find advice to set -`packages = false`; that is a desktop fix. The stock PyPI wheel's loader `exec()`s a -`config.py` at import time, so compiling packages to `.pyc` and stripping the sources -breaks it with `ImportError: OpenCV loader: missing configuration file: ['config.py']`. -The loader in *these* wheels never reads that file, and the recipe's own on-device tests -run with packages compiled. If you build a desktop or web target from the same project, -scope the workaround to that target rather than turning compilation off everywhere: +## Configuration + +- **`extract_packages` is not required here:** + [`extract_packages`](https://flet.dev/docs/publish/android/#extract-packages) unzips a + package onto the filesystem, which matters for libraries that read config files or load + their native extension through `__file__`-relative paths — exactly what the stock PyPI + `opencv-python` loader does. These wheels replace that loader with one that resolves the + extension through the import system and reads nothing from the package directory, so `cv2` + works left zipped. The entry costs only disk and would matter again if upstream moved to + multi-phase module init, so it stays — but it is not a fix for anything, and it will not + bring back `cv2.Mat` or `cv2.typing` (see [Things to know](#things-to-know)). +- **Avoid disabling compilation of packages + ([`[tool.flet.compile].packages`](https://flet.dev/docs/publish/#compilation-and-cleanup)) + on mobile:** You might find advice to set `packages = false`; that is a desktop fix. The + stock PyPI wheel's loader `exec()`s a `config.py` at import time, so compiling packages to + `.pyc` and stripping the sources breaks it with + `ImportError: OpenCV loader: missing configuration file: ['config.py']`. The loader in + *these* wheels never reads that file, and the recipe's own on-device tests run with + packages compiled. If you build a desktop or web target from the same project, scope the + workaround to that target rather than turning compilation off everywhere: + + ```toml + [tool.flet.macos.compile] + packages = false + ``` -```toml -[tool.flet.macos.compile] -packages = false -``` +## Examples -Builds for all three Android ABIs Flet targets (arm64-v8a, armeabi-v7a, x86_64) and for -iOS device and both simulator slices, on Python 3.12, 3.13 and 3.14. +See runnable Flet apps in [`examples/`](examples): + +- [`shape-finder`](examples/shape-finder) — segments shapes out of a noisy scene and shows + the annotated frame. ## Storage @@ -92,8 +111,9 @@ cv2.imwrite(out_path, frame) Use [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) for a frame you re-derive on demand and [`FLET_APP_STORAGE_CACHE`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_cache) -for something you can afford to lose. Images you ship with the app are assets, not storage, -and belong in `src/assets/`. +for something you can afford to lose. Images you ship with the app are assets, not storage: +put them in your [assets directory](https://flet.dev/docs/cookbook/assets) and read them via +[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). Most of the time you want no file at all: [`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) and @@ -101,15 +121,9 @@ Most of the time you want no file at all: images between `numpy` arrays and `bytes` in memory, which is also how a result reaches the screen — see [Things to know](#things-to-know). -## Examples - -See runnable Flet apps in [`examples/`](examples): - -- [`shape-finder`](examples/shape-finder) — segments shapes out of a noisy scene and shows the annotated frame. - ## Threading -**OpenCV is genuinely multi-threaded here**, which sets it apart from most of the numerical +OpenCV is genuinely multi-threaded here, which sets it apart from most of the numerical wheels on this index. Android builds with a pthreads parallel framework, iOS with Grand Central Dispatch, and everything routed through OpenCV's `parallel_for_` — resizes, warps, filters, `dnn` inference, most of `imgproc` — spreads across the phone's cores by itself. @@ -135,8 +149,8 @@ writing into the same `numpy` array is your problem, not something OpenCV will d ## Android notes -The Android build carries two image formats the iOS one does not: **TIFF** (libtiff 4.7.1) -and **JPEG 2000** (OpenJPEG 2.5.3). Both platforms have JPEG (libjpeg-turbo), PNG, WebP, +The Android build carries two image formats the iOS one does not: TIFF (libtiff 4.7.1) +and JPEG 2000 (OpenJPEG 2.5.3). Both platforms have JPEG (libjpeg-turbo), PNG, WebP, GIF, HDR, PXM, PFM and Sun raster; neither has AVIF or OpenEXR. So a `.tiff` or `.jp2` round-trip that passes on an emulator will fail on an iPhone — see [iOS notes](#ios-notes). @@ -146,19 +160,19 @@ arm64) is compiled into the Android wheel and disabled in the iOS one, and Andro additionally gets the Carotene HAL for a set of `imgproc` operations. Both are transparent: the same call returns the same answer on either platform, and only the time it takes moves. -The **NDK Camera and MediaNDK video backends are compiled in** — `ANDROID_NATIVE` is a +The NDK Camera and MediaNDK video backends are compiled in — `ANDROID_NATIVE` is a registered `videoio` backend and the extension links `libcamera2ndk.so` and `libmediandk.so`. That is a statement about the binary, not a working camera: nothing in this recipe opens one, the app would additionally need the `CAMERA` [permission](https://flet.dev/docs/publish/android/#permissions), and [`VideoCapture`](https://docs.opencv.org/5.x/main_modules/videoio.html) inside a Flet app is -untested here. Treat it as worth trying, not as supported — the route that is known to work -is [`flet-camera`](https://pypi.org/project/flet-camera/) to acquire frames and cv2 to -process them. +untested here. The route that is known to work is +[`flet-camera`](https://pypi.org/project/flet-camera/) to acquire frames and cv2 to process +them. ## iOS notes -**No TIFF, no JPEG 2000.** Writing one raises — +**No TIFF, no JPEG 2000:** Writing one raises — `cv2.error: (-2:Unspecified error) could not find a writer for the specified extension` from `imwrite`, and `could not find encoder for the specified extension` from `imencode` — while *reading* one fails silently: `cv2.imdecode` returns `None` for a format it has no @@ -166,7 +180,7 @@ decoder for, exactly as it does for a corrupt buffer, so check the return value relying on an exception. JPEG, PNG and WebP cover everything else. The build also has no OpenEXR and no AVIF, which matches Android. -The vendored MLAS kernels are **off** on iOS: their object files do not survive into the +The vendored MLAS kernels are *off* on iOS: their object files do not survive into the iOS framework binary, and `import cv2` failed at `dlopen` with an undefined `MlasGemmBatch` until they were disabled. `dnn` falls back to OpenCV's built-in SGEMM, which gives the same answer on the same model; how much throughput that costs has not been measured here. @@ -177,7 +191,7 @@ on Android, that is what the binary contains and not a tested path. ## Things to know -- **There is no GUI, so `cv2.imshow` raises.** Both builds report `GUI: NONE`, and every +- **There is no GUI, so `cv2.imshow` raises:** Both builds report `GUI: NONE`, and every [highgui](https://docs.opencv.org/5.x/main_modules/highgui.html) entry point — [`imshow`](https://docs.opencv.org/5.x/main_modules/highgui.html#imshow), `waitKey`, `namedWindow`, the trackbars — fails with @@ -196,7 +210,7 @@ on Android, that is what the binary contains and not a tested path. frame is the wrong shape — [`ft.RawImage`](https://flet.dev/docs/controls/rawimage/#rawimage-vs-image) takes raw RGBA over a dedicated channel and paces itself. -- **On device, `cv2` is the extension module, not the package.** OpenCV's compiled bindings +- **On device, `cv2` is the extension module, not the package:** OpenCV's compiled bindings insist on being loaded under the exact top-level name `cv2`, and the loader in these wheels does that by loading the relocated native extension as `cv2` — which, because OpenCV uses single-phase module init, replaces the `cv2` package in `sys.modules` with @@ -210,14 +224,14 @@ on Android, that is what the binary contains and not a tested path. if one of your dependencies does `from cv2.typing import MatLike` outside a `TYPE_CHECKING` block; annotate with `numpy.ndarray` in your own code and the question does not arise. No `extract_packages` setting changes this. -- **No FFmpeg, so no video files.** The desktop wheel bundles 99 shared libraries — +- **No FFmpeg, so no video files:** The desktop wheel bundles 99 shared libraries — the whole of FFmpeg, OpenEXR, Tesseract, SDL2 — and the mobile wheels bundle none of them; that is the entire difference in the file list between the two, everything else is statically linked in. `cv2.VideoCapture("clip.mp4")` therefore has no FFmpeg to fall back on. Android's MediaNDK backend can in principle decode what the OS decodes, iOS has AVFoundation, and neither has been exercised by this recipe. Still images are the supported path. -- **Haar cascades are gone, and not because of this build.** OpenCV 5 removed +- **Haar cascades are gone, and not because of this build:** OpenCV 5 removed `cv2.CascadeClassifier` upstream; the symbol is absent from the desktop wheel of the same version too, and no cascade XML ships in `cv2/data/` on any platform, so `cv2.data.haarcascades` (where the module exists at all) points at an empty directory. Use @@ -225,61 +239,59 @@ on Android, that is what the binary contains and not a tested path. YuNet ONNX model bundled in `src/assets/` — smaller and considerably more accurate — or `cv2.QRCodeDetector` and `cv2.barcode` for codes. ArUco did *not* move to contrib: it is in `objdetect` now, so `cv2.aruco` is in this wheel. -- **Size.** The wheels are 12–17 MB and unpack to 24–43 MB depending on architecture +- **Size:** The wheels are 12–17 MB and unpack to 24–43 MB depending on architecture (Android arm64-v8a: 13.8 MB and 33.9 MB; armeabi-v7a: 12.2 MB and 23.9 MB; x86_64: 16.7 MB and 42.9 MB; iOS arm64: 13.3 MB and 38.8 MB). Essentially all of that is the single `cv2` extension — 33.0 MB of the Android arm64 total, 37.8 MB of the iOS one — so - there is nothing to trim with `[tool.flet.cleanup]`: no test suite, no data files, and + there is nothing to trim with + [`[tool.flet.cleanup]`](https://flet.dev/docs/publish/#compilation-and-cleanup): no test + suite, no data files, and the 451 KB of `.pyi` type stubs are stripped during packaging anyway. Building only the ABIs you ship is the lever that exists; see [target architectures](https://flet.dev/docs/publish/android/#supported-target-architectures). ## Build notes (maintainers) -Each patch carries its rationale at the top of the file and each build flag is justified in -`meta.yaml` next to the flag, so this section is what neither of those records. - The recipe builds OpenCV's *own* CMake tree with the python bindings forced back on (upstream disables them for `ANDROID` and `APPLE_FRAMEWORK`, which is what most of the patch undoes), rather than going the PEP 517 shim route used for packages with no usable sdist. `opencv-python`'s sdist drives scikit-build with a `CMAKE_ARGS` handoff, and that handoff is the whole integration — which is why the same recipe shape is copy-pasted across all three distributions, with the flavour selected only by the package name and, on iOS -contrib, one extra `BUILD_opencv_rgbd=OFF`. **Keep the three `meta.yaml` files in step**: a +contrib, one extra `BUILD_opencv_rgbd=OFF`. **Keep the three `meta.yaml` files in step:** A fix applied to one and not the others produces three wheels claiming the same OpenCV version with different contents, and nothing in CI compares them. -`extract_packages: [cv2]` is retained deliberately even though it is, for this version, a -no-op: the loader's extra-submodule pass it was added for cannot succeed regardless (the -package module is no longer in `sys.modules` by the time it runs). It costs nothing, it is -what the on-device tests exercise, and if upstream ever moves to multi-phase module init -the pass starts working again and the entry becomes load-bearing without anyone touching it. +`extract_packages: [cv2]` is a no-op for this version and kept anyway; the user-facing +reasoning is in [Configuration](#configuration). The mechanism behind the no-op: the +loader's extra-submodule pass the entry was added for runs after the package module has +already left `sys.modules`, so it cannot succeed however the package is packed. What to re-verify on a bump, in rough order of how quietly it can go wrong: -- **That `cv2` is still the extension module rather than the package**, since a good deal of +- **That `cv2` is still the extension module rather than the package:** A good deal of [Things to know](#things-to-know) hangs off it. It follows from `PyModule_Create2` in the binary — single-phase init, so `module_from_spec` registers the extension in `sys.modules` under `cv2` and the package module is dropped. The current tests do not pin it; the quickest check is `cv2.__spec__.loader` on device, or `hasattr(cv2, "typing")` being `False`. If a release switches to multi-phase init the behaviour flips silently and the bullet needs rewriting, not updating. -- **The two platforms' codec lists.** No TIFF and no JPEG 2000 on iOS is read out of +- **The two platforms' codec lists:** No TIFF and no JPEG 2000 on iOS is read out of `getBuildInformation()` in the shipped binary, and it is a consequence of which 3rdparty libraries the iOS configure step found, not of anything the recipe sets — so it can move either way on a bump without a build failure. Re-extract the build information from both `.so` files and diff the `Media I/O` blocks before repeating the claim, and do the same for `Video I/O` (`MEDIANDK`/`NDK Camera` on Android, `AVFoundation` on iOS) and for the `GUI: NONE` line the `imshow` bullet rests on. -- **The module list, and with it the contrib boundary.** `cv2.ml` living in contrib and +- **The module list, and with it the contrib boundary:** `cv2.ml` living in contrib and ArUco living in `objdetect` are OpenCV 5 facts, not permanent ones. The `OpenCV modules: To be built` line of each build differs between the three flavours and is the cheapest way to regenerate the Install section's comparison. -- **Headless being identical to the main build on mobile.** It holds only while there is no +- **Headless being identical to the main build on mobile:** It holds only while there is no GUI backend to disable in the first place. If a future toolchain gives the Android or iOS build a working `highgui`, headless stops being a synonym and both the Install section and the `imshow` bullet change together. -- **The desktop-wheel comparison.** That the mobile wheels differ from the PyPI wheel of the +- **The desktop-wheel comparison:** That the mobile wheels differ from the PyPI wheel of the same version in exactly the 99 bundled `.dylibs` and nothing else is what backs the "no FFmpeg" bullet. Re-run that diff; a new pure-Python file upstream would also change it. diff --git a/recipes/opencv-python/examples/shape-finder/README.md b/recipes/opencv-python/examples/shape-finder/README.md index d43404ee..dbb60810 100644 --- a/recipes/opencv-python/examples/shape-finder/README.md +++ b/recipes/opencv-python/examples/shape-finder/README.md @@ -2,18 +2,21 @@ Nine shapes are drawn on a grid, then buried under Gaussian noise you control with a slider. [OpenCV](https://opencv.org/) segments them back out, names each one from its -contour, and the annotated picture comes back on screen as JPEG bytes. +contour, and the annotated picture comes back on screen as JPEG bytes. The table reports +what was placed against what was found, how many contours survived the threshold before the +area filter, and how long the pipeline took. What it demonstrates: - **Showing an OpenCV result without a GUI backend** — the mobile wheels have none, so - `cv2.imshow` raises. The frame is encoded with + [`cv2.imshow`](https://docs.opencv.org/5.x/main_modules/highgui.html#imshow) raises. The + frame is encoded with [`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) and handed straight to [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src), which accepts `bytes` as well as a path. JPEG rather than PNG because the buffer crosses the Flet transport on every run — at the top of the slider a PNG of the same frame is about four times larger. -- **A real segmentation pipeline in one handler** — +- **A real segmentation pipeline in one call** — [`cvtColor`](https://docs.opencv.org/5.x/main_modules/imgproc_color_conversions.html#cvtcolor), an Otsu [`threshold`](https://docs.opencv.org/5.x/main_modules/imgproc_misc.html#threshold) that picks its own cut point, @@ -27,9 +30,8 @@ What it demonstrates: [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) that a background thread needs. The slider fires on [`on_change_end`](https://flet.dev/docs/controls/slider/#flet.Slider.on_change_end), not - `on_change`, so one drag runs the pipeline once instead of once per pixel travelled. -- **Which stage actually survives noise** — the table reports the number of contours - found *before* the minimum-area filter alongside the final counts. + [`on_change`](https://flet.dev/docs/controls/slider/#flet.Slider.on_change), so one drag + runs the pipeline once instead of once per pixel travelled. Push the slider up and that contour count runs from nine into five figures while the shape counts hold: it is the area filter, not the threshold, doing the work. Push it all diff --git a/recipes/opencv-python/examples/shape-finder/src/main.py b/recipes/opencv-python/examples/shape-finder/src/main.py index 5bc0cc15..b8fb37f0 100644 --- a/recipes/opencv-python/examples/shape-finder/src/main.py +++ b/recipes/opencv-python/examples/shape-finder/src/main.py @@ -1,97 +1,5 @@ -import time - -import cv2 import flet as ft -import numpy as np - -SIZE = 480 -CELL = SIZE // 3 -KINDS = ("triangle", "rectangle", "circle") -MIN_AREA = 900 - - -def scene(rng): - """Draw one random shape per cell of a 3x3 grid and report what was placed. - - The grid is what makes the counts meaningful: findContours with RETR_EXTERNAL - returns one contour per connected blob, so two shapes allowed to touch would come - back as a single contour and no count could ever match. - """ - canvas = np.full((SIZE, SIZE, 3), 20, np.uint8) - placed = dict.fromkeys(KINDS, 0) - for row_index in range(3): - for col_index in range(3): - kind = KINDS[int(rng.integers(0, len(KINDS)))] - cx = col_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) - cy = row_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) - r = int(rng.integers(38, 56)) - colour = tuple(int(c) for c in rng.integers(120, 255, 3)) - if kind == "circle": - cv2.circle(canvas, (cx, cy), r, colour, -1) - elif kind == "rectangle": - cv2.rectangle(canvas, (cx - r, cy - r), (cx + r, cy + r), colour, -1) - else: - corners = np.array( - [[cx, cy - r], [cx - r, cy + r], [cx + r, cy + r]], np.int32 - ) - cv2.fillPoly(canvas, [corners], colour) - placed[kind] += 1 - return canvas, placed - - -def analyse(canvas, noise): - """Bury the scene in noise, segment the shapes back out, and label each one. - - Four compiled OpenCV stages in one call — a colour conversion, an Otsu threshold - that picks its own cut point, contour extraction, and a polygon approximation whose - vertex count names the shape. Returns the annotated picture, the counts by kind, the - number of contours found *before* the area filter, and the milliseconds spent. - - That raw contour count is the interesting number: it is what noise inflates, from - nine into the thousands, while the area filter keeps the answer at nine. - """ - rng = np.random.default_rng() - noisy = canvas.astype(np.int16) + rng.normal(0, noise, canvas.shape) - noisy = np.clip(noisy, 0, 255).astype(np.uint8) - - started = time.perf_counter() - gray = cv2.cvtColor(noisy, cv2.COLOR_BGR2GRAY) - _, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - found = dict.fromkeys(KINDS, 0) - for contour in contours: - if cv2.contourArea(contour) < MIN_AREA: - continue - # 3% of the perimeter is loose enough to collapse a noisy edge into one - # straight side, and tight enough to leave a circle with far more than four. - corners = cv2.approxPolyDP(contour, 0.03 * cv2.arcLength(contour, True), True) - kind = {3: "triangle", 4: "rectangle"}.get(len(corners), "circle") - found[kind] += 1 - cv2.drawContours(noisy, [contour], -1, (255, 255, 255), 2) - top = contour[contour[:, :, 1].argmin()][0] - cv2.putText( - noisy, - kind, - (int(top[0]) - 26, int(top[1]) - 8), - cv2.FONT_HERSHEY_SIMPLEX, - 0.4, - (255, 255, 255), - 1, - cv2.LINE_AA, - ) - return noisy, found, len(contours), (time.perf_counter() - started) * 1000 - - -def jpeg(image): - """Encode a BGR array as JPEG bytes, which is what ft.Image.src takes directly. - - JPEG rather than PNG because this buffer crosses the Flet transport on every run, - and at the top of the noise slider a PNG of the same frame is about four times - larger. - """ - _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 80]) - return buffer.tobytes() +from shapes import KINDS, VERSION, analyse, scene def row(label, *cells): @@ -102,18 +10,12 @@ def row(label, *cells): def main(page: ft.Page): - """Show a noise slider over a scene OpenCV has to segment, and the counts it got. - - The picture reaches the screen as JPEG bytes rather than through a window: there is - no GUI backend in the mobile wheels, so cv2.imshow raises, and ft.Image.src taking - bytes directly is what replaces it. - """ - canvas, placed = scene(np.random.default_rng()) + canvas, placed = scene() def redraw(): """Draw a fresh set of shapes, then segment the new scene.""" nonlocal canvas, placed - canvas, placed = scene(np.random.default_rng()) + canvas, placed = scene() segment() def segment(): @@ -132,8 +34,7 @@ def compute(): At the very top of the slider the per-kind labels do slip, because noise roughens an outline until approxPolyDP reads a circle as four-sided. """ - annotated, found, contours, elapsed = analyse(canvas, noise.value) - frame = jpeg(annotated) + frame, found, contours, elapsed = analyse(canvas, noise.value) view.src = frame results.controls = [ row("", "placed", "found"), @@ -155,9 +56,9 @@ def compute(): content=ft.Column( scroll=ft.ScrollMode.AUTO, controls=[ - ft.Text(f"OpenCV {cv2.__version__} — {SIZE}×{SIZE} scene", size=12), + ft.Text(VERSION, size=12), view := ft.Image( - src=jpeg(canvas), + src=b"", fit=ft.BoxFit.CONTAIN, border_radius=8, gapless_playback=True, diff --git a/recipes/opencv-python/examples/shape-finder/src/shapes.py b/recipes/opencv-python/examples/shape-finder/src/shapes.py new file mode 100644 index 00000000..c7646f7c --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/src/shapes.py @@ -0,0 +1,95 @@ +import time + +import cv2 +import numpy as np + +SIZE = 480 +CELL = SIZE // 3 +KINDS = ("triangle", "rectangle", "circle") +MIN_AREA = 900 +VERSION = f"OpenCV {cv2.__version__} — {SIZE}×{SIZE} scene" + + +def scene(): + """Draw one random shape per cell of a 3x3 grid and report what was placed. + + The grid is what makes the counts meaningful: findContours with RETR_EXTERNAL + returns one contour per connected blob, so two shapes allowed to touch would come + back as a single contour and no count could ever match. + """ + rng = np.random.default_rng() + canvas = np.full((SIZE, SIZE, 3), 20, np.uint8) + placed = dict.fromkeys(KINDS, 0) + for row_index in range(3): + for col_index in range(3): + kind = KINDS[int(rng.integers(0, len(KINDS)))] + cx = col_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) + cy = row_index * CELL + CELL // 2 + int(rng.integers(-14, 15)) + r = int(rng.integers(38, 56)) + colour = tuple(int(c) for c in rng.integers(120, 255, 3)) + if kind == "circle": + cv2.circle(canvas, (cx, cy), r, colour, -1) + elif kind == "rectangle": + cv2.rectangle(canvas, (cx - r, cy - r), (cx + r, cy + r), colour, -1) + else: + corners = np.array( + [[cx, cy - r], [cx - r, cy + r], [cx + r, cy + r]], np.int32 + ) + cv2.fillPoly(canvas, [corners], colour) + placed[kind] += 1 + return canvas, placed + + +def analyse(canvas, noise): + """Bury the scene in noise, segment the shapes back out, and label each one. + + Four compiled OpenCV stages in one call — a colour conversion, an Otsu threshold + that picks its own cut point, contour extraction, and a polygon approximation whose + vertex count names the shape. Returns the annotated picture, the counts by kind, the + number of contours found *before* the area filter, and the milliseconds spent. + + That raw contour count is the interesting number: it is what noise inflates, from + nine into the thousands, while the area filter keeps the answer at nine. + """ + rng = np.random.default_rng() + noisy = canvas.astype(np.int16) + rng.normal(0, noise, canvas.shape) + noisy = np.clip(noisy, 0, 255).astype(np.uint8) + + started = time.perf_counter() + gray = cv2.cvtColor(noisy, cv2.COLOR_BGR2GRAY) + _, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + found = dict.fromkeys(KINDS, 0) + for contour in contours: + if cv2.contourArea(contour) < MIN_AREA: + continue + # 3% of the perimeter is loose enough to collapse a noisy edge into one + # straight side, and tight enough to leave a circle with far more than four. + corners = cv2.approxPolyDP(contour, 0.03 * cv2.arcLength(contour, True), True) + kind = {3: "triangle", 4: "rectangle"}.get(len(corners), "circle") + found[kind] += 1 + cv2.drawContours(noisy, [contour], -1, (255, 255, 255), 2) + top = contour[contour[:, :, 1].argmin()][0] + cv2.putText( + noisy, + kind, + (int(top[0]) - 26, int(top[1]) - 8), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (255, 255, 255), + 1, + cv2.LINE_AA, + ) + return _jpeg(noisy), found, len(contours), (time.perf_counter() - started) * 1000 + + +def _jpeg(image): + """Encode a BGR array as JPEG bytes, which is what ft.Image.src takes directly. + + JPEG rather than PNG because this buffer crosses the Flet transport on every run, + and at the top of the noise slider a PNG of the same frame is about four times + larger. + """ + _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 80]) + return buffer.tobytes() diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md index 9c25fc01..9a1441c6 100644 --- a/recipes/pymupdf/README.md +++ b/recipes/pymupdf/README.md @@ -18,10 +18,25 @@ Import it as `pymupdf`. The historical `fitz` name is still shipped as a separat module and still works, which matters because most PyMuPDF code you will find in the wild opens with `import fitz`. +## Supported targets + +| Platform | Architectures | +| -------- | ------------- | +| [Android](https://flet.dev/docs/publish/android/#supported-target-architectures) | `arm64-v8a`, `armeabi-v7a`, `x86_64` | +| [iOS device](https://flet.dev/docs/publish/ios/#flet-build-ipa) | `arm64` | +| [iOS simulator](https://flet.dev/docs/publish/ios/#flet-build-ios-simulator) | `arm64`, `x86_64` | + +Built for Python 3.12, 3.13 and 3.14. + +This page describes pymupdf 1.27.2.3. Other published versions can differ in both Python +and architecture coverage — [the index listing](https://pypi.flet.dev/pymupdf/) is the +record of every wheel that actually exists. + ## Install +Add it to your `pyproject.toml`: + ```toml -# pyproject.toml dependencies = [ "flet", "pymupdf", @@ -35,14 +50,18 @@ relocating its bundled libraries into framework bundles, and on the marker files behind; on an older Flet the libraries land somewhere the loader will not look and the app dies at `import pymupdf` with `Library not loaded: @rpath/libmupdf.dylib`. -Builds for all three Android ABIs Flet targets (arm64-v8a, armeabi-v7a, x86_64) and for iOS -device and simulator, on Python 3.12, 3.13 and 3.14. +## Examples + +See runnable Flet apps in [`examples/`](examples): + +- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it to + an image, and highlights search hits on the rendered page. ## Storage Most of the time you want no file at all. A document can be opened from a `bytes` object and -written back to one, and a rendered page goes straight into a Flet control like [`Image`](https://flet.dev/docs/controls/image) -which supports `bytes` as source: +written back to one, and a rendered page goes straight into a Flet control like +[`Image`](https://flet.dev/docs/controls/image), which supports `bytes` as source: ```python doc = pymupdf.open(stream=blob, filetype="pdf") # no path @@ -64,7 +83,7 @@ doc.save(os.path.join(data, "report.pdf")) is for documents the user expects to keep; [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) is for anything you can regenerate, such as a cache of rendered page images, and may be -cleared between launches. A PDF shipped with the app is an asset: put it in your +cleared between launches. A PDF shipped with the app is an asset: put it in your [assets directory](https://flet.dev/docs/cookbook/assets) and read it later on using [`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). @@ -72,13 +91,6 @@ cleared between launches. A PDF shipped with the app is an asset: put it in your needs the document to have been opened from that same path, so it is only available for files you own on disk — not for the `stream=` case. -## Examples - -See runnable Flet apps in [`examples/`](examples): - -- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it to - an image, and highlights search hits on the rendered page. - ## Threading **PyMuPDF does not support concurrent use, and it will not tell you when you break the @@ -198,7 +210,7 @@ preload is why the [Flet floor](#install) exists. It is inert on Android and on version anyway, which is why the ~2 MB ZXing library is left out. Likewise the `curl`, `X11` and `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet app. -- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG:** +- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG:** `page.get_pixmap(dpi=...)` returns raw RGB samples, and they grow with the square of the scale: a text-filled A4 page is 1.4 MB at 72 dpi, 5.7 MB at 144 and **24.9 MB at 300**, where the PNG `tobytes("png")` produces is 14 KB, 248 KB and 522 KB. Only the PNG crosses diff --git a/recipes/pymupdf/examples/render-and-read/src/main.py b/recipes/pymupdf/examples/render-and-read/src/main.py index 12645b22..f3b7949c 100644 --- a/recipes/pymupdf/examples/render-and-read/src/main.py +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -1,5 +1,4 @@ import flet as ft - from document import TITLES, VERSIONS, render From 71b37ccdeb6fc9a86b759762d3f9c69e988fc626 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 20 Aug 2026 20:49:43 +0200 Subject: [PATCH 11/11] update --- recipes/opencv-python/README.md | 437 +++++++++++++------------------- recipes/pymupdf/README.md | 429 +++++++++++++------------------ 2 files changed, 349 insertions(+), 517 deletions(-) diff --git a/recipes/opencv-python/README.md b/recipes/opencv-python/README.md index 3ac92954..936513ed 100644 --- a/recipes/opencv-python/README.md +++ b/recipes/opencv-python/README.md @@ -1,299 +1,220 @@ # opencv-python -[`opencv-python`](https://github.com/opencv/opencv-python) is the `cv2` binding for +[`opencv-python`](https://github.com/opencv/opencv-python) provides the `cv2` bindings for [OpenCV](https://opencv.org/): image filtering and geometry, contours and shape analysis, -feature detectors, camera calibration, stitching, optical flow, and a +feature detection, camera calibration, stitching, optical flow, and a [`dnn`](https://docs.opencv.org/5.x/main_modules/dnn.html) module that runs ONNX models. -On mobile it is what lets a camera frame be measured, corrected or classified *on the -device* — the whole library is compiled into the wheel, so nothing is uploaded and nothing -needs a network. - -## Supported targets - -| Platform | Architectures | -| -------- | ------------- | -| [Android](https://flet.dev/docs/publish/android/#supported-target-architectures) | `arm64-v8a`, `armeabi-v7a`, `x86_64` | -| [iOS device](https://flet.dev/docs/publish/ios/#flet-build-ipa) | `arm64` | -| [iOS simulator](https://flet.dev/docs/publish/ios/#flet-build-ios-simulator) | `arm64`, `x86_64` | - -Built for Python 3.12, 3.13 and 3.14. - -This page describes opencv-python 5.0.0.93. Other published versions can differ in both -Python and architecture coverage — [the index listing](https://pypi.flet.dev/opencv-python/) -is the record of every wheel that actually exists. +In a Flet app, those operations run on the device, so camera frames and model inputs do not +need to be uploaded for processing. ## Install -Add it to your `pyproject.toml`: +Add one OpenCV distribution to your `pyproject.toml`: ```toml dependencies = [ "flet", "opencv-python", ] - -[tool.flet.android] -extract_packages = ["cv2"] ``` -**Pick exactly one of the three distributions:** `opencv-python`, -[`opencv-contrib-python`](../opencv-contrib-python) and -[`opencv-python-headless`](../opencv-python-headless) all install a top-level package -called `cv2`, so two of them in one environment silently overwrite each other's files and -you end up running whichever landed last — upstream's own -[warning](https://github.com/opencv/opencv-python#installation-and-usage), and it applies -here unchanged. Which one: - -- **`opencv-python`** unless you have a reason otherwise. It is the whole of OpenCV's main - tree — `core`, `imgproc`, `imgcodecs`, `features`, `flann`, `calib`, `geometry`, - `stereo`, `objdetect`, `photo`, `ptcloud`, `stitching`, `video`, `videoio` and `dnn`. -- **`opencv-contrib-python`** is a strict superset, adding thirty-seven further modules — - `face`, `tracking`, `ximgproc`, `xphoto`, `optflow`, `img_hash`, `wechat_qrcode`, `text`, - `bgsegm`, `dnn_superres`, `gapi` among them — for 20.5 MB of wheel against 13.8 MB on - Android arm64. The one that catches people out is `cv2.ml` (`SVM`, `KNearest`, - `RTrees`, `ANN_MLP`): OpenCV 5 moved the `ml` module into contrib, so `cv2.ml` raises - `AttributeError` on the base wheel where OpenCV 4 had it. Take contrib for a named - module you need, not by default. -- **`opencv-python-headless`** exists so that a pin someone else wrote — albumentations - and most OCR stacks require `opencv-python-headless` by name — resolves to something. - It saves you nothing here: on mobile it is *the same build*. Its wheel has the identical - file list, an extension the same size to within 5 KB, and a `getBuildInformation()` that - differs from `opencv-python`'s in one line — the CI machine's kernel version — because - there is no GUI backend in either to leave out (see - [Things to know](#things-to-know)). - -## Configuration - -- **`extract_packages` is not required here:** - [`extract_packages`](https://flet.dev/docs/publish/android/#extract-packages) unzips a - package onto the filesystem, which matters for libraries that read config files or load - their native extension through `__file__`-relative paths — exactly what the stock PyPI - `opencv-python` loader does. These wheels replace that loader with one that resolves the - extension through the import system and reads nothing from the package directory, so `cv2` - works left zipped. The entry costs only disk and would matter again if upstream moved to - multi-phase module init, so it stays — but it is not a fix for anything, and it will not - bring back `cv2.Mat` or `cv2.typing` (see [Things to know](#things-to-know)). -- **Avoid disabling compilation of packages - ([`[tool.flet.compile].packages`](https://flet.dev/docs/publish/#compilation-and-cleanup)) - on mobile:** You might find advice to set `packages = false`; that is a desktop fix. The - stock PyPI wheel's loader `exec()`s a `config.py` at import time, so compiling packages to - `.pyc` and stripping the sources breaks it with - `ImportError: OpenCV loader: missing configuration file: ['config.py']`. The loader in - *these* wheels never reads that file, and the recipe's own on-device tests run with - packages compiled. If you build a desktop or web target from the same project, scope the - workaround to that target rather than turning compilation off everywhere: - - ```toml - [tool.flet.macos.compile] - packages = false - ``` +`numpy` is installed automatically. + +**Install exactly one OpenCV distribution.** All three choices below provide the same +top-level package, `cv2`. Installing more than one lets their files overwrite each other and +leaves the result dependent on installation order. This is the same restriction documented +in upstream's [installation guidance](https://github.com/opencv/opencv-python#installation-and-usage). + +| Distribution | Choose it when | Android arm64 wheel | +| --- | --- | ---: | +| `opencv-python` | The default. It contains OpenCV's main modules, including `imgproc`, `imgcodecs`, `features`, `calib`, `objdetect`, `video`, `videoio` and `dnn`. | ~13.8 MB | +| [`opencv-contrib-python`](../opencv-contrib-python) | You need a named contrib module such as `face`, `tracking`, `ximgproc`, `xphoto`, `optflow`, `wechat_qrcode`, `text`, `dnn_superres`, `gapi` or `ml`. OpenCV 5 moved `cv2.ml` into contrib, so it raises `AttributeError` with the base wheel. | ~20.5 MB | +| [`opencv-python-headless`](../opencv-python-headless) | Another dependency requires this distribution name. On mobile it is the same build as `opencv-python`, because neither wheel has a GUI backend; choosing it does not reduce the payload. | ~13.8 MB | + +The sizes are approximate compressed-wheel measurements for the current recipe and are shown +only to make the distribution choice visible. Final application size depends on the selected +architectures and packaging format. ## Examples See runnable Flet apps in [`examples/`](examples): -- [`shape-finder`](examples/shape-finder) — segments shapes out of a noisy scene and shows - the annotated frame. +- [`shape-finder`](examples/shape-finder) — segments shapes out of a noisy scene and shows the + annotated frame. + +## Usage in a Flet app -## Storage +### Storage [`imread`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imread) and -[`imwrite`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imwrite) take ordinary -filesystem paths, so anything the app writes belongs in -[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data) -— the app-private directory that is never auto-deleted and is included in backups: +[`imwrite`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imwrite) accept ordinary +filesystem paths. Put images the user expects to keep in +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data): ```python out_path = os.path.join(os.getenv("FLET_APP_STORAGE_DATA", "."), "capture.png") cv2.imwrite(out_path, frame) ``` -Use [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) -for a frame you re-derive on demand and -[`FLET_APP_STORAGE_CACHE`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_cache) -for something you can afford to lose. Images you ship with the app are assets, not storage: -put them in your [assets directory](https://flet.dev/docs/cookbook/assets) and read them via -[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). - -Most of the time you want no file at all: -[`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) and -[`imdecode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imdecode) move whole -images between `numpy` arrays and `bytes` in memory, which is also how a result reaches the -screen — see [Things to know](#things-to-know). - -## Threading - -OpenCV is genuinely multi-threaded here, which sets it apart from most of the numerical -wheels on this index. Android builds with a pthreads parallel framework, iOS with Grand -Central Dispatch, and everything routed through OpenCV's `parallel_for_` — resizes, warps, -filters, `dnn` inference, most of `imgproc` — spreads across the phone's cores by itself. -[`cv2.setNumThreads(n)`](https://docs.opencv.org/5.x/main_modules/core_utils.html#setnumthreads) -caps that, `cv2.setNumThreads(0)` makes it serial, and the `OPENCV_FOR_THREADS_NUM` -environment variable does the same thing before the first call. - -[`cv2.getNumThreads()`](https://docs.opencv.org/5.x/main_modules/core_utils.html#getnumthreads) -does not tell you what you set on the GCD backend. Measured on macOS, which uses the same -backend as iOS: after `setNumThreads(1)`, `setNumThreads(2)` and `setNumThreads(8)` it kept -returning the core count, while the wall-clock time of a large `GaussianBlur` moved by more -than 4× — so the setting takes effect and the getter is not evidence of it. Time the call -rather than reading the number back. - -None of that helps the UI thread. A pipeline over a full-resolution camera frame will -freeze the UI wherever it runs, so push it to -[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) and end -the handler with an explicit -[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) — auto-update does -not reach background threads. OpenCV itself imposes no thread rules on you: arrays and -results move between threads freely, and there is no handle to serialise. Two threads -writing into the same `numpy` array is your problem, not something OpenCV will detect. - -## Android notes - -The Android build carries two image formats the iOS one does not: TIFF (libtiff 4.7.1) -and JPEG 2000 (OpenJPEG 2.5.3). Both platforms have JPEG (libjpeg-turbo), PNG, WebP, -GIF, HDR, PXM, PFM and Sun raster; neither has AVIF or OpenEXR. So a `.tiff` or `.jp2` -round-trip that passes on an emulator will fail on an iPhone — see -[iOS notes](#ios-notes). - -`dnn` has kernels here that iOS does not. OpenCV 5's vendored MLAS (NEON SGEMM and SGEMV on -arm64) is compiled into the Android wheel and disabled in the iOS one, and Android -additionally gets the Carotene HAL for a set of `imgproc` operations. Both are transparent: -the same call returns the same answer on either platform, and only the time it takes moves. - -The NDK Camera and MediaNDK video backends are compiled in — `ANDROID_NATIVE` is a -registered `videoio` backend and the extension links `libcamera2ndk.so` and -`libmediandk.so`. That is a statement about the binary, not a working camera: nothing in -this recipe opens one, the app would additionally need the `CAMERA` -[permission](https://flet.dev/docs/publish/android/#permissions), and -[`VideoCapture`](https://docs.opencv.org/5.x/main_modules/videoio.html) inside a Flet app is -untested here. The route that is known to work is -[`flet-camera`](https://pypi.org/project/flet-camera/) to acquire frames and cv2 to process -them. - -## iOS notes - -**No TIFF, no JPEG 2000:** Writing one raises — -`cv2.error: (-2:Unspecified error) could not find a writer for the specified extension` -from `imwrite`, and `could not find encoder for the specified extension` from `imencode` — -while *reading* one fails silently: `cv2.imdecode` returns `None` for a format it has no -decoder for, exactly as it does for a corrupt buffer, so check the return value rather than -relying on an exception. JPEG, PNG and WebP cover everything else. The build also has no -OpenEXR and no AVIF, which matches Android. - -The vendored MLAS kernels are *off* on iOS: their object files do not survive into the -iOS framework binary, and `import cv2` failed at `dlopen` with an undefined `MlasGemmBatch` -until they were disabled. `dnn` falls back to OpenCV's built-in SGEMM, which gives the same -answer on the same model; how much throughput that costs has not been measured here. -Apple's Accelerate framework is linked in, as are UIKit, CoreGraphics and QuartzCore. - -`videoio` registers the AVFoundation backend and the build reports `iOS capture: YES`; as -on Android, that is what the binary contains and not a tested path. +Use [`FLET_APP_STORAGE_CACHE`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_cache) +for regenerable derivatives and +[`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) +for throwaway frames. Images and models shipped with the app are assets: put them in the +[assets directory](https://flet.dev/docs/cookbook/assets) and use +[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir) +when an API needs their absolute filesystem path. + +Often no file is needed. [`imencode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imencode) +and [`imdecode`](https://docs.opencv.org/5.x/main_modules/imgcodecs.html#imdecode) move complete +images between `numpy` arrays and `bytes`, including the result sent to a Flet image control. + +### Threading + +OpenCV parallelises many native operations itself. Android uses a pthreads backend and iOS +uses Grand Central Dispatch; work routed through OpenCV's `parallel_for_`, including many +resizes, warps, filters and `dnn` operations, can use multiple cores without application-level +threads. [`cv2.setNumThreads(n)`](https://docs.opencv.org/5.x/main_modules/core_utils.html#setnumthreads) +caps that internal parallelism, while `cv2.setNumThreads(0)` makes it serial. + +Internal parallelism does not protect the Flet UI thread. Move a full-resolution pipeline or +model inference into +[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread), catch and +display exceptions inside the worker, and finish with an explicit +[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update). Arrays and results can +move between threads, but concurrent writes to the same `numpy` array remain a data race that +OpenCV will not detect. + +On the GCD backend, [`cv2.getNumThreads()`](https://docs.opencv.org/5.x/main_modules/core_utils.html#getnumthreads) +may continue to report the core count after `setNumThreads()` changes the effective limit. +Measure the operation if the distinction matters; the getter is not reliable evidence that +the setting was ignored. + +### App size + +Depending on the architecture, the wheel is approximately 12–17 MB compressed and 24–43 MB +unpacked. Almost all of that is the single `cv2` extension, so there is no test suite or data +directory worth removing with +[`[tool.flet.cleanup]`](https://flet.dev/docs/publish/#compilation-and-cleanup). + +On Android, use an app bundle, split APKs, or narrow +[`target_arch`](https://flet.dev/docs/publish/android/#supported-target-architectures) when the +application does not need every ABI. Wheel size is not the amount added directly to the final +APK or IPA; packaging and compression determine that result. + +### Android + +Android includes TIFF and JPEG 2000 support in addition to JPEG, PNG, WebP, GIF, HDR, PXM, +PFM and Sun raster. The same TIFF or `.jp2` operation does not work on iOS. Neither platform +includes AVIF or OpenEXR. + +The Android `dnn` build includes arm64 MLAS kernels, and some `imgproc` operations can use the +Carotene HAL. These are transparent optimisations: code and results stay portable, while +throughput may differ from iOS. + +OpenCV reports Android camera and MediaNDK video backends, but this recipe does not validate +[`VideoCapture`](https://docs.opencv.org/5.x/main_modules/videoio.html) inside a Flet app. A +known consumer path is [`flet-camera`](https://pypi.org/project/flet-camera/) for capture and +`cv2` for processing. Direct camera access would also require the Android +[`CAMERA` permission](https://flet.dev/docs/publish/android/#permissions). + +### iOS + +iOS has no TIFF or JPEG 2000 codec. `imwrite` and `imencode` raise a `cv2.error` saying that +no writer or encoder exists; `imdecode` returns `None` for an unsupported input just as it +does for corrupt data, so check the return value. JPEG, PNG and WebP are the portable choices. + +The iOS `dnn` build uses OpenCV's built-in SGEMM instead of the vendored MLAS kernels. It +returns the same result for the same model, but the throughput difference has not been +measured. OpenCV reports an AVFoundation `videoio` backend; direct capture through it remains +unvalidated by this recipe. + +### Other considerations + +Leave Flet's package compilation enabled on mobile. Advice to set +[`[tool.flet.compile].packages = false`](https://flet.dev/docs/publish/#compilation-and-cleanup) +addresses the stock desktop wheel, whose loader executes a source `config.py`. This mobile +wheel uses a different loader and is tested with package compilation enabled. If a desktop +target in the same project needs that workaround, scope it to that target instead: + +```toml +[tool.flet.macos.compile] +packages = false +``` ## Things to know -- **There is no GUI, so `cv2.imshow` raises:** Both builds report `GUI: NONE`, and every - [highgui](https://docs.opencv.org/5.x/main_modules/highgui.html) entry point — - [`imshow`](https://docs.opencv.org/5.x/main_modules/highgui.html#imshow), `waitKey`, - `namedWindow`, the trackbars — fails with - `cv2.error: (-213:The function/feature is not implemented) The function is not - implemented. Rebuild the library with Windows, GTK+ 2.x or Cocoa support`. The - replacement is one line, because - [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src) accepts `bytes` as - well as a path: +- **There is no GUI, so `cv2.imshow` raises.** Both mobile builds report `GUI: NONE`, and the + [highgui](https://docs.opencv.org/5.x/main_modules/highgui.html) window functions fail with + `cv2.error: (-213:The function/feature is not implemented)`. Encode a still image for + [`ft.Image.src`](https://flet.dev/docs/controls/image/#flet.Image.src): ```python view.src = cv2.imencode(".jpg", frame)[1].tobytes() ``` Set [`gapless_playback=True`](https://flet.dev/docs/controls/image/#flet.Image.gapless_playback) - so the control does not blank between frames. For a continuous stream, encoding every - frame is the wrong shape — - [`ft.RawImage`](https://flet.dev/docs/controls/rawimage/#rawimage-vs-image) takes raw RGBA - over a dedicated channel and paces itself. -- **On device, `cv2` is the extension module, not the package:** OpenCV's compiled bindings - insist on being loaded under the exact top-level name `cv2`, and the loader in these - wheels does that by loading the relocated native extension as `cv2` — which, because - OpenCV uses single-phase module init, replaces the `cv2` package in `sys.modules` with - the extension itself. Everything the C++ side defines is present and normal: - `cv2.__version__`, every function and constant, and the native submodules `cv2.dnn`, - `cv2.aruco`, `cv2.utils`, `cv2.videoio_registry`. What is gone is the handful of - pure-Python submodules the desktop wheel merges in afterwards — **`cv2.Mat`, - `cv2.typing`, `cv2.data`, `cv2.mat_wrapper` and `cv2.misc` do not exist**, and - `import cv2.typing` fails with - `ModuleNotFoundError: No module named 'cv2.typing'; 'cv2' is not a package`. That matters - if one of your dependencies does `from cv2.typing import MatLike` outside a - `TYPE_CHECKING` block; annotate with `numpy.ndarray` in your own code and the question - does not arise. No `extract_packages` setting changes this. -- **No FFmpeg, so no video files:** The desktop wheel bundles 99 shared libraries — - the whole of FFmpeg, OpenEXR, Tesseract, SDL2 — and the mobile wheels bundle none of - them; that is the entire difference in the file list between the two, everything else is - statically linked in. `cv2.VideoCapture("clip.mp4")` therefore has no FFmpeg to fall back - on. Android's MediaNDK backend can in principle decode what the OS decodes, iOS has - AVFoundation, and neither has been exercised by this recipe. Still images are the - supported path. -- **Haar cascades are gone, and not because of this build:** OpenCV 5 removed - `cv2.CascadeClassifier` upstream; the symbol is absent from the desktop wheel of the same - version too, and no cascade XML ships in `cv2/data/` on any platform, so - `cv2.data.haarcascades` (where the module exists at all) points at an empty directory. Use - [`cv2.FaceDetectorYN`](https://docs.opencv.org/5.x/main_modules/objdetect.html) with a - YuNet ONNX model bundled in `src/assets/` — smaller and considerably more accurate — or - `cv2.QRCodeDetector` and `cv2.barcode` for codes. ArUco did *not* move to contrib: it is - in `objdetect` now, so `cv2.aruco` is in this wheel. -- **Size:** The wheels are 12–17 MB and unpack to 24–43 MB depending on architecture - (Android arm64-v8a: 13.8 MB and 33.9 MB; armeabi-v7a: 12.2 MB and 23.9 MB; x86_64: - 16.7 MB and 42.9 MB; iOS arm64: 13.3 MB and 38.8 MB). Essentially all of that is the - single `cv2` extension — 33.0 MB of the Android arm64 total, 37.8 MB of the iOS one — so - there is nothing to trim with - [`[tool.flet.cleanup]`](https://flet.dev/docs/publish/#compilation-and-cleanup): no test - suite, no data files, and - the 451 KB of `.pyi` type stubs are stripped during packaging anyway. Building only the - ABIs you ship is the lever that exists; see - [target architectures](https://flet.dev/docs/publish/android/#supported-target-architectures). + when replacing frames. For a continuous stream, + [`ft.RawImage`](https://flet.dev/docs/controls/rawimage/#rawimage-vs-image) avoids repeatedly + encoding images and sends paced raw RGBA data over a dedicated channel. + +- **On device, `cv2` is the extension module rather than the original package.** Native + functions, constants and submodules such as `cv2.dnn`, `cv2.aruco`, `cv2.utils` and + `cv2.videoio_registry` are present. The desktop wheel's pure-Python additions are not: + `cv2.Mat`, `cv2.typing`, `cv2.data`, `cv2.mat_wrapper` and `cv2.misc` do not exist, and + `import cv2.typing` raises `ModuleNotFoundError: No module named 'cv2.typing'; 'cv2' is not + a package`. This can also break a dependency that imports `cv2.typing` at runtime. Use + `numpy.ndarray` for annotations in your own code. + +- **There is no FFmpeg, so video-file support is not portable.** + `cv2.VideoCapture("clip.mp4")` has no FFmpeg backend to fall back on. Android's MediaNDK and + iOS's AVFoundation may decode formats supported by the OS, but those paths are not exercised + by this recipe. Still images are the supported path. + +- **Haar cascades are absent because OpenCV 5 removed them upstream.** + `cv2.CascadeClassifier` is also absent from the desktop wheel of the same OpenCV generation, + and no cascade XML files ship. For faces, use + [`cv2.FaceDetectorYN`](https://docs.opencv.org/5.x/main_modules/objdetect.html) with a YuNet + ONNX model bundled as an app asset. `cv2.QRCodeDetector`, `cv2.barcode` and `cv2.aruco` remain + available in the base wheel. ## Build notes (maintainers) -The recipe builds OpenCV's *own* CMake tree with the python bindings forced back on -(upstream disables them for `ANDROID` and `APPLE_FRAMEWORK`, which is what most of the -patch undoes), rather than going the PEP 517 shim route used for packages with no usable -sdist. `opencv-python`'s sdist drives scikit-build with a `CMAKE_ARGS` handoff, and that -handoff is the whole integration — which is why the same recipe shape is copy-pasted across -all three distributions, with the flavour selected only by the package name and, on iOS -contrib, one extra `BUILD_opencv_rgbd=OFF`. **Keep the three `meta.yaml` files in step:** A -fix applied to one and not the others produces three wheels claiming the same OpenCV -version with different contents, and nothing in CI compares them. - -`extract_packages: [cv2]` is a no-op for this version and kept anyway; the user-facing -reasoning is in [Configuration](#configuration). The mechanism behind the no-op: the -loader's extra-submodule pass the entry was added for runs after the package module has -already left `sys.modules`, so it cannot succeed however the package is packed. - -What to re-verify on a bump, in rough order of how quietly it can go wrong: - -- **That `cv2` is still the extension module rather than the package:** A good deal of - [Things to know](#things-to-know) hangs off it. It follows from `PyModule_Create2` in the - binary — single-phase init, so `module_from_spec` registers the extension in `sys.modules` - under `cv2` and the package module is dropped. The current tests do not pin it; the - quickest check is `cv2.__spec__.loader` on device, or `hasattr(cv2, "typing")` being - `False`. If a release switches to multi-phase init the behaviour flips silently and the - bullet needs rewriting, not updating. -- **The two platforms' codec lists:** No TIFF and no JPEG 2000 on iOS is read out of - `getBuildInformation()` in the shipped binary, and it is a consequence of which 3rdparty - libraries the iOS configure step found, not of anything the recipe sets — so it can move - either way on a bump without a build failure. Re-extract the build information from both - `.so` files and diff the `Media I/O` blocks before repeating the claim, and do the same - for `Video I/O` (`MEDIANDK`/`NDK Camera` on Android, `AVFoundation` on iOS) and for the - `GUI: NONE` line the `imshow` bullet rests on. -- **The module list, and with it the contrib boundary:** `cv2.ml` living in contrib and - ArUco living in `objdetect` are OpenCV 5 facts, not permanent ones. The - `OpenCV modules: To be built` line of each build differs between the three flavours and - is the cheapest way to regenerate the Install section's comparison. -- **Headless being identical to the main build on mobile:** It holds only while there is no - GUI backend to disable in the first place. If a future toolchain gives the Android or iOS - build a working `highgui`, headless stops being a synonym and both the Install section - and the `imshow` bullet change together. -- **The desktop-wheel comparison:** That the mobile wheels differ from the PyPI wheel of the - same version in exactly the 99 bundled `.dylibs` and nothing else is what backs the - "no FFmpeg" bullet. Re-run that diff; a new pure-Python file upstream would also change - it. -- **The sizes** are measured per architecture from the built wheels. Re-measure, do not - scale. +### Recipe shape + +The three OpenCV distributions share one recipe shape: OpenCV's own CMake tree is built with +the Python bindings enabled, and the distribution name selects the base, contrib or headless +flavour. Keep the three `meta.yaml` files in step. A fix applied to only one can produce wheels +claiming the same OpenCV generation but exposing different behavior, and CI does not compare +their contents. + +The patch preamble owns the explanation of the binding, loader and iOS `dnn` changes; +`meta.yaml` comments own individual build settings. Do not duplicate those mechanisms here. + +### Re-verification checklist + +- **Loader shape:** Confirm whether `cv2` still becomes the single-phase extension module and + whether `cv2.typing`, `cv2.Mat` and the other pure-Python additions remain absent. If + upstream moves to multi-phase module initialisation, rewrite the consumer note rather than + carrying the old limitation forward. +- **Android package layout:** Test the wheel from zipped site-packages. Add + `extract_packages` to consumer guidance only if a real runtime filesystem read makes it + mandatory, and include the failure symptom. +- **Codec and backend lists:** Regenerate the `Media I/O`, `Video I/O` and `GUI` sections from + `cv2.getBuildInformation()` on both platforms before repeating the TIFF, JPEG 2000, + camera-backend and no-GUI claims. +- **Base versus contrib:** Check the built module list, especially `ml`, ArUco and every module + named in the Install comparison. +- **Headless equivalence:** Reconfirm that it remains the same mobile build as the base wheel. + That stops being true if a working mobile GUI backend is added. +- **iOS inference:** Confirm that MLAS remains disabled there and that `dnn` inference still + succeeds with OpenCV's fallback implementation. +- **Size:** Re-measure the distribution comparison and compressed/unpacked ranges from the + resulting wheels rather than scaling old figures. + +### Coverage gaps + +The device tests cover importing `cv2`, its numpy dependency, image encode/decode and resize. +They do not exercise GUI failure, TIFF or JPEG 2000, video files, direct camera capture, +`dnn` inference, the pure-Python submodule boundary, or zipped-package behavior without the +recipe's current extraction setting. Treat those as inspection or example-backed claims until +the corresponding device coverage exists. diff --git a/recipes/pymupdf/README.md b/recipes/pymupdf/README.md index 9a1441c6..2621d54e 100644 --- a/recipes/pymupdf/README.md +++ b/recipes/pymupdf/README.md @@ -1,40 +1,17 @@ # pymupdf [`pymupdf`](https://pymupdf.readthedocs.io/) is the Python binding for -[MuPDF](https://mupdf.com/), and it is the reason a phone can do anything useful with a PDF -without sending it somewhere. It opens PDF, XPS, EPUB, CBZ and image files; renders any page -to a bitmap at any scale; pulls the text back out with coordinates; and writes documents from -scratch. On mobile that matters twice over — the file never leaves the device, and rendering -a page locally is the difference between a viewer and a download button. +[MuPDF](https://mupdf.com/). It opens PDF, XPS, EPUB, CBZ and image files; renders pages to +bitmaps; extracts text with coordinates; and creates or edits documents. In a Flet app, those +operations happen on the device, so a document does not need to leave the app for rendering or +text extraction. -The wheel is self-contained: four native libraries ship inside it, i.e., MuPDF itself -(`libmupdf`), its C++ wrapper (`libmupdfcpp`), the SWIG module over that wrapper (`_mupdf`) -and PyMuPDF's own accelerator (`_extra`). They have to find each other at load time, -and how that works differs between the -platforms, so it is described under [Android notes](#android-notes) and -[iOS notes](#ios-notes) rather than here. - -Import it as `pymupdf`. The historical `fitz` name is still shipped as a separate top-level -module and still works, which matters because most PyMuPDF code you will find in the wild -opens with `import fitz`. - -## Supported targets - -| Platform | Architectures | -| -------- | ------------- | -| [Android](https://flet.dev/docs/publish/android/#supported-target-architectures) | `arm64-v8a`, `armeabi-v7a`, `x86_64` | -| [iOS device](https://flet.dev/docs/publish/ios/#flet-build-ipa) | `arm64` | -| [iOS simulator](https://flet.dev/docs/publish/ios/#flet-build-ios-simulator) | `arm64`, `x86_64` | - -Built for Python 3.12, 3.13 and 3.14. - -This page describes pymupdf 1.27.2.3. Other published versions can differ in both Python -and architecture coverage — [the index listing](https://pypi.flet.dev/pymupdf/) is the -record of every wheel that actually exists. +Import the package as `pymupdf`. The historical `fitz` name is still included as a separate +top-level module, so existing code that begins with `import fitz` continues to work. ## Install -Add it to your `pyproject.toml`: +Add PyMuPDF to your `pyproject.toml`: ```toml dependencies = [ @@ -43,261 +20,195 @@ dependencies = [ ] ``` -**iOS needs Flet 0.86 or newer:** The iOS wheel relies on -[serious-python](https://github.com/flet-dev/serious-python) 4.2.1 -([PR #223](https://github.com/flet-dev/serious-python/pull/223)) -relocating its bundled libraries into framework bundles, and on the marker files that leaves -behind; on an older Flet the libraries land somewhere the loader will not look and the app -dies at `import pymupdf` with `Library not loaded: @rpath/libmupdf.dylib`. +**iOS requires Flet 0.86.0 or newer.** The wheel relies on the native-library relocation and +marker-file support shipped by the corresponding serious-python runtime. With an older Flet +version, the app fails at `import pymupdf` with +`Library not loaded: @rpath/libmupdf.dylib`. A bare `flet` dependency resolves to a current +release; this matters when another dependency or an application pin holds Flet below 0.86.0. ## Examples See runnable Flet apps in [`examples/`](examples): -- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it to - an image, and highlights search hits on the rendered page. +- [`render-and-read`](examples/render-and-read) — builds a three-page PDF, rasterises it to an + image, and highlights search hits on the rendered page. + +## Usage in a Flet app -## Storage +### Storage -Most of the time you want no file at all. A document can be opened from a `bytes` object and -written back to one, and a rendered page goes straight into a Flet control like -[`Image`](https://flet.dev/docs/controls/image), which supports `bytes` as source: +PyMuPDF can work entirely in memory. Open a document from `bytes`, render a page, and send the +encoded result directly to [`ft.Image`](https://flet.dev/docs/controls/image/): ```python -doc = pymupdf.open(stream=blob, filetype="pdf") # no path +doc = pymupdf.open(stream=blob, filetype="pdf") png = doc[0].get_pixmap(dpi=144).tobytes("png") -ft.Image(src=png) +view = ft.Image(src=png) ``` -When a document does belong on disk, put it in Flet's app storage — the working directory is -not a durable location on either platform: +Put documents the user expects to keep in +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data): ```python -import os - -data = os.getenv("FLET_APP_STORAGE_DATA", ".") # survives restarts and updates -doc.save(os.path.join(data, "report.pdf")) +data_dir = os.getenv("FLET_APP_STORAGE_DATA", ".") +doc.save(os.path.join(data_dir, "report.pdf")) ``` -[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data) -is for documents the user expects to keep; +From Flet 0.86.0, this durable directory is also the process working directory in production +and under `flet run`, so a relative write lands there. Using the environment variable +explicitly still makes the destination and intent clear. + +Use [`FLET_APP_STORAGE_CACHE`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_cache) +for regenerable rendered pages and [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_temp) -is for anything you can regenerate, such as a cache of rendered page images, and may be -cleared between launches. A PDF shipped with the app is an asset: put it in your -[assets directory](https://flet.dev/docs/cookbook/assets) and read it later on using -[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir). - -[`doc.save(path, incremental=True)`](https://pymupdf.readthedocs.io/en/latest/document.html#Document.save) -needs the document to have been opened from that same path, so it is only available for -files you own on disk — not for the `stream=` case. - -## Threading - -**PyMuPDF does not support concurrent use, and it will not tell you when you break the -rule.** Upstream is unambiguous — *"PyMuPDF does not support multithreaded use, even with -Python's newer free-threading mode"* — and the package calls MuPDF's -`reinit_singlethreaded()` at import, which switches off the locking MuPDF would otherwise -use. Two overlapping calls do not raise; they corrupt state, and on a phone that surfaces as -a native crash with no Python traceback. - -None of the four libraries starts a thread of its own: no extension in either wheel -references `pthread_create`, or any OpenMP symbol. So all the concurrency is whatever your -app introduces. - -Rendering is genuinely slow enough to need a thread — a full page at a useful scale is -several megapixels — and MuPDF releases the GIL while it works, so -[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) really -does keep the UI live. But `run_thread` submits to a thread *pool*, so two handlers started -close together will run inside MuPDF at the same time. Serialise them yourself: +for throwaway intermediate files. A PDF shipped with the application is an asset: put it in +the [assets directory](https://flet.dev/docs/cookbook/assets) and use +[`FLET_ASSETS_DIR`](https://flet.dev/docs/reference/environment-variables/#flet_assets_dir) +when PyMuPDF needs an absolute path. -```python -MUPDF = threading.Lock() +[`Document.save(..., incremental=True)`](https://pymupdf.readthedocs.io/en/latest/document.html#Document.save) +requires the document to have been opened from the same path. It is not available for a +document opened with `stream=`. + +### Threading + +**PyMuPDF does not support concurrent use.** Upstream warns that multithreaded use can produce +incorrect behavior or crash Python, and the package does not reliably turn an overlap into a +catchable exception. + +Rendering is slow enough to move off the Flet UI thread, and MuPDF releases the GIL while it +works. Use [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread), +but serialise every PyMuPDF call behind one application-wide lock because `run_thread` uses a +thread pool and two quick events can overlap: -def work(): - with MUPDF: - png = DOC[index].get_pixmap(dpi=dpi).tobytes("png") - sheet.src = png - page.update() # auto-update does not reach background threads +```python +MUPDF_LOCK = threading.Lock() + +def render_page(): + try: + with MUPDF_LOCK: + png = DOC[index].get_pixmap(dpi=dpi).tobytes("png") + sheet.src = png + except Exception as exc: + status.value = str(exc) + page.update() ``` -Disabling the button that starts the work is not a substitute — it cannot catch a tap already -in flight. Note also that exceptions raised inside `run_thread` are swallowed, so wrap the -body if you want to see a `pymupdf.FileDataError` rather than a screen that never updates. -If you need real parallelism, upstream's answer is -[multiprocessing](https://flet.dev/docs/cookbook/multiprocessing) with one document per -process — which mobile rules out, since neither platform lets an app spawn children. -[Subinterpreters](https://flet.dev/docs/cookbook/subinterpreters), the in-process -alternative on Python 3.14, do not help either: PyMuPDF's extensions use single-phase -init, so importing it inside one fails with `ImportError: module _extra does not support -loading in subinterpreters`, and every entry point — `pymupdf`, `fitz`, `pymupdf.mupdf` — -trips the same check. That is upstream's to change, not this recipe's; it fails the same -way on desktop. On device, one thread behind the lock is the whole story. - -## Android notes - -The four libraries are installed as `jniLibs` and resolve each other by `DT_NEEDED` name at -`dlopen` time, which is why this recipe builds MuPDF with unversioned sonames: an APK only -accepts bare `lib*.so`, so a stock `libmupdf.so.27.2` soname would leave `_mupdf` asking for -a file that cannot be packaged. What ships is `libmupdf.so`, and the dependency entries -naming it match. - -`libmupdfcpp`, `_mupdf` and `libmupdf` all link `libc++_shared.so` (from -`flet-libcpp-shared` dependency); Android does not provide the NDK C++ -runtime itself. Every `PT_LOAD` segment is 16 KB-aligned, so the wheels load on Android 15 -devices with 16 KB pages. - -| | arm64-v8a | armeabi-v7a | x86_64 | -| --- | --- | --- | --- | -| `libmupdf.so` | 55.4 MB | 52.7 MB | 56.0 MB | -| `_mupdf` | 12.3 MB | 11.3 MB | 12.4 MB | -| `libmupdfcpp.so` | 1.9 MB | 1.5 MB | 2.0 MB | -| `_extra` | 0.2 MB | 0.2 MB | 0.2 MB | -| **wheel / unpacked** | **40.8 / 73 MB** | **40.2 / 69 MB** | **41.3 / 74 MB** | - -## iOS notes - -All four binaries are `MH_DYLIB`, which is what `flet build ipa` requires — a CMake-style -`MH_BUNDLE` fails at link rather than at import. - -Their inter-dependencies are the interesting part. serious-python relocates each bundled -binary into its own framework bundle, but rewrites only the extension modules' own -install-ids: a `.dylib`'s id, and every dependency entry in every file, is left as it was. -So this recipe points them at the framework paths at build time, and `pymupdf/__init__.py` -loads `libmupdf` and then `libmupdfcpp` with `RTLD_GLOBAL` before importing `_extra` — which -lets dyld satisfy each `@rpath` reference from an image that is already in memory. That -preload is why the [Flet floor](#install) exists. It is inert on Android and on desktop. - -| | device arm64 | simulator arm64 | simulator x86_64 | -| --- | --- | --- | --- | -| `libmupdf.dylib` | 54.3 MB | 54.9 MB | 55.0 MB | -| `_mupdf.so` | 12.9 MB | 13.0 MB | 12.9 MB | -| `libmupdfcpp.dylib` | 1.8 MB | 1.8 MB | 1.8 MB | -| `_extra.so` | 0.2 MB | 0.2 MB | 0.2 MB | -| **wheel / unpacked** | **40.3 / 73 MB** | **40.9 / 73 MB** | **41.0 / 73 MB** | +Disabling the initiating button is useful UI feedback but is not a concurrency guard: a second +handler may already be queued. Keep the lock around the complete native operation, catch and +display worker exceptions, and finish with an explicit +[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update). + +Upstream recommends multiprocessing for parallel PyMuPDF workloads on desktop, with each +process opening its own document. Flet does not support +[`multiprocessing`](https://flet.dev/docs/cookbook/multiprocessing/) on Android or iOS, so a +mobile app cannot use that route. [`subinterpreters`](https://flet.dev/docs/cookbook/subinterpreters/) +are not a fallback here either: importing PyMuPDF inside one raises +`ImportError: module _extra does not support loading in subinterpreters`. On mobile, treat one +locked worker at a time as the supported execution model. + +### Rendering and memory + +[`Page.get_pixmap`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_pixmap) returns +raw pixel samples, whose memory grows with the square of the rendering DPI. A text-filled A4 +page measured approximately 1.4 MB at 72 dpi, 5.7 MB at 144 dpi and 24.9 MB at 300 dpi before +PNG encoding. The encoded PNG from that measurement was much smaller, but the raw pixmap still +has to exist while it is produced. + +Render at the scale the UI actually needs, convert the pixmap to PNG bytes, and release it +promptly. Set +[`gapless_playback=True`](https://flet.dev/docs/controls/image/#flet.Image.gapless_playback) +when replacing pages so the image does not blank between renders. + +### App size + +Expect approximately 40–41 MB of compressed wheel and 69–74 MB unpacked per architecture. +Most of that is `libmupdf` and its compiled-in fonts, so +[`[tool.flet.cleanup]`](https://flet.dev/docs/publish/#compilation-and-cleanup) cannot +meaningfully reduce it. + +On Android, use an app bundle, split APKs, or narrow +[`target_arch`](https://flet.dev/docs/publish/android/#supported-target-architectures) when the +application does not need every ABI. These figures describe the package payload, not the exact +amount added to the final APK or IPA; packaging and compression determine that result. + +### Other considerations + +A desktop `flet run` uses PyPI's desktop wheel. The Python API is the same, but that wheel can +have a different compiled-in font and optional-feature set. Read +`pymupdf.TOOLS.fitz_config` when the distinction matters, and validate mobile-specific +behavior on a device or emulator/simulator. ## Things to know -- **Fonts are compiled into the library, and that is most of the wheel:** MuPDF turns its - bundled fonts into C arrays at build time, so text renders on a device that has no - PostScript fonts and no fontconfig — including scripts a PDF did not embed a font for. - This build keeps the whole set: the base-14 faces, 159 Noto families, `DroidSansFallback` - and `SourceHanSerif` for CJK, Arabic, Tibetan and emoji. It is also why `libmupdf` here is - 55 MB against 31 MB in the same-version wheel PyPI ships for macOS, which excludes most of - the Noto set. If your PDFs embed their own fonts — most produced by real software do — you - are paying for a fallback you will not use, but the choice is made at build time and - cannot be changed from an app. -- **The base-14 faces are Latin-1 only:** `page.insert_text(..., fontname="helv")` with an em - dash, a curly quote or any non-Latin-1 character silently rasterises it as `?`. There is no - exception; the string you read back with `get_text` is not what you see. Use +- **There is no OCR.** MuPDF is built without Tesseract, so + [`page.get_textpage_ocr()`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_textpage_ocr) + raises `OCR Disabled in this build`. A scanned PDF still renders, but it contains no + extractable text unless the document already has a text layer. + +- **There is no signature creation or verification.** MuPDF is built without libcrypto, so + PKCS#7 signing and verification are unavailable. Password-based PDF encryption is separate + and remains supported: `doc.authenticate(password)` opens a protected document, and + `Document.save()` accepts encryption and owner/user password options. + +- **The built-in fonts make rendering self-contained but have an insertion trap.** The wheel + compiles the base-14 faces and a broad Noto/CJK fallback set into MuPDF, so documents render + without fontconfig or system PostScript fonts. However, + `page.insert_text(..., fontname="helv")` uses a Latin-1 base-14 face: an em dash, curly quote + or other unsupported character is silently rendered as `?`. Use [`insert_htmlbox`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_htmlbox), - which lays text out through MuPDF's HTML engine and picks a font that has the glyph, or - embed a font of your own with + which selects a font containing the glyph, or embed one explicitly with [`insert_font`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_font). -- **There is no OCR:** MuPDF is built without Tesseract, so - [`page.get_textpage_ocr()`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.get_textpage_ocr) - and anything else that builds an OCR device raises `OCR Disabled in this build`. It fails - loudly rather than returning nothing, which is the good case — but a scanned PDF is a page - of images to this build: it renders perfectly and extracts no text. Tesseract would bring - its own language data files as well as the engine, which is not something to add by - accident. -- **There is no signature support:** MuPDF is built without libcrypto, so PKCS#7 signing and - signature *verification* are unavailable. Encryption is unaffected — the standard security - handler is MuPDF's own code, so opening a password-protected PDF with - `pymupdf.open(path)` then `doc.authenticate(password)` works, as does saving with - `encryption=` and owner/user passwords. -- **Also absent:** barcode generation and decoding — MuPDF's own entry points answer - `Barcode functionality not included`, though PyMuPDF exposes no Python API for them at this - version anyway, which is why the ~2 MB ZXing library is left out. Likewise the `curl`, - `X11` and `glut` integrations, which are desktop viewer plumbing with no meaning in a Flet - app. -- **Rendering is the API to reach for, and the pixmap is the memory hazard, not the PNG:** - `page.get_pixmap(dpi=...)` returns raw RGB samples, and they grow with the square of the - scale: a text-filled A4 page is 1.4 MB at 72 dpi, 5.7 MB at 144 and **24.9 MB at 300**, - where the PNG `tobytes("png")` produces is 14 KB, 248 KB and 522 KB. Only the PNG crosses - into Flet. Render at the scale you will actually display, drop the pixmap as soon as you - have the bytes, and set - [`gapless_playback=True`](https://flet.dev/docs/controls/image/) on the `ft.Image` or it - blanks between frames. -- **Size:** The wheel is about 41 MB and unpacks to 69–74 MB depending on the slice, nearly - all of it `libmupdf`. There is no test suite or header directory to trim with - `[tool.flet.cleanup]` — the library *is* the package. What you can do is ship fewer copies: - on Android, `split_per_abi` or a `target_arch` narrowed to the ABIs you support. -- **The Python API is upstream's, unchanged**, so upstream's documentation and the answers - you find online apply as written. The wheel ships the same 13 Python files as the - same-version desktop wheel, nine of them byte-identical; the four that differ are - `__init__.py` (the iOS preload described above), `_build.py` (build metadata) and the two - SWIG-generated layers, which are regenerated per target by construction. -- **`flet run` on your desktop uses PyPI's wheel, not this one:** That build has a different - font set and different compiled-in features, so a desktop run proves your code and not the - device build. `pymupdf.TOOLS.fitz_config` reports what the wheel actually has, and it - differs between the two. ## Build notes (maintainers) -Both patches carry their own explanation in a preamble, and every `meta.yaml` setting is -justified in a comment beside it; what follows is what neither file records. - -**Shape.** This is a single self-contained recipe, not the `flet-libmupdf` native library -plus consumer that the chain-recipe pattern would suggest — and a working `flet-libmupdf` -was in fact built and then abandoned. PyMuPDF's build does not consume an external MuPDF in -any useful way: it downloads its own copy, and then generates the C++ wrapper *and* the SWIG -layer from those exact headers, so a separately-built MuPDF only duplicates the compile -without removing a step. Everything the recipe does is therefore aimed at the one upstream -build, through `MUPDF_MAKE` and a patch. - -**Why the codegen is the hard part.** PyMuPDF parses MuPDF's headers with libclang and -generates a C++ wrapper, on the build host, under crossenv's cross-python. That interpreter -reports the *target* — `platform.system()` is `Android` or `iOS` — which matches no branch -upstream has, and libclang is given no sysroot, so the generator falls back to hardcoded -64-bit type sizes. That is the whole reason the patch exists, and why the recipe cannot be -reduced to environment variables. - -**pipcl is pinned in `requirements.build`, and that pin is load-bearing.** PyMuPDF asks for a -bare `pipcl`, which is both the build backend and the linker for `_mupdf`/`_extra`, and the -patch monkeypatches one of its functions. It shipped twelve releases in four months. Raise -the pin deliberately, with a build, rather than letting it float. - -**1.28 is a separate project, not a bump.** PyMuPDF 1.28 rewrote `setup.py` around `pipcl`'s -API — five of the eight hunks reject — and removed `PYMUPDF_SETUP_FLAVOUR` entirely, so the -dev headers and static library this recipe drops would ship unconditionally and need a new -hunk to suppress. MuPDF 1.28 also vendors `cmark-gfm`, an unproven C dependency for these -five slices. The MuPDF-script surgery, by contrast, applies unchanged. Do it as its own -change with its own CI run. - -What to re-verify on a bump, in rough order of how quietly it can go wrong: - -- **That barcode is still off:** `MUPDF_MAKE` says `barcode=no`, and that setting alone does - nothing: MuPDF's wrapper script appends `barcode=yes` after it and make lets the last - command-line assignment win, so the patch has to rewrite that token too. If either half is - lost the build stays green and ZXing quietly returns. Check `strings libmupdf.so | grep - ZXing` is empty. -- **The sonames, on Android.** They must be unversioned. A change in how `SO_VERSION=` is - handled upstream produces a wheel that builds, packages and then fails to `dlopen` on - device — the first symptom is an on-device test failure, not a build error. -- **`_extra` on both platforms:** It is the one library pipcl links from its own flag list, - ignoring everything forge exports, so it is where dropped link flags show up: 16 KB - `PT_LOAD` alignment on Android, and `LC_BUILD_VERSION` with a sane `minos` rather than a - legacy `LC_VERSION_MIN_IPHONEOS` on iOS. Both are re-added by the patch and both are easy - to lose. -- **Mach-O filetype `MH_DYLIB` on all three iOS slices**, and that the preload block still - sits above the `from . import extra` line it is meant to precede — an upstream reshuffle of - `src/__init__.py` moves the import without failing the patch. -- **The compiled-out feature list**, read out of the built library rather than off the - `MUPDF_MAKE` flags. The barcode case above is precisely why: a flag in the recipe is not - evidence about the wheel. -- **Whether `extract_packages` is necessary:** It holds only if something in the - package opens a bundled file by path. A new data file upstream flips it, and the symptom is - an import failure on Android only. -- **The font set**, which is the size story and the [Things to know](#things-to-know) claim - about non-Latin text. If shrinking the wheel ever becomes the priority, MuPDF's `TOFU` - family of defines is the lever — `tofu=yes` drops the Noto fonts and keeps CJK, which is - roughly what upstream's own desktop wheels do — but it changes what renders on a device - and has not been tested here. -- **All sizes and counts.** Re-measure from the built wheels; do not scale. - -The tests cover import through both names, page composition, rendering to real pixels, the -base-14 fonts, PNG encoding, search geometry, structured text, image round-trip, page -surgery, an encrypted round-trip, PNG and CBZ input, and a save/reopen through the -filesystem. What they do not touch: `insert_htmlbox` and the HTML engine behind it, EPUB and -XPS input (both only asserted through `fitz_config`), and any of the compiled-out features — -the absence of OCR and signing is checked by reading the built library, not on device. +### Recipe shape + +This is one self-contained recipe rather than a `flet-libmupdf` native-library recipe followed +by a PyMuPDF consumer. That split was built and rejected: PyMuPDF downloads its own matching +MuPDF source and generates the C++ wrapper and SWIG layer from those exact headers, so a +separately built MuPDF duplicates work without removing a build step. + +The resulting wheel contains four interdependent native binaries: MuPDF (`libmupdf`), its C++ +wrapper (`libmupdfcpp`), the SWIG module (`_mupdf`) and PyMuPDF's accelerator (`_extra`). The +patch preambles own the crossenv code-generation and iOS preload explanations; `meta.yaml` +comments own individual build settings. Do not duplicate those mechanisms here. + +### Upgrade hazards + +The `pipcl` build requirement is deliberately pinned. It is both the build backend and the +linker for `_mupdf` and `_extra`, and the cross-compilation patch depends on its current API. +Raise the pin only with a complete rebuild and device test. + +Moving to the 1.28 source series is not a routine version bump. Its `setup.py` uses a rewritten +`pipcl` API, removes `PYMUPDF_SETUP_FLAVOUR`, and introduces another vendored native dependency. +Treat that migration as a recipe redesign with its own validation pass; remove this warning +once that work has landed. + +### Re-verification checklist + +- **Android sonames and dependencies:** `libmupdf`, `libmupdfcpp` and `_mupdf` must refer to + unversioned `lib*.so` names that can be packaged as `jniLibs`, and the C++ binaries must still + receive `libc++_shared.so` through the wheel dependency. +- **Android 16 KB alignment:** Inspect every `PT_LOAD` segment, especially `_extra`, whose + linker invocation does not inherit all of forge's flags automatically. +- **iOS file types and loading:** All four binaries must be `MH_DYLIB`, and the preload block + must still execute before `pymupdf.extra` imports the extension modules. +- **Compiled features:** Read them from the built library or `fitz_config`; recipe flags alone + are not proof that OCR, libcrypto and barcode support stayed disabled. +- **Android package layout:** Test from zipped site-packages. Add `extract_packages` to consumer + guidance only if a real runtime filesystem read makes it mandatory, and include the failure + symptom. +- **Fonts:** Recheck the set that underpins the multilingual rendering, insertion guidance and + size figures. MuPDF's `TOFU` configuration is the build-time size lever, but changing it also + changes rendering behavior. +- **Size:** Re-measure the compressed and unpacked ranges from the resulting wheels rather than + scaling old figures. + +### Coverage gaps + +The device tests cover import through both names, document composition, real-pixel rendering, +base-14 fonts, PNG encoding, search geometry, structured text, image round-trip, page editing, +encryption, PNG and CBZ input, and a save/reopen filesystem round-trip. They do not exercise +`insert_htmlbox`, EPUB or XPS input, or the deliberately compiled-out features. Keep those gaps +in mind when changing any corresponding consumer claim.