diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index 027bad6d..b6c55135 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -52,9 +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` — and per hard experience, 3.12 is the only leg whose mobile - tests pass on this fork; never dispatch `mobile_test_pythons=ALL`). +- 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. @@ -208,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 | @@ -218,7 +235,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`. | | `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..e0c23c6d 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,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** — 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. - ## 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/opencv-python/README.md b/recipes/opencv-python/README.md new file mode 100644 index 00000000..936513ed --- /dev/null +++ b/recipes/opencv-python/README.md @@ -0,0 +1,220 @@ +# opencv-python + +[`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 detection, camera calibration, stitching, optical flow, and a +[`dnn`](https://docs.opencv.org/5.x/main_modules/dnn.html) module that runs ONNX models. +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 one OpenCV distribution to your `pyproject.toml`: + +```toml +dependencies = [ + "flet", + "opencv-python", +] +``` + +`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. + +## Usage in a Flet app + +### 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) 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_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 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) + 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) + +### 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/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..dbb60810 --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/README.md @@ -0,0 +1,54 @@ +# 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. 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`](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 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, + [`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`](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 +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..b8fb37f0 --- /dev/null +++ b/recipes/opencv-python/examples/shape-finder/src/main.py @@ -0,0 +1,102 @@ +import flet as ft +from shapes import KINDS, VERSION, analyse, scene + + +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): + canvas, placed = scene() + + def redraw(): + """Draw a fresh set of shapes, then segment the new scene.""" + nonlocal canvas, placed + canvas, placed = scene() + 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. + """ + frame, found, contours, elapsed = analyse(canvas, noise.value) + 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(VERSION, size=12), + view := ft.Image( + src=b"", + 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) 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 new file mode 100644 index 00000000..2621d54e --- /dev/null +++ b/recipes/pymupdf/README.md @@ -0,0 +1,214 @@ +# pymupdf + +[`pymupdf`](https://pymupdf.readthedocs.io/) is the Python binding for +[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. + +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 PyMuPDF to your `pyproject.toml`: + +```toml +dependencies = [ + "flet", + "pymupdf", +] +``` + +**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. + +## Usage in a Flet app + +### Storage + +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") +png = doc[0].get_pixmap(dpi=144).tobytes("png") +view = ft.Image(src=png) +``` + +Put documents the user expects to keep in +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data): + +```python +data_dir = os.getenv("FLET_APP_STORAGE_DATA", ".") +doc.save(os.path.join(data_dir, "report.pdf")) +``` + +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) +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. + +[`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: + +```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 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 + +- **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 selects a font containing the glyph, or embed one explicitly with + [`insert_font`](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_font). + +## Build notes (maintainers) + +### 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. 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..7153c406 --- /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, 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, how long it took, and how big the PNG came out. + +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 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. + 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, and the handler ends with the explicit + [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) that a background + 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. + +## 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/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 new file mode 100644 index 00000000..f3b7949c --- /dev/null +++ b/recipes/pymupdf/examples/render-and-read/src/main.py @@ -0,0 +1,107 @@ +import flet as ft +from document import TITLES, VERSIONS, render + + +def main(page: ft.Page): + state = {"index": 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["term"]) + sheet.src = png + position.value = ( + f"{state['index'] + 1} / {len(TITLES)} · {TITLES[state['index']]}" + ) + found.value = ( + "" if not state["term"] else f"{hits} hit{'' if hits == 1 else 's'}" + ) + stats.value = ( + 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 + + 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.""" + 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(VERSIONS, size=11), + 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, + ), + 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=lambda: go(-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=lambda: go(1) + ), + ], + ), + ft.Row( + controls=[ + stats := ft.Text(size=11, expand=True), + spinner := ft.ProgressRing( + width=14, height=14, visible=False + ), + ] + ), + ] + ), + ) + ) + + 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..1b0f107a --- /dev/null +++ b/recipes/pymupdf/meta.yaml @@ -0,0 +1,125 @@ +package: + name: pymupdf + version: "1.27.2.3" + +build: + 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 + # 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 %} + +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 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..3a0a5bad --- /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 +rasteriser, the base-14 fonts compiled into the library, the image codecs, and +the PDF writer. +""" + +import pymupdf + + +def render(page, dpi=72): + """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 rasteriser 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 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. + """ + 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 rasterise 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() diff --git a/src/forge/build.py b/src/forge/build.py index 4eec7731..38a20a65 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -565,10 +565,26 @@ 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, }