This commit is contained in:
2026-08-27 08:10:18 +02:00
parent 8c05a513fe
commit 91e68c827a
6 changed files with 259 additions and 4 deletions
@@ -29,7 +29,7 @@ class AppContainer(context: Context) {
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(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 { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt
/** /**
* Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation: * 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). * wenn eine Datei nachträglich außerhalb der App entfernt wurde).
*/ */
suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? 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 { class InternalPhotoStorage(private val context: Context) : PhotoStorage {
@@ -212,4 +234,59 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
) )
}.getOrNull() }.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()
}
} }
@@ -1,11 +1,13 @@
package de.medapp.wunddoku.pdf package de.medapp.wunddoku.pdf
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.media.PhotoStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File 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 * reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
* ist bzw. welche Unicode-Symbole sie abdeckt. * 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/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine * Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
* MediaStore-Nutzung, kein öffentlicher Speicher (PDF-005/006 mit FileProvider/Sharesheet folgen * MediaStore-Nutzung, kein öffentlicher Speicher.
* später).
*/ */
class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPdfExporter { class AndroidPdfDocumentExporter(
private val context: Context,
private val photoStorage: PhotoStorage,
) : DocumentationPdfExporter {
companion object { companion object {
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm. // 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_GAP = 6f
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP 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) 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 tempFile = File(targetDir, "${model.fileName}.tmp")
val pdfDocument = PdfDocument() 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<Bitmap>()
try { try {
val paints = ExportPaints() val paints = ExportPaints()
val cursor = PdfPageCursor( val cursor = PdfPageCursor(
@@ -79,6 +132,9 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd
for (block in model.blocks) { for (block in model.blocks) {
drawBlock(cursor, block, paints) drawBlock(cursor, block, paints)
} }
for ((index, photo) in model.photos.withIndex()) {
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
}
cursor.finish() cursor.finish()
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) } FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
@@ -99,6 +155,10 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd
) )
} finally { } finally {
pdfDocument.close() 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 strokeCap = Paint.Cap.ROUND
color = Color.BLACK 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) { 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) 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<Bitmap>,
) {
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)
}
}
} }
@@ -48,6 +48,14 @@ object DocumentationToPdfExportModelMapper {
birthDate = snapshot.birthDate, 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( return PdfExportModel(
documentationId = documentation.id, documentationId = documentation.id,
fileName = fileName, fileName = fileName,
@@ -56,6 +64,7 @@ object DocumentationToPdfExportModelMapper {
patientDisplayAddress = snapshot.displayAddress, patientDisplayAddress = snapshot.displayAddress,
createdAt = createdAtDate, createdAt = createdAtDate,
blocks = editedBlocks, blocks = editedBlocks,
photos = photos,
) )
} }
} }
@@ -19,6 +19,8 @@ data class PdfExportModel(
val createdAt: LocalDate, val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */ /** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>, val blocks: List<PdfExportBlock>,
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>,
) )
data class PdfExportBlock( data class PdfExportBlock(
@@ -34,3 +36,12 @@ data class PdfExportItem(
val isFreeText: Boolean, val isFreeText: Boolean,
val freeTextValue: String, 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,
)
@@ -1,8 +1,10 @@
package de.medapp.wunddoku.pdf package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.Layout import android.text.Layout
import android.text.StaticLayout import android.text.StaticLayout
@@ -51,6 +53,14 @@ internal class PdfPageCursor(
val contentWidth: Int val contentWidth: Int
get() = (pageWidth - marginLeft - marginRight).toInt() 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 contentBottom(): Float = pageHeight - marginBottom
private fun startIfNeeded() { private fun startIfNeeded() {
@@ -77,6 +87,42 @@ internal class PdfPageCursor(
canvas = null 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 /** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
* aufgerufen werden. */ * aufgerufen werden. */
fun finish() { fun finish() {