Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ on:
branches: [main]

jobs:
tests:
php:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4']
nativephp: ['^3.0', '^4.0']

name: PHP ${{ matrix.php }}
name: PHP ${{ matrix.php }} - NativePHP ${{ matrix.nativephp }}

steps:
- uses: actions/checkout@v5
Expand All @@ -26,7 +28,7 @@ jobs:
coverage: xdebug

- name: Install dependencies
run: composer install --no-interaction --prefer-dist
run: composer update --with "nativephp/mobile:${{ matrix.nativephp }}" --no-interaction --prefer-dist

- name: Check code style
run: vendor/bin/pint --test
Expand Down Expand Up @@ -66,3 +68,15 @@ jobs:

- name: Lint Swift files
run: swiftlint lint --strict --config resources/ios/.swiftlint.yml resources/ios/Sources/

# Single stable check for branch protection to require: it passes only when
# every job above passed, so the rule never needs editing when the matrix changes.
tests:
if: always()
needs: [php, lint-kotlin, lint-swift]
runs-on: ubuntu-latest
name: tests
steps:
- name: Require every job to have succeeded
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
run: exit 1
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0-beta.1] - 2026-10-03

> **Pre-release.** The native fixes below compile in a NativePHP Mobile v4 app on iOS and
> Android, and the PDF functions run on the iOS simulator, but scanning itself has not yet been
> run on a physical device — the scanner camera is not available in simulators. Install with
> `composer require ikromjon/nativephp-mobile-document-scanner:^1.5@beta`. 1.5.0 follows once a
> device scan confirms them.

### Added

- NativePHP for Mobile v4 support — widened the `nativephp/mobile` constraint to `^3.0|^4.0`
so the package installs on apps running v4. The bridge, event and manifest contracts the
plugin uses are unchanged in v4, so no PHP or native code changed for this

### Fixed

- `jpegQuality` is now honoured on Android — scanned pages are re-encoded at the requested
quality instead of being copied verbatim from ML Kit (the option was previously a no-op)
- `maxPages` is now enforced on iOS — VisionKit has no page-limit API, so the plugin truncates
to the first `maxPages` pages after scanning (the option was previously a no-op)
- iOS PDF output no longer stretches every page to US Letter (612x792); each page is now sized
to its source image, matching Android
- `pageCount` in `DocumentScanned` now reports the number of scanned pages on both platforms
for PDF output (iOS previously reported `1`, the number of files)

### Changed

- **Breaking:** raised platform baselines to the NativePHP Mobile v3 minimums —
Android `min_version` 21 → 29, iOS `min_version` 13.0 → 18.0
- Declared `illuminate/support` (`^11.0|^12.0|^13.0`) as an explicit dependency instead of
relying on transitive resolution
- Removed the `ocr` keyword from `composer.json` — the plugin does not perform OCR
- Bumped `orchestra/testbench` to `^10.0|^11.0` (Laravel 12/13). The previous `^9.0`
constraint resolved only to Laravel 11, which Composer now refuses to install because
every 11.x release carries an unresolved security advisory
- CI now tests `nativephp/mobile` `^3.0` and `^4.0` explicitly on PHP 8.3 and 8.4, and reports
one aggregate `tests` check. Without the explicit axis every job resolves v4, because
`nativephp/mobile` 4.5.0 restored PHP 8.3 support

## [1.4.0] - 2026-04-10

### Added
Expand Down Expand Up @@ -74,6 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Pest test suite with full coverage
- `declare(strict_types=1)` in all PHP files

[1.5.0]: https://github.com/Ikromjon1998/nativephp-mobile-document-scanner/releases/tag/v1.5.0
[1.4.0]: https://github.com/Ikromjon1998/nativephp-mobile-document-scanner/releases/tag/v1.4.0
[1.3.0]: https://github.com/Ikromjon1998/nativephp-mobile-document-scanner/releases/tag/v1.3.0
[1.2.0]: https://github.com/Ikromjon1998/nativephp-mobile-document-scanner/releases/tag/v1.2.0
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ composer require ikromjon/nativephp-mobile-document-scanner
php artisan native:plugin:register ikromjon/nativephp-mobile-document-scanner
```

> **NativePHP Mobile v4:** v4 support ships in 1.5.0, which is in pre-release until its native
> changes are confirmed on a device. Until then, install it with
> `composer require ikromjon/nativephp-mobile-document-scanner:^1.5@beta`.

Build your app (plugin requires a native build):

```bash
Expand Down Expand Up @@ -126,6 +130,11 @@ DocumentScanner::scan(new ScanOptions(
| `galleryImport` | bool | Android only | Allow importing from device gallery |
| `scannerMode` | ScannerMode\|string | Android only | `base`, `filter`, or `full` |

> **Platform note on `maxPages`:** Android enforces the limit inside the scanner UI (ML Kit
> `setPageLimit`). VisionKit has no equivalent API, so on iOS the user can keep scanning past the
> limit — the plugin keeps only the first `maxPages` pages and reports that count in
> `DocumentScanned`.

## Full Livewire Example

A complete component that scans documents and displays the results:
Expand Down Expand Up @@ -319,8 +328,8 @@ composer analyse
## Requirements

- PHP 8.3+
- NativePHP Mobile v3+
- iOS 13+ / Android API 21+
- NativePHP Mobile v3 or v4
- iOS 18+ / Android API 29+

## License

Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Feature roadmap for the NativePHP Mobile Document Scanner plugin.
|---|------|----------|--------|-----|
| 9 | README Quick-Start | High | **Done** | [docs/epics/09-readme-quick-start.md](docs/epics/09-readme-quick-start.md) |
| 10 | Silent Failure Handling | High | **Done** | [docs/epics/10-silent-failure-handling.md](docs/epics/10-silent-failure-handling.md) |
| 11 | Platform-Specific Option Hints | Medium | Not Started | [docs/epics/11-platform-option-hints.md](docs/epics/11-platform-option-hints.md) |
| 11 | Platform-Specific Option Hints | Medium | **Done** | [docs/epics/11-platform-option-hints.md](docs/epics/11-platform-option-hints.md) |
| 12 | JS Import Ergonomics | Medium | Not Started | [docs/epics/12-js-import-ergonomics.md](docs/epics/12-js-import-ergonomics.md) |
| 13 | maxPages Null Convention | Low | Not Started | [docs/epics/13-max-pages-null-convention.md](docs/epics/13-max-pages-null-convention.md) |

Expand All @@ -21,7 +21,7 @@ Feature roadmap for the NativePHP Mobile Document Scanner plugin.
| 4 | Scanned File Management | Medium | Not Started | [docs/epics/04-file-management.md](docs/epics/04-file-management.md) |
| 5 | Scan Result DTO | Low | Not Started | [docs/epics/05-scan-result-dto.md](docs/epics/05-scan-result-dto.md) |
| 6 | Image Post-Processing | Low | Not Started | [docs/epics/06-image-post-processing.md](docs/epics/06-image-post-processing.md) |
| 3 | iOS Max Pages Enforcement | Low | Not Started | [docs/epics/03-ios-max-pages.md](docs/epics/03-ios-max-pages.md) |
| 3 | iOS Max Pages Enforcement | Low | **Done** | [docs/epics/03-ios-max-pages.md](docs/epics/03-ios-max-pages.md) |
| 7 | Real Device Test App | Low | Not Started | [docs/epics/07-test-app.md](docs/epics/07-test-app.md) |

### Completed
Expand All @@ -36,6 +36,7 @@ Feature roadmap for the NativePHP Mobile Document Scanner plugin.

## Release History

- v1.5.0: NativePHP Mobile v4 support, iOS `maxPages` enforcement, Android `jpegQuality` fix, iOS PDF page sizing, v3 platform baselines (Epic 3)
- v1.4.0: JPEG-to-PDF conversion, PDF page thumbnails (Epics 14 & 15)
- v1.3.0: Warning log, quick-start README, real native linting, coverage 95% (Epics 9 & 10)
- v1.2.0: Scanner mode selection (Epic 2)
Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
"mobile",
"document-scanner",
"scanner",
"ocr",
"visionkit",
"laravel"
],
"require": {
"php": "^8.3",
"nativephp/mobile": "^3.0"
"illuminate/support": "^11.0|^12.0|^13.0",
"nativephp/mobile": "^3.0|^4.0"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^3.0",
"phpstan/phpstan": "^2.0",
"larastan/larastan": "^3.0",
Expand Down
2 changes: 1 addition & 1 deletion docs/epics/03-ios-max-pages.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Epic 3: iOS Max Pages Enforcement

**Priority:** Low
**Status:** Not Started
**Status:** Done

## Goal

Expand Down
6 changes: 3 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
## Requirements

- PHP 8.3 or higher
- Laravel 10 or higher
- NativePHP Mobile v3+
- iOS 13+ or Android API 21+
- Laravel 11, 12, or 13
- NativePHP Mobile v3 or v4
- iOS 18+ or Android API 29+

## Step 1: Install the Package

Expand Down
4 changes: 2 additions & 2 deletions nativephp.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
],

"android": {
"min_version": 21,
"min_version": 29,
"permissions": [
"android.permission.CAMERA"
],
Expand All @@ -35,7 +35,7 @@
},

"ios": {
"min_version": "13.0",
"min_version": "18.0",
"frameworks": [
"VisionKit"
]
Expand Down
52 changes: 45 additions & 7 deletions resources/android/src/DocumentScannerFunctions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.pdf.PdfDocument
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.activity.result.ActivityResult
Expand Down Expand Up @@ -36,6 +37,7 @@ object DocumentScannerFunctions {

private var scannerLauncher: ActivityResultLauncher<IntentSenderRequest>? = null
private var currentOutputFormat = "jpeg"
private var currentJpegQuality = 90

fun applyConfig(config: Map<*, *>) {
(config["default_max_pages"] as? Number)?.let { defaultMaxPages = it.toInt() }
Expand Down Expand Up @@ -85,6 +87,47 @@ object DocumentScannerFunctions {
}
}

/**
* Write a scanned page to [destFile] as JPEG at the requested quality.
*
* ML Kit hands back pages already encoded at its own quality, so honouring
* `jpegQuality` means decoding and re-encoding. Falls back to a raw byte
* copy if the page cannot be decoded.
*/
private fun writeJpeg(
activity: FragmentActivity,
uri: Uri,
destFile: File,
quality: Int,
) {
val bitmap =
try {
activity.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decode scanned page", e)
null
}

if (bitmap == null) {
activity.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
return
}

try {
FileOutputStream(destFile).use { output ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), output)
}
} finally {
bitmap.recycle()
}
}

private fun handleScanResult(
activity: FragmentActivity,
data: Intent?,
Expand Down Expand Up @@ -120,13 +163,7 @@ object DocumentScannerFunctions {
} else {
scanResult.pages?.forEachIndexed { index, page ->
val destFile = File(dir, "scan_${timestamp}_$index.jpg")
page.imageUri.let { uri ->
activity.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
writeJpeg(activity, page.imageUri, destFile, currentJpegQuality)
paths.put(destFile.absolutePath)
}
}
Expand Down Expand Up @@ -169,6 +206,7 @@ object DocumentScannerFunctions {
val scannerMode = parameters["scannerMode"] as? String ?: defaultScannerMode

currentOutputFormat = outputFormat
currentJpegQuality = jpegQuality.coerceIn(1, 100)

val mode =
when (scannerMode) {
Expand Down
32 changes: 22 additions & 10 deletions resources/ios/Sources/DocumentScannerFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ class DocumentScannerDelegate: NSObject, VNDocumentCameraViewControllerDelegate
private var outputFormat: String = "jpeg"
private var jpegQuality: CGFloat = 0.9
private var storageDir: String = "scanned-documents"
private var maxPages: Int = 0

func configure(outputFormat: String, jpegQuality: Int, storageDir: String) {
func configure(outputFormat: String, jpegQuality: Int, storageDir: String, maxPages: Int) {
self.outputFormat = outputFormat
self.jpegQuality = CGFloat(max(1, min(100, jpegQuality))) / 100.0
self.storageDir = storageDir
self.maxPages = maxPages
}

private func storageDirectory() throws -> URL {
Expand Down Expand Up @@ -56,17 +58,23 @@ class DocumentScannerDelegate: NSObject, VNDocumentCameraViewControllerDelegate
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
var paths: [String] = []

// VisionKit has no page-limit API, so `maxPages` is enforced here by
// only keeping the first N scanned pages.
let pageLimit = maxPages > 0 ? min(maxPages, scan.pageCount) : scan.pageCount
var pageCount = pageLimit

if outputFormat == "pdf" {
let pdfPath = dir.appendingPathComponent("scan_\(timestamp).pdf")
let pageSize = CGSize(width: 612, height: 792) // US Letter
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: pageSize))

// Size each page to its own image so non-Letter documents are not
// stretched, matching the Android implementation.
let renderer = UIGraphicsPDFRenderer(bounds: .zero)
let data = renderer.pdfData { context in
for i in 0..<scan.pageCount {
context.beginPage()
for i in 0..<pageLimit {
let image = scan.imageOfPage(at: i)
let rect = CGRect(origin: .zero, size: pageSize)
image.draw(in: rect)
let pageRect = CGRect(origin: .zero, size: image.size)
context.beginPage(withBounds: pageRect, pageInfo: [:])
image.draw(in: pageRect)
}
}

Expand All @@ -81,7 +89,7 @@ class DocumentScannerDelegate: NSObject, VNDocumentCameraViewControllerDelegate
return
}
} else {
for i in 0..<scan.pageCount {
for i in 0..<pageLimit {
let image = scan.imageOfPage(at: i)
let filePath = dir.appendingPathComponent("scan_\(timestamp)_\(i).jpg")

Expand All @@ -94,13 +102,14 @@ class DocumentScannerDelegate: NSObject, VNDocumentCameraViewControllerDelegate
}
}
}
pageCount = paths.count
}

dispatchEvent(
"Ikromjon\\DocumentScanner\\Events\\DocumentScanned",
[
"paths": paths,
"pageCount": paths.count,
"pageCount": pageCount,
"outputFormat": outputFormat,
]
)
Expand Down Expand Up @@ -142,11 +151,14 @@ enum DocumentScannerFunctions {
let jpegQuality = parameters["jpegQuality"] as? Int
?? config["default_jpeg_quality"] as? Int ?? 90
let storageDir = config["storage_directory"] as? String ?? "scanned-documents"
let maxPagesLimit = config["max_pages_limit"] as? Int ?? 100
let effectiveMaxPages = maxPages > 0 ? min(maxPages, maxPagesLimit) : 0

DocumentScannerDelegate.shared.configure(
outputFormat: outputFormat,
jpegQuality: jpegQuality,
storageDir: storageDir
storageDir: storageDir,
maxPages: effectiveMaxPages
)

DispatchQueue.main.async {
Expand Down
Loading
Loading