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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ let package = Package(
dependencies: ["PrinterPlugin"],
path: "ios/Tests/PrinterPluginTests")
]
)
)
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,74 @@ npx cap sync

<docgen-index>

* [`printWebView()`](#printwebview)
* [`printFile(...)`](#printfile)
* [`printWebView(...)`](#printwebview)
* [Interfaces](#interfaces)
* [Type Aliases](#type-aliases)

</docgen-index>

<docgen-api>
<!--Update the source file JSDoc comments and rerun docgen to update the docs below-->

### printWebView()
### printFile(...)

```typescript
printWebView() => Promise<void>
printFile(options: PrintFileOptions) => Promise<void>
```

Present the printing user interface to print a file.

The promise settles after the operating system no longer needs the source
file, so the file can be safely deleted in a `finally` block.

Only available on Android and iOS.

| Param | Type |
| ------------- | ------------------------------------------------------------- |
| **`options`** | <code><a href="#printfileoptions">PrintFileOptions</a></code> |

--------------------


### printWebView(...)

```typescript
printWebView(options?: PrintOptions | undefined) => Promise<void>
```

Present the printing user interface to print the web view content.

| Param | Type |
| ------------- | ----------------------------------------------------- |
| **`options`** | <code><a href="#printoptions">PrintOptions</a></code> |

--------------------


### Interfaces


#### PrintFileOptions

| Prop | Type | Description |
| -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **`path`** | <code>string</code> | The path to the file. Android supports file paths, `file://` URLs, and `content://` URLs. iOS supports file paths and local `file://` URLs. |
| **`mimeType`** | <code>string</code> | The MIME type of the file. Only used on Android. |


#### PrintOptions

| Prop | Type | Description | Default |
| ---------- | ------------------- | -------------------------- | ----------------------- |
| **`name`** | <code>string</code> | The name of the print job. | <code>'Document'</code> |


### Type Aliases


#### PrintWebViewOptions

<code><a href="#printoptions">PrintOptions</a></code>

</docgen-api>
2 changes: 2 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
androidxPrintVersion = project.hasProperty('androidxPrintVersion') ? rootProject.ext.androidxPrintVersion : '1.1.0'
}

buildscript {
Expand Down Expand Up @@ -52,6 +53,7 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.print:print:$androidxPrintVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,60 @@
package jp.rdlabo.capacitor.plugin.printer;

import android.content.Context;
import android.net.Uri;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.print.PrintAttributes;
import android.print.PrintDocumentAdapter;
import android.print.PrintDocumentInfo;
import android.print.PrintManager;
import android.webkit.WebView;
import androidx.print.PrintHelper;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Locale;

@CapacitorPlugin(name = "Printer")
public class PrinterPlugin extends Plugin {

private static final String DEFAULT_JOB_NAME = "Document";

@PluginMethod
public void printFile(PluginCall call) {
String path = call.getString("path");
String mimeType = call.getString("mimeType");
if (path == null || path.trim().isEmpty()) {
call.reject("path must be provided");
return;
}
if (mimeType == null || mimeType.trim().isEmpty()) {
call.reject("mimeType must be provided");
return;
}

Uri uri = toUri(path);
String jobName = getFileName(uri);
String normalizedMimeType = mimeType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
getActivity().runOnUiThread(() -> {
if (isSupportedImageMimeType(normalizedMimeType)) {
printImage(call, uri, jobName);
} else if ("application/pdf".equals(normalizedMimeType)) {
printPdf(call, uri, jobName);
} else {
call.reject("Unsupported MIME type: " + mimeType);
}
});
}

@PluginMethod
public void printWebView(PluginCall call) {
getActivity().runOnUiThread(() -> {
Expand All @@ -28,11 +70,146 @@ public void printWebView(PluginCall call) {
return;
}

String jobName = "WebView Print";
String jobName = normalizeJobName(call.getString("name"));
PrintDocumentAdapter printAdapter = webView.createPrintDocumentAdapter(jobName);
printManager.print(jobName, printAdapter, new PrintAttributes.Builder().build());

call.resolve();
});
}

private void printImage(PluginCall call, Uri uri, String jobName) {
try {
PrintHelper printHelper = new PrintHelper(getContext());
printHelper.setScaleMode(PrintHelper.SCALE_MODE_FIT);
printHelper.printBitmap(jobName, uri, () -> call.resolve());
} catch (FileNotFoundException | SecurityException exception) {
call.reject("Unable to read file: " + exception.getLocalizedMessage(), exception);
}
}

private void printPdf(PluginCall call, Uri uri, String jobName) {
PrintManager printManager = (PrintManager) getContext().getSystemService(Context.PRINT_SERVICE);
if (printManager == null) {
call.reject("Print service not available");
return;
}

try (InputStream ignored = openInputStream(uri)) {
// Validate access before opening the asynchronous print job.
} catch (IOException | SecurityException exception) {
call.reject("Unable to read file: " + exception.getLocalizedMessage(), exception);
return;
}

printManager.print(jobName, new PdfPrintDocumentAdapter(uri, jobName, call), new PrintAttributes.Builder().build());
}

private InputStream openInputStream(Uri uri) throws FileNotFoundException {
if ("file".equalsIgnoreCase(uri.getScheme())) {
return new FileInputStream(new File(uri.getPath()));
}
InputStream input = getContext().getContentResolver().openInputStream(uri);
if (input == null) {
throw new FileNotFoundException("Unable to open " + uri);
}
return input;
}

private static Uri toUri(String path) {
Uri uri = Uri.parse(path);
return uri.getScheme() == null ? Uri.fromFile(new File(path)) : uri;
}

private static String getFileName(Uri uri) {
String fileName = uri.getLastPathSegment();
return fileName == null || fileName.trim().isEmpty() ? DEFAULT_JOB_NAME : fileName;
}

private static String normalizeJobName(String name) {
return name == null || name.trim().isEmpty() ? DEFAULT_JOB_NAME : name.trim();
}

private static boolean isSupportedImageMimeType(String mimeType) {
return switch (mimeType) {
case "image/gif", "image/heic", "image/heif", "image/jpeg", "image/png" -> true;
default -> false;
};
}

private final class PdfPrintDocumentAdapter extends PrintDocumentAdapter {

private final Uri uri;
private final String name;
private final PluginCall call;
private volatile String failureMessage;

private PdfPrintDocumentAdapter(Uri uri, String name, PluginCall call) {
this.uri = uri;
this.name = name;
this.call = call;
}

@Override
public void onLayout(
PrintAttributes oldAttributes,
PrintAttributes newAttributes,
CancellationSignal cancellationSignal,
LayoutResultCallback callback,
android.os.Bundle extras
) {
if (cancellationSignal.isCanceled()) {
callback.onLayoutCancelled();
return;
}
PrintDocumentInfo info = new PrintDocumentInfo.Builder(name).setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT).build();
callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes));
}

@Override
public void onWrite(
android.print.PageRange[] pages,
ParcelFileDescriptor destination,
CancellationSignal cancellationSignal,
WriteResultCallback callback
) {
failureMessage = null;
new Thread(() -> {
boolean cancelled = false;
try (
InputStream input = openInputStream(uri);
OutputStream output = new FileOutputStream(destination.getFileDescriptor())
) {
byte[] buffer = new byte[8192];
int length;
while ((length = input.read(buffer)) != -1) {
if (cancellationSignal.isCanceled()) {
cancelled = true;
break;
}
output.write(buffer, 0, length);
}
} catch (IOException | SecurityException exception) {
failureMessage = exception.getLocalizedMessage();
callback.onWriteFailed(exception.getLocalizedMessage());
return;
}
if (cancelled) {
callback.onWriteCancelled();
} else {
callback.onWriteFinished(new android.print.PageRange[] { android.print.PageRange.ALL_PAGES });
}
})
.start();
}

@Override
public void onFinish() {
if (failureMessage == null) {
call.resolve();
} else {
call.reject("Unable to print file: " + failureMessage);
}
}
}
}
63 changes: 53 additions & 10 deletions ios/Sources/PrinterPlugin/PrinterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,73 @@ public class PrinterPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "PrinterPlugin"
public let jsName = "Printer"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "printFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "printWebView", returnType: CAPPluginReturnPromise)
]

@objc func printFile(_ call: CAPPluginCall) {
guard let path = call.getString("path"), !path.isEmpty else {
call.reject("path must be provided")
return
}

let fileURL: URL
if let url = URL(string: path), url.isFileURL {
fileURL = url
} else {
fileURL = URL(fileURLWithPath: path)
}

guard FileManager.default.fileExists(atPath: fileURL.path) else {
call.reject("File not found")
return
}
guard UIPrintInteractionController.canPrint(fileURL) else {
call.reject("File type is not printable")
return
}

DispatchQueue.main.async {
let printController = UIPrintInteractionController.shared
let printInfo = UIPrintInfo(dictionary: nil)
printInfo.outputType = .general
printInfo.jobName = fileURL.lastPathComponent.isEmpty ? "Document" : fileURL.lastPathComponent
printController.printInfo = printInfo
printController.printingItem = fileURL
self.present(printController, call: call)
}
}

@objc func printWebView(_ call: CAPPluginCall) {
DispatchQueue.main.async {
guard let webView = self.webView else {
call.reject("WebView not available")
return
}

let printController = UIPrintInteractionController.shared
let printInfo = UIPrintInfo(dictionary: nil)
printInfo.outputType = .general
let requestedName = call.getString("name")?.trimmingCharacters(in: .whitespacesAndNewlines)
if let requestedName, !requestedName.isEmpty {
printInfo.jobName = requestedName
} else {
printInfo.jobName = "Document"
}
printController.printInfo = printInfo
printController.printFormatter = webView.viewPrintFormatter()

printController.present(animated: true) { _, completed, error in
if let error = error {
call.reject("Print failed: \(error.localizedDescription)")
} else if completed {
call.resolve()
} else {
call.reject("Print cancelled")
}
self.present(printController, call: call)
}
}

private func present(_ printController: UIPrintInteractionController, call: CAPPluginCall) {
printController.present(animated: true) { _, completed, error in
if let error = error {
call.reject("Print failed: \(error.localizedDescription)")
} else if completed {
call.resolve()
} else {
call.reject("Print cancelled")
}
}
}
Expand Down
Loading