raggedright typesetting

This commit is contained in:
minjaesong
2024-05-04 02:50:00 +09:00
parent 06fcffde56
commit fb4cfb6e6d
3 changed files with 151 additions and 87 deletions

Binary file not shown.

View File

@@ -7,10 +7,13 @@ import net.torvald.terrarumsansbitmap.gdx.CodePoint
import net.torvald.terrarumsansbitmap.gdx.CodepointSequence import net.torvald.terrarumsansbitmap.gdx.CodepointSequence
import net.torvald.terrarumsansbitmap.gdx.TerrarumSansBitmap import net.torvald.terrarumsansbitmap.gdx.TerrarumSansBitmap
import net.torvald.terrarumsansbitmap.gdx.TerrarumSansBitmap.Companion.getHash import net.torvald.terrarumsansbitmap.gdx.TerrarumSansBitmap.Companion.getHash
import java.lang.Math.pow
import kotlin.math.* import kotlin.math.*
import kotlin.properties.Delegates import kotlin.properties.Delegates
enum class TypesettingStrategy {
JUSTIFIED, RAGGED_RIGHT
}
/** /**
* Despite "CJK" texts needing their own typesetting rule, in this code Korean texts are typesetted much like * Despite "CJK" texts needing their own typesetting rule, in this code Korean texts are typesetted much like
* the western texts minus the hyphenation rule (it does hyphenate just like the western texts, but omits the * the western texts minus the hyphenation rule (it does hyphenate just like the western texts, but omits the
@@ -22,9 +25,22 @@ class MovableType(
val font: TerrarumSansBitmap, val font: TerrarumSansBitmap,
val inputText: CodepointSequence, val inputText: CodepointSequence,
textWidth: Int, textWidth: Int,
internal val isNull: Boolean = false strategy: TypesettingStrategy = TypesettingStrategy.JUSTIFIED
): Disposable { ): Disposable {
private var isNull = false
internal constructor(
font: TerrarumSansBitmap,
inputText: CodepointSequence,
textWidth: Int,
strategy: TypesettingStrategy = TypesettingStrategy.JUSTIFIED,
isNull: Boolean
) : this(font, inputText, textWidth, strategy) {
this.isNull = isNull
}
var height = 0; private set var height = 0; private set
internal val hash: Long = inputText.getHash() internal val hash: Long = inputText.getHash()
private var disposed = false private var disposed = false
@@ -168,35 +184,52 @@ class MovableType(
// if adding the box would cause overflow // if adding the box would cause overflow
if (slugWidthForOverflowCalc + box.width > paperWidth) { if (slugWidthForOverflowCalc + box.width > paperWidth) {
// text overflow occured; set the width to the max value // if adding the box would cause overflow (justified)
width = paperWidth if (strategy == TypesettingStrategy.JUSTIFIED) {
// text overflow occured; set the width to the max value
width = paperWidth
val initialGlueCount = slug.getGlueSizeSum(font) val initialGlueCount = slug.getGlueSizeSum(font)
// badness: always positive and weighted // badness: always positive and weighted
// widthDelta: can be positive or negative // widthDelta: can be positive or negative
var (badnessW, widthDeltaW, _) = getBadnessW(box, initialGlueCount) // widthDeltaW is always positive var (badnessW, widthDeltaW, _) = getBadnessW(
var (badnessT, widthDeltaT, _) = getBadnessT(box, initialGlueCount) // widthDeltaT is always positive box,
var (badnessH, widthDeltaH, hyph) = getBadnessH(box, box.width - slugWidthForOverflowCalc, initialGlueCount) // widthDeltaH can be anything initialGlueCount
) // widthDeltaW is always positive
var (badnessT, widthDeltaT, _) = getBadnessT(
box,
initialGlueCount
) // widthDeltaT is always positive
var (badnessH, widthDeltaH, hyph) = getBadnessH(
box,
box.width - slugWidthForOverflowCalc,
initialGlueCount
) // widthDeltaH can be anything
badnessT -= 0.1 // try to break even badnessT -= 0.1 // try to break even
badnessH -= 0.01 // try to break even badnessH -= 0.01 // try to break even
val disableHyphThre = 5.0 val disableHyphThre = 5.0
// disable hyphenation if badness of others is lower than the threshold // disable hyphenation if badness of others is lower than the threshold
if ((badnessW <= disableHyphThre || badnessT <= disableHyphThre)) { if ((badnessW <= disableHyphThre || badnessT <= disableHyphThre)) {
badnessH = Double.POSITIVE_INFINITY badnessH = Double.POSITIVE_INFINITY
} }
// disable hyphenation if hyphenating a word is impossible // disable hyphenation if hyphenating a word is impossible
if (hyph == null) { if (hyph == null) {
badnessH = Double.POSITIVE_INFINITY badnessH = Double.POSITIVE_INFINITY
} }
if (badnessH.isInfinite() && badnessW.isInfinite() && badnessT.isInfinite()) { if (badnessH.isInfinite() && badnessW.isInfinite() && badnessT.isInfinite()) {
throw Error("Typesetting failed: badness of all three strategies diverged to infinity\ntext (${slug.size} tokens): ${slug.map { it.block.text }.filter { it.isNotGlue() }.joinToString(" ") { it.toReadable() }}") throw Error(
} "Typesetting failed: badness of all three strategies diverged to infinity\ntext (${slug.size} tokens): ${
slug.map { it.block.text }.filter { it.isNotGlue() }
.joinToString(" ") { it.toReadable() }
}"
)
}
// println("\nLine: ${slug.map { it.block.text }.filter { it.isNotGlue() }.joinToString(" ") { it.toReadable() }}") // println("\nLine: ${slug.map { it.block.text }.filter { it.isNotGlue() }.joinToString(" ") { it.toReadable() }}")
@@ -204,11 +237,11 @@ class MovableType(
// println("T diff: $widthDeltaT, badness: $badnessT") // println("T diff: $widthDeltaT, badness: $badnessT")
// println("H diff: $widthDeltaH, badness: $badnessH") // println("H diff: $widthDeltaH, badness: $badnessH")
val (selectedBadness, selectedWidthDelta, selectedStrat) = listOf( val (selectedBadness, selectedWidthDelta, selectedStrat) = listOf(
Triple(badnessW, widthDeltaW, "Widen"), Triple(badnessW, widthDeltaW, "Widen"),
Triple(badnessT, widthDeltaT, "Tighten"), Triple(badnessT, widthDeltaT, "Tighten"),
Triple(badnessH, widthDeltaH, "Hyphenate"), Triple(badnessH, widthDeltaH, "Hyphenate"),
).minByOrNull { it.first }!! ).minByOrNull { it.first }!!
// if (selectedStrat == "Hyphenate") { // if (selectedStrat == "Hyphenate") {
@@ -221,61 +254,77 @@ class MovableType(
// println(" Line ${typesettedSlugs.size + 1} Strat: $selectedStrat (badness $selectedBadness, delta $selectedWidthDelta; full badness WTH = $badnessW, $badnessT, $badnessH; full delta WTH = $widthDeltaW, $widthDeltaT, $widthDeltaH)") // println(" Line ${typesettedSlugs.size + 1} Strat: $selectedStrat (badness $selectedBadness, delta $selectedWidthDelta; full badness WTH = $badnessW, $badnessT, $badnessH; full delta WTH = $widthDeltaW, $widthDeltaT, $widthDeltaH)")
// println(" Interim Slug: [ ${slug.map { it.block.text.toReadable() }.joinToString(" | ")} ]") // println(" Interim Slug: [ ${slug.map { it.block.text.toReadable() }.joinToString(" | ")} ]")
when (selectedStrat) { when (selectedStrat) {
"Widen", "Tighten" -> { "Widen", "Tighten" -> {
// widen/tighten the spacing between blocks // widen/tighten the spacing between blocks
// widen: 1, tighten: -1 // widen: 1, tighten: -1
val operation = if (selectedStrat == "Widen") 1 else -1 val operation = if (selectedStrat == "Widen") 1 else -1
// Widen: remove the trailing glue(s?) in the slug // Widen: remove the trailing glue(s?) in the slug
if (selectedStrat == "Widen") { if (selectedStrat == "Widen") {
while (slug.lastOrNull()?.block?.isGlue() == true) { while (slug.lastOrNull()?.block?.isGlue() == true) {
slug.removeLast() slug.removeLast()
}
} }
} // Tighten: add the box to the slug
// Tighten: add the box to the slug else {
else { addToSlug(box)
addToSlug(box) // remove glues on the upcoming blocks
// remove glues on the upcoming blocks while (boxes.firstOrNull()?.isGlue() == true) {
while (boxes.firstOrNull()?.isGlue() == true) { boxes.removeFirst()
boxes.removeFirst() }
}
moveSlugsToFitTheWidth(operation, slug, selectedWidthDelta.absoluteValue)
// put the trailing word back into the upcoming words
if (selectedStrat == "Widen") {
addHyphenatedTail(box)
}
// if tightening leaves an empty line behind, signal the typesetter to discard that line
else if (selectedStrat == "Tighten" && boxes.isEmpty()) {
ignoreThisLine = true
} }
} }
moveSlugsToFitTheWidth(operation, slug, selectedWidthDelta.absoluteValue) "Hyphenate" -> {
// insert hyphen-head to the slug
// widen/tighten the spacing between blocks using widthDeltaH
// insert hyphen-tail to the list of upcoming boxes
// put the trailing word back into the upcoming words val (hyphHead, hyphTail) = hyph as Pair<NoTexGlyphLayout, NoTexGlyphLayout>
if (selectedStrat == "Widen") {
addHyphenatedTail(box) // widen: 1, tighten: -1
} val operation = widthDeltaH.sign
// if tightening leaves an empty line behind, signal the typesetter to discard that line
else if (selectedStrat == "Tighten" && boxes.isEmpty()) { // insert hyphHead into the slug
ignoreThisLine = true addToSlug(hyphHead)
moveSlugsToFitTheWidth(operation, slug, selectedWidthDelta.absoluteValue)
// put the tail into the upcoming words
addHyphenatedTail(hyphTail)
} }
} }
"Hyphenate" -> {
// insert hyphen-head to the slug
// widen/tighten the spacing between blocks using widthDeltaH
// insert hyphen-tail to the list of upcoming boxes
val (hyphHead, hyphTail) = hyph as Pair<NoTexGlyphLayout, NoTexGlyphLayout>
// widen: 1, tighten: -1
val operation = widthDeltaH.sign
// insert hyphHead into the slug
addToSlug(hyphHead)
moveSlugsToFitTheWidth(operation, slug, selectedWidthDelta.absoluteValue)
// put the tail into the upcoming words
addHyphenatedTail(hyphTail)
}
}
// println(" > Line ${typesettedSlugs.size + 1} Final Slug: [ ${slug.map { it.block.text.toReadable() }.joinToString(" | ")} ]") // println(" > Line ${typesettedSlugs.size + 1} Final Slug: [ ${slug.map { it.block.text.toReadable() }.joinToString(" | ")} ]")
dispatchSlug() dispatchSlug()
}
// if adding the box would cause overflow (ragged right)
else if (strategy == TypesettingStrategy.RAGGED_RIGHT) {
// remove trailing glues
while (slug.lastOrNull()?.block?.isGlue() == true) {
slug.removeLast()
}
addHyphenatedTail(box)
dispatchSlug()
}
else {
throw UnsupportedOperationException("Unknown typesetting strategy: ${strategy.name}")
}
} }
// typeset the boxes normally // typeset the boxes normally
else { else {
@@ -425,14 +474,7 @@ class MovableType(
fun getControlHeader(row: Int, word: Int): CodepointSequence { fun getControlHeader(row: Int, word: Int): CodepointSequence {
val index = row * 65536 or word val index = row * 65536 or word
// println("GetControlHeader $row, $word -> $index")
// println(" ControlChars: ${controlCharList.joinToString()}")
val ret = CodepointSequence(controlCharList.filter { index > it.second }.map { it.first }) val ret = CodepointSequence(controlCharList.filter { index > it.second }.map { it.first })
// println(" Filtered: ${ret.joinToString()}")
return ret return ret
} }
@@ -837,7 +879,7 @@ class MovableType(
private val whitespaceGlues = hashMapOf( private val whitespaceGlues = hashMapOf(
0x20 to 4, 0x20 to 4,
0x3000 to 16, 0x3000 to 16,
0xf0520 to 9, 0xF0520 to 7, // why????
) )
private val cjpuncts = listOf(0x203c, 0x2047, 0x2048, 0x2049, 0x3001, 0x3002, 0x3006, 0x303b, 0x30a0, 0x30fb, 0x30fc, 0x301c, 0xff01, 0xff0c, 0xff0e, 0xff1a, 0xff1b, 0xff1f, 0xff5e, 0xff65).toSortedSet() private val cjpuncts = listOf(0x203c, 0x2047, 0x2048, 0x2049, 0x3001, 0x3002, 0x3006, 0x303b, 0x30a0, 0x30fb, 0x30fc, 0x301c, 0xff01, 0xff0c, 0xff0e, 0xff1a, 0xff1b, 0xff1f, 0xff5e, 0xff65).toSortedSet()
private val cjparenStarts = listOf(0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x30fb, 0xff65).toSortedSet() private val cjparenStarts = listOf(0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x30fb, 0xff65).toSortedSet()
@@ -856,10 +898,14 @@ class MovableType(
private const val GLUE_NEGATIVE_ONE = 0xFFFE0 private const val GLUE_NEGATIVE_ONE = 0xFFFE0
private const val GLUE_NEGATIVE_SIXTEEN = 0xFFFEF private const val GLUE_NEGATIVE_SIXTEEN = 0xFFFEF
private fun CharArray.toSurrogatedString(): String = if (this.size == 1) "${this[0]}" else "${this[0]}${this[1]}"
private inline fun Int.codepointToString() = Character.toChars(this).toSurrogatedString()
private fun CodepointSequence.toReadable() = this.joinToString("") { private fun CodepointSequence.toReadable() = this.joinToString("") {
if (it in 0x00..0x1f) if (it in 0x00..0x1f)
"${(0x2400 + it).toChar()}" "${(0x2400 + it).toChar()}"
else if (it == 0x20) else if (it == 0x20 || it == 0xF0520)
"\u2423" "\u2423"
else if (it == NBSP) else if (it == NBSP)
"{NBSP}" "{NBSP}"
@@ -869,6 +915,18 @@ class MovableType(
"{ZWSP}" "{ZWSP}"
else if (it in GLUE_NEGATIVE_ONE..GLUE_POSITIVE_SIXTEEN) else if (it in GLUE_NEGATIVE_ONE..GLUE_POSITIVE_SIXTEEN)
" <glue ${it.glueCharToGlueSize()}> " " <glue ${it.glueCharToGlueSize()}> "
else if (it in 0xF0541..0xF055A) {
(it - 0xF0541 + 0x1D670).codepointToString()
}
else if (it in 0xF0561..0xF057A) {
(it - 0xF0561 + 0x1D68A).codepointToString()
}
else if (it in 0xF0530..0xF0539) {
(it - 0xF0530 + 0x1D7F6).codepointToString()
}
else if (it in 0xF0520..0xF057F) {
(it - 0xF0520 + 0x20).codepointToString()
}
else if (it >= 0xF0000) else if (it >= 0xF0000)
it.toHex() + " " it.toHex() + " "
else else

View File

@@ -33,6 +33,7 @@ import com.badlogic.gdx.utils.GdxRuntimeException
import net.torvald.terrarumsansbitmap.DiacriticsAnchor import net.torvald.terrarumsansbitmap.DiacriticsAnchor
import net.torvald.terrarumsansbitmap.GlyphProps import net.torvald.terrarumsansbitmap.GlyphProps
import net.torvald.terrarumsansbitmap.MovableType import net.torvald.terrarumsansbitmap.MovableType
import net.torvald.terrarumsansbitmap.TypesettingStrategy
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.* import java.util.*
@@ -510,22 +511,27 @@ class TerrarumSansBitmap(
* This method alone will NOT draw the text to the screen, use [MovableType.draw]. * This method alone will NOT draw the text to the screen, use [MovableType.draw].
*/ */
fun typesetParagraph(batch: Batch, charSeq: CharSequence, targetWidth: Int): MovableType = fun typesetParagraph(batch: Batch, charSeq: CharSequence, targetWidth: Int): MovableType =
typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth.toFloat()) typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth.toFloat(), TypesettingStrategy.JUSTIFIED)
/** /**
* Typesets given string and returns the typesetted results, with which the desired text can be drawn on the screen. * Typesets given string and returns the typesetted results, with which the desired text can be drawn on the screen.
* This method alone will NOT draw the text to the screen, use [MovableType.draw]. * This method alone will NOT draw the text to the screen, use [MovableType.draw].
*/ */
fun typesetParagraph(batch: Batch, charSeq: CharSequence, targetWidth: Float): MovableType = fun typesetParagraph(batch: Batch, charSeq: CharSequence, targetWidth: Float): MovableType =
typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth) typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth, TypesettingStrategy.JUSTIFIED)
fun typesetParagraphRaggedRight(batch: Batch, charSeq: CharSequence, targetWidth: Int): MovableType =
typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth.toFloat(), TypesettingStrategy.RAGGED_RIGHT)
fun typesetParagraphRaggedRight(batch: Batch, charSeq: CharSequence, targetWidth: Float): MovableType =
typesetParagraphNormalised(batch, normaliseStringForMovableType(charSeq), targetWidth, TypesettingStrategy.RAGGED_RIGHT)
private val nullType = MovableType(this, "".toCodePoints(2), 0, true) private val nullType = MovableType(this, "".toCodePoints(2), 0, isNull = true)
/** /**
* Typesets given string and returns the typesetted results, with which the desired text can be drawn on the screen. * Typesets given string and returns the typesetted results, with which the desired text can be drawn on the screen.
* This method alone will NOT draw the text to the screen, use [MovableType.draw]. * This method alone will NOT draw the text to the screen, use [MovableType.draw].
*/ */
fun typesetParagraphNormalised(batch: Batch, codepoints: CodepointSequence, targetWidth: Float): MovableType { fun typesetParagraphNormalised(batch: Batch, codepoints: CodepointSequence, targetWidth: Float, strategy: TypesettingStrategy): MovableType {
val charSeqNotBlank = codepoints.size > 0 // determine emptiness BEFORE you hack a null chars in val charSeqNotBlank = codepoints.size > 0 // determine emptiness BEFORE you hack a null chars in
val newCodepoints = codepoints val newCodepoints = codepoints
@@ -535,7 +541,7 @@ class TerrarumSansBitmap(
var cacheObj = getTypesetCache(charSeqHash) var cacheObj = getTypesetCache(charSeqHash)
if (cacheObj == null || flagFirstRun) { if (cacheObj == null || flagFirstRun) {
cacheObj = MovableType(this, codepoints, targetWidth.toInt()) cacheObj = MovableType(this, codepoints, targetWidth.toInt(), strategy)
addToTypesetCache(cacheObj) addToTypesetCache(cacheObj)
} }