Skip to content

Commit 2c657ca

Browse files
committed
Natively support PDF export from desktop Storyboard
1 parent 13a6401 commit 2c657ca

9 files changed

Lines changed: 192 additions & 119 deletions

File tree

storyboard-easel/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ kotlin {
3838

3939
jvmMain {
4040
dependencies {
41+
implementation("io.github.vinceglb:filekit-compose:0.8.8")
4142
implementation("org.apache.pdfbox:pdfbox:3.0.1")
4243
}
4344
}

storyboard-easel/src/commonMain/kotlin/dev/bnorm/storyboard/easel/notes/StoryboardNotes.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ fun NotesTab(title: String, content: @Composable () -> Unit) {
3131
val storyboardNotes = LocalStoryboardNotes.current
3232
if (storyboardNotes != null) {
3333
val tabContent = rememberUpdatedState(content)
34-
DisposableEffect(tabContent) {
34+
DisposableEffect(title, tabContent) {
3535
val tab = StoryboardNotes.Tab(title, tabContent.value)
3636
storyboardNotes.addTab(tab)
3737
onDispose {

storyboard-easel/src/jvmMain/kotlin/dev/bnorm/storyboard/easel/DesktopStoryboard.kt

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
package dev.bnorm.storyboard.easel
22

3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material.MaterialTheme
36
import androidx.compose.runtime.Composable
47
import androidx.compose.runtime.CompositionLocalProvider
58
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.rememberCoroutineScope
10+
import androidx.compose.ui.Modifier
611
import androidx.compose.ui.input.key.Key
712
import androidx.compose.ui.input.key.KeyShortcut
8-
import androidx.compose.ui.window.ApplicationScope
9-
import androidx.compose.ui.window.MenuBarScope
10-
import androidx.compose.ui.window.Window
11-
import androidx.compose.ui.window.WindowPlacement
13+
import androidx.compose.ui.window.*
1214
import dev.bnorm.storyboard.core.Storyboard
15+
import dev.bnorm.storyboard.easel.export.ExportProgressPopup
16+
import dev.bnorm.storyboard.easel.export.StoryboardPdfExporter
1317
import dev.bnorm.storyboard.easel.notes.LocalStoryboardNotes
1418
import dev.bnorm.storyboard.easel.notes.StoryboardNotes
15-
import dev.bnorm.storyboard.easel.notes.StoryboardNotesWindow
19+
import kotlinx.coroutines.launch
1620

1721
@Composable
1822
fun ApplicationScope.DesktopStoryboard(storyboard: Storyboard) {
1923
val notes = remember { StoryboardNotes() }
2024
val state = rememberDesktopState(storyboard)
2125

26+
val coroutineScope = rememberCoroutineScope()
27+
val exporter = remember { StoryboardPdfExporter(storyboard) }
28+
2229
if (state == null) {
2330
// Need a window to keep the application from closing
2431
Window(onCloseRequest = ::exitApplication, visible = false) {}
@@ -44,11 +51,45 @@ fun ApplicationScope.DesktopStoryboard(storyboard: Storyboard) {
4451
notes.visible = it
4552
}
4653
}
54+
Menu("Export") {
55+
Item(
56+
text = "PDF",
57+
enabled = exporter.status == null,
58+
onClick = { coroutineScope.launch { exporter.export() } },
59+
)
60+
}
4761
}
4862

49-
CompositionLocalProvider(LocalStoryboardNotes provides notes) {
50-
StoryboardWindow(storyboard, { menuBar() }, state.storyboard)
63+
Window(
64+
onCloseRequest = ::exitApplication,
65+
state = state.storyboard,
66+
title = storyboard.title,
67+
) {
68+
MenuBar { menuBar() }
69+
70+
CompositionLocalProvider(LocalStoryboardNotes provides notes) {
71+
Storyboard(
72+
storyboard = storyboard,
73+
modifier = Modifier.fillMaxSize()
74+
.background(MaterialTheme.colors.background),
75+
)
76+
}
77+
78+
exporter.status?.let { ExportProgressPopup(it) }
5179
}
5280

53-
StoryboardNotesWindow(storyboard, notes, { menuBar() }, state.notes)
81+
if (notes.visible) {
82+
Window(
83+
onCloseRequest = { notes.visible = false },
84+
state = state.notes,
85+
title = "Notes",
86+
) {
87+
MenuBar { menuBar() }
88+
89+
StoryboardNotes(
90+
storyboard = storyboard,
91+
notes = notes,
92+
)
93+
}
94+
}
5495
}

storyboard-easel/src/jvmMain/kotlin/dev/bnorm/storyboard/easel/StoryboardWindow.kt

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package dev.bnorm.storyboard.easel.export
2+
3+
import androidx.compose.foundation.border
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Spacer
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.shape.RoundedCornerShape
8+
import androidx.compose.material.LinearProgressIndicator
9+
import androidx.compose.material.MaterialTheme
10+
import androidx.compose.material.Surface
11+
import androidx.compose.material.Text
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.ui.Alignment
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.unit.dp
16+
import androidx.compose.ui.window.Popup
17+
18+
@Composable
19+
fun ExportProgressPopup(status: ExportStatus) {
20+
Popup(alignment = Alignment.Center) {
21+
Surface(
22+
elevation = 8.dp,
23+
shape = RoundedCornerShape(8.dp),
24+
modifier = Modifier.border(2.dp, MaterialTheme.colors.primary, RoundedCornerShape(8.dp))
25+
) {
26+
Column(Modifier.padding(16.dp)) {
27+
Text(status.message)
28+
Spacer(Modifier.padding(4.dp))
29+
LinearProgressIndicator(status.progress)
30+
}
31+
}
32+
}
33+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package dev.bnorm.storyboard.easel.export
2+
3+
class ExportStatus(
4+
val progress: Float,
5+
val message: String,
6+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package dev.bnorm.storyboard.easel.export
2+
3+
import androidx.compose.runtime.getValue
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.setValue
6+
import androidx.compose.ui.renderComposeScene
7+
import dev.bnorm.storyboard.core.Storyboard
8+
import dev.bnorm.storyboard.ui.SlidePreview
9+
import io.github.vinceglb.filekit.core.FileKit
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.runInterruptible
12+
import kotlinx.coroutines.withContext
13+
import org.apache.pdfbox.pdmodel.PDDocument
14+
import org.apache.pdfbox.pdmodel.PDPage
15+
import org.apache.pdfbox.pdmodel.PDPageContentStream
16+
import org.apache.pdfbox.pdmodel.common.PDRectangle
17+
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject
18+
import org.jetbrains.skia.EncodedImageFormat
19+
import org.jetbrains.skia.Image
20+
import java.io.ByteArrayOutputStream
21+
import java.nio.file.Path
22+
import java.nio.file.StandardOpenOption
23+
import kotlin.io.path.writeBytes
24+
25+
class StoryboardPdfExporter(
26+
private val storyboard: Storyboard,
27+
) {
28+
var status by mutableStateOf<ExportStatus?>(null)
29+
private set
30+
31+
suspend fun export(
32+
width: Int = storyboard.size.width.value.toInt(),
33+
height: Int = storyboard.size.height.value.toInt(),
34+
) {
35+
val file = FileKit.saveFile(
36+
bytes = null,
37+
baseName = storyboard.title,
38+
extension = "pdf",
39+
initialDirectory = null,
40+
platformSettings = null,
41+
)
42+
if (file != null) {
43+
withContext(Dispatchers.IO) {
44+
runInterruptible {
45+
exportAsPdf(
46+
path = file.file.toPath(),
47+
width = width,
48+
height = height,
49+
)
50+
}
51+
}
52+
}
53+
}
54+
55+
private fun exportAsPdf(path: Path, width: Int, height: Int) {
56+
try {
57+
val doc = PDDocument()
58+
59+
val frames = storyboard.frames
60+
for ((page, frame) in frames.withIndex()) {
61+
status = ExportStatus(page.toFloat() / frames.size, "Generating PDF...")
62+
val image = renderComposeScene(width, height) {
63+
SlidePreview(storyboard, frame)
64+
}
65+
66+
createPage(image, page, doc, width, height)
67+
}
68+
69+
val bytes = ByteArrayOutputStream()
70+
doc.save(bytes)
71+
doc.close()
72+
73+
status = ExportStatus(1.0f, "Saving PDF...")
74+
path.writeBytes(
75+
array = bytes.toByteArray(),
76+
StandardOpenOption.CREATE,
77+
StandardOpenOption.WRITE,
78+
StandardOpenOption.TRUNCATE_EXISTING
79+
)
80+
} finally {
81+
status = null
82+
}
83+
}
84+
85+
private fun createPage(
86+
image: Image,
87+
index: Int,
88+
doc: PDDocument,
89+
width: Int,
90+
height: Int,
91+
) {
92+
val bytes = image.encodeToData(EncodedImageFormat.PNG)?.bytes
93+
val name = "slide-${index.toString().padStart(3, '0')}"
94+
95+
val page = PDPage(PDRectangle(width.toFloat(), height.toFloat()))
96+
doc.addPage(page)
97+
98+
val contentStream = PDPageContentStream(doc, page)
99+
contentStream.drawImage(PDImageXObject.createFromByteArray(doc, bytes, name), 0f, 0f)
100+
contentStream.close()
101+
}
102+
}

storyboard-easel/src/jvmMain/kotlin/dev/bnorm/storyboard/easel/export/pdf.kt

Lines changed: 0 additions & 52 deletions
This file was deleted.

storyboard-easel/src/jvmMain/kotlin/dev/bnorm/storyboard/easel/notes/StoryboardNotesWindow.kt

Lines changed: 0 additions & 28 deletions
This file was deleted.

0 commit comments

Comments
 (0)