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') }}
+
')
+ expect(html).not.toContain('title: meta')
+ })
+
+ // The native print WebView enables JavaScript for its readiness probe on
+ // the explicit premise that the export document can never carry active
+ // content (see the boundary note in PdfExporter.java). These lock that
+ // premise in: a Muya renderer refactor that let scripts through would fail
+ // here before the trust boundary silently widened.
+ it('neutralizes script tags in the exported document', { timeout: 30_000 }, async () => {
+ const html = await renderMarkdownToPdfExportHtml({
+ markdown: 'Safe paragraph.\n\n',
+ title: 'Sanitizer boundary',
+ textDirection: 'ltr',
+ muya: fakeMuya({ math: true }),
+ })
+
+ const body = html.slice(html.indexOf(''))
+ expect(body).toContain('Safe paragraph.')
+ // escapeInBlockHtml neutralizes ok', config, false)).not.toContain('