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 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())
@@ -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()
}
}
@@ -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/<documentationId>/<Dateiname>.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<Bitmap>()
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<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,
)
// 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,
)
}
}
@@ -19,6 +19,8 @@ data class PdfExportModel(
val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>,
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>,
)
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,
)
@@ -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() {