Skip to content

Commit

Permalink
Migrate from jspdf to pdf-lib.
Browse files Browse the repository at this point in the history
The unfortunate driving factor for this is that jspdf contains a
reference to an external script in its save method, which is disallowed
in Chrome extensions, even if the codepath is unused. Resolving this
would require forking jspdf, or having the logic moved out of the main
save method, neither of which feels great.

A pleasant side effect is that the generated PDFs are significantly
smaller, perhaps because jspdf doesn't subset the fonts. A drawback is
that the library size is significantly higher.

We also upgrade the Kotlin version since the prior one was encountering
compiler errors.
  • Loading branch information
jpd236 committed Jun 25, 2023
1 parent 2d816a5 commit db0271a
Show file tree
Hide file tree
Showing 20 changed files with 454 additions and 277 deletions.
7 changes: 4 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ plugins {
signing
id("io.github.gradle-nexus.publish-plugin") version "1.1.0"
id("org.jetbrains.dokka") version "1.7.20"
kotlin("multiplatform") version "1.8.10"
kotlin("plugin.serialization") version "1.8.10"
kotlin("multiplatform") version "1.8.22"
kotlin("plugin.serialization") version "1.8.22"
}

group = "com.jeffpdavidson.kotwords"
Expand Down Expand Up @@ -86,7 +86,8 @@ kotlin {
val jsMain by getting {
dependencies {
implementation(npm("jszip", "3.10.1"))
implementation(npm("jspdf", "2.5.1"))
implementation(npm("pdf-lib", "1.17.1"))
implementation(npm("@pdf-lib/fontkit", "1.1.1"))
implementation("org.jetbrains.kotlinx:kotlinx-html-js:0.8.0")
}
}
Expand Down
98 changes: 65 additions & 33 deletions src/commonMain/kotlin/com/jeffpdavidson/kotwords/formats/Pdf.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ object Pdf {
* Inspired by [puz2pdf](https://sourceforge.net/projects/puz2pdf) and
* [Crossword Nexus's PDF converter](https://crosswordnexus.com/js/puz_functions.js).
*/
internal fun asPdf(
internal suspend fun asPdf(
puzzle: Puzzle,
fontFamily: PdfFontFamily,
blackSquareLightnessAdjustment: Float,
gridRenderer: (
gridRenderer: suspend (
document: PdfDocument,
puzzle: Puzzle,
blackSquareLightnessAdjustment: Float,
Expand All @@ -103,7 +103,7 @@ object Pdf {
fontFamily: PdfFontFamily,
) -> DrawGridResult,
): ByteArray = with(puzzle) {
PdfDocument().run {
PdfDocument.create().run {
val pageWidth = width
val pageHeight = height
val headerWidth = pageWidth - 2 * MARGIN
Expand Down Expand Up @@ -217,7 +217,7 @@ object Pdf {
}

/** Default grid drawing function for [asPdf]. */
fun drawGrid(
suspend fun drawGrid(
document: PdfDocument,
puzzle: Puzzle,
blackSquareLightnessAdjustment: Float,
Expand All @@ -242,7 +242,6 @@ object Pdf {

// Fill in the square background if a background color is specified, or if this is a black square.
// Otherwise, use a white background.
addRect(squareX, squareY, gridSquareSize, gridSquareSize)
val backgroundColor = when {
square.backgroundColor.isNotBlank() ->
getAdjustedColor(RGB(square.backgroundColor), blackSquareLightnessAdjustment)
Expand All @@ -251,21 +250,20 @@ object Pdf {
}
setFillColor(backgroundColor.r, backgroundColor.g, backgroundColor.b)
if (square.cellType == Puzzle.CellType.VOID) {
fill()
drawRect(squareX, squareY, gridSquareSize, gridSquareSize, fill = true)
} else {
setStrokeColor(gridBlackColor.r, gridBlackColor.g, gridBlackColor.b)
fillAndStroke()
drawRect(squareX, squareY, gridSquareSize, gridSquareSize, fill = true, stroke = true)
}


if (square.backgroundImage is Puzzle.Image.Data) {
val imageBytes = square.backgroundImage.bytes.toByteArray()
drawImage(squareX, squareY, gridSquareSize, gridSquareSize, imageBytes)
drawImage(squareX, squareY, gridSquareSize, gridSquareSize, square.backgroundImage)
}

if (!square.cellType.isBlack()) {
if (square.backgroundShape == Puzzle.BackgroundShape.CIRCLE) {
addCircle(squareX, squareY, gridSquareSize / 2)
stroke()
drawCircle(squareX, squareY, gridSquareSize / 2, stroke = true)
}

if (square.number.isNotBlank() && !puzzle.diagramless) {
Expand Down Expand Up @@ -337,12 +335,11 @@ object Pdf {
val squareXEnd = squareX + gridSquareSize
val squareYEnd = squareY + gridSquareSize
when (borderDirection) {
Puzzle.BorderDirection.TOP -> addLine(squareX, squareYEnd, squareXEnd, squareYEnd)
Puzzle.BorderDirection.BOTTOM -> addLine(squareX, squareY, squareXEnd, squareY)
Puzzle.BorderDirection.LEFT -> addLine(squareX, squareY, squareX, squareYEnd)
Puzzle.BorderDirection.RIGHT -> addLine(squareXEnd, squareY, squareXEnd, squareYEnd)
Puzzle.BorderDirection.TOP -> drawLine(squareX, squareYEnd, squareXEnd, squareYEnd)
Puzzle.BorderDirection.BOTTOM -> drawLine(squareX, squareY, squareXEnd, squareY)
Puzzle.BorderDirection.LEFT -> drawLine(squareX, squareY, squareX, squareYEnd)
Puzzle.BorderDirection.RIGHT -> drawLine(squareXEnd, squareY, squareXEnd, squareYEnd)
}
stroke()
}
setLineWidth(1f)
}
Expand All @@ -357,7 +354,7 @@ object Pdf {
return HSL(hsl.h, hsl.s, (hsl.l + (1.0 - hsl.l) * lightnessAdjustment)).toSRGB()
}

private fun PdfDocument.drawSquareNumber(
private suspend fun PdfDocument.drawSquareNumber(
x: Float,
y: Float,
text: String,
Expand All @@ -368,13 +365,13 @@ object Pdf {
) {
// Erase a rectangle around the number to make sure it stands out if there is a circle.
setFillColor(backgroundColor.r, backgroundColor.g, backgroundColor.b)
addRect(
drawRect(
x,
y,
textWidth,
gridNumberSize - 2f
gridNumberSize - 2f,
fill = true,
)
fill()

setFillColor(0f, 0f, 0f)
beginText()
Expand All @@ -389,7 +386,7 @@ object Pdf {
*
* @return the updated Y position after all text has been drawn
*/
private fun PdfDocument.drawMultiLineText(
private suspend fun PdfDocument.drawMultiLineText(
text: String,
fontFamily: PdfFontFamily,
fontSize: Float,
Expand Down Expand Up @@ -439,7 +436,7 @@ object Pdf {

private data class FormattedChar(val char: Char, val format: Format)

internal fun splitTextToLines(
internal suspend fun splitTextToLines(
document: PdfDocument,
rawText: String,
fontFamily: PdfFontFamily,
Expand All @@ -450,7 +447,7 @@ object Pdf {
splitParagraphToLines(document, line, fontFamily, fontSize, lineWidth, isHtml)
}

private fun splitParagraphToLines(
private suspend fun splitParagraphToLines(
document: PdfDocument,
rawText: String,
fontFamily: PdfFontFamily,
Expand Down Expand Up @@ -543,9 +540,9 @@ object Pdf {
}

/** Run the given function on each word, using space as a separator. */
private fun forEachWord(
private suspend fun forEachWord(
text: List<FormattedChar>,
fn: (word: List<FormattedChar>, nextSeparator: FormattedChar?) -> Unit
fn: suspend (word: List<FormattedChar>, nextSeparator: FormattedChar?) -> Unit
) {
val currentWord = mutableListOf<FormattedChar>()
text.forEach { formattedChar ->
Expand All @@ -562,7 +559,7 @@ object Pdf {
}

/** Run the given function for each chunk of the given string which has the same format. */
private fun forEachFormat(text: List<FormattedChar>, fn: (text: String, format: Format) -> Unit) {
private suspend fun forEachFormat(text: List<FormattedChar>, fn: suspend (text: String, format: Format) -> Unit) {
val currentString = StringBuilder()
var currentFormat: Format? = null
text.forEach { formattedChar ->
Expand All @@ -579,7 +576,7 @@ object Pdf {
}

/** Split formatted [text] into lines (using spaces as word separators) to fit the given [lineWidth]. */
private fun splitTextToLines(
private suspend fun splitTextToLines(
document: PdfDocument,
text: List<FormattedChar>,
baseFont: PdfFont,
Expand Down Expand Up @@ -642,7 +639,7 @@ object Pdf {
val columnBottomY: Float,
)

private fun PdfDocument.showClueLists(
private suspend fun PdfDocument.showClueLists(
puzzle: Puzzle,
fontFamily: PdfFontFamily,
columnWidth: Float,
Expand Down Expand Up @@ -702,7 +699,7 @@ object Pdf {
*
* @return the updated Y position after all text has been drawn
*/
private fun PdfDocument.drawRichText(
private suspend fun PdfDocument.drawRichText(
richTextElements: List<RichTextElement>,
baseFont: PdfFont,
fontSize: Float,
Expand Down Expand Up @@ -767,7 +764,7 @@ object Pdf {
return positionY
}

private fun PdfDocument.showClueList(
private suspend fun PdfDocument.showClueList(
puzzle: Puzzle,
clues: Puzzle.ClueList,
isHtml: Boolean,
Expand Down Expand Up @@ -860,13 +857,48 @@ object Pdf {
return true to CluePosition(positionY = positionY, column = column, columnBottomY = columnBottomY)
}

private fun findBestFontSize(minSize: Float, maxSize: Float, testFn: (Float) -> Boolean): Float? {
private suspend fun findBestFontSize(minSize: Float, maxSize: Float, testFn: suspend (Float) -> Boolean): Float? {
val textSizes = generateSequence(maxSize) { it - TEXT_SIZE_DELTA }.takeWhile { it >= minSize }.toList()
val insertionIndex = -textSizes.binarySearch { size -> if (testFn(size)) 1 else -1 } - 1
val insertionIndex = -textSizes.binarySearchAsync { size -> if (testFn(size)) 1 else -1 } - 1
return if (insertionIndex > textSizes.lastIndex) null else textSizes[insertionIndex]
}

private fun PdfDocument.getTextWidth(text: String, format: Format, fontSize: Float): Float {
private suspend fun PdfDocument.getTextWidth(text: String, format: Format, fontSize: Float): Float {
return getTextWidth(text, format.font, format.script.getScaledFontSize(fontSize))
}

/** Copy of [binarySearch] with a suspending comparison function. */
// TODO: Remove when https://youtrack.jetbrains.com/issue/KT-17192 is resolved.
private suspend fun <T> List<T>.binarySearchAsync(
fromIndex: Int = 0,
toIndex: Int = size,
comparison: suspend (T) -> Int
): Int {
rangeCheck(size, fromIndex, toIndex)

var low = fromIndex
var high = toIndex - 1

while (low <= high) {
val mid = (low + high).ushr(1) // safe from overflows
val midVal = get(mid)
val cmp = comparison(midVal)

if (cmp < 0)
low = mid + 1
else if (cmp > 0)
high = mid - 1
else
return mid // key found
}
return -(low + 1) // key not found
}

private fun rangeCheck(size: Int, fromIndex: Int, toIndex: Int) {
when {
fromIndex > toIndex -> throw IllegalArgumentException("fromIndex ($fromIndex) is greater than toIndex ($toIndex).")
fromIndex < 0 -> throw IndexOutOfBoundsException("fromIndex ($fromIndex) is less than zero.")
toIndex > size -> throw IndexOutOfBoundsException("toIndex ($toIndex) is greater than size ($size).")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.jeffpdavidson.kotwords.formats

import com.jeffpdavidson.kotwords.model.Puzzle

/**
* A document canvas which can be rendered as a PDF.
*
* Implementations should assume one-page, letter-sized documents. All units are in points. Coordinates are measured as
* distance from the bottom-left corner of the document.
*/
expect class PdfDocument() {
expect class PdfDocument private constructor() {
val width: Float
val height: Float

Expand All @@ -20,10 +22,10 @@ expect class PdfDocument() {
fun newLineAtOffset(offsetX: Float, offsetY: Float)

/** Set the font to be used for text. */
fun setFont(font: PdfFont, size: Float)
suspend fun setFont(font: PdfFont, size: Float)

/** Get the width of the given [text] with font [font] and font size [size]. */
fun getTextWidth(text: String, font: PdfFont, size: Float): Float
suspend fun getTextWidth(text: String, font: PdfFont, size: Float): Float

/** Draw and stroke the given [text]. */
fun drawText(text: String)
Expand All @@ -37,27 +39,22 @@ expect class PdfDocument() {
/** Set the fill color. */
fun setFillColor(r: Float, g: Float, b: Float)

/** Add a line path from ([x1], [y1]) to ([x2], [y2]). */
fun addLine(x1: Float, y1: Float, x2: Float, y2: Float)

/** Add a rectangular path from bottom-left coordinates ([x], [y]). */
fun addRect(x: Float, y: Float, width: Float, height: Float)

/** Add a circular path of radius [r] from bottom-left coordinates ([x], [y]). */
fun addCircle(x: Float, y: Float, radius: Float)
/** Draw a line from ([x1], [y1]) to ([x2], [y2]). */
fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float)

/** Draw a stroke around the current path. */
fun stroke()
/** Draw a rectangle from bottom-left coordinates ([x], [y]). */
fun drawRect(x: Float, y: Float, width: Float, height: Float, stroke: Boolean = false, fill: Boolean = false)

/** Fill the current path. */
fun fill()

/** Draw a stroke around the current path and fill it. */
fun fillAndStroke()
/** Draw a circle of radius [radius] from bottom-left coordinates ([x], [y]). */
fun drawCircle(x: Float, y: Float, radius: Float, stroke: Boolean = false, fill: Boolean = false)

/** Draw the given image from bottom-left coordinates ([x], [y]). */
fun drawImage(x: Float, y: Float, width: Float, height: Float, imageData: ByteArray)
suspend fun drawImage(x: Float, y: Float, width: Float, height: Float, image: Puzzle.Image.Data)

/** Return this document as a PDF [ByteArray]. */
fun toByteArray(): ByteArray
suspend fun toByteArray(): ByteArray

companion object {
suspend fun create(): PdfDocument
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,12 @@ abstract class Puzzleable {
* @param blackSquareLightnessAdjustment Percentage (from 0 to 1) indicating how much to brighten black/colored
* squares (i.e. to save ink). 0 indicates no adjustment; 1 would be fully
* white.
* @param gridRenderer Optional function to render the grid, if custom rendering is desired. The default rendering
* draws a rectangular grid with square cells. Custom functions should fill a maximum width of
* gridWidth and return the resulting maximum height of the grid.
*/
open suspend fun asPdf(
fontFamily: PdfFontFamily = FONT_FAMILY_TIMES_ROMAN,
blackSquareLightnessAdjustment: Float = 0f,
gridRenderer: (
document: PdfDocument,
puzzle: Puzzle,
blackSquareLightnessAdjustment: Float,
gridWidth: Float,
gridX: Float,
gridY: Float,
fontFamily: PdfFontFamily,
) -> Pdf.DrawGridResult = Pdf::drawGrid,
): ByteArray {
return Pdf.asPdf(asPuzzle(), fontFamily, blackSquareLightnessAdjustment, gridRenderer)
return Pdf.asPdf(asPuzzle(), fontFamily, blackSquareLightnessAdjustment, Pdf::drawGrid)
}
}

Expand All @@ -124,14 +112,5 @@ abstract class DelegatingPuzzleable : Puzzleable() {
override suspend fun asPdf(
fontFamily: PdfFontFamily,
blackSquareLightnessAdjustment: Float,
gridRenderer: (
document: PdfDocument,
puzzle: Puzzle,
blackSquareLightnessAdjustment: Float,
gridWidth: Float,
gridX: Float,
gridY: Float,
fontFamily: PdfFontFamily
) -> Pdf.DrawGridResult
): ByteArray = getPuzzleable().asPdf(fontFamily, blackSquareLightnessAdjustment, gridRenderer)
): ByteArray = getPuzzleable().asPdf(fontFamily, blackSquareLightnessAdjustment)
}
Loading

0 comments on commit db0271a

Please sign in to comment.