Beta 1.0 Release #11
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user