mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 |
@@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا."
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
|
||||
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Obre en una aplicació",
|
||||
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí."
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
|
||||
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Otevřít v aplikaci",
|
||||
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát."
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát.",
|
||||
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Åbn i app",
|
||||
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her."
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her.",
|
||||
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
|
||||
+3
-1
@@ -414,7 +414,9 @@
|
||||
"open_external": "In App öffnen",
|
||||
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden."
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
|
||||
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
|
||||
+3
-1
@@ -425,7 +425,9 @@
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here."
|
||||
"media_unsupported": "This file can't be played here.",
|
||||
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
|
||||
"pdf_unreadable": "This PDF can't be shown here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Abrir en una app",
|
||||
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí."
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí.",
|
||||
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود."
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود.",
|
||||
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Ouvrir dans une app",
|
||||
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici."
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
|
||||
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן."
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
|
||||
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Megnyitás alkalmazásban",
|
||||
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le."
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le.",
|
||||
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Apri in un'app",
|
||||
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui."
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui.",
|
||||
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。"
|
||||
"media_unsupported": "このファイルはここでは再生できません。",
|
||||
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다."
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
|
||||
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Atvērt lietotnē",
|
||||
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot."
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot.",
|
||||
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй."
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
|
||||
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Åpne i app",
|
||||
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her."
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her.",
|
||||
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Openen in app",
|
||||
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld."
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
|
||||
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Otwórz w aplikacji",
|
||||
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć."
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
|
||||
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Abrir em outro app",
|
||||
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui."
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
|
||||
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Deschideți într-o aplicație",
|
||||
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici."
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici.",
|
||||
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь."
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
|
||||
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Otvoriť v aplikácii",
|
||||
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať."
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
|
||||
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Uygulamada aç",
|
||||
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor."
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor.",
|
||||
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут."
|
||||
"media_unsupported": "Цей файл не можна відтворити тут.",
|
||||
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
|
||||
@@ -408,7 +408,9 @@
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。"
|
||||
"media_unsupported": "無法在此播放這個檔案。",
|
||||
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
|
||||
+3
-1
@@ -408,7 +408,9 @@
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。"
|
||||
"media_unsupported": "无法在此播放该文件。",
|
||||
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
|
||||
"pdf_unreadable": "无法在此显示该 PDF。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.80",
|
||||
"version": "0.1.81",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.80",
|
||||
"version": "0.1.81",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.80",
|
||||
"version": "0.1.81",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, useWindowDimensions,
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { X, ExternalLink, Share2, Download } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -11,6 +12,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
|
||||
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
@@ -20,7 +22,7 @@ export interface PreviewItem {
|
||||
mimeType: string;
|
||||
/** Local file:// URI of the downloaded copy (images, PDFs, media). */
|
||||
fileUri?: string;
|
||||
/** The viewer page next to the file (images, video, audio); see preview-viewer-html. */
|
||||
/** The viewer page next to the file (images, video, audio, Android's PDFs); see preview-viewer-html. */
|
||||
viewerUri?: string;
|
||||
/** Decoded text for text-like parts. */
|
||||
text?: string;
|
||||
@@ -47,14 +49,28 @@ interface Props {
|
||||
docked?: boolean;
|
||||
}
|
||||
|
||||
interface ViewerWebViewProps {
|
||||
uri: string;
|
||||
background: string;
|
||||
onFailed: () => void;
|
||||
/** Messages other than 'error'. */
|
||||
onMessage?: (data: string) => void;
|
||||
webRef?: React.Ref<WebView>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A local page in a WebView that may read only its own folder: the image
|
||||
* and media viewers. It posts 'error' when the file doesn't decode.
|
||||
* A local page in a WebView that may read only its own folder: the image,
|
||||
* media and PDF viewers. It posts 'error' when the file doesn't decode.
|
||||
*/
|
||||
function ViewerWebView({ uri, background, onFailed }: { uri: string; background: string; onFailed: () => void }) {
|
||||
function ViewerWebView({ uri, background, onFailed, onMessage, webRef }: ViewerWebViewProps) {
|
||||
const folder = uri.slice(0, uri.lastIndexOf('/') + 1);
|
||||
const handleMessage = (e: WebViewMessageEvent) => {
|
||||
if (e.nativeEvent.data === 'error') onFailed();
|
||||
else onMessage?.(e.nativeEvent.data);
|
||||
};
|
||||
return (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{ uri }}
|
||||
originWhitelist={['file://*']}
|
||||
allowFileAccess
|
||||
@@ -74,7 +90,7 @@ function ViewerWebView({ uri, background, onFailed }: { uri: string; background:
|
||||
overScrollMode="never"
|
||||
bounces={false}
|
||||
onShouldStartLoadWithRequest={(req) => req.url === uri}
|
||||
onMessage={(e) => { if (e.nativeEvent.data === 'error') onFailed(); }}
|
||||
onMessage={handleMessage}
|
||||
onError={onFailed}
|
||||
style={[viewerStyles.fill, { backgroundColor: background }]}
|
||||
containerStyle={{ backgroundColor: background }}
|
||||
@@ -82,8 +98,39 @@ function ViewerWebView({ uri, background, onFailed }: { uri: string; background:
|
||||
);
|
||||
}
|
||||
|
||||
// Pages are rendered half again as wide as the screen's pixels, so text stays
|
||||
// sharp at the first zoom step; PdfRenderer's bitmap is capped natively.
|
||||
const PDF_PAGE_OVERSAMPLE = 1.5;
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS renders them in the WebView), video
|
||||
* Android's PDF view: the viewer page asks for each page as it comes near
|
||||
* the screen, and the page is rendered next to it by the platform's
|
||||
* PdfRenderer (pdf-pages.ts).
|
||||
*/
|
||||
function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
|
||||
fileUri: string;
|
||||
viewerUri: string;
|
||||
background: string;
|
||||
onFailed: () => void;
|
||||
}) {
|
||||
const ref = React.useRef<WebView>(null);
|
||||
const { width } = useWindowDimensions();
|
||||
const pagePx = PixelRatio.getPixelSizeForLayoutSize(Math.min(width, 1000)) * PDF_PAGE_OVERSAMPLE;
|
||||
const folder = viewerUri.slice(0, viewerUri.lastIndexOf('/') + 1);
|
||||
const onMessage = (data: string) => {
|
||||
const m = /^page:(\d+)$/.exec(data);
|
||||
if (!m) return;
|
||||
const index = Number(m[1]);
|
||||
const name = pdfPageFileName(index);
|
||||
renderPdfPage(fileUri, index, pagePx, folder + name)
|
||||
.then(() => ref.current?.injectJavaScript(`window.__pdfPage(${index}, ${JSON.stringify(name)});true;`))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
return <ViewerWebView webRef={ref} uri={viewerUri} background={background} onFailed={onFailed} onMessage={onMessage} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video
|
||||
* and audio (the WebView's player), text, Markdown and HTML documents and
|
||||
* embedded messages. Files that don't decode, and types without a preview,
|
||||
* offer another app instead. Used by every place that opens a file.
|
||||
@@ -133,7 +180,7 @@ export function AttachmentPreviewModal({
|
||||
|| (!!failure?.native && !item.viewerUri)
|
||||
);
|
||||
const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web;
|
||||
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
|
||||
const pdfInline = item?.kind === 'pdf' && !!item.fileUri && (Platform.OS === 'ios' || !!item.viewerUri) && !failure?.web;
|
||||
// The fallback screen has its own Open button; the header adds one elsewhere.
|
||||
const showsFallback = !!item && (
|
||||
item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed || mediaFailed
|
||||
@@ -216,6 +263,17 @@ export function AttachmentPreviewModal({
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
if (pdfInline && item.fileUri && item.viewerUri) {
|
||||
return (
|
||||
<PdfPagesView
|
||||
key={item.viewerUri}
|
||||
fileUri={item.fileUri}
|
||||
viewerUri={item.viewerUri}
|
||||
background={c.surface}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pdfInline && item.fileUri) {
|
||||
return (
|
||||
<WebView
|
||||
@@ -230,7 +288,7 @@ export function AttachmentPreviewModal({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.'));
|
||||
return renderFallback(t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."));
|
||||
case 'text':
|
||||
return (
|
||||
<ScrollView style={styles.flex} contentContainerStyle={styles.textScroll}>
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from '../../lib/file-preview';
|
||||
import type { PreviewKind } from '../../lib/file-preview';
|
||||
import { markdownToHtml } from '../../lib/markdown-html';
|
||||
import { imageViewerHtml, mediaViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
|
||||
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
|
||||
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { PreviewItem } from './AttachmentPreviewModal';
|
||||
@@ -74,16 +75,22 @@ function writeViewer(local: File, html: string): string {
|
||||
return page.uri;
|
||||
}
|
||||
|
||||
interface Notices {
|
||||
tooLarge: string;
|
||||
pdfProtected: string;
|
||||
pdfUnreadable: string;
|
||||
}
|
||||
|
||||
async function buildItem(
|
||||
kind: Exclude<PreviewKind, 'none'>,
|
||||
source: PreviewSource,
|
||||
local: File,
|
||||
tooLarge: string,
|
||||
notices: Notices,
|
||||
colors: ViewerColors,
|
||||
): Promise<PreviewItem> {
|
||||
const base = { name: source.name, mimeType: source.type };
|
||||
// The source may not know its size; the copy does.
|
||||
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: tooLarge };
|
||||
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge };
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { ...base, kind, text: await local.text() };
|
||||
@@ -98,6 +105,17 @@ async function buildItem(
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) };
|
||||
case 'pdf': {
|
||||
// iOS's WebView shows the PDF itself; Android renders its pages.
|
||||
if (Platform.OS !== 'android') return { ...base, kind, fileUri: local.uri };
|
||||
try {
|
||||
const pages = await openPdfPages(local.uri);
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, pdfViewerHtml(local.name, pages, colors)) };
|
||||
} catch (e) {
|
||||
const protectedPdf = e instanceof PdfOpenError && e.reason === 'protected';
|
||||
return { ...base, kind: 'none', notice: protectedPdf ? notices.pdfProtected : notices.pdfUnreadable };
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { ...base, kind, fileUri: local.uri };
|
||||
}
|
||||
@@ -141,7 +159,9 @@ export function useFilePreview(opts: {
|
||||
seq.current += 1;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
if (!open) return;
|
||||
closePdfPages(open.file.uri);
|
||||
if (!open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => end, [end]);
|
||||
|
||||
@@ -181,9 +201,14 @@ export function useFilePreview(opts: {
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await buildItem(
|
||||
kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'), colors.current,
|
||||
);
|
||||
const next = await buildItem(kind, src, local, {
|
||||
tooLarge: t('files.preview_too_large', 'This file is too large to preview.'),
|
||||
pdfProtected: t(
|
||||
'email_viewer.preview.pdf_protected',
|
||||
'This PDF is password-protected. Open it in another app to enter the password.',
|
||||
),
|
||||
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
|
||||
}, colors.current);
|
||||
if (current()) setItem(next);
|
||||
} catch (e) {
|
||||
if (!current()) return;
|
||||
|
||||
@@ -37,10 +37,11 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown');
|
||||
});
|
||||
|
||||
it('shows PDFs in-app only where the platform renders them', () => {
|
||||
it('shows PDFs in-app on both platforms', () => {
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'REPORT.PDF', type: '' }, ANDROID)).toBe('pdf');
|
||||
});
|
||||
|
||||
it('plays the video formats each platform plays', () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { openPdfPages, PdfOpenError, renderPdfPage, canRenderPdfPages } from '../pdf-pages';
|
||||
|
||||
const modules = NativeModules as Record<string, unknown>;
|
||||
|
||||
function nativeError(code: string, message: string): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
describe('pdf-pages', () => {
|
||||
afterEach(() => { delete modules.BulwarkPdf; });
|
||||
|
||||
it('reports the page sizes the renderer returns', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [{ width: 595, height: 842 }] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).resolves.toEqual([{ width: 595, height: 842 }]);
|
||||
});
|
||||
|
||||
it('tells a password-protected PDF from an unreadable one', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_PROTECTED', 'password'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'protected' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_OPEN', 'not a PDF'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toBeInstanceOf(PdfOpenError);
|
||||
});
|
||||
|
||||
it('fails cleanly in a build without the renderer', async () => {
|
||||
expect(canRenderPdfPages()).toBe(false);
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
await expect(renderPdfPage('file:///x.pdf', 0, 100, 'file:///p.png')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('renders at a whole pixel width', async () => {
|
||||
const renderPage = vi.fn(async () => 'file:///p.png');
|
||||
modules.BulwarkPdf = { renderPage };
|
||||
await renderPdfPage('file:///x.pdf', 2, 1620.6, 'file:///p.png');
|
||||
expect(renderPage).toHaveBeenCalledWith('file:///x.pdf', 2, 1621, 'file:///p.png');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export interface FilePreviewOptions {
|
||||
/**
|
||||
* The platform's viewers decide what plays: video and audio play in the
|
||||
* system WebView (Chromium on Android, WebKit on iOS), whose formats
|
||||
* differ, and only iOS renders PDFs in-app.
|
||||
* differ.
|
||||
*/
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
@@ -139,9 +139,8 @@ export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, op
|
||||
|| (ios && (IOS_IMAGE_MIME.has(mime) || IOS_IMAGE_EXT.has(ext)));
|
||||
return shown ? 'image' : 'none';
|
||||
}
|
||||
if (mime === 'application/pdf' || (!media && ext === 'pdf')) {
|
||||
return opts.platform === 'ios' ? 'pdf' : 'none';
|
||||
}
|
||||
// iOS's WebView shows PDFs; Android renders their pages (pdf-pages.ts).
|
||||
if (mime === 'application/pdf' || (!media && ext === 'pdf')) return 'pdf';
|
||||
for (const kind of ['video', 'audio'] as const) {
|
||||
const formats = MEDIA[kind][opts.platform];
|
||||
if (mime.startsWith(`${kind}/`)) return formats.mime.has(mime) || formats.ext.has(ext) ? kind : 'none';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Android's PDF pages for the file preview, rendered by BulwarkPdfModule
|
||||
// (the platform's PdfRenderer) into PNGs next to the PDF. iOS needs none of
|
||||
// this: its WebView shows PDFs itself.
|
||||
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
export interface PdfPageSize {
|
||||
/** In points. */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface NativePdf {
|
||||
open(fileUri: string): Promise<{ pages: PdfPageSize[] }>;
|
||||
renderPage(fileUri: string, index: number, width: number, outUri: string): Promise<string>;
|
||||
close(fileUri: string): Promise<void>;
|
||||
}
|
||||
|
||||
function native(): NativePdf | null {
|
||||
if (Platform.OS !== 'android') return null;
|
||||
return (NativeModules.BulwarkPdf as NativePdf | undefined) ?? null;
|
||||
}
|
||||
|
||||
/** Whether this build renders PDFs in-app. */
|
||||
export function canRenderPdfPages(): boolean {
|
||||
return native() !== null;
|
||||
}
|
||||
|
||||
export type PdfOpenFailure = 'protected' | 'unreadable';
|
||||
|
||||
export class PdfOpenError extends Error {
|
||||
constructor(readonly reason: PdfOpenFailure, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pages of a PDF, or a {@link PdfOpenError} saying why it can't be shown. */
|
||||
export async function openPdfPages(fileUri: string): Promise<PdfPageSize[]> {
|
||||
const pdf = native();
|
||||
if (!pdf) throw new PdfOpenError('unreadable', 'PDF rendering is not available');
|
||||
try {
|
||||
const { pages } = await pdf.open(fileUri);
|
||||
if (!pages.length) throw new PdfOpenError('unreadable', 'The PDF has no pages');
|
||||
return pages;
|
||||
} catch (e) {
|
||||
if (e instanceof PdfOpenError) throw e;
|
||||
const code = (e as { code?: string } | null)?.code;
|
||||
throw new PdfOpenError(code === 'E_PDF_PROTECTED' ? 'protected' : 'unreadable', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Render page `index` (0-based) `width` pixels wide into `outUri`. */
|
||||
export function renderPdfPage(fileUri: string, index: number, width: number, outUri: string): Promise<string> {
|
||||
const pdf = native();
|
||||
if (!pdf) return Promise.reject(new Error('PDF rendering is not available'));
|
||||
return pdf.renderPage(fileUri, index, Math.round(width), outUri);
|
||||
}
|
||||
|
||||
/** Let go of the open document. */
|
||||
export function closePdfPages(fileUri: string): void {
|
||||
void native()?.close(fileUri).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Name of the image a page is rendered into, next to the viewer page. */
|
||||
export function pdfPageFileName(index: number): string {
|
||||
return `__page-${index + 1}.png`;
|
||||
}
|
||||
@@ -102,3 +102,41 @@ export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, color
|
||||
+ `<audio id="m" src="${src}" controls autoplay preload="auto"></audio></div>${script}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's PDF viewer: one placeholder per page at the page's proportions,
|
||||
* filled with an image the app renders when the page comes near the screen
|
||||
* (it posts `page:<index>` and the app answers by calling
|
||||
* `window.__pdfPage(index, src)`). The WebView zooms the whole document, and a
|
||||
* badge shows the page at the middle of the screen.
|
||||
*/
|
||||
export function pdfViewerHtml(
|
||||
fileName: string,
|
||||
pages: ReadonlyArray<{ width: number; height: number }>,
|
||||
colors: ViewerColors,
|
||||
): string {
|
||||
const sizes = JSON.stringify(pages.map((p) => [Math.max(1, Math.round(p.width)), Math.max(1, Math.round(p.height))]));
|
||||
return page(
|
||||
fileName,
|
||||
{ ...colors, background: colors.surface },
|
||||
'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=6,user-scalable=yes',
|
||||
'body{height:auto;min-height:100%}'
|
||||
+ '#doc{padding:8px 8px 56px;display:flex;flex-direction:column;align-items:center;gap:8px}'
|
||||
+ '.page{width:100%;max-width:1000px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);position:relative}'
|
||||
+ '.page img{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .15s}'
|
||||
+ '.page img.on{opacity:1}'
|
||||
+ `#badge{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:4px 12px;border-radius:999px;`
|
||||
+ `background:rgba(0,0,0,.65);color:#fff;font-size:13px;font-variant-numeric:tabular-nums;transition:opacity .3s}`
|
||||
+ '#badge.hide{opacity:0}',
|
||||
'<div id="doc"></div><div id="badge"></div>'
|
||||
+ `<script>${REPORT}(function(){var sizes=${sizes},doc=document.getElementById('doc'),badge=document.getElementById('badge'),asked={},els=[];`
|
||||
+ `sizes.forEach(function(s,i){var d=document.createElement('div');d.className='page';d.style.aspectRatio=s[0]+'/'+s[1];d.dataset.i=i;`
|
||||
+ `var img=document.createElement('img');img.alt='';img.onload=function(){img.className='on'};d.appendChild(img);doc.appendChild(d);els.push(d)});`
|
||||
+ `var io=new IntersectionObserver(function(es){es.forEach(function(e){var i=+e.target.dataset.i;if(e.isIntersecting&&!asked[i]){asked[i]=1;report('page:'+i)}})},{rootMargin:'100% 0px'});`
|
||||
+ `els.forEach(function(el){io.observe(el)});`
|
||||
+ `window.__pdfPage=function(i,src){var img=els[i]&&els[i].firstChild;if(img)img.src=src};`
|
||||
+ `var t;function where(){var mid=innerHeight/2,n=1;for(var i=0;i<els.length;i++){var r=els[i].getBoundingClientRect();if(r.top<=mid){n=i+1}else break}`
|
||||
+ `badge.textContent=n+' / '+els.length;badge.className='';clearTimeout(t);t=setTimeout(function(){badge.className='hide'},1200)}`
|
||||
+ `addEventListener('scroll',where,{passive:true});where();report('loaded')})();</script>`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user