diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 9da8a5eb6..b841e1eb5 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -84,7 +84,6 @@ kotlin { implementation(libs.xmlutil.core) implementation(libs.epub4kmp.core) implementation(libs.pdfkmp) - implementation(libs.pdfkmp.markdown) implementation(libs.kotlinx.html) } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt new file mode 100644 index 000000000..77f0784a3 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt @@ -0,0 +1,550 @@ +package com.darkrockstudios.apps.hammer.common.components.projecthome + +import com.conamobile.pdfkmp.dsl.ContainerScope +import com.conamobile.pdfkmp.dsl.TextScope +import com.conamobile.pdfkmp.geometry.Padding +import com.conamobile.pdfkmp.layout.BoxAlignment +import com.conamobile.pdfkmp.layout.VerticalAlignment +import com.conamobile.pdfkmp.style.BorderSides +import com.conamobile.pdfkmp.style.BorderStroke +import com.conamobile.pdfkmp.style.FontWeight +import com.conamobile.pdfkmp.style.PdfColor +import com.conamobile.pdfkmp.style.TableBorder +import com.conamobile.pdfkmp.style.TableColumn +import com.conamobile.pdfkmp.style.TextStyle +import com.conamobile.pdfkmp.unit.Dp +import com.conamobile.pdfkmp.unit.Sp +import org.intellij.markdown.IElementType +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.findChildOfType +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMTokenTypes +import org.intellij.markdown.parser.MarkdownParser + +/** Theme accents for prose rendering; null falls back to neutral defaults. */ +internal data class ProseColors( + val primary: PdfColor? = null, + val secondary: PdfColor? = null, +) { + val link: PdfColor get() = primary ?: PdfColor.Blue +} + +/** + * Renders markdown prose into a PDF container with book typography: every paragraph gets + * a first-line indent, and consecutive paragraphs run without a blank line between them — + * the indent is the separator. + * + * Parsing uses the same intellij-markdown engine as the EPUB export (GFM flavour, so + * `~~strikethrough~~` and pipe tables work) instead of pdfkmp's markdown module, whose + * layout offers no paragraph indent control. + */ +internal fun ContainerScope.proseMarkdown(markdown: String, colors: ProseColors = ProseColors()) { + val blocks = parseProseMarkdown(markdown) + if (blocks.isEmpty()) return + val base = TextStyle(lineHeight = Sp(TextStyle().fontSize.value * BODY_LEADING)) + column { + blocks.forEachIndexed { index, block -> + if (index > 0) { + val previous = blocks[index - 1] + when { + // Consecutive paragraphs carry their own separation via the indent. + previous is ProseBlock.Paragraph && block is ProseBlock.Paragraph -> Unit + previous is ProseBlock.Heading -> spacer(height = HEADING_SPACING) + else -> spacer(height = BLOCK_SPACING) + } + } + when (block) { + is ProseBlock.Paragraph -> renderParagraph(block.spans, base, colors) + is ProseBlock.Heading -> renderHeading(block, base, colors) + is ProseBlock.Listing -> renderListing(block, base, colors) + is ProseBlock.Quote -> renderQuote(block, base, colors) + is ProseBlock.CodeBlock -> renderCode(block, base) + ProseBlock.Rule -> divider() + is ProseBlock.Table -> renderTable(block, base, colors) + } + } + } +} + +/** One contiguous styled run of paragraph text. */ +internal data class ProseSpan( + val text: String, + val bold: Boolean = false, + val italic: Boolean = false, + val strikethrough: Boolean = false, + val code: Boolean = false, + val link: String? = null, +) + +/** A block-level markdown element, reduced to what the PDF prose layout renders. */ +internal sealed interface ProseBlock { + data class Paragraph(val spans: List) : ProseBlock + data class Heading(val level: Int, val spans: List) : ProseBlock + data class Listing(val ordered: Boolean, val items: List>) : ProseBlock + data class Quote(val paragraphs: List>) : ProseBlock + data class CodeBlock(val code: String) : ProseBlock + data object Rule : ProseBlock + data class Table(val header: List>, val rows: List>>) : ProseBlock +} + +/** Parses [markdown] (GFM flavour) into renderable blocks. Unknown syntax degrades to paragraph text. */ +internal fun parseProseMarkdown(markdown: String): List { + val root = MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(markdown) + return ProseWalker(markdown).blocks(root) +} + +// --------------------------------------------------------------------------- +// AST walking +// --------------------------------------------------------------------------- + +private val HEADING_LEVELS: Map = mapOf( + MarkdownElementTypes.ATX_1 to 1, + MarkdownElementTypes.ATX_2 to 2, + MarkdownElementTypes.ATX_3 to 3, + MarkdownElementTypes.ATX_4 to 4, + MarkdownElementTypes.ATX_5 to 5, + MarkdownElementTypes.ATX_6 to 6, + MarkdownElementTypes.SETEXT_1 to 1, + MarkdownElementTypes.SETEXT_2 to 2, +) + +/** CommonMark backslash escapes: `\X` for ASCII punctuation X resolves to bare X. */ +private val ESCAPE_REGEX = Regex("""\\([!-/:-@\[-`{-~])""") + +private fun String.resolveEscapes(): String = + if ('\\' in this) ESCAPE_REGEX.replace(this) { it.groupValues[1] } else this + +private class ProseWalker(private val source: String) { + + fun blocks(root: ASTNode): List = root.children.mapNotNull { block(it) } + + private fun block(node: ASTNode): ProseBlock? = when (node.type) { + MarkdownElementTypes.PARAGRAPH -> + inline(node).ifEmpty { null }?.let { ProseBlock.Paragraph(it) } + + in HEADING_LEVELS -> heading(node) + + MarkdownElementTypes.UNORDERED_LIST -> ProseBlock.Listing(ordered = false, items = listItems(node)) + MarkdownElementTypes.ORDERED_LIST -> ProseBlock.Listing(ordered = true, items = listItems(node)) + + MarkdownElementTypes.BLOCK_QUOTE -> + quoteParagraphs(node).ifEmpty { null }?.let { ProseBlock.Quote(it) } + + MarkdownElementTypes.CODE_FENCE -> ProseBlock.CodeBlock(fenceContent(node)) + MarkdownElementTypes.CODE_BLOCK -> ProseBlock.CodeBlock(indentedCode(node)) + + MarkdownTokenTypes.HORIZONTAL_RULE -> ProseBlock.Rule + + GFMElementTypes.TABLE -> table(node) + + // Reference-link definitions and inter-block whitespace produce no output. + MarkdownElementTypes.LINK_DEFINITION, + MarkdownTokenTypes.EOL, + MarkdownTokenTypes.WHITE_SPACE, + -> null + + else -> inline(node).ifEmpty { null }?.let { ProseBlock.Paragraph(it) } + } + + private fun heading(node: ASTNode): ProseBlock? { + val content = node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT) + ?: node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT) + ?: return null + val spans = inline(content).ifEmpty { return null } + return ProseBlock.Heading(level = HEADING_LEVELS.getValue(node.type), spans = spans) + } + + private fun listItems(listNode: ASTNode): List> { + val items = mutableListOf>() + for (item in listNode.children) { + if (item.type != MarkdownElementTypes.LIST_ITEM) continue + val spans = mutableListOf() + for (child in item.children) { + when (child.type) { + MarkdownElementTypes.PARAGRAPH -> { + if (spans.isNotEmpty()) spans += ProseSpan("\n") + spans += inline(child) + } + // Nested lists are flattened into follow-on items. + MarkdownElementTypes.UNORDERED_LIST, + MarkdownElementTypes.ORDERED_LIST, + -> { + if (spans.isNotEmpty()) { + items += spans.toList() + spans.clear() + } + items += listItems(child) + } + else -> Unit + } + } + if (spans.isNotEmpty()) items += spans.toList() + } + return items + } + + private fun quoteParagraphs(node: ASTNode): List> { + val paragraphs = mutableListOf>() + for (child in node.children) { + when (child.type) { + MarkdownElementTypes.BLOCK_QUOTE -> paragraphs += quoteParagraphs(child) + MarkdownTokenTypes.BLOCK_QUOTE, + MarkdownTokenTypes.EOL, + MarkdownTokenTypes.WHITE_SPACE, + -> Unit + + else -> inline(child).ifEmpty { null }?.let { paragraphs += it } + } + } + return paragraphs + } + + private fun fenceContent(node: ASTNode): String { + val lines = mutableListOf() + var current: String? = null + var pastOpeningLine = false + for (child in node.children) { + when (child.type) { + MarkdownTokenTypes.CODE_FENCE_END -> break + MarkdownTokenTypes.EOL -> { + if (pastOpeningLine) lines += current.orEmpty() + pastOpeningLine = true + current = null + } + + MarkdownTokenTypes.CODE_FENCE_CONTENT -> + current = current.orEmpty() + child.getTextInNode(source) + + else -> Unit + } + } + current?.let { lines += it } + return lines.joinToString("\n") + } + + private fun indentedCode(node: ASTNode): String = + node.children + .filter { it.type == MarkdownTokenTypes.CODE_LINE } + .joinToString("\n") { + it.getTextInNode(source).toString().removePrefix(" ").removePrefix("\t") + } + + private fun table(node: ASTNode): ProseBlock.Table? { + val header = node.children.firstOrNull { it.type == GFMElementTypes.HEADER } + ?.let { cells(it) } ?: return null + val rows = node.children + .filter { it.type == GFMElementTypes.ROW } + .map { row -> cells(row) } + return ProseBlock.Table(header = header, rows = rows) + } + + private fun cells(row: ASTNode): List> = + row.children.filter { it.type == GFMTokenTypes.CELL }.map { inline(it) } + + // -- inline content ------------------------------------------------------ + + private data class Flags( + val bold: Boolean = false, + val italic: Boolean = false, + val strikethrough: Boolean = false, + val link: String? = null, + ) + + private fun inline(holder: ASTNode): List { + val raw = mutableListOf() + if (holder.children.isEmpty()) { + collect(holder, Flags(), raw) + } else { + holder.children.forEach { collect(it, Flags(), raw) } + } + return normalise(raw) + } + + private fun collect(node: ASTNode, flags: Flags, out: MutableList) { + when (node.type) { + MarkdownElementTypes.EMPH -> + node.children.drop(1).dropLast(1) + .forEach { collect(it, flags.copy(italic = true), out) } + + MarkdownElementTypes.STRONG -> + node.children.drop(2).dropLast(2) + .forEach { collect(it, flags.copy(bold = true), out) } + + GFMElementTypes.STRIKETHROUGH -> + trimEqualDelimiters(node.children, GFMTokenTypes.TILDE) + .forEach { collect(it, flags.copy(strikethrough = true), out) } + + MarkdownElementTypes.CODE_SPAN -> { + val text = node.children + .drop(1).dropLast(1) + .joinToString("") { it.getTextInNode(source) } + .replace('\n', ' ') + .trim() + if (text.isNotEmpty()) out += span(text, flags).copy(code = true) + } + + MarkdownElementTypes.INLINE_LINK -> { + val url = linkDestination(node) + linkText(node)?.let { collect(it, flags.copy(link = url ?: flags.link), out) } + } + + // Reference links can't be resolved without the link map; keep their text. + MarkdownElementTypes.FULL_REFERENCE_LINK, + MarkdownElementTypes.SHORT_REFERENCE_LINK, + -> linkText(node)?.let { collect(it, flags, out) } + + // Images degrade to their alt text. + MarkdownElementTypes.IMAGE -> + node.findChildOfType(MarkdownElementTypes.INLINE_LINK) + ?.let { linkText(it) } + ?.let { collect(it, flags, out) } + + MarkdownElementTypes.LINK_TEXT -> + node.children.drop(1).dropLast(1).forEach { collect(it, flags, out) } + + MarkdownElementTypes.AUTOLINK -> { + val url = node.getTextInNode(source).toString().removeSurrounding("<", ">") + out += span(url, flags.copy(link = url)) + } + + GFMTokenTypes.GFM_AUTOLINK -> { + val url = node.getTextInNode(source).toString() + out += span(url, flags.copy(link = url)) + } + + MarkdownTokenTypes.HARD_LINE_BREAK -> out += span("\n", flags) + + MarkdownTokenTypes.EOL, + MarkdownTokenTypes.WHITE_SPACE, + -> out += span(" ", flags) + + else -> if (node.children.isEmpty()) { + out += span(node.getTextInNode(source).toString().resolveEscapes(), flags) + } else { + node.children.forEach { collect(it, flags, out) } + } + } + } + + private fun span(text: String, flags: Flags): ProseSpan = ProseSpan( + text = text, + bold = flags.bold, + italic = flags.italic, + strikethrough = flags.strikethrough, + link = flags.link, + ) + + private fun linkText(node: ASTNode): ASTNode? = + node.findChildOfType(MarkdownElementTypes.LINK_TEXT) + + private fun linkDestination(node: ASTNode): String? = + node.findChildOfType(MarkdownElementTypes.LINK_DESTINATION) + ?.getTextInNode(source)?.toString() + ?.removeSurrounding("<", ">") + ?.resolveEscapes() + ?.takeIf { it.isNotBlank() } + + private fun trimEqualDelimiters(children: List, delimiter: IElementType): List { + var left = 0 + var right = children.lastIndex + while (left < right && children[left].type == delimiter && children[right].type == delimiter) { + left++ + right-- + } + return children.subList(left, right + 1) + } + + /** Merges adjacent same-style runs, collapses doubled spaces, trims the paragraph edges. */ + private fun normalise(spans: List): List { + val merged = mutableListOf() + for (s in spans) { + val last = merged.lastOrNull() + if (last != null && !last.code && !s.code && last.copy(text = "") == s.copy(text = "")) { + merged[merged.lastIndex] = last.copy(text = last.text + s.text) + } else { + merged += s + } + } + // A hard break lexes as the trailing spaces plus a separate EOL, so spaces can + // straddle the newline we emit; strip them rather than render a stray indent. + val collapsed = merged.map { s -> + if (s.code) s + else s.copy(text = s.text.replace(SPACE_AROUND_NEWLINE, "\n").replace(MULTI_SPACE, " ")) + } + return collapsed + .mapIndexed { index, s -> + var text = s.text + if (index == 0) text = text.trimStart() + if (index == collapsed.lastIndex) text = text.trimEnd() + s.copy(text = text) + } + .filter { it.text.isNotEmpty() } + } +} + +private val MULTI_SPACE = Regex(" {2,}") +private val SPACE_AROUND_NEWLINE = Regex(" *\n *") + +// --------------------------------------------------------------------------- +// Rendering onto the PdfKmp DSL +// --------------------------------------------------------------------------- + +/** + * First-line indent rendered as a run of no-break spaces (~1.3em). The layout engine + * only treats ' ', '\t', '\n' as strippable whitespace, so NBSPs survive as glyphs on + * the paragraph's first line and vanish into normal wrapping. Swap for a real + * firstLineIndent parameter if pdfkmp grows one. + */ +private const val FIRST_LINE_INDENT = "\u00A0\u00A0\u00A0\u00A0\u00A0" + +private val HEADING_SCALES = listOf(2.0f, 1.6f, 1.3f, 1.15f, 1.05f, 1.0f) +private val BLOCK_SPACING = Dp(8f) +private val HEADING_SPACING = Dp(12f) +private val CODE_BACKGROUND = PdfColor(0.95f, 0.95f, 0.95f) + +/** Baseline-to-baseline distance as a multiple of font size. */ +private const val BODY_LEADING = 1.5f +private const val HEADING_LEADING = 1.25f + +private fun ContainerScope.renderParagraph(spans: List, base: TextStyle, colors: ProseColors) { + // A paragraph that is exactly one link renders through the clickable link DSL + // (flush — it reads as a block element, not prose); links inside running text + // are styled but not clickable (no per-span link areas). + val onlyLink = spans.singleOrNull()?.takeIf { it.link != null } + if (onlyLink != null) { + link(onlyLink.link!!) { + text(onlyLink.text) { + color = colors.link + underline = true + bold = onlyLink.bold + italic = onlyLink.italic + strikethrough = onlyLink.strikethrough + } + } + return + } + richText { + defaultSpanStyle = base + lineHeight = base.lineHeight + span(FIRST_LINE_INDENT) + for (s in spans) span(s.text) { applyFlags(s, colors) } + } +} + +private fun ContainerScope.renderHeading(block: ProseBlock.Heading, base: TextStyle, colors: ProseColors) { + val scale = HEADING_SCALES.getOrElse(block.level - 1) { 1f } + val size = base.fontSize.value * scale + val accent = when (block.level) { + 1 -> colors.primary + 2 -> colors.secondary + else -> null + } + richText { + defaultSpanStyle = base.copy( + fontSize = Sp(size), + fontWeight = FontWeight.Bold, + color = accent ?: base.color, + ) + lineHeight = Sp(size * HEADING_LEADING) + for (s in block.spans) span(s.text) { applyFlags(s, colors) } + } +} + +private fun ContainerScope.renderListing(block: ProseBlock.Listing, base: TextStyle, colors: ProseColors) { + column(spacing = Dp(4f)) { + block.items.forEachIndexed { index, item -> + val marker = if (block.ordered) "${index + 1}." else "•" + row(verticalAlignment = VerticalAlignment.Top) { + box(width = if (block.ordered) Dp(20f) else Dp(16f)) { + aligned(BoxAlignment.TopStart) { + text(marker) { color = base.color } + } + } + weighted(1f) { + richText { + defaultSpanStyle = base + lineHeight = base.lineHeight + for (s in item) span(s.text) { applyFlags(s, colors) } + } + } + } + } + } +} + +private fun ContainerScope.renderQuote(block: ProseBlock.Quote, base: TextStyle, colors: ProseColors) { + column( + padding = Padding(left = Dp(12f), top = Dp(4f), right = Dp(4f), bottom = Dp(4f)), + borderEach = BorderSides( + left = BorderStroke(width = Dp(3f), color = PdfColor.LightGray), + ), + spacing = Dp(4f), + ) { + val quoteStyle = base.copy(color = PdfColor.Gray) + for (paragraph in block.paragraphs) { + richText { + defaultSpanStyle = quoteStyle + lineHeight = base.lineHeight + for (s in paragraph) span(s.text) { applyFlags(s, colors) } + } + } + } +} + +private fun ContainerScope.renderCode(block: ProseBlock.CodeBlock, base: TextStyle) { + // No bundled monospace face; approximate with a smaller size on a grey card. + card(background = CODE_BACKGROUND, cornerRadius = Dp(4f)) { + for (line in block.code.split("\n")) { + text(line.ifEmpty { " " }) { + fontSize = Sp(base.fontSize.value * 0.9f) + color = base.color + } + } + } +} + +private fun ContainerScope.renderTable(block: ProseBlock.Table, base: TextStyle, colors: ProseColors) { + val columnCount = block.header.size.coerceAtLeast(1) + table( + columns = List(columnCount) { TableColumn.Weight(1f) }, + border = TableBorder(), + ) { + header { + for (cellSpans in block.header) { + cell { + richText { + defaultSpanStyle = base.copy(fontWeight = FontWeight.Bold) + for (s in cellSpans) span(s.text) { applyFlags(s, colors) } + } + } + } + } + for (bodyRow in block.rows) { + row { + for (cellSpans in bodyRow.take(columnCount) + List((columnCount - bodyRow.size).coerceAtLeast(0)) { emptyList() }) { + cell { + richText { + defaultSpanStyle = base + for (s in cellSpans) span(s.text) { applyFlags(s, colors) } + } + } + } + } + } + } +} + +private fun TextScope.applyFlags(s: ProseSpan, colors: ProseColors) { + if (s.bold) bold = true + if (s.italic) italic = true + if (s.strikethrough) strikethrough = true + if (s.code) fontSize = Sp(fontSize.value * 0.9f) + if (s.link != null) { + color = colors.link + underline = true + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt index 8e5715668..93bd49884 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt @@ -2,9 +2,8 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.conamobile.pdfkmp.geometry.Padding import com.conamobile.pdfkmp.layout.PageBreakStrategy -import com.conamobile.pdfkmp.markdown.MarkdownTheme -import com.conamobile.pdfkmp.markdown.markdown import com.conamobile.pdfkmp.pdf +import com.conamobile.pdfkmp.style.PdfColor import com.conamobile.pdfkmp.unit.dp import com.conamobile.pdfkmp.unit.sp import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData @@ -16,8 +15,10 @@ private fun chapterAnchorId(index: Int): String = "chapter-$index" /** * Renders the story as a PDF mirroring the EPUB layout: a title page, a clickable table of contents, - * then one auto-paginating section per chapter. Chapter bodies are rendered from markdown so headings, - * emphasis, and lists are laid out as formatted text rather than literal syntax. + * then one auto-paginating section per chapter. Chapter bodies are rendered from markdown with book + * typography (first-line paragraph indents) via [proseMarkdown]. Like the EPUB stylesheet, the + * project theme's primary color accents the title page, contents, chapter headings, and links; + * secondary accents h2 headings within chapters. */ fun writeStoryAsPdf( sink: BufferedSink, @@ -27,6 +28,9 @@ fun writeStoryAsPdf( ) { val authorName = projectData.authorName?.takeIf { it.isNotBlank() } val effective = chapters.ifEmpty { listOf(StoryChapter(projectName, "")) } + val primary = projectData.theme?.primary?.let(::argbHexToPdfColor) + val secondary = projectData.theme?.secondary?.let(::argbHexToPdfColor) + val proseColors = ProseColors(primary = primary, secondary = secondary) val document = pdf { // Bottom/side margins so text doesn't run to the page edge; Slice lets long bodies flow @@ -39,19 +43,37 @@ fun writeStoryAsPdf( authorName?.let { author = it } } - // Title page. + // Title page — accent rule under the title mirrors the EPUB's title-page hr. page { - text(projectName) { fontSize = 36.sp; bold = true } - authorName?.let { text("by $it") { fontSize = 16.sp } } + text(projectName) { + fontSize = 36.sp + bold = true + primary?.let { color = it } + } + spacer(height = 10.dp) + box(width = 200.dp) { + divider(thickness = 2.dp, color = primary ?: PdfColor.Gray) + } + authorName?.let { + spacer(height = 10.dp) + text("by $it") { fontSize = 16.sp; italic = true } + } } // Contents page — each row links to the matching chapter's anchor below. page { - text(TOC_TITLE) { fontSize = 26.sp; bold = true } + text(TOC_TITLE) { + fontSize = 26.sp + bold = true + primary?.let { color = it } + } + spacer(height = 10.dp) column(spacing = 6.dp) { effective.forEachIndexed { index, chapter -> linkToAnchor(chapterAnchorId(index)) { - text("${index + 1}. ${chapter.name}") + text("${index + 1}. ${chapter.name}") { + primary?.let { color = it } + } } } } @@ -64,9 +86,14 @@ fun writeStoryAsPdf( page { bookmark(chapter.name) anchor(chapterAnchorId(index)) - text("${index + 1}. ${chapter.name}") { fontSize = 22.sp; bold = true } + text("${index + 1}. ${chapter.name}") { + fontSize = 22.sp + bold = true + primary?.let { color = it } + } if (chapter.markdown.isNotBlank()) { - markdown(chapter.markdown.stripBackslashEscapes(), theme = MarkdownTheme()) + spacer(height = 14.dp) + proseMarkdown(chapter.markdown, proseColors) } } } @@ -75,31 +102,18 @@ fun writeStoryAsPdf( sink.write(document.toByteArray()) } -private const val ASCII_PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - -/** Unescaping these would create active markdown (emphasis/code/link/strikethrough), so keep them escaped. */ -private const val MARKDOWN_DELIMITERS = "*_`[]~" - -/** - * Resolves CommonMark backslash escapes (`\!`, `\(`, `\-`, …) to the bare punctuation a compliant - * parser would emit. pdfkmp-markdown leaves the backslash in, so imported content that was escaped - * upstream would otherwise show stray backslashes throughout the prose. Markdown delimiters are left - * escaped so `\*literal\*` is not turned back into emphasis. - */ -private fun String.stripBackslashEscapes(): String { - if ('\\' !in this) return this - val out = StringBuilder(length) - var i = 0 - while (i < length) { - val c = this[i] - val next = if (i + 1 < length) this[i + 1] else null - if (c == '\\' && next != null && next in ASCII_PUNCTUATION && next !in MARKDOWN_DELIMITERS) { - out.append(next) - i += 2 - } else { - out.append(c) - i++ - } +/** Project themes store colors as ARGB hex (`#FFRRGGBB`, sometimes `#RRGGBB`); null for anything else. */ +internal fun argbHexToPdfColor(argb: String): PdfColor? { + val hex = argb.trim().removePrefix("#") + val rgb = when (hex.length) { + 6 -> hex + 8 -> hex.substring(2) + else -> return null } - return out.toString() + val value = rgb.toIntOrNull(16) ?: return null + return PdfColor( + ((value shr 16) and 0xFF) / 255f, + ((value shr 8) and 0xFF) / 255f, + (value and 0xFF) / 255f, + ) } diff --git a/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt b/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt new file mode 100644 index 000000000..f6c66ddaa --- /dev/null +++ b/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt @@ -0,0 +1,189 @@ +package components.projecthome + +import com.darkrockstudios.apps.hammer.common.components.projecthome.ProseBlock +import com.darkrockstudios.apps.hammer.common.components.projecthome.ProseSpan +import com.darkrockstudios.apps.hammer.common.components.projecthome.argbHexToPdfColor +import com.darkrockstudios.apps.hammer.common.components.projecthome.parseProseMarkdown +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PdfProseMarkdownTest { + + private fun List.plain(): String = joinToString("") { it.text } + + private fun paragraph(block: ProseBlock): ProseBlock.Paragraph = assertIs(block) + + @Test + fun `blank input produces no blocks`() { + assertTrue(parseProseMarkdown("").isEmpty()) + assertTrue(parseProseMarkdown(" \n\n ").isEmpty()) + } + + @Test + fun `paragraphs are split on blank lines and soft wraps become spaces`() { + val blocks = parseProseMarkdown("First line\ncontinues here.\n\nSecond paragraph.") + + assertEquals(2, blocks.size) + assertEquals("First line continues here.", paragraph(blocks[0]).spans.plain()) + assertEquals("Second paragraph.", paragraph(blocks[1]).spans.plain()) + } + + @Test + fun `emphasis produces styled spans`() { + val blocks = parseProseMarkdown("Plain **bold** and *italic* and ***both***.") + + val spans = paragraph(blocks.single()).spans + assertEquals("Plain bold and italic and both.", spans.plain()) + + val bold = spans.single { it.text == "bold" } + assertTrue(bold.bold) + assertTrue(!bold.italic) + + val italic = spans.single { it.text == "italic" } + assertTrue(italic.italic) + assertTrue(!italic.bold) + + val both = spans.single { it.text == "both" } + assertTrue(both.bold) + assertTrue(both.italic) + } + + @Test + fun `strikethrough produces styled spans`() { + val blocks = parseProseMarkdown("This is ~~gone~~ now.") + + val spans = paragraph(blocks.single()).spans + assertEquals("This is gone now.", spans.plain()) + assertTrue(spans.single { it.text == "gone" }.strikethrough) + } + + @Test + fun `backslash escapes resolve to bare punctuation without styling`() { + val blocks = parseProseMarkdown("""Keep \*these\* literal \(and these\).""") + + val spans = paragraph(blocks.single()).spans + assertEquals("Keep *these* literal (and these).", spans.plain()) + assertTrue(spans.none { it.italic || it.bold }) + } + + @Test + fun `atx headings carry their level`() { + val blocks = parseProseMarkdown("# Title\n\n### Sub\n\nBody.") + + val h1 = assertIs(blocks[0]) + assertEquals(1, h1.level) + assertEquals("Title", h1.spans.plain()) + + val h3 = assertIs(blocks[1]) + assertEquals(3, h3.level) + assertEquals("Sub", h3.spans.plain()) + + paragraph(blocks[2]) + } + + @Test + fun `horizontal rule becomes a rule block`() { + val blocks = parseProseMarkdown("Before.\n\n---\n\nAfter.") + + assertEquals(3, blocks.size) + assertIs(blocks[1]) + } + + @Test + fun `unordered list keeps inline styling per item`() { + val blocks = parseProseMarkdown("- plain item\n- **bold** item") + + val list = assertIs(blocks.single()) + assertTrue(!list.ordered) + assertEquals(2, list.items.size) + assertEquals("plain item", list.items[0].plain()) + assertEquals("bold item", list.items[1].plain()) + assertTrue(list.items[1].first { it.text == "bold" }.bold) + } + + @Test + fun `ordered list is marked ordered`() { + val blocks = parseProseMarkdown("1. first\n2. second") + + val list = assertIs(blocks.single()) + assertTrue(list.ordered) + assertEquals(listOf("first", "second"), list.items.map { it.plain() }) + } + + @Test + fun `inline link is styled with its destination`() { + val blocks = parseProseMarkdown("See [the site](https://example.com) today.") + + val spans = paragraph(blocks.single()).spans + assertEquals("See the site today.", spans.plain()) + assertEquals("https://example.com", spans.single { it.text == "the site" }.link) + assertNull(spans.first().link) + } + + @Test + fun `standalone link paragraph is a single linked span`() { + val blocks = parseProseMarkdown("[the site](https://example.com)") + + val spans = paragraph(blocks.single()).spans + val span = spans.single() + assertEquals("the site", span.text) + assertEquals("https://example.com", span.link) + } + + @Test + fun `inline code is flagged and keeps its text verbatim`() { + val blocks = parseProseMarkdown("Run `the *command*` now.") + + val spans = paragraph(blocks.single()).spans + val code = spans.single { it.code } + assertEquals("the *command*", code.text) + } + + @Test + fun `hard line break becomes a newline within the paragraph`() { + val blocks = parseProseMarkdown("line one \nline two") + + assertEquals("line one\nline two", paragraph(blocks.single()).spans.plain()) + } + + @Test + fun `fenced code block keeps lines including blank ones`() { + val blocks = parseProseMarkdown("```\nfirst\n\nsecond\n```") + + val code = assertIs(blocks.single()) + assertEquals("first\n\nsecond", code.code) + } + + @Test + fun `blockquote collects its paragraphs`() { + val blocks = parseProseMarkdown("> quoted text\n>\n> more quoted") + + val quote = assertIs(blocks.single()) + assertEquals(listOf("quoted text", "more quoted"), quote.paragraphs.map { it.plain() }) + } + + @Test + fun `pipe table parses header and rows`() { + val blocks = parseProseMarkdown("| a | b |\n|---|---|\n| one | **two** |") + + val table = assertIs(blocks.single()) + assertEquals(listOf("a", "b"), table.header.map { it.plain() }) + assertEquals(listOf("one", "two"), table.rows.single().map { it.plain() }) + assertTrue(table.rows.single()[1].single().bold) + } + + @Test + fun `argb hex theme colors convert to pdf colors`() { + val color = argbHexToPdfColor("#FF3366CC") + assertEquals(0x33 / 255f, color!!.red) + assertEquals(0x66 / 255f, color.green) + assertEquals(0xCC / 255f, color.blue) + + assertEquals(color, argbHexToPdfColor("3366CC")) + assertNull(argbHexToPdfColor("not-a-color")) + assertNull(argbHexToPdfColor("#12345")) + } +} diff --git a/common/src/desktopTest/kotlin/components/projecthome/PdfProseVisualCheck.kt b/common/src/desktopTest/kotlin/components/projecthome/PdfProseVisualCheck.kt new file mode 100644 index 000000000..38b750ede --- /dev/null +++ b/common/src/desktopTest/kotlin/components/projecthome/PdfProseVisualCheck.kt @@ -0,0 +1,47 @@ +package components.projecthome + +import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData +import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme +import com.darkrockstudios.apps.hammer.common.components.projecthome.StoryChapter +import com.darkrockstudios.apps.hammer.common.components.projecthome.writeStoryAsPdf +import okio.FileSystem +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable + +/** Manual visual check: renders a sample PDF to a real temp file for eyeballing. Not part of CI. */ +class PdfProseVisualCheck { + + @Test + @EnabledIfEnvironmentVariable(named = "PDF_VISUAL_CHECK", matches = "1") + fun `render sample pdf`() { + val markdown = """ + The opening paragraph of a chapter sits flush against the margin, as is the convention in book typesetting. It runs long enough to wrap across several lines so the wrapping behaviour around the indent can be seen clearly on the page. + + The second paragraph begins with a first-line indent, marking the paragraph break without a blank line. It also runs long enough to wrap, which shows that only the first line is indented and continuation lines return to the margin. + + A third paragraph continues the prose with the same indent, with **bold**, *italic*, and ~~struck~~ words along the way to confirm inline styling still works. + + --- + + After a scene break the next paragraph is flush again, signalling a fresh section to the reader. + + And the one after it is indented once more. + + ## A Secondary Heading + + A paragraph under an in-chapter h2 heading, which takes the secondary accent color, with a [link](https://example.com) that should take the primary accent. + """.trimIndent() + + val chapters = listOf(StoryChapter("A Sample Chapter", markdown)) + val out = System.getProperty("java.io.tmpdir").toPath() / "prose-check.pdf" + val projectData = ProjectData( + authorName = "Test Author", + theme = ProjectTheme(primary = "#FF6750A4", secondary = "#FF7D5260"), + ) + FileSystem.SYSTEM.write(out) { + writeStoryAsPdf(this, "Indent Check", projectData, chapters) + } + println("Wrote $out") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9501d21df..32664f420 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -175,7 +175,6 @@ epub4kmp_core = { module = "com.darkrockstudios:epub4kmp-core", version.ref = "e xmlutil_core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } poi_ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poi" } pdfkmp = { module = "io.github.conamobiledev:pdfkmp", version.ref = "pdfkmp" } -pdfkmp_markdown = { module = "io.github.conamobiledev:pdfkmp-markdown", version.ref = "pdfkmp" } kotlinx_html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx_html" } multiplatform_window_size = { group = "dev.chrisbanes.material3", name = "material3-window-size-class-multiplatform", version.ref = "multiplatform_window_size" } material = { module = "com.google.android.material:material", version.ref = "material" }