Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions backend/svg/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ func newBackend(out io.Writer, w, h int, dpr float64, o options) *backend {
}

// The ids the accessible title and description are written under. A document
// has one chart in it, so they need no counter.
// has one chart in it, so they need no counter; [IDPrefix] is what keeps two
// documents on one page apart.
const (
titleID = "figure-title"
descID = "figure-desc"
Expand All @@ -74,11 +75,11 @@ func (b *backend) accessibilityAttrs(head *bytes.Buffer) {
head.WriteString(` role="img" aria-labelledby="`)
switch {
case b.desc.Title != "" && b.desc.Detail != "":
head.WriteString(titleID + " " + descID)
head.WriteString(b.opts.idPrefix + titleID + " " + b.opts.idPrefix + descID)
case b.desc.Title != "":
head.WriteString(titleID)
head.WriteString(b.opts.idPrefix + titleID)
default:
head.WriteString(descID)
head.WriteString(b.opts.idPrefix + descID)
}
head.WriteString(`"`)
}
Expand All @@ -88,13 +89,13 @@ func (b *backend) accessibilityAttrs(head *bytes.Buffer) {
// reader should find them.
func (b *backend) accessibilityElements(head *bytes.Buffer) {
if b.desc.Title != "" {
head.WriteString(`<title id="` + titleID + `">`)
head.WriteString(`<title id="` + b.opts.idPrefix + titleID + `">`)
xmlEscape(head, b.desc.Title)
head.WriteString(`</title>`)
b.nl(head)
}
if b.desc.Detail != "" {
head.WriteString(`<desc id="` + descID + `">`)
head.WriteString(`<desc id="` + b.opts.idPrefix + descID + `">`)
xmlEscape(head, b.desc.Detail)
head.WriteString(`</desc>`)
b.nl(head)
Expand All @@ -103,7 +104,7 @@ func (b *backend) accessibilityElements(head *bytes.Buffer) {

func (b *backend) id(prefix string) string {
b.nextID++
return prefix + strconv.Itoa(b.nextID)
return b.opts.idPrefix + prefix + strconv.Itoa(b.nextID)
}

// nl writes a newline in pretty mode and nothing otherwise, so the compact and
Expand Down
53 changes: 53 additions & 0 deletions backend/svg/svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,59 @@ func TestClipAndTransformGroup(t *testing.T) {
}
}

// Two documents inlined into one page share an id namespace, and both count
// from one. Every id a document defines, and every reference to one, has to
// carry the prefix, or the second chart's cells are clipped to the first's
// panel and its aria-labelledby reads the first one's title.
func TestIDPrefixReachesEveryIdAndReference(t *testing.T) {
b, finish := open(t, svg.IDPrefix("chart2-"))
b.(ir.Semantics).Describe(ir.Description{Title: "T", Detail: "D"})
var clip ir.Path
clip.Rect(ir.R(0, 0, 50, 50))
b.Push(&clip, ir.Affine{A: 1, D: 1})
b.Markers(ir.MarkerCircle, []ir.Point{{X: 1, Y: 1}}, ir.MarkerStyle{Size: 6, Fill: ir.RGB(0, 128, 0)})
var p ir.Path
p.Rect(ir.R(0, 0, 10, 10))
b.FillPath(&p, ir.Fill{
Color: ir.RGB(0, 0, 0), End: ir.Point{X: 10},
Stops: []ir.GradientStop{{Offset: 0, Color: ir.RGB(0, 0, 0)}, {Offset: 1, Color: ir.RGB(255, 255, 255)}},
}, ir.NonZero)
b.Pop()
got := finish()

for _, want := range []string{
`<clipPath id="chart2-c1">`, `clip-path="url(#chart2-c1)"`,
`<path id="chart2-m2"`, `href="#chart2-m2"`,
`<linearGradient id="chart2-g3"`, `fill="url(#chart2-g3)"`,
`aria-labelledby="chart2-figure-title chart2-figure-desc"`,
`<title id="chart2-figure-title">`, `<desc id="chart2-figure-desc">`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %s in:\n%s", want, got)
}
}
for _, unprefixed := range []string{`id="c1"`, `id="m2"`, `id="g3"`, `id="figure-`, `"#c1`, `"#m2`, `(#c1`, `(#g3`, `="figure-`, `"figure-`} {
if strings.Contains(got, unprefixed) {
t.Errorf("found unprefixed %s in:\n%s", unprefixed, got)
}
}
}

func TestABadIDPrefixIsReportedOnOpen(t *testing.T) {
for _, p := range []string{"1chart", "-x", `a"b`, "a b", "ä"} {
var buf bytes.Buffer
if _, err := svg.Writer(&buf, svg.IDPrefix(p)).Open(ir.Surface{WidthPx: 10, HeightPx: 10, DPR: 1}); err == nil {
t.Errorf("IDPrefix(%q) opened without an error", p)
}
}
for _, p := range []string{"", "a", "_x", "chart-2.", "Fig_3-"} {
var buf bytes.Buffer
if _, err := svg.Writer(&buf, svg.IDPrefix(p)).Open(ir.Surface{WidthPx: 10, HeightPx: 10, DPR: 1}); err != nil {
t.Errorf("IDPrefix(%q): %v", p, err)
}
}
}

func TestUnbalancedPushIsAnError(t *testing.T) {
var buf bytes.Buffer
target := svg.Writer(&buf)
Expand Down
43 changes: 43 additions & 0 deletions backend/svg/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type options struct {
fontFail error
family string
pretty bool
idPrefix string
idFail error
}

// WithFont supplies a TrueType or OpenType file whose metrics are used for
Expand Down Expand Up @@ -47,6 +49,44 @@ func WithFontFamily(family string) Option {
// smaller, and golden files are diffed by tooling rather than read.
func Pretty() Option { return func(o *options) { o.pretty = true } }

// IDPrefix puts p in front of every id the document defines: its clip paths,
// marker shapes and gradients, and the <title> and <desc> its aria-labelledby
// names.
//
// A standalone file needs none, which is why the default is no prefix and
// leaves the output byte for byte as it was. It is for several documents
// inlined into one HTML page, which share one id namespace: every chart counts
// its ids from one, so the second chart's url(#c1) resolves to the first
// chart's clip and its cells are cut to the wrong rectangle, and its
// aria-labelledby reads out the first chart's title. Give each document its own
// prefix — "sales-", "chart2-" — and they cannot meet.
//
// p must be usable at the front of an XML name: an ASCII letter or underscore,
// then letters, digits, '-', '_' or '.'. Anything else is reported on Open
// rather than written into an attribute it would break.
func IDPrefix(p string) Option {
return func(o *options) {
if !validPrefix(p) {
o.idFail = fmt.Errorf("id prefix %q is not the start of an XML name", p)
return
}
o.idPrefix = p
}
}

func validPrefix(p string) bool {
for i := 0; i < len(p); i++ {
c := p[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
case i > 0 && (c >= '0' && c <= '9' || c == '-' || c == '.'):
default:
return false
}
}
return true
}

// Writer returns a Target that writes an SVG document to w.
func Writer(w io.Writer, opts ...Option) ir.Target {
return &target{w: nopCloser{w}, opts: build(opts)}
Expand Down Expand Up @@ -78,6 +118,9 @@ func (t *target) Open(s ir.Surface) (ir.Backend, error) {
if t.opts.fontFail != nil {
return nil, fmt.Errorf("figure/backend/svg: %w", t.opts.fontFail)
}
if t.opts.idFail != nil {
return nil, fmt.Errorf("figure/backend/svg: %w", t.opts.idFail)
}
if t.w == nil {
f, err := os.Create(t.path)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions geom/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import (
// A layer given [ColorBy] takes each label's ink from the fill that scale
// gives the row, dark on light and light on dark, so that a qualitative
// palette does not leave half its categories unreadable. [Color] overrides it.
// The layer reads only its own options, never the layer under it: labels over
// a heatmap need the heatmap's ColorBy repeated on the text layer, with the
// same scale value so the two agree and the chart keeps one colourbar —
// without it every label is drawn in the theme's label ink, dark on dark cells.
//
// AvoidOverlap opts into the renderer's panel-local label layout. Point labels
// may move; box labels remain anchored to their own box and are dropped if they
Expand Down
Loading