Skip to content

Add Android PDF export with share sheet delivery#96

Merged
Renakoni merged 1 commit into
mainfrom
feature/android-pdf-export
Jul 10, 2026
Merged

Add Android PDF export with share sheet delivery#96
Renakoni merged 1 commit into
mainfrom
feature/android-pdf-export

Conversation

@Renakoni

Copy link
Copy Markdown
Owner

Summary

"Export as PDF" joins the editor document action menu. It renders the latest editor content — including unsaved changes — into a real paginated A4 PDF with selectable text, then opens the Android share sheet with the file attached as application/pdf under a document-derived name (Trip notes.pdf). No Save As picker, no system print dialog.

Chosen PDF-generation approach

Reused HTML pipeline + native headless print. Two stages:

  1. HTML — the vendored Muya already contains the desktop export engine (MarkdownToHtml.generate), which produces a fully self-contained document: inlined GitHub styles, KaTeX with fonts embedded as data URIs (math renders offline), Prism-highlighted code, and mermaid/vega/plantuml/flowchart code blocks pre-rendered to SVG in the app WebView before the HTML leaves it. The new pdfExportHtml module adds A4 print CSS (@page margins, heading break-avoidance, forced background printing so code blocks keep their fill), strips leading front matter (the export renderer would otherwise show it as content; desktop hides it), and rewrites marktext-image://local/… sources to file:// URIs in the app sandbox — the export sanitizer explicitly allows file: (the same allowance the desktop uses for exported images).
  2. PDF — a new exportMarkdownPdf plugin method loads that HTML in a detached, JavaScript-disabled WebView and drives createPrintDocumentAdapter (onLayout/onWrite) straight into a file in a new shared-pdf/ cache directory. This is the same Chromium print pipeline as the system "Save as PDF" printer — true vector text, proper pagination — minus the dialog. The LayoutResultCallback/WriteResultCallback constructors are package-private, so a small PdfPrintAdapterDriver lives in the android.print package (the long-established technique used by WebView-to-PDF converters; verified working on the API 35 emulator). The exporter owns a 30 s timeout, a render-settle delay for image decoding, and single-shot cleanup. The share Intent reuses the existing FileProvider authority and chooser conventions (self excluded, ClipData, read grant).

Alternatives investigated

  • System print dialog (PrintManager.print) — produces the same PDF but forces the user through the print preview UI; explicitly out of scope.
  • PdfDocument + WebView.draw — manual pagination, no break-inside support, inconsistent output under hardware acceleration. The print adapter path is the sanctioned one.
  • JS-side generation (jsPDF / html2canvas) — rasterized output, poor math/code fidelity, large added dependency. Rejected.
  • Porting Electron printToPDF — that API is main-process Electron; only its options were mirrored (A4, backgrounds on, break-avoidance CSS).

What was reused from the desktop implementation

The desktop reference (E:\marktext-contrib-latest, fast-forwarded to upstream develop 43bd8b77 first, feature branch and local files untouched) generates HTML with the same MarkdownToHtml engine this repo vendors — so the entire rendering stage is shared code, not a port. Mirrored from the desktop wrapper: print CSS margin/break rules, background printing, front-matter hiding, file-URI image resolution, and the RTL dir pass-through (upstream #4874/#4553); the text-direction setting flows into the exported document. Deliberately not copied: header/footer table wrapping, [TOC] injection, heading auto-numbering, theme/page-size settings UI — desktop-only complexity this scope does not need.

Architecture

Mirrors the Markdown share flow exactly at every layer: exportCurrentDocumentPdf in currentDocumentPersistence (same guards, editor flush with pending-IME commit, draft autosave, busy flag, status text) → exportAndroidPdfWorkflow (pure, injected collaborators) → androidDocuments bridge → plugin. Rendering concerns live in features/editor/pdfExportHtml.ts; the native printer is its own PdfExporter class. Progress and failure states: menu row disabled while exporting, status Exporting PDFShare sheet opened, failures map through the shared error table (PDF_EXPORT_FAILED, PDF_WRITE_FAILED) with en/zh translations.

Known limitations / deferred

  • Images referenced as marktext-image://android/… (picker used with "copy images" off) resolve to content:// URIs, which the export sanitizer strips — they degrade to their alt text. The default copy-on-import path exports correctly.
  • Remote http(s) images render when the network answers within the settle window; offline they degrade gracefully.
  • Fixed A4 with 18 mm margins; no page-size/margin/header-footer settings (intentionally, per scope).
  • Diagram code blocks export as SVG via the shared pipeline, but the editor itself has no diagram preview today, so this path has no first-party UI producing such documents yet.

Testing

  • Web unit (341 passed / 56 files): new suites for the export workflow (success, RTL pass-through, render failure, native failure mapping), pdfExportHtml (front-matter stripping incl. thematic-break false positives, image-source rewriting incl. resolver-throw and plain-URL passthrough, print CSS invariants), persistence orchestration (busy-flag reentry guard, draft autosave before export, failure status, flush semantics), and PDF filename derivation.
  • Native (JVM): SharePreparationTest covers the PDF name normalization (sanitize, extension append, case-preserving .PDF, null/blank → Untitled.pdf); gradlew testDebugUnitTest green.
  • Builds: pnpm lint, pnpm build (vue-tsc + vite), gradlew assembleDebug all clean; related Mobile WebView e2e specs (document actions, document lifecycle, editor toolbar — 25 tests) pass.
  • Manual on API 35 emulator: imported a document with heading, bold, inline code, link, KaTeX block math, table, highlighted JS code fence, list, and quote → Export as PDF → share sheet opened with PDF Export Smoke.pdf; pulled the file: valid %PDF-1.4, A4, selectable text, math as vector glyphs, code block background preserved. Second document with an imported local image: image renders in the PDF between its surrounding paragraphs. Native and JS logs confirm the full pipeline.

@Renakoni Renakoni force-pushed the feature/android-pdf-export branch from 18b08ae to ec62ac7 Compare July 10, 2026 17:33
@Renakoni

Copy link
Copy Markdown
Owner Author

All four findings verified against the code and fixed in the updated commit (ec62ac7).

[P1] Rendering parity — fixed by passing the live Muya instance. Confirmed the mismatch exactly as described: the app defaults superSubScript/isGitlabCompatibilityEnabled to false and the standalone renderer defaults them to true, and user-enabled footnotes were silently off in the export. renderMarkdownToPdfExportHtml now accepts the editor instance and constructs new MarkdownToHtml(markdown, muya) — the desktop pattern — so footnote/super-sub/GitLab flags plus sequenceTheme, plantumlServer, and mermaidTheme all flow from the active editor. App wires it via the editor session''s getEditor(). Added rendering-parity tests that run the real renderer: footnotes enabled → <section class="footnotes"> with the footnote body present, and disabled superSubScript → no <sub>, while the no-instance defaults produce the opposite — plus an RTL dir="rtl" assertion.

[P1] Timeout now cancels and cleans up the active print stage. PdfExporter was rebuilt around an ExportSession that owns every live resource: WebView, timeout runnable, readiness-poll runnable, one caller-owned CancellationSignal shared by layout and write, the output descriptor, and the output file. Every outcome (success, load error, print failure, timeout) funnels through one single-shot complete() that unschedules pending runnables, cancels the in-flight layout/write, closes the descriptor, destroys the WebView, and deletes the partial PDF on any non-success path. The specific race you called out is gone: the delayed print path re-checks the finished flag before touching the WebView, so a timeout at 30.0s can no longer let a 30.3s runnable call createPrintDocumentAdapter() on a destroyed WebView. The driver additionally handles onLayoutCancelled/onWriteCancelled and wraps onStart/onLayout/onWrite/onFinish in RuntimeException guards so a torn-down adapter reports through the listener instead of throwing uncaught; duplicate deliveries are dropped by the session guard.

[P2] Front matter — switched to the desktop approach. You were right that the YAML-only regex was both incomplete (TOML/JSON front matter untouched) and unnecessary: the renderer''s marked options default frontMatter: true, so it already parses all supported styles into pre.front-matter. The regex stripping is deleted; the export CSS now carries pre.front-matter { display: none !important; }, byte-for-byte the desktop rule. Verified on-device: a YAML front-matter document exports with the metadata absent from both the rendered page and the PDF text layer. (Test note: the parity test asserts the absence of the old failure mode — no <hr>/setext artifacts, no visible metadata paragraph — rather than the pre wrapper itself, because happy-dom''s DOMPurify pass drops all <pre> wrappers in that environment; the real WebView keeps them, as the code blocks in the smoke PDFs show.)

[P2] Fixed settle delay replaced with a bounded readiness gate. The session now enables JavaScript solely for a readiness probe (the document is locally generated and DOMPurify-sanitized, scripts/handlers already stripped) and polls every 250 ms for document.readyState === ''complete'', every image complete (loaded or failed — a broken remote image cannot stall the export), and document.fonts.status === ''loaded''. Printing starts as soon as the document reports ready; if readiness is never reached, it proceeds after a 10 s budget with a warning log rather than failing the export, and the whole session remains bounded by the 30 s timeout.

Re-verified: 342/342 unit (56 files), lint, vue-tsc + vite build, gradlew compileDebugJavaWithJavac testDebugUnitTest and assembleDebug all green; emulator re-smoke exported the front-matter+image document (metadata hidden, image rendered through the readiness gate) and the original rich document byte-identical to the pre-fix output.

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.
@Renakoni Renakoni force-pushed the feature/android-pdf-export branch from ec62ac7 to 6e71770 Compare July 10, 2026 18:07
@Renakoni

Copy link
Copy Markdown
Owner Author

Addressed the SetJavaScriptEnabled warning as recommended — documentation plus a scoped suppression, with regression tests guarding the trust boundary.

Lint suppression + boundary documentation. ExportSession.start() in PdfExporter.java now carries @SuppressLint("SetJavaScriptEnabled") with a boundary note enumerating exactly why enabling JS is contained: the WebView is transient and detached and destroyed on every outcome; no JavascriptInterface and no Capacitor bridge are attached; the only script that runs is the readiness probe; the loaded HTML is locally generated and DOMPurify-sanitized (scripts, event handlers, javascript: URLs stripped); and a "do not attach interfaces or load remote documents into this WebView" warning for future maintainers. The setJavaScriptEnabled(true) line points back to that note.

Regression tests — with an honest scope note. While writing the sanitizer tests I found that the boundary is not uniformly assertable in the unit environment, so I split it by what each layer can reliably prove:

  • Script tags (the actual risk for this WebView): asserted through the full export path — a <script> in the source is neutralized to &lt;script&gt; and never appears as a live tag. This is deterministic because escapeInBlockHtml escapes script/style/title before DOMPurify runs, independent of the DOM environment. It is also the highest-risk vector here: a print WebView auto-runs scripts but never receives the clicks or navigations that onclick/javascript: would require.
  • Event handlers and dangerous URL schemes: asserted on muya''s sanitize primitive — the exact function MarkdownToHtml.renderHtml composes with — verifying onclick, javascript: hrefs, and javascript: image sources are stripped.

The reason for the split: muya''s DOMPurify binds a window singleton at import (const { sanitize } = DOMPurify()), and under happy-dom the full-render round-trip does not reproduce that attribute/URL stripping deterministically — I measured javascript: surviving and onclick stripping inconsistently across runs, while the sanitize primitive strips all of them reliably. Asserting the flaky full-render path would have added a flaky test; the primitive-level assertion covers the same guarantee without that, and the real WebView plus muya''s own sanitizer suite cover the integrated path. Both tests are annotated with this rationale. Ran the file 3× to confirm the new tests are stable.

Verification: 344/344 unit (56 files), lint, vue-tsc + vite build, and gradlew compileDebugJavaWithJavac all green; the SetJavaScriptEnabled warning is gone. (An unrelated pre-existing WrongConstant lint note on takePersistableUriPermission at the combined-URI-flags call predates this branch — the project''s CI gates on assembleDebug + testDebugUnitTest, not lint — so it is left untouched to keep this PR surgical.)

@Renakoni Renakoni merged commit 57e2992 into main Jul 10, 2026
5 checks passed
@Renakoni Renakoni deleted the feature/android-pdf-export branch July 10, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant