From: Lanius Trolling Date: Sun, 14 Apr 2024 12:39:19 +0000 (-0400) Subject: Improve SVG-handling code X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=969fb17f34a28374201b755d9f4cd3023825c40c;p=factbooks Improve SVG-handling code --- diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt new file mode 100644 index 0000000..9c36df5 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/Fonts.kt @@ -0,0 +1,441 @@ +package info.mechyrdia.lore + +import com.jaredrummler.fontreader.truetype.FontFileReader +import com.jaredrummler.fontreader.truetype.TTFFile +import com.jaredrummler.fontreader.util.GlyphSequence +import info.mechyrdia.data.* +import info.mechyrdia.route.KeyedEnumSerializer +import info.mechyrdia.yieldThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.awt.Font +import java.awt.Shape +import java.awt.geom.AffineTransform +import java.awt.geom.GeneralPath +import java.awt.geom.PathIterator +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.IntBuffer +import kotlin.properties.ReadOnlyProperty + +@Serializable(with = TextAlignmentSerializer::class) +enum class TextAlignment { + LEFT { + override fun processWidth(widthDiff: Int): Int { + return 0 + } + }, + CENTER { + override fun processWidth(widthDiff: Int): Int { + return widthDiff / 2 + } + }, + RIGHT { + override fun processWidth(widthDiff: Int): Int { + return widthDiff + } + }; + + abstract fun processWidth(widthDiff: Int): Int +} + +object TextAlignmentSerializer : KeyedEnumSerializer(TextAlignment.entries) + +data class SvgDoc( + val width: Double, + val height: Double, + val viewBoxX: Double, + val viewBoxY: Double, + val viewBoxW: Double, + val viewBoxH: Double, + val path: SvgPath +) + +data class SvgPath( + val d: String, + val fillRule: String +) + +fun > C.svg(svgDoc: SvgDoc) = declaration(standalone = false) + .defaultXmlns("http://www.w3.org/2000/svg") + .root( + "svg", + attributes = mapOf( + "width" to svgDoc.width.xmlValue, + "height" to svgDoc.height.xmlValue, + "viewBox" to listOf( + svgDoc.viewBoxX, + svgDoc.viewBoxY, + svgDoc.viewBoxW, + svgDoc.viewBoxH, + ).joinToString(separator = " ") { it.xmlValue } + ) + ) { "path"(attributes = mapOf("d" to svgDoc.path.d, "fill-rule" to svgDoc.path.fillRule)) } + +object MechyrdiaSansFont { + private val logger: Logger = LoggerFactory.getLogger(MechyrdiaSansFont::class.java) + + suspend fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): SvgDoc { + val (file, font) = getFont(bold, italic) + val shape = layoutText(text, file, font, align) + return createSvgDocument(shape, 80.0 / file.unitsPerEm, 12.0) + } + + private val fontsRoot = StoragePath("fonts") + private fun fontFile(name: String) = fontsRoot / "$name.ttf" + private suspend fun loadFont(fontFile: StoragePath): Pair? { + val bytes = FileStorage.instance.readFile(fontFile) ?: return null + + return withContext(Dispatchers.IO) { + val file = TTFFile(true, true) + file.readFont(FontFileReader(ByteArrayInputStream(bytes))) + + val font = Font + .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes)) + .deriveFont(file.unitsPerEm.toFloat()) + + file to font + } + } + + private fun loadedFont(fontName: String): ReadOnlyProperty Pair?> { + return storedData(fontFile(fontName), ::loadFont) + } + + private val mechyrdiaSans by loadedFont("mechyrdia-sans") + private val mechyrdiaSansB by loadedFont("mechyrdia-sans-bold") + private val mechyrdiaSansI by loadedFont("mechyrdia-sans-italic") + private val mechyrdiaSansBI by loadedFont("mechyrdia-sans-bold-italic") + + private val mechyrdiaSansFonts = listOf(mechyrdiaSans, mechyrdiaSansI, mechyrdiaSansB, mechyrdiaSansBI) + private suspend fun getFont(bold: Boolean, italic: Boolean): Pair { + return mechyrdiaSansFonts[(if (bold) 2 else 0) + (if (italic) 1 else 0)]()!! + } + + private fun TTFFile.getGlyph(cp: Int): Int { + return try { + unicodeToGlyph(cp) + } catch (ex: IOException) { + 0 + } + } + + private fun String.toCodePointSequence() = sequence { + val l = length + var i = 0 + while (i < l) { + val cp = Character.codePointAt(this@toCodePointSequence, i) + i += if (Character.isSupplementaryCodePoint(cp)) 2 else 1 + yield(cp) + } + } + + private fun String.toCodePointArray(): IntArray { + val iter = toCodePointSequence().iterator() + + return IntArray(codePointCount(0, length)) { _ -> + assert(iter.hasNext()) + iter.next() + } + } + + private fun TTFFile.getGlyphs(str: String): GlyphSequence { + val codes = str.toCodePointArray() + val glyphs = IntArray(codes.size) { i -> getGlyph(codes[i]) } + + return GlyphSequence(IntBuffer.wrap(codes), IntBuffer.wrap(glyphs), null) + } + + private fun TTFFile.getBasicWidths(glyphSequence: GlyphSequence): IntArray { + return IntArray(glyphSequence.glyphCount) { i -> + if (i == 0) + mtx[glyphSequence.getGlyph(i)].wx + else { + val prev = glyphSequence.getGlyph(i - 1) + val curr = glyphSequence.getGlyph(i) + (rawKerning[prev]?.get(curr) ?: 0) + mtx[curr].wx + } + } + } + + private fun TTFFile.getGlyphPositions(glyphSequence: GlyphSequence, widths: IntArray): Array { + val adjustments = Array(glyphSequence.glyphCount) { IntArray(4) } + gpos.position(glyphSequence, "latn", "*", 0, widths, adjustments) + + // I don't know why this is necessary, + // but it gives me the results I want. + for (adjustment in adjustments) { + adjustment[0] *= 2 + adjustment[1] *= 2 + adjustment[2] *= 2 + adjustment[3] *= 2 + } + + return adjustments + } + + private fun getWidth(widths: IntArray, glyphPositions: Array): Int { + return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum() + } + + private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape { + val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB) + val g2d = img.createGraphics() + try { + val charHolder = CharArray(2) + val lineHeight = file.rawLowerCaseAscent - file.rawLowerCaseDescent + + val lines = text.split("\r\n", "\n", "\r") + val lineGlyphs = lines.map { file.getGlyphs(it) } + val lineBasics = lineGlyphs.map { file.getBasicWidths(it) } + val lineAdjust = lineGlyphs.zip(lineBasics) { glyphs, widths -> file.getGlyphPositions(glyphs, widths) } + val lineWidths = lineBasics.zip(lineAdjust) { width, adjust -> getWidth(width, adjust) } + val blockWidth = lineWidths.max() + var ly = 0 + + yieldThread() + + val shape = GeneralPath() + val tf = AffineTransform() + for ((li, line) in lines.withIndex()) { + if (line.isNotBlank()) { + val lineWidth = lineWidths[li] + val lx = align.processWidth(blockWidth - lineWidth) + + var cx = 0 + var cy = 0 + + val basicAdv = lineBasics[li] + val adjusted = lineAdjust[li] + val glyphSeq = lineGlyphs[li] + for ((ci, codePoint) in glyphSeq.getCharacterArray(false).withIndex()) { + val length = Character.toChars(codePoint, charHolder, 0) + val glyph = font.layoutGlyphVector(g2d.fontRenderContext, charHolder, 0, length, Font.LAYOUT_LEFT_TO_RIGHT) + val glyphShape = glyph.outline as GeneralPath + val glyphShift = adjusted[ci] + + tf.setToTranslation((lx + cx + glyphShift[0]).toDouble(), (ly + cy + glyphShift[1]).toDouble()) + shape.append(glyphShape.getPathIterator(tf), false) + + cx += glyphShift[2] + basicAdv[ci] + cy += glyphShift[3] + } + } + + ly += lineHeight + + yieldThread() + } + + return shape + } catch (ex: Exception) { + logger.error("Error converting text $text to font shape", ex) + return GeneralPath() + } finally { + g2d.dispose() + } + } + + private fun createSvgDocument(shape: Shape, scale: Double, padding: Double = 0.0): SvgDoc { + val viewBox = shape.bounds2D + val vBoxPad = padding / scale + val sizePad = padding * 2 + + val path = shape.calculateSvgPath() + + return SvgDoc( + width = (viewBox.width * scale) + sizePad, + height = (viewBox.height * scale) + sizePad, + viewBoxX = viewBox.minX - vBoxPad, + viewBoxY = viewBox.minY - vBoxPad, + viewBoxW = viewBox.width + (vBoxPad * 2), + viewBoxH = viewBox.height + (vBoxPad * 2), + path = path + ) + } + + private fun Shape.calculateSvgPath(): SvgPath { + val iterator = getPathIterator(null) + val d = buildString { + val coords = DoubleArray(6) + var isFirst = true + + while (!iterator.isDone) { + if (isFirst) + isFirst = false + else + append(' ') + + when (val segment = iterator.currentSegment(coords)) { + PathIterator.SEG_MOVETO -> { + append("M ${coords[0]},${coords[1]}") + } + + PathIterator.SEG_LINETO -> { + append("L ${coords[0]},${coords[1]}") + } + + PathIterator.SEG_QUADTO -> { + append("Q ${coords[0]},${coords[1]} ${coords[2]},${coords[3]}") + } + + PathIterator.SEG_CUBICTO -> { + append("C ${coords[0]},${coords[1]} ${coords[2]},${coords[3]} ${coords[4]},${coords[5]}") + } + + PathIterator.SEG_CLOSE -> { + append("Z") + } + + else -> error("Invalid segment type $segment") + } + + iterator.next() + + yieldThread() + } + } + + val fillRule = when (val winding = iterator.windingRule) { + PathIterator.WIND_EVEN_ODD -> "evenodd" + PathIterator.WIND_NON_ZERO -> "nonzero" + else -> error("Invalid winding rule $winding") + } + + return SvgPath(d, fillRule) + } +} + +object TylanAlphabetFont { + private val allowedTranslitCharacters = setOf( + ' ', '\r', '\n', '\t', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'x', 'y', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+', + '[', '{', '}', ']', '\\', '|', '/', '<', + '.', ',', ':', ';', '\'', '"', '?', '>', + ) + + private val replacements = listOf( + Regex("([0-9xy]+)(?![\\s0-9xy])") to "$1 ", + Regex("(? + partial.replace(regex, replacement) + } +} + +object PokhwalishAlphabetFont { + private val allowedTranslitCharacters = setOf( + ' ', '\r', '\n', '\t', + 'a', 'b', 'c', 'd', 'e', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'y', 'z', + '.', ',', '\'', '?', '!', + ) + + private val replacements = listOf( + // Vowels + Regex("aa") to "A", + Regex("ae") to "A", + Regex("ee") to "E", + Regex("ei") to "E", + Regex("ey") to "E", + Regex("ie") to "I", + Regex("ii") to "I", + Regex("iy") to "I", + Regex("ao") to "O", + Regex("au") to "O", + Regex("oo") to "O", + Regex("ou") to "U", + Regex("ue") to "U", + Regex("ui") to "U", + Regex("uu") to "U", + // Consonants + Regex("tz") to "C", + Regex("hh") to "K", + Regex("kh") to "K", + Regex("gh") to "G", + Regex("ng(?![aeiouAEIOU])") to "N", + Regex("ng([aeiouAEIOU])") to "Ng$1", + Regex("n'g") to "ng", + Regex("qh") to "Q", + Regex("th") to "T", + + Regex("ck") to "q", + Regex("c") to "", + Regex("k") to "q", + ) + + fun pokhwalToFontAlphabet(pokhwal: String) = replacements.fold(pokhwal.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) -> + partial.replace(regex, replacement) + } +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt deleted file mode 100644 index ac5411e..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt +++ /dev/null @@ -1,408 +0,0 @@ -package info.mechyrdia.lore - -import com.jaredrummler.fontreader.truetype.FontFileReader -import com.jaredrummler.fontreader.truetype.TTFFile -import com.jaredrummler.fontreader.util.GlyphSequence -import info.mechyrdia.data.FileStorage -import info.mechyrdia.data.StoragePath -import info.mechyrdia.route.KeyedEnumSerializer -import info.mechyrdia.yieldThread -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.awt.Font -import java.awt.Shape -import java.awt.geom.AffineTransform -import java.awt.geom.GeneralPath -import java.awt.geom.PathIterator -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import java.io.IOException -import java.nio.IntBuffer -import kotlin.properties.ReadOnlyProperty - -@Serializable(with = TextAlignmentSerializer::class) -enum class TextAlignment { - LEFT { - override fun processWidth(widthDiff: Int): Int { - return 0 - } - }, - CENTER { - override fun processWidth(widthDiff: Int): Int { - return widthDiff / 2 - } - }, - RIGHT { - override fun processWidth(widthDiff: Int): Int { - return widthDiff - } - }; - - abstract fun processWidth(widthDiff: Int): Int -} - -object TextAlignmentSerializer : KeyedEnumSerializer(TextAlignment.entries) - -object MechyrdiaSansFont { - private val logger: Logger = LoggerFactory.getLogger(MechyrdiaSansFont::class.java) - - suspend fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): String { - val (file, font) = getFont(bold, italic) - return layoutText(text, file, font, align).toSvgDocument(80.0 / file.unitsPerEm, 12.0) - } - - private val fontsRoot = StoragePath("fonts") - private fun fontFile(name: String) = fontsRoot / "$name.ttf" - private suspend fun loadFont(fontFile: StoragePath): Pair? { - val bytes = FileStorage.instance.readFile(fontFile) ?: return null - - return withContext(Dispatchers.IO) { - val file = TTFFile(true, true) - file.readFont(FontFileReader(ByteArrayInputStream(bytes))) - - val font = Font - .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes)) - .deriveFont(file.unitsPerEm.toFloat()) - - file to font - } - } - - private fun loadedFont(fontName: String): ReadOnlyProperty Pair?> { - return storedData(fontFile(fontName), ::loadFont) - } - - private val mechyrdiaSans by loadedFont("mechyrdia-sans") - private val mechyrdiaSansB by loadedFont("mechyrdia-sans-bold") - private val mechyrdiaSansI by loadedFont("mechyrdia-sans-italic") - private val mechyrdiaSansBI by loadedFont("mechyrdia-sans-bold-italic") - - private val mechyrdiaSansFonts = listOf(mechyrdiaSans, mechyrdiaSansI, mechyrdiaSansB, mechyrdiaSansBI) - private suspend fun getFont(bold: Boolean, italic: Boolean): Pair { - return mechyrdiaSansFonts[(if (bold) 2 else 0) + (if (italic) 1 else 0)]()!! - } - - private fun TTFFile.getGlyph(cp: Int): Int { - return try { - unicodeToGlyph(cp) - } catch (ex: IOException) { - 0 - } - } - - private fun String.toCodePointSequence() = sequence { - val l = length - var i = 0 - while (i < l) { - val cp = Character.codePointAt(this@toCodePointSequence, i) - i += if (Character.isSupplementaryCodePoint(cp)) 2 else 1 - yield(cp) - } - } - - private fun String.toCodePointArray(): IntArray { - val iter = toCodePointSequence().iterator() - - return IntArray(codePointCount(0, length)) { _ -> - assert(iter.hasNext()) - iter.next() - } - } - - private fun TTFFile.getGlyphs(str: String): GlyphSequence { - val codes = str.toCodePointArray() - val glyphs = IntArray(codes.size) { i -> getGlyph(codes[i]) } - - return GlyphSequence(IntBuffer.wrap(codes), IntBuffer.wrap(glyphs), null) - } - - private fun TTFFile.getBasicWidths(glyphSequence: GlyphSequence): IntArray { - return IntArray(glyphSequence.glyphCount) { i -> - if (i == 0) - mtx[glyphSequence.getGlyph(i)].wx - else { - val prev = glyphSequence.getGlyph(i - 1) - val curr = glyphSequence.getGlyph(i) - (rawKerning[prev]?.get(curr) ?: 0) + mtx[curr].wx - } - } - } - - private fun TTFFile.getGlyphPositions(glyphSequence: GlyphSequence, widths: IntArray): Array { - val adjustments = Array(glyphSequence.glyphCount) { IntArray(4) } - gpos.position(glyphSequence, "latn", "*", 0, widths, adjustments) - - // I don't know why this is necessary, - // but it gives me the results I want. - for (adjustment in adjustments) { - adjustment[0] *= 2 - adjustment[1] *= 2 - adjustment[2] *= 2 - adjustment[3] *= 2 - } - - return adjustments - } - - private fun getWidth(widths: IntArray, glyphPositions: Array): Int { - return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum() - } - - private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape { - val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB) - val g2d = img.createGraphics() - try { - val charHolder = CharArray(2) - val lineHeight = file.rawLowerCaseAscent - file.rawLowerCaseDescent - - val lines = text.split("\r\n", "\n", "\r") - val lineGlyphs = lines.map { file.getGlyphs(it) } - val lineBasics = lineGlyphs.map { file.getBasicWidths(it) } - val lineAdjust = lineGlyphs.zip(lineBasics) { glyphs, widths -> file.getGlyphPositions(glyphs, widths) } - val lineWidths = lineBasics.zip(lineAdjust) { width, adjust -> getWidth(width, adjust) } - val blockWidth = lineWidths.max() - var ly = 0 - - yieldThread() - - val shape = GeneralPath() - val tf = AffineTransform() - for ((li, line) in lines.withIndex()) { - if (line.isNotBlank()) { - val lineWidth = lineWidths[li] - val lx = align.processWidth(blockWidth - lineWidth) - - var cx = 0 - var cy = 0 - - val basicAdv = lineBasics[li] - val adjusted = lineAdjust[li] - val glyphSeq = lineGlyphs[li] - for ((ci, codePoint) in glyphSeq.getCharacterArray(false).withIndex()) { - val length = Character.toChars(codePoint, charHolder, 0) - val glyph = font.layoutGlyphVector(g2d.fontRenderContext, charHolder, 0, length, Font.LAYOUT_LEFT_TO_RIGHT) - val glyphShape = glyph.outline as GeneralPath - val glyphShift = adjusted[ci] - - tf.setToTranslation((lx + cx + glyphShift[0]).toDouble(), (ly + cy + glyphShift[1]).toDouble()) - shape.append(glyphShape.getPathIterator(tf), false) - - cx += glyphShift[2] + basicAdv[ci] - cy += glyphShift[3] - } - } - - ly += lineHeight - - yieldThread() - } - - return shape - } catch (ex: Exception) { - logger.error("Error converting text $text to font shape", ex) - return GeneralPath() - } finally { - g2d.dispose() - } - } - - private fun Shape.toSvgDocument(scale: Double, padding: Double = 0.0): String { - return buildString { - appendLine("") - - val viewBox = bounds2D - val vBoxPad = padding / scale - val sizePad = padding * 2 - - appendLine("") - appendLine(toSvgPath()) - appendLine("") - } - } - - private fun Shape.toSvgPath(): String { - return buildString { - append(" { - append("M ${coords[0]},${coords[1]}") - } - - PathIterator.SEG_LINETO -> { - append("L ${coords[0]},${coords[1]}") - } - - PathIterator.SEG_QUADTO -> { - append("Q ${coords[0]},${coords[1]} ${coords[2]},${coords[3]}") - } - - PathIterator.SEG_CUBICTO -> { - append("C ${coords[0]},${coords[1]} ${coords[2]},${coords[3]} ${coords[4]},${coords[5]}") - } - - PathIterator.SEG_CLOSE -> { - append("Z") - } - - else -> error("Invalid segment type $segment") - } - - iterator.next() - - yieldThread() - } - - append("\" fill-rule=\"") - when (val winding = iterator.windingRule) { - PathIterator.WIND_EVEN_ODD -> append("evenodd") - PathIterator.WIND_NON_ZERO -> append("nonzero") - else -> error("Invalid winding rule $winding") - } - - append("\" />") - } - } -} - -object TylanAlphabetFont { - private val allowedTranslitCharacters = setOf( - ' ', '\r', '\n', '\t', - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'x', 'y', - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - '~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+', - '[', '{', '}', ']', '\\', '|', '/', '<', - '.', ',', ':', ';', '\'', '"', '?', '>', - ) - - private val replacements = listOf( - Regex("([0-9xy]+)(?![\\s0-9xy])") to "$1 ", - Regex("(? - partial.replace(regex, replacement) - } -} - -object PokhwalishAlphabetFont { - private val allowedTranslitCharacters = setOf( - ' ', '\r', '\n', '\t', - 'a', 'b', 'c', 'd', 'e', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'y', 'z', - '.', ',', '\'', '?', '!', - ) - - private val replacements = listOf( - // Vowels - Regex("aa") to "A", - Regex("ae") to "A", - Regex("ee") to "E", - Regex("ei") to "E", - Regex("ey") to "E", - Regex("ie") to "I", - Regex("ii") to "I", - Regex("iy") to "I", - Regex("ao") to "O", - Regex("au") to "O", - Regex("oo") to "O", - Regex("ou") to "U", - Regex("ue") to "U", - Regex("ui") to "U", - Regex("uu") to "U", - // Consonants - Regex("tz") to "C", - Regex("hh") to "K", - Regex("kh") to "K", - Regex("gh") to "G", - Regex("ng(?![aeiouAEIOU])") to "N", - Regex("ng([aeiouAEIOU])") to "Ng$1", - Regex("n'g") to "ng", - Regex("qh") to "Q", - Regex("th") to "T", - - Regex("ck") to "q", - Regex("c") to "", - Regex("k") to "q", - ) - - fun pokhwalToFontAlphabet(pokhwal: String) = replacements.fold(pokhwal.lowercase().filter { it in allowedTranslitCharacters }) { partial, (regex, replacement) -> - partial.replace(regex, replacement) - } -}