Skip to content

Commit

Permalink
Add a thin wrapper around Skia's PDF backend
Browse files Browse the repository at this point in the history
  • Loading branch information
LoadingByte committed Feb 10, 2025
1 parent eb1f04e commit c1166c3
Show file tree
Hide file tree
Showing 11 changed files with 521 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fun skiaHeadersDirs(skiaDir: File): List<File> =
skiaDir.resolve("include/utils"),
skiaDir.resolve("include/codec"),
skiaDir.resolve("include/svg"),
skiaDir.resolve("include/docs"),
skiaDir.resolve("modules/skottie/include"),
skiaDir.resolve("modules/skparagraph/include"),
skiaDir.resolve("modules/skshaper/include"),
Expand Down
82 changes: 82 additions & 0 deletions skiko/src/commonMain/kotlin/org/jetbrains/skia/Document.kt
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)
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 skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFDateTime.kt
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 skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFDocument.kt
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 skiko/src/commonMain/kotlin/org/jetbrains/skia/pdf/PDFMetadata.kt
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 skiko/src/commonTest/kotlin/org/jetbrains/skia/pdf/PDFDocumentTest.kt
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>."
)
}

}
26 changes: 26 additions & 0 deletions skiko/src/jvmMain/cpp/common/Document.cc
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();
}
Loading

0 comments on commit c1166c3

Please sign in to comment.