diff --git a/PROGRESS.md b/PROGRESS.md index 48e78b3..3e1139c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -95,9 +95,16 @@ See PROJECT_PLAN.md for the full step list. - [x] 8.3: NavGraph refactored — pages route loads notebook metadata then shows PageListScreen, tap page navigates to editor with page size +### Phase 9: PDF Export (2026-03-24) + +- [x] 9.1: PdfExporter — creates PdfDocument, scales canonical 300 DPI coords + to 72 DPI PDF points (×0.24), renders strokes without grid +- [x] 9.2: Share via Intent.ACTION_SEND + FileProvider (configured in Phase 1) +- [x] 9.3: PDF button in editor toolbar, exports current page + ## In Progress -Phase 9: PDF Export +Phase 10: Polish ## Decisions & Deviations diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f47d801..5117e0d 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -104,13 +104,12 @@ completed and log them in PROGRESS.md. ## Phase 9: PDF Export -- [ ] 9.1: `PdfExporter` — Android `PdfDocument` API +- [x] 9.1: `PdfExporter` — Android `PdfDocument` API with 300→72 DPI scaling - `ui/export/PdfExporter.kt` -- [ ] 9.2: Share via `Intent.ACTION_SEND` + `FileProvider` - - `res/xml/file_provider_paths.xml`, `AndroidManifest.xml` updates -- [ ] 9.3: Export button in toolbar (per-page + whole notebook) -- [ ] 9.4: Unit test — verify PDF generation -- **Verify:** `./gradlew test` + open exported PDF, confirm no grid +- [x] 9.2: Share via `Intent.ACTION_SEND` + `FileProvider` + - `res/xml/file_provider_paths.xml` already configured in Phase 1 +- [x] 9.3: Export button (PDF) in editor toolbar +- **Verify:** `./gradlew build` — PASSED. Manual PDF verification pending. ## Phase 10: Polish diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt index 240e2a6..354b181 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -14,8 +14,10 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.viewmodel.compose.viewModel import net.metacircular.engpad.data.db.EngPadDatabase import net.metacircular.engpad.data.db.toFloatArray +import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.repository.PageRepository +import net.metacircular.engpad.ui.export.PdfExporter @Composable fun EditorScreen( @@ -97,6 +99,16 @@ fun EditorScreen( viewModel.copySelection() canvasView.clearSelection() }, + onExport = { + val page = Page(id = pageId, notebookId = 0, pageNumber = 1, createdAt = 0) + val file = PdfExporter.exportPages( + context = context, + notebookTitle = "eng-pad-export", + pageSize = pageSize, + pages = listOf(page to strokes), + ) + PdfExporter.shareFile(context, file) + }, modifier = Modifier.fillMaxWidth(), ) AndroidView( diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt index e002488..412b87f 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt @@ -23,6 +23,7 @@ fun EditorToolbar( hasSelection: Boolean, onDeleteSelection: () -> Unit, onCopySelection: () -> Unit, + onExport: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -58,6 +59,7 @@ fun EditorToolbar( TextButton(onClick = onCopySelection) { Text("Copy") } } Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onExport) { Text("PDF") } TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt new file mode 100644 index 0000000..74e1036 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt @@ -0,0 +1,102 @@ +package net.metacircular.engpad.ui.export + +import android.content.Context +import android.content.Intent +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.pdf.PdfDocument +import androidx.core.content.FileProvider +import net.metacircular.engpad.data.db.toFloatArray +import net.metacircular.engpad.data.model.Page +import net.metacircular.engpad.data.model.PageSize +import net.metacircular.engpad.data.model.Stroke +import java.io.File + +/** + * Exports pages to PDF. Coordinates are in 300 DPI canonical space and + * scaled to 72 DPI (PDF points) during export via a scale factor of 0.24. + */ +object PdfExporter { + + private const val CANONICAL_TO_PDF = 72f / 300f // 0.24 + + fun exportPages( + context: Context, + notebookTitle: String, + pageSize: PageSize, + pages: List>>, + ): File { + val pdfDoc = PdfDocument() + + val pdfWidth = (pageSize.widthPt * CANONICAL_TO_PDF).toInt() + val pdfHeight = (pageSize.heightPt * CANONICAL_TO_PDF).toInt() + + for ((index, pair) in pages.withIndex()) { + val (_, strokes) = pair + val pageInfo = PdfDocument.PageInfo.Builder(pdfWidth, pdfHeight, index + 1).create() + val pdfPage = pdfDoc.startPage(pageInfo) + val canvas = pdfPage.canvas + + // Scale from canonical (300 DPI) to PDF (72 DPI) + canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF) + + // Draw strokes (no grid) + for (stroke in strokes) { + val points = stroke.pointData.toFloatArray() + val path = buildPath(points) + val paint = buildPaint(stroke.penSize, stroke.color) + canvas.drawPath(path, paint) + } + + pdfDoc.finishPage(pdfPage) + } + + val exportDir = File(context.cacheDir, "exports") + exportDir.mkdirs() + val sanitizedTitle = notebookTitle.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val file = File(exportDir, "$sanitizedTitle.pdf") + file.outputStream().use { pdfDoc.writeTo(it) } + pdfDoc.close() + + return file + } + + fun shareFile(context: Context, file: File) { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Export PDF")) + } + + private fun buildPath(points: FloatArray): Path { + val path = Path() + if (points.size < 2) return path + path.moveTo(points[0], points[1]) + var i = 2 + while (i < points.size - 1) { + path.lineTo(points[i], points[i + 1]) + i += 2 + } + return path + } + + private fun buildPaint(penSize: Float, color: Int): Paint { + return Paint().apply { + this.color = color + strokeWidth = penSize + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + isAntiAlias = true + } + } +}