diff --git a/Package.swift b/Package.swift index 8c003da..66aed80 100644 --- a/Package.swift +++ b/Package.swift @@ -25,4 +25,4 @@ let package = Package( dependencies: ["PrinterPlugin"], path: "ios/Tests/PrinterPluginTests") ] -) \ No newline at end of file +) diff --git a/README.md b/README.md index 4bcddc4..a3d522f 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,74 @@ npx cap sync -* [`printWebView()`](#printwebview) +* [`printFile(...)`](#printfile) +* [`printWebView(...)`](#printwebview) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) -### printWebView() +### printFile(...) ```typescript -printWebView() => Promise +printFile(options: PrintFileOptions) => Promise ``` +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`** | PrintFileOptions | + +-------------------- + + +### printWebView(...) + +```typescript +printWebView(options?: PrintOptions | undefined) => Promise +``` + +Present the printing user interface to print the web view content. + +| Param | Type | +| ------------- | ----------------------------------------------------- | +| **`options`** | PrintOptions | + -------------------- + +### Interfaces + + +#### PrintFileOptions + +| Prop | Type | Description | +| -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **`path`** | string | The path to the file. Android supports file paths, `file://` URLs, and `content://` URLs. iOS supports file paths and local `file://` URLs. | +| **`mimeType`** | string | The MIME type of the file. Only used on Android. | + + +#### PrintOptions + +| Prop | Type | Description | Default | +| ---------- | ------------------- | -------------------------- | ----------------------- | +| **`name`** | string | The name of the print job. | 'Document' | + + +### Type Aliases + + +#### PrintWebViewOptions + +PrintOptions + diff --git a/android/build.gradle b/android/build.gradle index 69f29b8..6ce7d29 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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 { @@ -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" diff --git a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java index 5af31a2..08a50c4 100644 --- a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java +++ b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java @@ -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(() -> { @@ -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); + } + } + } } diff --git a/ios/Sources/PrinterPlugin/PrinterPlugin.swift b/ios/Sources/PrinterPlugin/PrinterPlugin.swift index cda1aef..9c82805 100644 --- a/ios/Sources/PrinterPlugin/PrinterPlugin.swift +++ b/ios/Sources/PrinterPlugin/PrinterPlugin.swift @@ -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") } } } diff --git a/src/definitions.ts b/src/definitions.ts index 2d054f3..9f5ef4e 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,3 +1,40 @@ export interface PrinterPlugin { - printWebView(): Promise; + /** + * 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. + */ + printFile(options: PrintFileOptions): Promise; + + /** + * Present the printing user interface to print the web view content. + */ + printWebView(options?: PrintWebViewOptions): Promise; } + +export interface PrintFileOptions { + /** + * The path to the file. Android supports file paths, `file://` URLs, and + * `content://` URLs. iOS supports file paths and local `file://` URLs. + */ + path: string; + + /** + * The MIME type of the file. Only used on Android. + */ + mimeType: string; +} + +export interface PrintOptions { + /** + * The name of the print job. + * + * @default 'Document' + */ + name?: string; +} + +export type PrintWebViewOptions = PrintOptions; diff --git a/src/web.ts b/src/web.ts index e80ec3a..4d03e59 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,9 +1,15 @@ import { WebPlugin } from '@capacitor/core'; -import type { PrinterPlugin } from './definitions'; +import type { PrinterPlugin, PrintFileOptions, PrintWebViewOptions } from './definitions'; export class PrinterWeb extends WebPlugin implements PrinterPlugin { - async printWebView(): Promise { - console.log('Printing web view...'); + async printFile(options: PrintFileOptions): Promise { + void options; + throw this.unavailable('printFile is not available on the web.'); + } + + async printWebView(options?: PrintWebViewOptions): Promise { + void options; + window.print(); } }