// pikchr.go renders Pikchr diagrams to sanitized inline SVG — the third
// markup Fossil dispatches on (text/x-pikchr), and the ```pikchr fence
// inside markdown.
//
// SVG is a real XSS surface (script, event handlers, foreignObject,
// javascript: in xlink:href), so the diagram does NOT ride the general
// document policy. It gets its own narrow allow-list, derived by
// enumerating what the Pikchr module actually emits — six elements and a
// closed set of geometry attributes, none of them script-bearing. That
// is defence in depth: even if Pikchr's own label escaping had a hole,
// nothing outside this vocabulary survives.
package markup
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"craftdesign.group/quarry/internal/pikchr"
)
// svgPolicy admits exactly Pikchr's output vocabulary. Deliberately
// absent: script, foreignObject, use, image, a, and every on* handler —
// none of which Pikchr emits, so refusing them costs nothing.
var svgPolicy = func() *bluemonday.Policy {
p := bluemonday.NewPolicy()
p.AllowElements("svg", "path", "polygon", "circle", "ellipse", "text")
number := regexp.MustCompile(`^-?[0-9.]+$`)
// Geometry and path data: digits, separators and path commands only.
p.AllowAttrs("x", "y", "cx", "cy", "r", "rx", "ry").Matching(number).
OnElements("svg", "path", "polygon", "circle", "ellipse", "text")
p.AllowAttrs("d", "points").Matching(regexp.MustCompile(`^[MmLlHhVvCcSsQqTtAaZz0-9eE,.\s+-]*$`)).
OnElements("path", "polygon")
p.AllowAttrs("viewBox").Matching(regexp.MustCompile(`^[0-9.\s-]+$`)).OnElements("svg")
p.AllowAttrs("width", "height").Matching(number).OnElements("svg")
p.AllowAttrs("xmlns").Matching(regexp.MustCompile(`^http://www\.w3\.org/2000/svg$`)).OnElements("svg")
// Presentation: a fixed set of paint properties, plus the text
// alignment hints and the class Pikchr tags its root with.
p.AllowAttrs("fill").Matching(regexp.MustCompile(`^(none|currentColor|rgb\([0-9, ]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$`)).
OnElements("path", "polygon", "circle", "ellipse", "text", "svg")
p.AllowAttrs("text-anchor").Matching(regexp.MustCompile(`^(start|middle|end)$`)).OnElements("text")
p.AllowAttrs("dominant-baseline").Matching(regexp.MustCompile(`^[a-z-]+$`)).OnElements("text")
p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-zA-Z0-9 _-]{0,64}$`)).
OnElements("svg", "path", "polygon", "circle", "ellipse", "text")
p.AllowAttrs("data-pikchr-date").Matching(regexp.MustCompile(`^[0-9T:.Z -]{0,32}$`)).OnElements("svg")
// Pikchr writes a shape's paint as a style attribute and a label's
// weight, slant and size as attributes. Each is matched on its own
// value: a property with no matcher is not "allowed" but DROPPED,
// because the sanitizer has no built-in rule for SVG paint, and
// that left every shape at the SVG default (solid black, no stroke)
// and every label at one weight and size.
paint := regexp.MustCompile(`^(none|currentColor|rgb\([0-9., ]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$`)
p.AllowStyles("fill", "stroke").Matching(paint).
OnElements("path", "polygon", "circle", "ellipse", "text", "svg")
p.AllowStyles("stroke-width").Matching(regexp.MustCompile(`^[0-9.]+$`)).
OnElements("path", "polygon", "circle", "ellipse")
p.AllowStyles("stroke-dasharray").Matching(regexp.MustCompile(`^[0-9., ]+$`)).
OnElements("path", "polygon", "circle", "ellipse")
p.AllowStyles("font-size").Matching(regexp.MustCompile(`^(initial|[0-9.]+(%|px|em)?)$`)).
OnElements("text", "svg")
p.AllowAttrs("font-weight").Matching(regexp.MustCompile(`^(normal|bold|[1-9]00)$`)).OnElements("text")
p.AllowAttrs("font-style").Matching(regexp.MustCompile(`^(normal|italic)$`)).OnElements("text")
p.AllowAttrs("font-size").Matching(regexp.MustCompile(`^[0-9.]+(%|px|em)?$`)).OnElements("text")
p.AllowAttrs("transform").Matching(regexp.MustCompile(`^rotate\(-?[0-9.]+(,? ?-?[0-9.]+,? ?-?[0-9.]+)?\)$`)).OnElements("text")
return p
}()
// RenderPikchr compiles Pikchr source to a sanitized inline SVG. A
// syntax error (or an unavailable module) comes back as an error; the
// caller decides how to show it, which is always as escaped text rather
// than a broken page.
func RenderPikchr(src []byte) ([]byte, error) {
svg, err := pikchr.Render(src)
if err != nil {
return nil, err
}
return svgPolicy.SanitizeBytes(themeNeutral(svg)), nil
}
// pikchrBlack matches Pikchr's hardcoded default ink.
var pikchrBlack = regexp.MustCompile(`rgb\(0, ?0, ?0\)`)
// themeNeutral swaps Pikchr's default black for currentColor so a
// diagram inherits the page's text colour. Quarry's pages follow
// prefers-color-scheme, and the server cannot know the reader's theme
// at render time — black ink would be invisible on the dark theme
// (Fossil solves the same problem with a server-side --dark-mode flag,
// which cannot work for a CSS-themed page). Author-specified colours
// are left exactly as written.
func themeNeutral(svg []byte) []byte {
return pikchrBlack.ReplaceAll(svg, []byte("currentColor"))
}
// pikchrFence matches a ```pikchr fenced block — how a diagram is
// embedded in a markdown document (fossil supports the same fence).
var pikchrFence = regexp.MustCompile("(?s)```pikchr[ \t]*\r?\n(.*?)```")
// The placeholder must be plain alphanumerics: an HTML parser rewrites
// control bytes (a NUL becomes U+FFFD), so a marker built from them does
// not survive sanitization. A per-render nonce keeps user text from
// colliding with it.
func pikchrHold(nonce string, i int) string {
return fmt.Sprintf("qpikchr%sx%dx", nonce, i)
}
// svgBlock matches one inline SVG element and its contents. Ungreedy,
// so two diagrams in a document are two matches rather than one span
// that swallows everything between them.
var svgBlock = regexp.MustCompile(`(?is)<svg\b[^>]*>.*?</svg>`)
// SanitizeDoc is Sanitize for repository-authored HTML that may carry
// PRE-RENDERED diagrams.
//
// The document policy admits no <svg> at all, and should not: an
// embedded doc is content somebody else wrote, and SVG is a real XSS
// surface (script, event handlers, foreignObject, javascript: in
// xlink:href). So a document that shipped its diagrams as baked SVG —
// rather than as a ```pikchr fence quarry compiles itself — had them
// silently removed, which is what zit's submanifest guide showed:
// prose about diagrams, and no diagrams.
//
// Each SVG is lifted out, run through the SAME narrow policy a diagram
// quarry compiled gets, and put back. That policy is an allow-list of
// six elements and a closed set of geometry attributes, so output that
// really is Pikchr's survives untouched and anything else is stripped
// to nothing. The gate does not move; what changes is that a diagram
// is no longer refused for arriving already drawn.
func SanitizeDoc(in string) string {
if !svgBlock.MatchString(in) {
return Sanitize(in)
}
var buf [8]byte
rand.Read(buf[:])
nonce := hex.EncodeToString(buf[:])
var kept []string
held := svgBlock.ReplaceAllStringFunc(in, func(m string) string {
clean := strings.TrimSpace(svgPolicy.Sanitize(m))
// A diagram the policy empties is one that was not a diagram.
// It leaves nothing behind rather than an empty frame.
if clean == "" {
return ""
}
kept = append(kept, string(Figure([]byte(clean), nil)))
return pikchrHold(nonce, len(kept)-1)
})
return string(restorePikchrFences([]byte(Sanitize(held)), kept, nonce))
}
// liftPikchrFences replaces each ```pikchr block with a placeholder,
// returning the rendered diagrams (or an escaped-source fallback when a
// diagram will not compile) and the nonce that keys them.
func liftPikchrFences(src []byte) ([]byte, []string, string) {
if !pikchrFence.Match(src) {
return src, nil, ""
}
var buf [8]byte
rand.Read(buf[:])
nonce := hex.EncodeToString(buf[:])
var out []string
replaced := pikchrFence.ReplaceAllFunc(src, func(m []byte) []byte {
body := pikchrFence.FindSubmatch(m)[1]
svg, err := RenderPikchr(body)
if err != nil {
out = append(out, `<pre>`+escapeHTML(string(body))+`</pre>`+
`<p><small>pikchr: `+escapeHTML(err.Error())+`</small></p>`)
} else {
out = append(out, string(Figure(svg, body)))
}
return []byte(pikchrHold(nonce, len(out)-1))
})
return replaced, out, nonce
}
// restorePikchrFences swaps the diagrams back in. A placeholder sits
// alone in its paragraph, so the wrapping <p> is unwrapped to keep the
// SVG a block element rather than phrasing content inside a paragraph.
func restorePikchrFences(html []byte, diagrams []string, nonce string) []byte {
s := string(html)
for i, d := range diagrams {
h := pikchrHold(nonce, i)
s = strings.ReplaceAll(s, "<p>"+h+"</p>", d)
s = strings.ReplaceAll(s, h, d)
}
return []byte(s)
}
// svgWidth reads a rendered diagram's drawn width from its viewBox.
var svgWidth = regexp.MustCompile(`(?i)viewBox="[0-9.-]+ [0-9.-]+ ([0-9.]+) [0-9.]+"`)
// copyPikchr is the Copy button's click, as a Datastar expression: the
// page already runs Datastar, so a diagram adds no script of its own.
// It copies the frame's source and marks the button done for two
// seconds. Where there is no Clipboard API (a plain-http forge on a
// LAN) or it refuses, the frame switches to the source and selects it,
// ready for the reader's own copy.
const copyPikchr = `(navigator.clipboard ? navigator.clipboard.writeText(el.closest('.pikchr-fig').querySelector('.pikchr-src code').textContent) : Promise.reject())` +
`.then(() => (el.classList.add('done'), setTimeout(() => el.classList.remove('done'), 2000)),` +
` () => (el.closest('.pikchr-fig').querySelector('.pikchr-vs').checked = true,` +
` getSelection().selectAllChildren(el.closest('.pikchr-fig').querySelector('.pikchr-src code'))))`
// Figure frames a sanitized diagram for the page. A diagram is drawn
// for a desktop column, and on a phone it is wider than the column: the
// frame scales it down to fit (the stylesheet reads its drawn width from
// --pk-w), so nothing scrolls sideways.
// Tapping it opens it alone at full size (CSS :target, no script), and
// the back button closes it. The id is the diagram's own hash, so two
// posts on one page never share one and a link to it is stable.
//
// With its pikchr source (src non-nil), the frame carries a bar under
// the diagram: switch to the source in place, copy it, or open it in
// pikchr-studio. The switch is two radio buttons and CSS, so it works
// with scripts off; Copy is a Datastar click, and the stylesheet hides
// it when scripts are off. A diagram that arrived pre-rendered, with no
// source (SanitizeDoc), has no bar.
func Figure(svg, src []byte) []byte {
sum := sha256.Sum256(svg)
id := "pk-" + hex.EncodeToString(sum[:5])
style := ""
if m := svgWidth.FindSubmatch(svg); m != nil {
if w, err := strconv.ParseFloat(string(m[1]), 64); err == nil && w > 0 {
style = fmt.Sprintf(` style="--pk-w:%.0fpx"`, w)
}
}
if src != nil {
// What the reader sees, copies and opens in the studio is one
// text: the fence's body without its trailing newlines.
src = bytes.TrimRight(src, "\r\n")
}
var b strings.Builder
b.WriteString(`<figure class="pikchr-fig" id="` + id + `"` + style + `>`)
if src != nil {
b.WriteString(`<input type="radio" class="pikchr-v pikchr-vd" name="` + id + `-v" id="` + id + `-d" checked>` +
`<input type="radio" class="pikchr-v pikchr-vs" name="` + id + `-v" id="` + id + `-s">`)
}
b.WriteString(`<div class="pikchr-view"><a class="pikchr-open" href="#` + id + `" title="Open the diagram full size">` +
string(svg) + `</a></div>`)
if src != nil {
b.WriteString(`<pre class="pikchr-src"><code>` + escapeHTML(string(src)) + `</code></pre>`)
}
b.WriteString(`<a class="pikchr-close" href="#_" title="Close">×</a>`)
if src != nil {
b.WriteString(`<figcaption class="pikchr-bar"><span class="pikchr-kind">pikchr</span>` +
`<span class="pikchr-seg"><label for="` + id + `-d">Diagram</label><label for="` + id + `-s">Source</label></span>` +
`<span class="pikchr-gap"></span>` +
`<button type="button" class="pikchr-act pikchr-copy" aria-label="Copy the pikchr source" data-on:click="` + copyPikchr + `">` +
`<svg class="ic pikchr-ic-copy" aria-hidden="true"><use href="#i-copy"/></svg>` +
`<svg class="ic pikchr-ic-done" aria-hidden="true"><use href="#i-check"/></svg>` +
`<span class="pikchr-lbl"><span class="pikchr-copy-idle">Copy</span><span class="pikchr-copy-done" role="status">Copied</span></span></button>`)
if link := StudioLink(src); link != "" {
b.WriteString(`<a class="pikchr-act pikchr-studio" href="` + escapeHTML(link) + `" target="_blank" rel="noopener"` +
` aria-label="Edit in pikchr-studio"><svg class="ic" aria-hidden="true"><use href="#i-pencil"/></svg>` +
`<span class="pikchr-lbl"><span class="pikchr-long">Edit in </span>pikchr-studio</span></a>`)
}
b.WriteString(`</figcaption>`)
}
b.WriteString(`</figure>`)
return []byte(b.String())
}