Materials: images with captions and video support
- markdown: new directives `:::figure assets/x.png | Подпись` and `:::video assets/x.mp4 | Подпись` rendered as figure/figcaption and a video player (works with HTML escaping on); sanitizer allows video/source/ track/figure with safe attributes only - assets: video extensions (mp4/webm/ogg) allowed, separate limits — images up to 10 MB, video up to 100 MB; correct content types - upload limits raised: web/academic multipart 120 MB per file / 200 MB per request, nginx client_max_body_size 200m (covers media inside ZIP imports) - docs: new docs/06-authoring.md (markdown, images, video, assets, limits, import semantics); import message now says "медиафайлов"
This commit is contained in:
@@ -15,12 +15,15 @@ import org.owasp.html.PolicyFactory
|
||||
* Renders LMS markdown to sanitized HTML.
|
||||
*
|
||||
* Supported beyond CommonMark: tables, strikethrough, task lists, footnotes,
|
||||
* autolinks and the custom spoiler container:
|
||||
* autolinks and custom containers:
|
||||
*
|
||||
* ```
|
||||
* :::spoiler Заголовок
|
||||
* скрытый текст (тоже markdown)
|
||||
* :::
|
||||
*
|
||||
* :::figure assets/scheme.svg | Рис. 1. Подпись к рисунку
|
||||
* :::video assets/demo.mp4 | Видео: установка за 5 минут
|
||||
* ```
|
||||
*
|
||||
* The result is always passed through an allow-list HTML sanitizer, so authors
|
||||
@@ -54,13 +57,13 @@ class MarkdownRenderer(
|
||||
val out = StringBuilder()
|
||||
var cursor = 0
|
||||
for (match in SPOILER.findAll(markdown)) {
|
||||
out.append(renderSegment(markdown.substring(cursor, match.range.first)))
|
||||
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
|
||||
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
|
||||
val body = match.groupValues[2]
|
||||
val inner = if (depth < MAX_DEPTH) {
|
||||
renderRaw(body, depth + 1)
|
||||
} else {
|
||||
renderSegment(body)
|
||||
renderWithDirectives(body)
|
||||
}
|
||||
out.append("<details class=\"spoiler\"><summary>")
|
||||
out.append(escapeHtml(title))
|
||||
@@ -69,13 +72,50 @@ class MarkdownRenderer(
|
||||
out.append("</div></details>")
|
||||
cursor = match.range.last + 1
|
||||
}
|
||||
out.append(renderSegment(markdown.substring(cursor)))
|
||||
out.append(renderWithDirectives(markdown.substring(cursor)))
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderSegment(markdown: String): String =
|
||||
renderer.render(parser.parse(markdown))
|
||||
|
||||
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
|
||||
private fun renderWithDirectives(markdown: String): String {
|
||||
if (!markdown.contains(":::")) return renderSegment(markdown)
|
||||
val out = StringBuilder()
|
||||
var cursor = 0
|
||||
for (match in MEDIA.findAll(markdown)) {
|
||||
out.append(renderSegment(markdown.substring(cursor, match.range.first)))
|
||||
val kind = match.groupValues[1].lowercase()
|
||||
val url = match.groupValues[2].trim()
|
||||
val caption = match.groupValues[3].trim()
|
||||
if (kind == "video") {
|
||||
out.append("<figure class=\"md-video-figure\">")
|
||||
out.append("<video class=\"md-video\" controls preload=\"metadata\" src=\"")
|
||||
out.append(escapeHtml(url))
|
||||
out.append("\"></video>")
|
||||
if (caption.isNotBlank()) {
|
||||
out.append("<figcaption>").append(renderInline(caption)).append("</figcaption>")
|
||||
}
|
||||
out.append("</figure>")
|
||||
} else {
|
||||
out.append("<figure class=\"md-figure\">")
|
||||
out.append("<img src=\"").append(escapeHtml(url)).append("\" alt=\"")
|
||||
out.append(escapeHtml(caption)).append("\">")
|
||||
if (caption.isNotBlank()) {
|
||||
out.append("<figcaption>").append(renderInline(caption)).append("</figcaption>")
|
||||
}
|
||||
out.append("</figure>")
|
||||
}
|
||||
cursor = match.range.last + 1
|
||||
}
|
||||
out.append(renderSegment(markdown.substring(cursor)))
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderInline(text: String): String =
|
||||
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||
|
||||
private fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
@@ -92,11 +132,19 @@ class MarkdownRenderer(
|
||||
"code", "pre",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
|
||||
"video", "source", "track", "audio",
|
||||
)
|
||||
.allowAttributes("href").onElements("a")
|
||||
.allowAttributes("title").onElements("a", "img")
|
||||
.allowAttributes("src", "alt").onElements("img")
|
||||
.allowAttributes("class").onElements("code", "pre", "div", "span", "details", "summary", "table", "th", "td")
|
||||
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||
.onElements("video")
|
||||
.allowAttributes("src", "type").onElements("source")
|
||||
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
|
||||
.allowAttributes("class").onElements(
|
||||
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
|
||||
"figure", "figcaption", "video", "img",
|
||||
)
|
||||
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
.allowAttributes("colspan", "rowspan").onElements("td", "th")
|
||||
.allowUrlProtocols("http", "https", "mailto")
|
||||
@@ -105,6 +153,9 @@ class MarkdownRenderer(
|
||||
|
||||
private companion object {
|
||||
const val MAX_DEPTH = 5
|
||||
val MEDIA = Regex(
|
||||
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
|
||||
)
|
||||
val SPOILER = Regex(
|
||||
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user