Skip to content

PresentationSearch.kt

src/jvmMain/kotlin/afield/ui/slides/PresentationSearch.kt · 75 lines

package afield.ui.slides

import afield.app.protocol.AudienceContent
import java.io.StringReader
import javax.swing.text.MutableAttributeSet
import javax.swing.text.html.HTML
import javax.swing.text.html.HTMLEditorKit
import javax.swing.text.html.parser.ParserDelegator

Reduces a slide to the plain text a presenter can search, by reading the audience HTML the talk would actually show.

The slideshow calls text for every step when a client asks for search text, and caches the result for the built talk. It parses the HTML with the JDK's own parser, drops <style> and <script> subtrees, keeps image alt text, and adds the one identifying string of the other audience types: a document name, or a webpage or video URL. A step with no audience content at all falls back to its presenter note. The result is capped at 16 384 characters.

Indexing stays on authored content. The presenter-note fallback is taken as written rather than with its template functions evaluated, because those can run user code, and a step whose audience provider throws (a slide file not written yet) is skipped so the rest of the talk still opens.

internal object PresentationSearch

Authored audience text only: indexing never starts a guest, invokes callbacks or fetches a URL.

const val MAX_TEXT = 16_384

fun text(step: SlideStep): String

body — 46 lines, from line 29
    fun text(step: SlideStep): String {
        val parts = linkedSetOf<String>()
        fun add(value: String) {
            val text = value.replace(Regex("\\s+"), " ").trim()
            if (text.isNotEmpty()) parts += text
        }
        fun content(content: AudienceContent) {
            when (content.type) {
                "html" -> {
                    var suppressed = 0
                    val text = StringBuilder()
                    ParserDelegator().parse(StringReader(content.html), object : HTMLEditorKit.ParserCallback() {
                        override fun handleStartTag(tag: HTML.Tag, attrs: MutableAttributeSet, pos: Int) {
                            if (tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT) suppressed++
                            if (suppressed == 0 && tag.isBlock) text.append(' ')
                        }
                        override fun handleEndTag(tag: HTML.Tag, pos: Int) {
                            if (tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT) suppressed = (suppressed - 1).coerceAtLeast(0)
                            if (suppressed == 0 && tag.isBlock) text.append(' ')
                        }
                        override fun handleText(data: CharArray, pos: Int) { if (suppressed == 0) text.append(data) }
                        override fun handleSimpleTag(tag: HTML.Tag, attrs: MutableAttributeSet, pos: Int) {
                            if (suppressed == 0) {
                                if (tag == HTML.Tag.BR || tag == HTML.Tag.HR) text.append(' ')
                                if (tag == HTML.Tag.IMG) attrs.getAttribute(HTML.Attribute.ALT)?.let { text.append(' ').append(it).append(' ') }
                            }
                        }
                    }, true)
                    add(text.toString())
                }
                "field-document" -> add(content.document)
                "webpage", "video" -> add(content.url)
            }
        }
        fun audience(view: SlideStep, noteFallback: Boolean) {
            // A missing future file must not prevent the presenter opening the rest of the talk.
            val value = runCatching { view.audienceProvider?.invoke() ?: view.audience }.getOrNull()
            if (value != null) content(value)
            // Custom template functions may execute user code; index the authored note, not its evaluation.
            else if (noteFallback && view.audienceProvider == null && view.audience == null) view.noteHtml?.let { content(AudienceContent(html = it)) }
        }
        audience(step, true)
        step.cues.forEach { add(it.title); audience(it, step.audience == null && step.audienceProvider == null) }
        return parts.joinToString(" ").take(MAX_TEXT)
    }
}