-
Notifications
You must be signed in to change notification settings - Fork 128
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a thin wrapper around Skia's PDF backend
- Loading branch information
1 parent
eb1f04e
commit c1166c3
Showing
11 changed files
with
521 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
82 changes: 82 additions & 0 deletions
82
skiko/src/commonMain/kotlin/org/jetbrains/skia/Document.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package org.jetbrains.skia | ||
|
||
import org.jetbrains.skia.impl.* | ||
import org.jetbrains.skia.impl.Library.Companion.staticLoad | ||
|
||
/** | ||
* High-level API for creating a document-based canvas. To use: | ||
* | ||
* 1. Create a document, e.g., via `PDFDocument.make(...)`. | ||
* 2. For each page of content: | ||
* ``` | ||
* canvas = doc.beginPage(...) | ||
* drawMyContent(canvas) | ||
* doc.endPage() | ||
* ``` | ||
* 3. Close the document with `doc.close()`. | ||
*/ | ||
class Document internal constructor(ptr: NativePointer, internal val _owner: Any) : RefCnt(ptr) { | ||
|
||
companion object { | ||
init { | ||
staticLoad() | ||
} | ||
} | ||
|
||
/** | ||
* Begins a new page for the document, returning the canvas that will draw | ||
* into the page. The document owns this canvas, and it will go out of | ||
* scope when endPage() or close() is called, or the document is deleted. | ||
* This will call endPage() if there is a currently active page. | ||
* | ||
* @throws IllegalArgumentException If no page can be created with the supplied arguments. | ||
*/ | ||
fun beginPage(width: Float, height: Float, content: Rect? = null): Canvas { | ||
Stats.onNativeCall() | ||
try { | ||
val ptr = interopScope { | ||
_nBeginPage(_ptr, width, height, toInterop(content?.serializeToFloatArray())) | ||
} | ||
require(ptr != NullPointer) { "Document page was created with invalid arguments." } | ||
return Canvas(ptr, false, this) | ||
} finally { | ||
reachabilityBarrier(this) | ||
} | ||
} | ||
|
||
/** | ||
* Call endPage() when the content for the current page has been drawn | ||
* (into the canvas returned by beginPage()). After this call the canvas | ||
* returned by beginPage() will be out-of-scope. | ||
*/ | ||
fun endPage() { | ||
Stats.onNativeCall() | ||
try { | ||
_nEndPage(_ptr) | ||
} finally { | ||
reachabilityBarrier(this) | ||
} | ||
} | ||
|
||
/** | ||
* Call close() when all pages have been drawn. This will close the file | ||
* or stream holding the document's contents. After close() the document | ||
* can no longer add new pages. Deleting the document will automatically | ||
* call close() if need be. | ||
*/ | ||
override fun close() { | ||
// Deleting the document (which super.close() does) will automatically invoke SkDocument::close. | ||
super.close() | ||
} | ||
|
||
} | ||
|
||
@ExternalSymbolName("org_jetbrains_skia_Document__1nBeginPage") | ||
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_Document__1nBeginPage") | ||
private external fun _nBeginPage( | ||
ptr: NativePointer, width: Float, height: Float, content: InteropPointer | ||
): NativePointer | ||
|
||
@ExternalSymbolName("org_jetbrains_skia_Document__1nEndPage") | ||
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_Document__1nEndPage") | ||
private external fun _nEndPage(ptr: NativePointer) |
9 changes: 9 additions & 0 deletions
9
skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFCompressionLevel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package org.jetbrains.skia.pdf | ||
|
||
enum class PDFCompressionLevel(internal val skiaRepresentation: Int) { | ||
DEFAULT(-1), | ||
NONE(0), | ||
LOW_BUT_FAST(1), | ||
AVERAGE(6), | ||
HIGH_BUT_SLOW(9); | ||
} |
33 changes: 33 additions & 0 deletions
33
skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFDateTime.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package org.jetbrains.skia.pdf | ||
|
||
/** | ||
* @property year Year, e.g., 2025. | ||
* @property month Month between 1 and 12. | ||
* @property day Day between 1 and 31. | ||
* @property hour Hour between 0 and 23. | ||
* @property minute Minute between 0 and 59. | ||
* @property second Second between 0 and 59. | ||
* @property timeZoneMinutes The number of minutes that the time zone is ahead of or behind UTC. | ||
*/ | ||
data class PDFDateTime( | ||
val year: Int, | ||
val month: Int, | ||
// Notice that we have omitted the dayOfWeek field here, as it is unused in Skia's PDF backend. | ||
val day: Int, | ||
val hour: Int, | ||
val minute: Int, | ||
val second: Int, | ||
val timeZoneMinutes: Int = 0 | ||
) { | ||
|
||
init { | ||
require(month in 1..12) { "Month must be between 1 and 12." } | ||
require(day in 1..31) { "Day must be between 1 and 31." } | ||
require(hour in 0..23) { "Hour must be between 0 and 23." } | ||
require(minute in 0..59) { "Minute must be between 0 and 59." } | ||
require(second in 0..59) { "Second must be between 0 and 59." } | ||
} | ||
|
||
internal fun asArray() = intArrayOf(year, month, day, hour, minute, second, timeZoneMinutes) | ||
|
||
} |
75 changes: 75 additions & 0 deletions
75
skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFDocument.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package org.jetbrains.skia.pdf | ||
|
||
import org.jetbrains.skia.Document | ||
import org.jetbrains.skia.ExternalSymbolName | ||
import org.jetbrains.skia.ModuleImport | ||
import org.jetbrains.skia.WStream | ||
import org.jetbrains.skia.impl.* | ||
import org.jetbrains.skia.impl.Library.Companion.staticLoad | ||
import org.jetbrains.skia.impl.Native.Companion.NullPointer | ||
|
||
object PDFDocument { | ||
|
||
init { | ||
staticLoad() | ||
} | ||
|
||
/** | ||
* Creates a PDF-backed document, writing the results into a WStream. | ||
* | ||
* PDF pages are sized in point units. 1 pt == 1/72 inch == 127/360 mm. | ||
* | ||
* @param out A PDF document will be written to this stream. The document may write | ||
* to the stream at anytime during its lifetime, until either close() is | ||
* called or the document is deleted. | ||
* @param metadata A PDFMetadata object. Any fields may be left empty. | ||
* @throws IllegalArgumentException If no PDF document can be created with the supplied arguments. | ||
*/ | ||
fun make(out: WStream, metadata: PDFMetadata = PDFMetadata()): Document { | ||
Stats.onNativeCall() | ||
val ptr = try { | ||
interopScope { | ||
_nMakeDocument( | ||
getPtr(out), | ||
toInterop(metadata.title), | ||
toInterop(metadata.author), | ||
toInterop(metadata.subject), | ||
toInterop(metadata.keywords), | ||
toInterop(metadata.creator), | ||
toInterop(metadata.producer), | ||
toInterop(metadata.creation?.asArray()), | ||
toInterop(metadata.modified?.asArray()), | ||
toInterop(metadata.lang), | ||
metadata.rasterDPI, | ||
metadata.pdfA, | ||
metadata.encodingQuality, | ||
metadata.compressionLevel.skiaRepresentation | ||
) | ||
} | ||
} finally { | ||
reachabilityBarrier(out) | ||
} | ||
require(ptr != NullPointer) { "PDF document was created with invalid arguments." } | ||
return Document(ptr, out) | ||
} | ||
|
||
} | ||
|
||
@ExternalSymbolName("org_jetbrains_skia_pdf_PDFDocument__1nMakeDocument") | ||
@ModuleImport("./skiko.mjs", "org_jetbrains_skia_pdf_PDFDocument__1nMakeDocument") | ||
private external fun _nMakeDocument( | ||
wstreamPtr: NativePointer, | ||
title: InteropPointer, | ||
author: InteropPointer, | ||
subject: InteropPointer, | ||
keywords: InteropPointer, | ||
creator: InteropPointer, | ||
producer: InteropPointer, | ||
creation: InteropPointer, | ||
modified: InteropPointer, | ||
lang: InteropPointer, | ||
rasterDPI: Float, | ||
pdfA: Boolean, | ||
encodingQuality: Int, | ||
compressionLevel: Int | ||
): NativePointer |
50 changes: 50 additions & 0 deletions
50
skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFMetadata.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
package org.jetbrains.skia.pdf | ||
|
||
/** | ||
* Optional metadata to be passed into the PDF factory function. | ||
* | ||
* @property title The document's title. | ||
* @property author The name of the person who created the document. | ||
* @property subject The subject of the document. | ||
* @property keywords Keywords associated with the document. | ||
* Commas may be used to delineate keywords within the string. | ||
* @property creator If the document was converted to PDF from another format, | ||
* the name of the conforming product that created the | ||
* original document from which it was converted. | ||
* @property producer The product that is converting this document to PDF. | ||
* @property creation The date and time the document was created. | ||
* The zero default value represents an unknown/unset time. | ||
* @property modified The date and time the document was most recently modified. | ||
* The zero default value represents an unknown/unset time. | ||
* @property lang The natural language of the text in the PDF. | ||
* @property rasterDPI The DPI (pixels-per-inch) at which features without native PDF support | ||
* will be rasterized (e.g. draw image with perspective, draw text with | ||
* perspective, ...). A larger DPI would create a PDF that reflects the | ||
* original intent with better fidelity, but it can make for larger PDF | ||
* files too, which would use more memory while rendering, and it would be | ||
* slower to be processed or sent online or to printer. | ||
* @property pdfA If true, include XMP metadata, a document UUID, and sRGB output intent | ||
* information. This adds length to the document and makes it | ||
* non-reproducible, but are necessary features for PDF/A-2b conformance | ||
* @property encodingQuality Encoding quality controls the trade-off between size and quality. By | ||
* default this is set to 101 percent, which corresponds to lossless | ||
* encoding. If this value is set to a value <= 100, and the image is | ||
* opaque, it will be encoded (using JPEG) with that quality setting. | ||
* @property compressionLevel PDF streams may be compressed to save space. | ||
* Use this to specify the desired compression vs time tradeoff. | ||
*/ | ||
data class PDFMetadata( | ||
val title: String? = null, | ||
val author: String? = null, | ||
val subject: String? = null, | ||
val keywords: String? = null, | ||
val creator: String? = null, | ||
val producer: String? = "Skia/PDF", | ||
val creation: PDFDateTime? = null, | ||
val modified: PDFDateTime? = null, | ||
val lang: String? = null, | ||
val rasterDPI: Float = 72f, | ||
val pdfA: Boolean = false, | ||
val encodingQuality: Int = 101, | ||
val compressionLevel: PDFCompressionLevel = PDFCompressionLevel.DEFAULT | ||
) |
109 changes: 109 additions & 0 deletions
109
skiko/src/commonTest/kotlin/org/jetbrains/skia/pdf/PDFDocumentTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
package org.jetbrains.skia.pdf | ||
|
||
import org.jetbrains.skia.Color | ||
import org.jetbrains.skia.OutputWStream | ||
import org.jetbrains.skia.Paint | ||
import org.jetbrains.skia.Rect | ||
import java.io.ByteArrayOutputStream | ||
import kotlin.test.* | ||
|
||
class PDFDocumentTest { | ||
|
||
@Test | ||
fun makeWithNullMetadata() { | ||
val metadata = PDFMetadata(producer = null, compressionLevel = PDFCompressionLevel.NONE) | ||
val baos = ByteArrayOutputStream() | ||
PDFDocument.make(OutputWStream(baos), metadata).use { doc -> | ||
assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.") | ||
doc.endPage() | ||
} | ||
val pdf = baos.toString(Charsets.UTF_8) | ||
assertDoesNotContain(pdf, "/Title") | ||
assertDoesNotContain(pdf, "/Author") | ||
assertDoesNotContain(pdf, "/Subject") | ||
assertDoesNotContain(pdf, "/Keywords") | ||
assertDoesNotContain(pdf, "/Creator") | ||
assertDoesNotContain(pdf, "/Producer") | ||
assertDoesNotContain(pdf, "/CreationDate") | ||
assertDoesNotContain(pdf, "/ModDate") | ||
assertDoesNotContain(pdf, "/Lang") | ||
} | ||
|
||
@Test | ||
fun makeWithNonNullMetadata() { | ||
val metadata = PDFMetadata( | ||
title = "My Novel", | ||
author = "Johann Wolfgang von Goethe", | ||
subject = "Literature", | ||
keywords = "Some,Important,Keywords", | ||
creator = "Skiko Test Suite", | ||
producer = "Skia", | ||
creation = PDFDateTime(2023, 7, 26, 13, 37, 42), | ||
modified = PDFDateTime(2024, 5, 12, 10, 20, 30, 150), | ||
lang = "de-DE", | ||
compressionLevel = PDFCompressionLevel.NONE | ||
) | ||
val baos = ByteArrayOutputStream() | ||
PDFDocument.make(OutputWStream(baos), metadata).use { doc -> | ||
assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.") | ||
doc.endPage() | ||
} | ||
val pdf = baos.toString(Charsets.UTF_8) | ||
assertContains(pdf, "/Title (${metadata.title})") | ||
assertContains(pdf, "/Author (${metadata.author})") | ||
assertContains(pdf, "/Subject (${metadata.subject})") | ||
assertContains(pdf, "/Keywords (${metadata.keywords})") | ||
assertContains(pdf, "/Creator (${metadata.creator})") | ||
assertContains(pdf, "/Producer (${metadata.producer})") | ||
assertContains(pdf, "/CreationDate (D:20230726133742+00'00')") | ||
assertContains(pdf, "/ModDate (D:20240512102030+02'30')") | ||
assertContains(pdf, "/Lang (${metadata.lang})") | ||
} | ||
|
||
@Test | ||
fun draw() { | ||
val metadata = PDFMetadata(compressionLevel = PDFCompressionLevel.NONE) | ||
val baos = ByteArrayOutputStream() | ||
PDFDocument.make(OutputWStream(baos), metadata).use { doc -> | ||
val canvas = assertNotNull(doc.beginPage(100f, 250f), "Canvas is null.") | ||
canvas.drawRect(Rect(10f, 20f, 35f, 50f), Paint().apply { color = Color.RED }) | ||
doc.endPage() | ||
} | ||
val pdf = baos.toString(Charsets.UTF_8) | ||
assertContains(pdf, "/MediaBox [0 0 100 250]") | ||
// Assert that the PDF contains some operations we would expect for our red rect drawing operation. | ||
assertContains(pdf, "1 0 0 rg") | ||
assertContains(pdf, "10 20 25 30 re") | ||
} | ||
|
||
@Test | ||
fun drawWithContentRect() { | ||
val metadata = PDFMetadata(compressionLevel = PDFCompressionLevel.NONE) | ||
val baos = ByteArrayOutputStream() | ||
PDFDocument.make(OutputWStream(baos), metadata).use { doc -> | ||
val canvas = assertNotNull(doc.beginPage(100f, 250f, Rect(60f, 40f, 90f, 220f)), "Canvas is null.") | ||
canvas.drawRect(Rect(10f, 20f, 35f, 50f), Paint().apply { color = Color.RED }) | ||
doc.endPage() | ||
} | ||
val pdf = baos.toString(Charsets.UTF_8) | ||
// Assert that the PDF contains the content rect somewhere. | ||
assertContains(pdf, "60 40 30 180 re") | ||
} | ||
|
||
@Test | ||
fun beginInvalidPage() { | ||
val doc = PDFDocument.make(OutputWStream(ByteArrayOutputStream())) | ||
assertFailsWith<IllegalArgumentException> { | ||
doc.beginPage(-10f, -20f) | ||
} | ||
} | ||
|
||
private fun assertDoesNotContain(charSequence: CharSequence, other: CharSequence) { | ||
assertTrue( | ||
other !in charSequence, | ||
"Expected the char sequence to not contain the substring.\n" + | ||
"CharSequence <$charSequence>, substring <$other>." | ||
) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
#include <jni.h> | ||
#include "SkDocument.h" | ||
#include "interop.hh" | ||
|
||
extern "C" JNIEXPORT jlong JNICALL Java_org_jetbrains_skia_DocumentKt__1nBeginPage | ||
(JNIEnv* env, jclass jclass, jlong ptr, jfloat width, jfloat height, jfloatArray jcontentArr) { | ||
SkDocument* instance = reinterpret_cast<SkDocument*>(static_cast<uintptr_t>(ptr)); | ||
jfloat* contentArr; | ||
SkRect content; | ||
SkRect* contentPtr = nullptr; | ||
if (jcontentArr != nullptr) { | ||
contentArr = env->GetFloatArrayElements(jcontentArr, 0); | ||
content = { contentArr[0], contentArr[1], contentArr[2], contentArr[3] }; | ||
contentPtr = &content; | ||
} | ||
SkCanvas* canvas = instance->beginPage(width, height, contentPtr); | ||
if (jcontentArr != nullptr) | ||
env->ReleaseFloatArrayElements(jcontentArr, contentArr, 0); | ||
return reinterpret_cast<jlong>(canvas); | ||
} | ||
|
||
extern "C" JNIEXPORT void JNICALL Java_org_jetbrains_skia_DocumentKt__1nEndPage | ||
(JNIEnv* env, jclass jclass, jlong ptr) { | ||
SkDocument* instance = reinterpret_cast<SkDocument*>(static_cast<uintptr_t>(ptr)); | ||
instance->endPage(); | ||
} |
Oops, something went wrong.