diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt index f16d544..ab99822 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -29,7 +29,7 @@ class AppContainer(context: Context) { private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) } - private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context) } + private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context, photoStorage) } val patientRepository: PatientRepository by lazy { PatientRepository(patientDao = database.patientDao()) diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index 43db5cc..e6fb579 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.util.UUID +import kotlin.math.roundToInt /** * Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation: @@ -86,6 +87,27 @@ interface PhotoStorage { * wenn eine Datei nachträglich außerhalb der App entfernt wurde). */ suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? + + /** + * Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens + * [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus + * (PDF-004: Fotoanhang). Läuft zweistufig: + * + * 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize` + * (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so + * groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von + * `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit + * [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion + * bewirkt. + * 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße + * (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach + * freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch + * nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis + * aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet. + * + * Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz). + */ + suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? } class InternalPhotoStorage(private val context: Context) : PhotoStorage { @@ -212,4 +234,59 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage { ) }.getOrNull() } + + override suspend fun decodeFitted( + documentationId: String, + fileName: String, + maxWidthPx: Int, + maxHeightPx: Int, + ): Bitmap? = withContext(Dispatchers.IO) { + val file = resolve(documentationId, fileName) + if (!file.exists()) return@withContext null + + runCatching { + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, boundsOptions) + val sourceWidth = boundsOptions.outWidth + val sourceHeight = boundsOptions.outHeight + if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null + + // Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf + // maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus. + val scale = minOf( + maxWidthPx.toFloat() / sourceWidth, + maxHeightPx.toFloat() / sourceHeight, + 1f, + ) + val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1) + + // Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein + // Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist. + val inSampleSize = ImageDownsampler.computeInSampleSize( + sourceWidth, + sourceHeight, + maxOf(targetWidth, targetHeight), + ) + val decodeOptions = BitmapFactory.Options().apply { + this.inSampleSize = inSampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) + ?: return@runCatching null + + // Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht + // bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem + // Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt. + if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) { + coarseBitmap + } else { + val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true) + // Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an + // PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch. + if (precise !== coarseBitmap) coarseBitmap.recycle() + precise + } + }.getOrNull() + } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index f48e350..9812652 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -1,11 +1,13 @@ package de.medapp.wunddoku.pdf import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.pdf.PdfDocument import android.text.TextPaint +import de.medapp.wunddoku.media.PhotoStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -22,11 +24,30 @@ import java.time.format.FormatStyle * reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert * ist bzw. welche Unicode-Symbole sie abdeckt. * + * PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede + * `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP- + * Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`, + * `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung + * erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE + * über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()` + * gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer + * einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten + * Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block) + * freigegeben - siehe [retainedPhotoBitmaps]. + * + * Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung + * pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an + * `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der + * exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]), + * dürfen dagegen sofort freigegeben werden. + * * Speicherort: `filesDir/pdf//.pdf` - ausschließlich intern, keine - * MediaStore-Nutzung, kein öffentlicher Speicher (PDF-005/006 mit FileProvider/Sharesheet folgen - * später). + * MediaStore-Nutzung, kein öffentlicher Speicher. */ -class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPdfExporter { +class AndroidPdfDocumentExporter( + private val context: Context, + private val photoStorage: PhotoStorage, +) : DocumentationPdfExporter { companion object { // A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm. @@ -47,6 +68,32 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd private const val CHECKBOX_GAP = 6f private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP + /** + * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und + * Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`- + * Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich + * vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig + * im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit + * dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine + * Farbtiefenreduktion bei medizinischen Wundfotos). + */ + private const val PHOTO_TARGET_DPI = 150 + + // Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe + // Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese + // Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal + // eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der + // jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst + // anhand des tatsächlich verbleibenden Platzes bestimmt). + private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN + private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN + + /** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */ + private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt() + + /** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */ + private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt() + private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @@ -63,6 +110,12 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd val tempFile = File(targetDir, "${model.fileName}.tmp") val pdfDocument = PdfDocument() + // Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet + // wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von + // PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der + // Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig + // bleiben und werden deshalb erst im finally-Block unten recycelt. + val retainedPhotoBitmaps = mutableListOf() try { val paints = ExportPaints() val cursor = PdfPageCursor( @@ -79,6 +132,9 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd for (block in model.blocks) { drawBlock(cursor, block, paints) } + for ((index, photo) in model.photos.withIndex()) { + drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps) + } cursor.finish() FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) } @@ -99,6 +155,10 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd ) } finally { pdfDocument.close() + // Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder + // bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist - + // in beiden Fällen werden diese Bitmaps nicht mehr gebraucht. + retainedPhotoBitmaps.forEach { runCatching { it.recycle() } } } } @@ -151,6 +211,13 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd strokeCap = Paint.Cap.ROUND color = Color.BLACK } + + /** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für + * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ + val photo = Paint().apply { + isAntiAlias = true + isFilterBitmap = true + } } private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) { @@ -233,4 +300,49 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark) } } + + /** + * Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004). + * Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt + * zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder + * ursprünglichen Dateinamen als Beschriftung. + * + * Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung + * in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür + * verantwortlich, diese erst NACH `writeTo()` freizugeben. + */ + private suspend fun drawPhotoPage( + cursor: PdfPageCursor, + model: PdfExportModel, + photo: PdfExportPhoto, + index: Int, + paints: ExportPaints, + retainedPhotoBitmaps: MutableList, + ) { + cursor.forceNewPage() + if (index == 0) { + cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f) + } + cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f) + + val bitmap = runCatching { + photoStorage.decodeFitted( + documentationId = model.documentationId, + fileName = photo.fileName, + maxWidthPx = PHOTO_MAX_WIDTH_PX, + maxHeightPx = PHOTO_MAX_HEIGHT_PX, + ) + }.getOrNull() + + if (bitmap != null) { + retainedPhotoBitmaps.add(bitmap) + cursor.drawBitmapFitted(bitmap, paints.photo) + } else { + // Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B: + // neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar + // bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte. + // Bewusst OHNE Dateipfad/technischen Fehlertext. + cursor.drawText("Foto konnte nicht geladen werden.", paints.item) + } + } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt index d8cff06..000ab2f 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -48,6 +48,14 @@ object DocumentationToPdfExportModelMapper { birthDate = snapshot.birthDate, ) + // Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im + // Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie + // bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem- + // Reihenfolge verwenden". + val photos = documentation.photos + .sortedBy { it.orderIndex } + .map { photo -> PdfExportPhoto(fileName = photo.fileName) } + return PdfExportModel( documentationId = documentation.id, fileName = fileName, @@ -56,6 +64,7 @@ object DocumentationToPdfExportModelMapper { patientDisplayAddress = snapshot.displayAddress, createdAt = createdAtDate, blocks = editedBlocks, + photos = photos, ) } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt index 6079dec..8ee35ec 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -19,6 +19,8 @@ data class PdfExportModel( val createdAt: LocalDate, /** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */ val blocks: List, + /** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */ + val photos: List, ) data class PdfExportBlock( @@ -34,3 +36,12 @@ data class PdfExportItem( val isFreeText: Boolean, val freeTextValue: String, ) + +/** + * Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] - + * `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über + * [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen). + */ +data class PdfExportPhoto( + val fileName: String, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt index e745a7f..23257e2 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -1,8 +1,10 @@ package de.medapp.wunddoku.pdf +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.RectF import android.graphics.pdf.PdfDocument import android.text.Layout import android.text.StaticLayout @@ -51,6 +53,14 @@ internal class PdfPageCursor( val contentWidth: Int get() = (pageWidth - marginLeft - marginRight).toInt() + /** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004: + * Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */ + val remainingHeight: Float + get() { + startIfNeeded() + return (contentBottom() - cursorY).coerceAtLeast(0f) + } + private fun contentBottom(): Float = pageHeight - marginBottom private fun startIfNeeded() { @@ -77,6 +87,42 @@ internal class PdfPageCursor( canvas = null } + /** + * Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen + * Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen + * Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die + * Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich + * `pageNumber` erhöht, nie zurücksetzt. + */ + fun forceNewPage() { + startNewPage() + } + + /** + * Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal + * über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der + * Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach + * [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode + * selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden + * darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest + * hineinskaliert (kleiner, nie abgeschnitten). + */ + fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) { + startIfNeeded() + val availableWidth = contentWidth.toFloat() + val availableHeight = remainingHeight + if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return + + val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) + val drawWidth = bitmap.width * scale + val drawHeight = bitmap.height * scale + val left = marginLeft + (availableWidth - drawWidth) / 2f + val top = cursorY + (availableHeight - drawHeight) / 2f + + canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) + cursorY = contentBottom() + } + /** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal * aufgerufen werden. */ fun finish() {