Skip to content

Commit c1166c3

Browse files
committed
Add a thin wrapper around Skia's PDF backend
1 parent eb1f04e commit c1166c3

File tree

11 files changed

+521
-0
lines changed

11 files changed

+521
-0
lines changed

skiko/buildSrc/src/main/kotlin/tasks/configuration/CommonTasksConfiguration.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ fun skiaHeadersDirs(skiaDir: File): List<File> =
2727
skiaDir.resolve("include/utils"),
2828
skiaDir.resolve("include/codec"),
2929
skiaDir.resolve("include/svg"),
30+
skiaDir.resolve("include/docs"),
3031
skiaDir.resolve("modules/skottie/include"),
3132
skiaDir.resolve("modules/skparagraph/include"),
3233
skiaDir.resolve("modules/skshaper/include"),
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.jetbrains.skia
2+
3+
import org.jetbrains.skia.impl.*
4+
import org.jetbrains.skia.impl.Library.Companion.staticLoad
5+
6+
/**
7+
* High-level API for creating a document-based canvas. To use:
8+
*
9+
* 1. Create a document, e.g., via `PDFDocument.make(...)`.
10+
* 2. For each page of content:
11+
* ```
12+
* canvas = doc.beginPage(...)
13+
* drawMyContent(canvas)
14+
* doc.endPage()
15+
* ```
16+
* 3. Close the document with `doc.close()`.
17+
*/
18+
class Document internal constructor(ptr: NativePointer, internal val _owner: Any) : RefCnt(ptr) {
19+
20+
companion object {
21+
init {
22+
staticLoad()
23+
}
24+
}
25+
26+
/**
27+
* Begins a new page for the document, returning the canvas that will draw
28+
* into the page. The document owns this canvas, and it will go out of
29+
* scope when endPage() or close() is called, or the document is deleted.
30+
* This will call endPage() if there is a currently active page.
31+
*
32+
* @throws IllegalArgumentException If no page can be created with the supplied arguments.
33+
*/
34+
fun beginPage(width: Float, height: Float, content: Rect? = null): Canvas {
35+
Stats.onNativeCall()
36+
try {
37+
val ptr = interopScope {
38+
_nBeginPage(_ptr, width, height, toInterop(content?.serializeToFloatArray()))
39+
}
40+
require(ptr != NullPointer) { "Document page was created with invalid arguments." }
41+
return Canvas(ptr, false, this)
42+
} finally {
43+
reachabilityBarrier(this)
44+
}
45+
}
46+
47+
/**
48+
* Call endPage() when the content for the current page has been drawn
49+
* (into the canvas returned by beginPage()). After this call the canvas
50+
* returned by beginPage() will be out-of-scope.
51+
*/
52+
fun endPage() {
53+
Stats.onNativeCall()
54+
try {
55+
_nEndPage(_ptr)
56+
} finally {
57+
reachabilityBarrier(this)
58+
}
59+
}
60+
61+
/**
62+
* Call close() when all pages have been drawn. This will close the file
63+
* or stream holding the document's contents. After close() the document
64+
* can no longer add new pages. Deleting the document will automatically
65+
* call close() if need be.
66+
*/
67+
override fun close() {
68+
// Deleting the document (which super.close() does) will automatically invoke SkDocument::close.
69+
super.close()
70+
}
71+
72+
}
73+
74+
@ExternalSymbolName("org_jetbrains_skia_Document__1nBeginPage")
75+
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_Document__1nBeginPage")
76+
private external fun _nBeginPage(
77+
ptr: NativePointer, width: Float, height: Float, content: InteropPointer
78+
): NativePointer
79+
80+
@ExternalSymbolName("org_jetbrains_skia_Document__1nEndPage")
81+
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_Document__1nEndPage")
82+
private external fun _nEndPage(ptr: NativePointer)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.jetbrains.skia.pdf
2+
3+
enum class PDFCompressionLevel(internal val skiaRepresentation: Int) {
4+
DEFAULT(-1),
5+
NONE(0),
6+
LOW_BUT_FAST(1),
7+
AVERAGE(6),
8+
HIGH_BUT_SLOW(9);
9+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.jetbrains.skia.pdf
2+
3+
/**
4+
* @property year Year, e.g., 2025.
5+
* @property month Month between 1 and 12.
6+
* @property day Day between 1 and 31.
7+
* @property hour Hour between 0 and 23.
8+
* @property minute Minute between 0 and 59.
9+
* @property second Second between 0 and 59.
10+
* @property timeZoneMinutes The number of minutes that the time zone is ahead of or behind UTC.
11+
*/
12+
data class PDFDateTime(
13+
val year: Int,
14+
val month: Int,
15+
// Notice that we have omitted the dayOfWeek field here, as it is unused in Skia's PDF backend.
16+
val day: Int,
17+
val hour: Int,
18+
val minute: Int,
19+
val second: Int,
20+
val timeZoneMinutes: Int = 0
21+
) {
22+
23+
init {
24+
require(month in 1..12) { "Month must be between 1 and 12." }
25+
require(day in 1..31) { "Day must be between 1 and 31." }
26+
require(hour in 0..23) { "Hour must be between 0 and 23." }
27+
require(minute in 0..59) { "Minute must be between 0 and 59." }
28+
require(second in 0..59) { "Second must be between 0 and 59." }
29+
}
30+
31+
internal fun asArray() = intArrayOf(year, month, day, hour, minute, second, timeZoneMinutes)
32+
33+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package org.jetbrains.skia.pdf
2+
3+
import org.jetbrains.skia.Document
4+
import org.jetbrains.skia.ExternalSymbolName
5+
import org.jetbrains.skia.ModuleImport
6+
import org.jetbrains.skia.WStream
7+
import org.jetbrains.skia.impl.*
8+
import org.jetbrains.skia.impl.Library.Companion.staticLoad
9+
import org.jetbrains.skia.impl.Native.Companion.NullPointer
10+
11+
object PDFDocument {
12+
13+
init {
14+
staticLoad()
15+
}
16+
17+
/**
18+
* Creates a PDF-backed document, writing the results into a WStream.
19+
*
20+
* PDF pages are sized in point units. 1 pt == 1/72 inch == 127/360 mm.
21+
*
22+
* @param out A PDF document will be written to this stream. The document may write
23+
* to the stream at anytime during its lifetime, until either close() is
24+
* called or the document is deleted.
25+
* @param metadata A PDFMetadata object. Any fields may be left empty.
26+
* @throws IllegalArgumentException If no PDF document can be created with the supplied arguments.
27+
*/
28+
fun make(out: WStream, metadata: PDFMetadata = PDFMetadata()): Document {
29+
Stats.onNativeCall()
30+
val ptr = try {
31+
interopScope {
32+
_nMakeDocument(
33+
getPtr(out),
34+
toInterop(metadata.title),
35+
toInterop(metadata.author),
36+
toInterop(metadata.subject),
37+
toInterop(metadata.keywords),
38+
toInterop(metadata.creator),
39+
toInterop(metadata.producer),
40+
toInterop(metadata.creation?.asArray()),
41+
toInterop(metadata.modified?.asArray()),
42+
toInterop(metadata.lang),
43+
metadata.rasterDPI,
44+
metadata.pdfA,
45+
metadata.encodingQuality,
46+
metadata.compressionLevel.skiaRepresentation
47+
)
48+
}
49+
} finally {
50+
reachabilityBarrier(out)
51+
}
52+
require(ptr != NullPointer) { "PDF document was created with invalid arguments." }
53+
return Document(ptr, out)
54+
}
55+
56+
}
57+
58+
@ExternalSymbolName("org_jetbrains_skia_pdf_PDFDocument__1nMakeDocument")
59+
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_pdf_PDFDocument__1nMakeDocument")
60+
private external fun _nMakeDocument(
61+
wstreamPtr: NativePointer,
62+
title: InteropPointer,
63+
author: InteropPointer,
64+
subject: InteropPointer,
65+
keywords: InteropPointer,
66+
creator: InteropPointer,
67+
producer: InteropPointer,
68+
creation: InteropPointer,
69+
modified: InteropPointer,
70+
lang: InteropPointer,
71+
rasterDPI: Float,
72+
pdfA: Boolean,
73+
encodingQuality: Int,
74+
compressionLevel: Int
75+
): NativePointer
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package org.jetbrains.skia.pdf
2+
3+
/**
4+
* Optional metadata to be passed into the PDF factory function.
5+
*
6+
* @property title The document's title.
7+
* @property author The name of the person who created the document.
8+
* @property subject The subject of the document.
9+
* @property keywords Keywords associated with the document.
10+
* Commas may be used to delineate keywords within the string.
11+
* @property creator If the document was converted to PDF from another format,
12+
* the name of the conforming product that created the
13+
* original document from which it was converted.
14+
* @property producer The product that is converting this document to PDF.
15+
* @property creation The date and time the document was created.
16+
* The zero default value represents an unknown/unset time.
17+
* @property modified The date and time the document was most recently modified.
18+
* The zero default value represents an unknown/unset time.
19+
* @property lang The natural language of the text in the PDF.
20+
* @property rasterDPI The DPI (pixels-per-inch) at which features without native PDF support
21+
* will be rasterized (e.g. draw image with perspective, draw text with
22+
* perspective, ...). A larger DPI would create a PDF that reflects the
23+
* original intent with better fidelity, but it can make for larger PDF
24+
* files too, which would use more memory while rendering, and it would be
25+
* slower to be processed or sent online or to printer.
26+
* @property pdfA If true, include XMP metadata, a document UUID, and sRGB output intent
27+
* information. This adds length to the document and makes it
28+
* non-reproducible, but are necessary features for PDF/A-2b conformance
29+
* @property encodingQuality Encoding quality controls the trade-off between size and quality. By
30+
* default this is set to 101 percent, which corresponds to lossless
31+
* encoding. If this value is set to a value <= 100, and the image is
32+
* opaque, it will be encoded (using JPEG) with that quality setting.
33+
* @property compressionLevel PDF streams may be compressed to save space.
34+
* Use this to specify the desired compression vs time tradeoff.
35+
*/
36+
data class PDFMetadata(
37+
val title: String? = null,
38+
val author: String? = null,
39+
val subject: String? = null,
40+
val keywords: String? = null,
41+
val creator: String? = null,
42+
val producer: String? = "Skia/PDF",
43+
val creation: PDFDateTime? = null,
44+
val modified: PDFDateTime? = null,
45+
val lang: String? = null,
46+
val rasterDPI: Float = 72f,
47+
val pdfA: Boolean = false,
48+
val encodingQuality: Int = 101,
49+
val compressionLevel: PDFCompressionLevel = PDFCompressionLevel.DEFAULT
50+
)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package org.jetbrains.skia.pdf
2+
3+
import org.jetbrains.skia.Color
4+
import org.jetbrains.skia.OutputWStream
5+
import org.jetbrains.skia.Paint
6+
import org.jetbrains.skia.Rect
7+
import java.io.ByteArrayOutputStream
8+
import kotlin.test.*
9+
10+
class PDFDocumentTest {
11+
12+
@Test
13+
fun makeWithNullMetadata() {
14+
val metadata = PDFMetadata(producer = null, compressionLevel = PDFCompressionLevel.NONE)
15+
val baos = ByteArrayOutputStream()
16+
PDFDocument.make(OutputWStream(baos), metadata).use { doc ->
17+
assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.")
18+
doc.endPage()
19+
}
20+
val pdf = baos.toString(Charsets.UTF_8)
21+
assertDoesNotContain(pdf, "/Title")
22+
assertDoesNotContain(pdf, "/Author")
23+
assertDoesNotContain(pdf, "/Subject")
24+
assertDoesNotContain(pdf, "/Keywords")
25+
assertDoesNotContain(pdf, "/Creator")
26+
assertDoesNotContain(pdf, "/Producer")
27+
assertDoesNotContain(pdf, "/CreationDate")
28+
assertDoesNotContain(pdf, "/ModDate")
29+
assertDoesNotContain(pdf, "/Lang")
30+
}
31+
32+
@Test
33+
fun makeWithNonNullMetadata() {
34+
val metadata = PDFMetadata(
35+
title = "My Novel",
36+
author = "Johann Wolfgang von Goethe",
37+
subject = "Literature",
38+
keywords = "Some,Important,Keywords",
39+
creator = "Skiko Test Suite",
40+
producer = "Skia",
41+
creation = PDFDateTime(2023, 7, 26, 13, 37, 42),
42+
modified = PDFDateTime(2024, 5, 12, 10, 20, 30, 150),
43+
lang = "de-DE",
44+
compressionLevel = PDFCompressionLevel.NONE
45+
)
46+
val baos = ByteArrayOutputStream()
47+
PDFDocument.make(OutputWStream(baos), metadata).use { doc ->
48+
assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.")
49+
doc.endPage()
50+
}
51+
val pdf = baos.toString(Charsets.UTF_8)
52+
assertContains(pdf, "/Title (${metadata.title})")
53+
assertContains(pdf, "/Author (${metadata.author})")
54+
assertContains(pdf, "/Subject (${metadata.subject})")
55+
assertContains(pdf, "/Keywords (${metadata.keywords})")
56+
assertContains(pdf, "/Creator (${metadata.creator})")
57+
assertContains(pdf, "/Producer (${metadata.producer})")
58+
assertContains(pdf, "/CreationDate (D:20230726133742+00'00')")
59+
assertContains(pdf, "/ModDate (D:20240512102030+02'30')")
60+
assertContains(pdf, "/Lang (${metadata.lang})")
61+
}
62+
63+
@Test
64+
fun draw() {
65+
val metadata = PDFMetadata(compressionLevel = PDFCompressionLevel.NONE)
66+
val baos = ByteArrayOutputStream()
67+
PDFDocument.make(OutputWStream(baos), metadata).use { doc ->
68+
val canvas = assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.")
69+
canvas.drawRect(Rect(10f, 20f, 35f, 50f), Paint().apply { color = Color.RED })
70+
doc.endPage()
71+
}
72+
val pdf = baos.toString(Charsets.UTF_8)
73+
assertContains(pdf, "/MediaBox [0 0 100 250]")
74+
// Assert that the PDF contains some operations we would expect for our red rect drawing operation.
75+
assertContains(pdf, "1 0 0 rg")
76+
assertContains(pdf, "10 20 25 30 re")
77+
}
78+
79+
@Test
80+
fun drawWithContentRect() {
81+
val metadata = PDFMetadata(compressionLevel = PDFCompressionLevel.NONE)
82+
val baos = ByteArrayOutputStream()
83+
PDFDocument.make(OutputWStream(baos), metadata).use { doc ->
84+
val canvas = assertNotNull(doc.beginPage(100f, 250f, Rect(60f, 40f, 90f, 220f)), "Canvas is null.")
85+
canvas.drawRect(Rect(10f, 20f, 35f, 50f), Paint().apply { color = Color.RED })
86+
doc.endPage()
87+
}
88+
val pdf = baos.toString(Charsets.UTF_8)
89+
// Assert that the PDF contains the content rect somewhere.
90+
assertContains(pdf, "60 40 30 180 re")
91+
}
92+
93+
@Test
94+
fun beginInvalidPage() {
95+
val doc = PDFDocument.make(OutputWStream(ByteArrayOutputStream()))
96+
assertFailsWith<IllegalArgumentException> {
97+
doc.beginPage(-10f, -20f)
98+
}
99+
}
100+
101+
private fun assertDoesNotContain(charSequence: CharSequence, other: CharSequence) {
102+
assertTrue(
103+
other !in charSequence,
104+
"Expected the char sequence to not contain the substring.\n" +
105+
"CharSequence <$charSequence>, substring <$other>."
106+
)
107+
}
108+
109+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#include <jni.h>
2+
#include "SkDocument.h"
3+
#include "interop.hh"
4+
5+
extern "C" JNIEXPORT jlong JNICALL Java_org_jetbrains_skia_DocumentKt__1nBeginPage
6+
(JNIEnv* env, jclass jclass, jlong ptr, jfloat width, jfloat height, jfloatArray jcontentArr) {
7+
SkDocument* instance = reinterpret_cast<SkDocument*>(static_cast<uintptr_t>(ptr));
8+
jfloat* contentArr;
9+
SkRect content;
10+
SkRect* contentPtr = nullptr;
11+
if (jcontentArr != nullptr) {
12+
contentArr = env->GetFloatArrayElements(jcontentArr, 0);
13+
content = { contentArr[0], contentArr[1], contentArr[2], contentArr[3] };
14+
contentPtr = &content;
15+
}
16+
SkCanvas* canvas = instance->beginPage(width, height, contentPtr);
17+
if (jcontentArr != nullptr)
18+
env->ReleaseFloatArrayElements(jcontentArr, contentArr, 0);
19+
return reinterpret_cast<jlong>(canvas);
20+
}
21+
22+
extern "C" JNIEXPORT void JNICALL Java_org_jetbrains_skia_DocumentKt__1nEndPage
23+
(JNIEnv* env, jclass jclass, jlong ptr) {
24+
SkDocument* instance = reinterpret_cast<SkDocument*>(static_cast<uintptr_t>(ptr));
25+
instance->endPage();
26+
}

0 commit comments

Comments
 (0)