From 6e71770131f4618ed1bdc30b9ac921361e8b33d3 Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 09:39:40 -0700 Subject: [PATCH] Add Android PDF export with share sheet delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document action menu gains "Export as PDF": the current editor content — including unsaved changes, flushed the same way the Markdown share flow does — is rendered to a real paginated PDF and handed to the Android share sheet as application/pdf with a document-derived filename, matching the export-and-share experience of desktop editors. HTML generation reuses the desktop export pipeline that already ships in the vendored Muya: MarkdownToHtml.generate produces a fully self-contained document (inlined GitHub styles, KaTeX with fonts embedded as data URIs, Prism-highlighted code, diagrams pre-rendered to SVG in the app WebView). On top of that the export path adds print CSS (A4 @page margins, heading break-avoidance, forced background printing), strips leading front matter the export renderer would show as content, and rewrites marktext-image://local/ sources to file URIs inside the app sandbox so the imported images resolve outside the Capacitor WebView. PDF bytes are produced natively: a detached JavaScript-free WebView loads the export document and its print adapter is driven straight to a file through the platform print pipeline — the same vector output as the system "Save as PDF" printer, with no print dialog. The LayoutResultCallback/WriteResultCallback constructors are package-private, so a small driver class lives in the android.print package, the established technique behind WebView-to-PDF converters. The exporter owns a 30s timeout, render settling, and single-shot cleanup; the plugin writes into a new shared-pdf cache directory and reuses the existing FileProvider and chooser conventions (self excluded, ClipData, read grant). The web flow mirrors the Markdown share exactly: an exportingPdf busy flag disables the menu row, status shows "Exporting PDF" then "Share sheet opened", and failures map through the shared error-code table (PDF_EXPORT_FAILED, PDF_WRITE_FAILED) with en/zh translations. Verified end to end on the API 35 emulator: styled document with KaTeX math, highlighted code with backgrounds, tables, quotes, and an imported local image exported to a selectable-text A4 PDF and offered through the share sheet under the document title. --- .../android/print/PdfPrintAdapterDriver.java | 105 +++++++ .../AndroidDocumentsPlugin.java | 91 ++++++ .../renakoni/marktextandroid/PdfExporter.java | 286 ++++++++++++++++++ .../marktextandroid/SharePreparation.java | 25 +- android/app/src/main/res/xml/file_paths.xml | 1 + .../marktextandroid/SharePreparationTest.java | 10 + src/App.vue | 15 + .../exportAndroidPdfWorkflow.test.ts | 102 +++++++ .../exportAndroidPdfWorkflow.ts | 84 +++++ .../currentDocumentPersistence.test.ts | 65 ++++ .../currentDocumentPersistence.ts | 54 ++++ src/features/editor/EditorScreen.vue | 6 + .../editor/components/EditorActionSheet.vue | 23 ++ src/features/editor/pdfExportHtml.test.ts | 153 ++++++++++ src/features/editor/pdfExportHtml.ts | 103 +++++++ src/lib/androidDocuments.ts | 37 +++ src/lib/androidImages.ts | 30 ++ src/lib/documentState.test.ts | 8 + src/lib/documentState.ts | 6 + src/lib/i18n.ts | 11 + 20 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/android/print/PdfPrintAdapterDriver.java create mode 100644 android/app/src/main/java/io/github/renakoni/marktextandroid/PdfExporter.java create mode 100644 src/features/android-documents/exportAndroidPdfWorkflow.test.ts create mode 100644 src/features/android-documents/exportAndroidPdfWorkflow.ts create mode 100644 src/features/editor/pdfExportHtml.test.ts create mode 100644 src/features/editor/pdfExportHtml.ts diff --git a/android/app/src/main/java/android/print/PdfPrintAdapterDriver.java b/android/app/src/main/java/android/print/PdfPrintAdapterDriver.java new file mode 100644 index 0000000..a182fc6 --- /dev/null +++ b/android/app/src/main/java/android/print/PdfPrintAdapterDriver.java @@ -0,0 +1,105 @@ +package android.print; + +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; + +/** + * Drives a PrintDocumentAdapter straight to a PDF file, without the system + * print dialog. Lives in the android.print package because the + * LayoutResultCallback / WriteResultCallback constructors are package-private: + * both classes are public SDK types on the greylist, and same-package + * compilation is the established way apps subclass them (the technique behind + * the common WebView-to-PDF converters). Runs on whichever thread the adapter + * expects — for WebView adapters that is the main thread. + * + * The caller owns the CancellationSignal: cancelling it aborts an in-flight + * layout or write, and the cancelled callbacks below still close the adapter + * lifecycle and report through the listener (the caller's completion guard + * drops duplicate deliveries). Synchronous adapter failures are reported the + * same way instead of escaping as uncaught exceptions. + */ +public final class PdfPrintAdapterDriver { + + public interface Listener { + void onSuccess(); + void onFailure(String message); + } + + private PdfPrintAdapterDriver() {} + + public static void print( + final PrintDocumentAdapter adapter, + PrintAttributes attributes, + final ParcelFileDescriptor destination, + final CancellationSignal cancellationSignal, + final Listener listener + ) { + try { + adapter.onStart(); + adapter.onLayout( + null, + attributes, + cancellationSignal, + new PrintDocumentAdapter.LayoutResultCallback() { + @Override + public void onLayoutFinished(PrintDocumentInfo info, boolean changed) { + try { + adapter.onWrite( + new PageRange[] { PageRange.ALL_PAGES }, + destination, + cancellationSignal, + new PrintDocumentAdapter.WriteResultCallback() { + @Override + public void onWriteFinished(PageRange[] pages) { + finish(adapter); + listener.onSuccess(); + } + + @Override + public void onWriteFailed(CharSequence error) { + finish(adapter); + listener.onFailure(error == null ? "PDF write failed" : error.toString()); + } + + @Override + public void onWriteCancelled() { + finish(adapter); + listener.onFailure("PDF write cancelled"); + } + } + ); + } catch (RuntimeException ex) { + finish(adapter); + listener.onFailure("PDF write could not start: " + ex); + } + } + + @Override + public void onLayoutFailed(CharSequence error) { + finish(adapter); + listener.onFailure(error == null ? "PDF layout failed" : error.toString()); + } + + @Override + public void onLayoutCancelled() { + finish(adapter); + listener.onFailure("PDF layout cancelled"); + } + }, + new Bundle() + ); + } catch (RuntimeException ex) { + finish(adapter); + listener.onFailure("PDF print could not start: " + ex); + } + } + + private static void finish(PrintDocumentAdapter adapter) { + try { + adapter.onFinish(); + } catch (RuntimeException ignored) { + // The adapter may already be torn down with its WebView. + } + } +} diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java index e4b0113..eda2a52 100644 --- a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java @@ -326,6 +326,97 @@ public void shareMarkdownDocuments(PluginCall call) { } } + @PluginMethod + public void exportMarkdownPdf(PluginCall call) { + Activity activity = getActivity(); + if (activity == null) { + call.reject("No Android activity is available for sharing", "SHARE_TARGET_UNAVAILABLE"); + return; + } + + String html = call.getString("html", null); + if (html == null || html.trim().length() == 0) { + call.reject("Rendered document content is required", "PDF_EXPORT_FAILED"); + return; + } + + String suggestedName = SharePreparation.normalizeSuggestedPdfName(call.getString("suggestedName", "")); + File outputFile; + try { + File exportDirectory = SharePreparation.getPdfExportCacheDirectory(getContext()); + outputFile = new File(exportDirectory, suggestedName); + if (!SharePreparation.isFileInDirectory(outputFile, exportDirectory)) { + throw new SecurityException("PDF export path escaped the expected directory"); + } + } catch (IOException | SecurityException ex) { + Log.e(TAG, "Failed to prepare PDF export directory", ex); + call.reject("Could not prepare the PDF file for sharing", "PDF_WRITE_FAILED", ex); + return; + } + + final File pdfFile = outputFile; + final String displayName = suggestedName; + activity.runOnUiThread(() -> + PdfExporter.export(activity, html, displayName, pdfFile, new PdfExporter.Callback() { + @Override + public void onSuccess() { + sharePdfExport(call, activity, pdfFile, displayName); + } + + @Override + public void onFailure(String code, String message) { + call.reject(message, code); + } + }) + ); + } + + private void sharePdfExport(PluginCall call, Activity activity, File pdfFile, String displayName) { + try { + Uri pdfUri = FileProvider.getUriForFile( + getContext(), + getContext().getPackageName() + ".fileprovider", + pdfFile + ); + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("application/pdf"); + shareIntent.putExtra(Intent.EXTRA_STREAM, pdfUri); + shareIntent.putExtra(Intent.EXTRA_TITLE, displayName); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, displayName); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + ArrayList streamUris = new ArrayList<>(); + streamUris.add(pdfUri); + shareIntent.setClipData(SharePreparation.buildShareClipData(getContext(), displayName, streamUris)); + + Intent chooser = Intent.createChooser(shareIntent, "Share PDF"); + chooser.putExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, + new ComponentName[] { new ComponentName(getContext(), MainActivity.class) } + ); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(chooser); + + JSObject result = new JSObject(); + result.put("displayName", displayName); + result.put("mimeType", "application/pdf"); + result.put("bytes", pdfFile.length()); + Log.i( + TAG, + "Opened Android share sheet for exported PDF: " + + safeForLog(displayName) + + ", bytes=" + + pdfFile.length() + ); + call.resolve(result); + } catch (ActivityNotFoundException ex) { + Log.w(TAG, "No Android share target is available", ex); + call.reject("No Android share target is available", "SHARE_TARGET_UNAVAILABLE", ex); + } catch (RuntimeException ex) { + Log.e(TAG, "Failed to share exported PDF", ex); + call.reject("Could not prepare the PDF file for sharing", "PDF_WRITE_FAILED", ex); + } + } + @PluginMethod public void renameMarkdownDocument(PluginCall call) { String sourceUri = call.getString("sourceUri", ""); diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/PdfExporter.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/PdfExporter.java new file mode 100644 index 0000000..7c9a9d1 --- /dev/null +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/PdfExporter.java @@ -0,0 +1,286 @@ +package io.github.renakoni.marktextandroid; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.print.PdfPrintAdapterDriver; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.util.Log; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import java.io.File; +import java.io.IOException; + +/** + * Renders export HTML in a detached WebView and prints it to a PDF file + * through the platform print pipeline — the same paginated vector output as + * the system "Save as PDF" printer, minus the dialog. The export document is + * self-contained (inlined styles, embedded fonts, pre-rendered math and + * diagrams); only image subresources load, from file URIs inside this app's + * sandbox or over the network. + */ +final class PdfExporter { + + private static final String TAG = "MarkTextAndroid"; + private static final long EXPORT_TIMEOUT_MS = 30_000; + // Bounded readiness gate: the document is polled until images and fonts + // reach a terminal state, then printed. If readiness is never reached + // (e.g. a remote image on a stalled connection), printing proceeds once + // the readiness budget runs out rather than failing the whole export. + private static final long READY_POLL_INTERVAL_MS = 250; + private static final long READY_POLL_BUDGET_MS = 10_000; + // document.readyState covers parsing; an image is ready once `complete` + // (loaded OR failed — a broken remote image must not stall the export); + // document.fonts settles once the embedded KaTeX fonts are applied. + private static final String DOCUMENT_READY_PROBE = + "(function(){" + + "if(document.readyState!=='complete')return false;" + + "var imgs=document.images;" + + "for(var i=0;i { + Log.e(TAG, "PDF export timed out"); + fail("PDF_EXPORT_FAILED", "PDF export timed out"); + }; + handler.postDelayed(timeoutRunnable, EXPORT_TIMEOUT_MS); + + try { + webView = new WebView(context); + } catch (RuntimeException ex) { + Log.e(TAG, "Could not create PDF export WebView", ex); + fail("PDF_EXPORT_FAILED", "Could not render the document for PDF export"); + return; + } + + WebSettings settings = webView.getSettings(); + // Only the readiness probe runs; see the boundary note on this + // method for why enabling JavaScript is safe here. + settings.setJavaScriptEnabled(true); + // The export HTML references imported images by file URI inside + // this app's own sandbox; nothing outside it is reachable from + // the sanitized document. + settings.setAllowFileAccess(true); + settings.setLoadsImagesAutomatically(true); + + webView.setWebViewClient(new WebViewClient() { + @Override + public void onPageFinished(WebView view, String url) { + if (finished || printStarted) { + return; + } + readinessDeadline = SystemClock.uptimeMillis() + READY_POLL_BUDGET_MS; + pollDocumentReady(); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + if (!request.isForMainFrame()) { + // A missing image must not abort the whole document. + return; + } + Log.e(TAG, "PDF export document failed to load: " + error.getDescription()); + fail("PDF_EXPORT_FAILED", "Could not render the document for PDF export"); + } + }); + + // The file base URL keeps the document in a file origin so the + // image file URIs above are loadable; the export HTML itself + // carries no relative references into the app assets. + webView.loadDataWithBaseURL("file:///android_asset/", html, "text/html", "utf-8", null); + } + + private void pollDocumentReady() { + pendingPollRunnable = null; + if (finished || printStarted) { + return; + } + + webView.evaluateJavascript(DOCUMENT_READY_PROBE, value -> { + if (finished || printStarted) { + return; + } + + boolean ready = "true".equals(value); + if (ready || SystemClock.uptimeMillis() >= readinessDeadline) { + if (!ready) { + Log.w(TAG, "PDF export proceeding before full resource readiness"); + } + print(); + return; + } + + pendingPollRunnable = this::pollDocumentReady; + handler.postDelayed(pendingPollRunnable, READY_POLL_INTERVAL_MS); + }); + } + + private void print() { + if (finished || printStarted) { + return; + } + printStarted = true; + + PrintAttributes attributes = new PrintAttributes.Builder() + .setMediaSize(PrintAttributes.MediaSize.ISO_A4) + .setResolution(new PrintAttributes.Resolution("pdf", "pdf", 600, 600)) + .setColorMode(PrintAttributes.COLOR_MODE_COLOR) + // Page margins come from the export document's @page CSS, so + // the print pipeline itself adds none. + .setMinMargins(PrintAttributes.Margins.NO_MARGINS) + .build(); + + try { + destination = ParcelFileDescriptor.open( + outputFile, + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_TRUNCATE | + ParcelFileDescriptor.MODE_READ_WRITE + ); + } catch (IOException ex) { + Log.e(TAG, "Could not open PDF export output file", ex); + fail("PDF_WRITE_FAILED", "Could not prepare the PDF file for sharing"); + return; + } + + try { + PrintDocumentAdapter adapter = webView.createPrintDocumentAdapter(jobName); + PdfPrintAdapterDriver.print(adapter, attributes, destination, printCancellation, new PdfPrintAdapterDriver.Listener() { + @Override + public void onSuccess() { + succeed(); + } + + @Override + public void onFailure(String message) { + Log.e(TAG, "PDF print pipeline failed: " + message); + fail("PDF_EXPORT_FAILED", "Could not export this document as a PDF"); + } + }); + } catch (RuntimeException ex) { + Log.e(TAG, "PDF print pipeline could not start", ex); + fail("PDF_EXPORT_FAILED", "Could not export this document as a PDF"); + } + } + + private void succeed() { + if (!complete()) { + return; + } + callback.onSuccess(); + } + + private void fail(String code, String message) { + if (!complete()) { + return; + } + if (outputFile.exists() && !outputFile.delete()) { + Log.w(TAG, "Could not delete partial PDF export output"); + } + callback.onFailure(code, message); + } + + /** Single-shot teardown of every session-owned resource. */ + private boolean complete() { + if (finished) { + return false; + } + finished = true; + + if (timeoutRunnable != null) { + handler.removeCallbacks(timeoutRunnable); + } + if (pendingPollRunnable != null) { + handler.removeCallbacks(pendingPollRunnable); + } + // Aborts an in-flight layout or write; the driver's cancelled + // callbacks then close the adapter lifecycle and report back + // here, where the delivery is dropped as a duplicate. + printCancellation.cancel(); + if (destination != null) { + try { + destination.close(); + } catch (IOException ex) { + Log.w(TAG, "Could not close PDF export descriptor", ex); + } + } + if (webView != null) { + webView.stopLoading(); + webView.destroy(); + } + return true; + } + } +} diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java index 05336c8..c3ab264 100644 --- a/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java @@ -29,6 +29,7 @@ final class SharePreparation { private static final String TAG = "MarkTextAndroid"; private static final String SHARE_CACHE_DIRECTORY = "shared-markdown"; + private static final String PDF_EXPORT_CACHE_DIRECTORY = "shared-pdf"; private static final Pattern MARKTEXT_IMAGE_SOURCE_PATTERN = Pattern.compile( "marktext-image://local/([^\\s)]+)", Pattern.CASE_INSENSITIVE @@ -57,6 +58,20 @@ static String normalizeSuggestedMarkdownName(String suggestedName) { return cleaned + ".md"; } + static String normalizeSuggestedPdfName(String suggestedName) { + String cleaned = suggestedName == null ? "" : suggestedName.trim(); + cleaned = cleaned.replaceAll("[\\\\/:*?\"<>|\\r\\n]+", " "); + cleaned = cleaned.replaceAll("\\s+", " ").trim(); + if (cleaned.length() == 0) { + cleaned = "Untitled"; + } + + if (cleaned.toLowerCase(Locale.US).endsWith(".pdf")) { + return cleaned; + } + return cleaned + ".pdf"; + } + static String uniqueShareFileName(String fileName, Set usedNames) { if (usedNames.add(fileName.toLowerCase(Locale.US))) { return fileName; @@ -115,6 +130,14 @@ static File getShareCacheDirectory(Context context) throws IOException { return directory; } + static File getPdfExportCacheDirectory(Context context) throws IOException { + File directory = new File(context.getCacheDir(), PDF_EXPORT_CACHE_DIRECTORY); + if (!directory.exists() && !directory.mkdirs()) { + throw new IOException("Could not create PDF export cache directory"); + } + return directory; + } + static Uri writeShareCacheFile( Context context, File directory, @@ -189,7 +212,7 @@ static String normalizeImportedImageFileName(String fileName) { return normalized; } - private static boolean isFileInDirectory(File file, File directory) { + static boolean isFileInDirectory(File file, File directory) { try { String directoryPath = directory.getCanonicalPath(); String filePath = file.getCanonicalPath(); diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index 5fc503e..2a323d3 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -2,6 +2,7 @@ + diff --git a/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java b/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java index 62df980..7a30336 100644 --- a/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java +++ b/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java @@ -23,6 +23,16 @@ public void normalizesSuggestedNamesToSafeMarkdownFiles() { assertEquals("Trip plan.md", SharePreparation.normalizeSuggestedMarkdownName(" Trip plan ")); } + @Test + public void normalizesSuggestedNamesToSafePdfFiles() { + assertEquals("Meeting A B.pdf", SharePreparation.normalizeSuggestedPdfName("Meeting: A/B?")); + assertEquals("Untitled.pdf", SharePreparation.normalizeSuggestedPdfName(" ")); + assertEquals("Untitled.pdf", SharePreparation.normalizeSuggestedPdfName(null)); + assertEquals("Trip notes.PDF", SharePreparation.normalizeSuggestedPdfName("Trip notes.PDF")); + assertEquals("notes.md.pdf", SharePreparation.normalizeSuggestedPdfName("notes.md")); + assertEquals("Trip plan.pdf", SharePreparation.normalizeSuggestedPdfName(" Trip plan ")); + } + @Test public void deduplicatesBatchFileNamesCaseInsensitively() { Set used = new HashSet<>(); diff --git a/src/App.vue b/src/App.vue index f552dd0..1a4d88d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ import EditorScreen from './features/editor/EditorScreen.vue' import { createAndroidMarkdownDocument, configureAndroidMarkdownSettings, + exportAndroidMarkdownPdf, getAndroidDocumentErrorCode, getAndroidDocumentUserMessage, isAndroidDocumentAccessAvailable, @@ -36,6 +37,7 @@ import { } from './lib/androidSelection' import { createEditorSelectionLifecycle } from './features/editor/selectionLifecycle' import { createEditorSession, normalizeEditorMarkdown } from './features/editor/editorSession' +import { renderMarkdownToPdfExportHtml } from './features/editor/pdfExportHtml' import { getAppBackButtonAction, getShowHomeAfterAndroidSaveAction, @@ -192,6 +194,7 @@ const promptLocalDraftSaveOnExit = ref(false) const savingLocalDraftToAndroid = ref(false) const savingAndroidDocumentCopy = ref(false) const sharingCurrentDocument = ref(false) +const exportingPdfDocument = ref(false) const { editorMenuOpen, editorToolbarExpanded, @@ -791,6 +794,7 @@ const { saveLocalDraftToAndroidDocument, saveAndroidDocumentCopy, shareCurrentMarkdownDocument, + exportCurrentDocumentPdf, flushCurrentDocument, } = createCurrentDocumentPersistence({ currentScreen, @@ -806,6 +810,7 @@ const { savingLocalDraftToAndroid, savingAndroidDocumentCopy, sharingCurrentDocument, + exportingPdfDocument, hasEditor, getEditorMarkdownSnapshot, syncDocumentFromEditor, @@ -832,6 +837,13 @@ const { writeAndroidMarkdownDocument, createAndroidMarkdownDocument, shareAndroidMarkdownDocument, + // The live editor instance carries the user's rendering settings + // (footnotes, super/subscript, GitLab compatibility, diagram options), so + // the PDF renders with editor parity. + renderPdfExportHtml: options => + renderMarkdownToPdfExportHtml({ ...options, muya: getEditor() }), + exportAndroidMarkdownPdf, + getPdfTextDirection: () => appearanceTextSettings.value.textDirection, getAndroidDocumentUserMessage, markdownSaveSettings, imageSharingSettings, @@ -1414,9 +1426,11 @@ onBeforeUnmount(() => { :character-count="characterCount" :line-count="lineCount" :can-share="canShareCurrentDocument()" + :can-export-pdf="canShareCurrentDocument()" :can-save-to-device="canSaveLocalDraftToAndroidDocument()" :can-save-copy="canSaveAndroidDocumentCopy()" :sharing="sharingCurrentDocument" + :exporting-pdf="exportingPdfDocument" :saving-to-device="savingLocalDraftToAndroid" :saving-copy="savingAndroidDocumentCopy" :link-sheet-open="linkSheetOpen" @@ -1437,6 +1451,7 @@ onBeforeUnmount(() => { @toggle-menu="toggleEditorMenu" @close-menu="closeEditorMenu" @share="shareCurrentMarkdownDocument" + @export-pdf="exportCurrentDocumentPdf" @save-to-device="saveLocalDraftToAndroidDocument" @save-copy="saveAndroidDocumentCopy" @run-toolbar-command="runEditorToolbarCommand" diff --git a/src/features/android-documents/exportAndroidPdfWorkflow.test.ts b/src/features/android-documents/exportAndroidPdfWorkflow.test.ts new file mode 100644 index 0000000..54644b2 --- /dev/null +++ b/src/features/android-documents/exportAndroidPdfWorkflow.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { exportAndroidPdfWorkflow } from './exportAndroidPdfWorkflow' +import { createUntitledDocument } from '../../lib/documentState' + +function createDocument(markdown: string) { + return { + ...createUntitledDocument({ markdown }), + displayName: 'Trip notes.md', + } +} + +describe('exportAndroidPdfWorkflow', () => { + it('renders the document and opens the PDF share sheet', async () => { + const renderPdfExportHtml = vi.fn(async () => '') + const exportAndroidMarkdownPdf = vi.fn(async () => ({ + displayName: 'Trip notes.pdf', + mimeType: 'application/pdf', + bytes: 4321, + })) + const logger = { info: vi.fn(), error: vi.fn() } + + const result = await exportAndroidPdfWorkflow({ + currentDocument: createDocument('# Trip notes\n\nbody'), + textDirection: 'ltr', + renderPdfExportHtml, + exportAndroidMarkdownPdf, + getAndroidDocumentUserMessage: () => 'unused', + logger, + }) + + expect(result.kind).toBe('exported') + expect(result.status).toBe('Share sheet opened') + expect(renderPdfExportHtml).toHaveBeenCalledWith({ + markdown: '# Trip notes\n\nbody', + title: 'Trip notes', + textDirection: 'ltr', + }) + expect(exportAndroidMarkdownPdf).toHaveBeenCalledWith( + '', + 'Trip notes.pdf', + ) + expect(logger.info).toHaveBeenCalled() + }) + + it('passes the RTL text direction through to the renderer', async () => { + const renderPdfExportHtml = vi.fn(async () => '') + + await exportAndroidPdfWorkflow({ + currentDocument: createDocument('نص'), + textDirection: 'rtl', + renderPdfExportHtml, + exportAndroidMarkdownPdf: vi.fn(async () => ({ + displayName: 'x.pdf', + mimeType: 'application/pdf', + bytes: 1, + })), + getAndroidDocumentUserMessage: () => 'unused', + }) + + expect(renderPdfExportHtml).toHaveBeenCalledWith( + expect.objectContaining({ textDirection: 'rtl' }), + ) + }) + + it('reports rendering failures through the user message mapper', async () => { + const error = new Error('muya render failed') + const exportAndroidMarkdownPdf = vi.fn() + + const result = await exportAndroidPdfWorkflow({ + currentDocument: createDocument('body'), + textDirection: 'ltr', + renderPdfExportHtml: vi.fn(async () => { + throw error + }), + exportAndroidMarkdownPdf, + getAndroidDocumentUserMessage: () => 'Could not export this document as a PDF.', + }) + + expect(result.kind).toBe('failed') + expect(result.status).toBe('Could not export this document as a PDF.') + expect(exportAndroidMarkdownPdf).not.toHaveBeenCalled() + }) + + it('reports native export failures with their mapped message', async () => { + const nativeError = Object.assign(new Error('print failed'), { code: 'PDF_EXPORT_FAILED' }) + const getAndroidDocumentUserMessage = vi.fn(() => 'Could not export this document as a PDF.') + + const result = await exportAndroidPdfWorkflow({ + currentDocument: createDocument('body'), + textDirection: 'ltr', + renderPdfExportHtml: vi.fn(async () => ''), + exportAndroidMarkdownPdf: vi.fn(async () => { + throw nativeError + }), + getAndroidDocumentUserMessage, + }) + + expect(result.kind).toBe('failed') + expect(result.status).toBe('Could not export this document as a PDF.') + expect(getAndroidDocumentUserMessage).toHaveBeenCalledWith(nativeError) + }) +}) diff --git a/src/features/android-documents/exportAndroidPdfWorkflow.ts b/src/features/android-documents/exportAndroidPdfWorkflow.ts new file mode 100644 index 0000000..02ae8c5 --- /dev/null +++ b/src/features/android-documents/exportAndroidPdfWorkflow.ts @@ -0,0 +1,84 @@ +import { + getSuggestedPdfFileName, + type MarkdownDocumentState, +} from '../../lib/documentState' +import type { AndroidPdfExportResult } from '../../lib/androidDocuments' + +interface WorkflowLogger { + info(message: string, data?: unknown): void + error(message: string, data?: unknown): void +} + +export type ExportAndroidPdfResult = + | { + kind: 'exported' + status: 'Share sheet opened' + exportResult: AndroidPdfExportResult + } + | { + kind: 'failed' + status: string + error: unknown + } + +export interface ExportAndroidPdfWorkflowOptions { + currentDocument: MarkdownDocumentState + textDirection: 'ltr' | 'rtl' + renderPdfExportHtml: (options: { + markdown: string + title: string + textDirection: 'ltr' | 'rtl' + }) => Promise + exportAndroidMarkdownPdf: ( + html: string, + suggestedName: string, + ) => Promise + getAndroidDocumentUserMessage: (error: unknown) => string + logger?: WorkflowLogger +} + +// Renders the current document to the self-contained export HTML, hands it to +// the native printer, and reports the outcome the same way the Markdown share +// workflow does. Rendering failures and native failures share one failure +// shape so the caller only needs the status text. +export async function exportAndroidPdfWorkflow({ + currentDocument, + textDirection, + renderPdfExportHtml, + exportAndroidMarkdownPdf, + getAndroidDocumentUserMessage, + logger, +}: ExportAndroidPdfWorkflowOptions): Promise { + const suggestedName = getSuggestedPdfFileName( + currentDocument.markdown, + currentDocument.displayName, + ) + + try { + const html = await renderPdfExportHtml({ + markdown: currentDocument.markdown, + title: suggestedName.replace(/\.pdf$/i, ''), + textDirection, + }) + const result = await exportAndroidMarkdownPdf(html, suggestedName) + logger?.info('Android PDF share sheet opened', { + displayName: result.displayName, + bytes: result.bytes, + autosaveTarget: currentDocument.autosaveTarget, + }) + + return { + kind: 'exported', + status: 'Share sheet opened', + exportResult: result, + } + } catch (error) { + logger?.error('Android PDF export failed', error) + + return { + kind: 'failed', + status: getAndroidDocumentUserMessage(error), + error, + } + } +} diff --git a/src/features/document-session/currentDocumentPersistence.test.ts b/src/features/document-session/currentDocumentPersistence.test.ts index 93fccdf..5e02d2d 100644 --- a/src/features/document-session/currentDocumentPersistence.test.ts +++ b/src/features/document-session/currentDocumentPersistence.test.ts @@ -92,6 +92,7 @@ function createPersistence(overrides: { savingLocalDraftToAndroid: ref(false), savingAndroidDocumentCopy: ref(false), sharingCurrentDocument: ref(false), + exportingPdfDocument: ref(false), hasEditor: () => overrides.hasEditor ?? true, getEditorMarkdownSnapshot: vi.fn( () => overrides.markdownSnapshot ?? documentState.value.markdown, @@ -126,6 +127,13 @@ function createPersistence(overrides: { imageCount: 0, sharedFileCount: 1, }), + renderPdfExportHtml: vi.fn().mockResolvedValue(''), + exportAndroidMarkdownPdf: vi.fn().mockResolvedValue({ + displayName: 'x.pdf', + mimeType: 'application/pdf', + bytes: 1, + }), + getPdfTextDirection: () => 'ltr' as const, getAndroidDocumentUserMessage: (error: unknown) => `user message: ${(error as Error)?.message ?? 'unknown'}`, markdownSaveSettings: ref(DEFAULT_MARKDOWN_SAVE_SETTINGS), @@ -343,4 +351,61 @@ describe('currentDocumentPersistence', () => { expect(shared).toBe(false) expect(options.shareAndroidMarkdownDocument).not.toHaveBeenCalled() }) + + it('exports the current document as a PDF and restores the busy flag', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + }) + + const exported = await persistence.exportCurrentDocumentPdf() + + expect(exported).toBe(true) + expect(options.closeEditorMenu).toHaveBeenCalled() + expect(options.syncDocumentFromEditor).toHaveBeenCalledWith(true, true) + expect(options.renderPdfExportHtml).toHaveBeenCalledWith( + expect.objectContaining({ textDirection: 'ltr' }), + ) + expect(options.exportAndroidMarkdownPdf).toHaveBeenCalled() + expect(options.status.value).toBe('Share sheet opened') + expect(options.exportingPdfDocument.value).toBe(false) + }) + + it('autosaves a local draft before exporting it as a PDF', async () => { + const dirtyDraft = updateDocumentMarkdown( + createUntitledDocument({ markdown: '', autosaveTarget: 'local-draft' }), + '# Draft body', + { markDirty: true }, + ) + const { persistence, options } = createPersistence({ documentState: dirtyDraft }) + + await persistence.exportCurrentDocumentPdf() + + expect(options.localDrafts.value[0]?.markdown).toBe('# Draft body') + expect(options.exportAndroidMarkdownPdf).toHaveBeenCalled() + }) + + it('surfaces PDF export failures as status text and clears the busy flag', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + }) + options.exportAndroidMarkdownPdf.mockRejectedValue(new Error('print failed')) + + const exported = await persistence.exportCurrentDocumentPdf() + + expect(exported).toBe(false) + expect(options.status.value).toBe('user message: print failed') + expect(options.exportingPdfDocument.value).toBe(false) + }) + + it('refuses to export while a PDF export is already in flight', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + }) + options.exportingPdfDocument.value = true + + const exported = await persistence.exportCurrentDocumentPdf() + + expect(exported).toBe(false) + expect(options.renderPdfExportHtml).not.toHaveBeenCalled() + }) }) diff --git a/src/features/document-session/currentDocumentPersistence.ts b/src/features/document-session/currentDocumentPersistence.ts index 28f35a3..b581ac6 100644 --- a/src/features/document-session/currentDocumentPersistence.ts +++ b/src/features/document-session/currentDocumentPersistence.ts @@ -6,6 +6,7 @@ import { import { saveAndroidDocumentCopyWorkflow } from '../android-documents/saveAndroidDocumentCopyWorkflow' import { saveLocalDraftToAndroidDocumentWorkflow } from '../android-documents/saveLocalDraftToAndroidDocumentWorkflow' import { shareAndroidMarkdownDocumentWorkflow } from '../android-documents/shareAndroidMarkdownDocumentWorkflow' +import { exportAndroidPdfWorkflow } from '../android-documents/exportAndroidPdfWorkflow' import { createAndroidRecoveryDraft, getAndroidRecoveryDraftId, @@ -15,6 +16,7 @@ import type { ImageSharingSettings } from '../android-documents/imageSharingSett import { createLocalDraftAutosaveResult } from '../local-drafts/localDraftAutosave' import type { MarkdownSaveSettings } from '../settings/advancedSettings' import type { + AndroidPdfExportResult, AndroidShareResult, OpenedAndroidDocument, } from '../../lib/androidDocuments' @@ -60,6 +62,7 @@ export interface CurrentDocumentPersistenceOptions { savingLocalDraftToAndroid: Ref savingAndroidDocumentCopy: Ref sharingCurrentDocument: Ref + exportingPdfDocument: Ref // Editor access and chrome, owned by App. hasEditor: () => boolean getEditorMarkdownSnapshot: (flushPending?: boolean) => string @@ -91,6 +94,16 @@ export interface CurrentDocumentPersistenceOptions { suggestedName: string, options: { attachImages: boolean, encoding: MarkdownSaveSettings['encoding'] }, ) => Promise + renderPdfExportHtml: (options: { + markdown: string + title: string + textDirection: 'ltr' | 'rtl' + }) => Promise + exportAndroidMarkdownPdf: ( + html: string, + suggestedName: string, + ) => Promise + getPdfTextDirection: () => 'ltr' | 'rtl' getAndroidDocumentUserMessage: (error: unknown) => string // Settings and messages. markdownSaveSettings: Ref @@ -109,6 +122,7 @@ export interface CurrentDocumentPersistence { saveLocalDraftToAndroidDocument(options?: { returnHomeAfterSave?: boolean }): Promise saveAndroidDocumentCopy(options?: { returnHomeAfterSave?: boolean }): Promise shareCurrentMarkdownDocument(): Promise + exportCurrentDocumentPdf(): Promise flushCurrentDocument(reason: string): Promise } @@ -508,6 +522,45 @@ export function createCurrentDocumentPersistence( } } + async function exportCurrentDocumentPdf() { + options.closeEditorMenu() + + if ( + !options.hasEditor() + || !options.canShareCurrentDocument() + || options.exportingPdfDocument.value + ) { + return false + } + + const currentDocument = options.syncDocumentFromEditor( + options.documentState.value.isDirty, + true, + ) + if (currentDocument.autosaveTarget === 'local-draft') { + saveDraft() + } + + options.exportingPdfDocument.value = true + options.status.value = 'Exporting PDF' + + try { + const result = await exportAndroidPdfWorkflow({ + currentDocument, + textDirection: options.getPdfTextDirection(), + renderPdfExportHtml: options.renderPdfExportHtml, + exportAndroidMarkdownPdf: options.exportAndroidMarkdownPdf, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + logger: options.documentLogger, + }) + + options.status.value = result.status + return result.kind === 'exported' + } finally { + options.exportingPdfDocument.value = false + } + } + async function flushCurrentDocument(reason: string) { if (options.currentScreen.value !== 'editor' || !options.hasEditor()) { return @@ -535,6 +588,7 @@ export function createCurrentDocumentPersistence( saveLocalDraftToAndroidDocument, saveAndroidDocumentCopy, shareCurrentMarkdownDocument, + exportCurrentDocumentPdf, flushCurrentDocument, } } diff --git a/src/features/editor/EditorScreen.vue b/src/features/editor/EditorScreen.vue index 04cd600..92b8a9f 100644 --- a/src/features/editor/EditorScreen.vue +++ b/src/features/editor/EditorScreen.vue @@ -29,9 +29,11 @@ const props = defineProps<{ characterCount: number lineCount: number canShare: boolean + canExportPdf: boolean canSaveToDevice: boolean canSaveCopy: boolean sharing: boolean + exportingPdf: boolean savingToDevice: boolean savingCopy: boolean linkSheetOpen: boolean @@ -57,6 +59,7 @@ const emit = defineEmits<{ 'toggle-menu': [] 'close-menu': [] share: [] + 'export-pdf': [] 'save-to-device': [] 'save-copy': [] 'run-toolbar-command': [commandId: MobileCommandId, restoreRange: Range | null] @@ -194,13 +197,16 @@ onBeforeUnmount(() => { diff --git a/src/features/editor/components/EditorActionSheet.vue b/src/features/editor/components/EditorActionSheet.vue index 76b882c..d6a56f8 100644 --- a/src/features/editor/components/EditorActionSheet.vue +++ b/src/features/editor/components/EditorActionSheet.vue @@ -3,9 +3,11 @@ import { useI18n } from '../../../lib/i18n' defineProps<{ canShare: boolean + canExportPdf: boolean canSaveToDevice: boolean canSaveCopy: boolean sharing: boolean + exportingPdf: boolean savingToDevice: boolean savingCopy: boolean }>() @@ -13,6 +15,7 @@ defineProps<{ const emit = defineEmits<{ close: [] share: [] + 'export-pdf': [] 'save-to-device': [] 'save-copy': [] }>() @@ -53,6 +56,26 @@ const { t } = useI18n() {{ t('editor.actions.share') }} +