Quellcode durchsuchen

Expand Slides shape catalog support

Add a shared Slides shape catalogue and use it across rendering, crop masks, PDF export, and shape pickers. This also replaces legacy shape-name handling with preset-based mappings in the PPTX/ODP readers and writers, adds round-trip coverage for preset geometries, and updates the Slides UI with layout previews, toolbar-based new-slide controls, thumbnail scaling, and rail keyboard delete behavior.
Toby Chui vor 2 Wochen
Ursprung
Commit
b259134d28

+ 172 - 16
src/mod/office/odp_reader.go

@@ -622,7 +622,7 @@ func (cv *odpConverter) shape(n *onode, slide *Slide, ox, oy, kx, ky float64) {
 		kind = odpCustomShapeKind(n)
 	default:
 		if n.attr("corner-radius") != "" {
-			kind = "round"
+			kind = "roundRect"
 		}
 	}
 	fill := st.Fill
@@ -654,7 +654,9 @@ func (cv *odpConverter) shape(n *onode, slide *Slide, ox, oy, kx, ky float64) {
 	cv.add(slide, &Object{Type: "shape", X: x, Y: y, W: w, H: h, Rot: rot, Props: props})
 }
 
-// odpCustomShapeKind maps a draw:custom-shape's enhanced geometry type
+// odpCustomShapeKind maps a draw:custom-shape's enhanced geometry type onto
+// the shape the editor draws for it - the inverse of odpShapeTypes
+// (odp_writer.go), plus the spellings other producers use.
 func odpCustomShapeKind(n *onode) string {
 	eg := n.first("enhanced-geometry")
 	if eg == nil {
@@ -664,33 +666,187 @@ func odpCustomShapeKind(n *onode) string {
 	case "ellipse", "circle":
 		return "ellipse"
 	case "round-rectangle", "rounded-rectangle":
-		return "round"
-	case "isosceles-triangle", "triangle":
+		return "roundRect"
+	case "triangle":
+		return "triangle"
+	case "star":
+		return "star5"
+	case "isosceles-triangle":
 		return "triangle"
 	case "right-triangle":
 		return "rtTriangle"
 	case "diamond":
 		return "diamond"
-	case "right-arrow":
-		return "arrow"
-	case "left-arrow":
-		return "leftArrow"
-	case "up-arrow":
-		return "upArrow"
-	case "down-arrow":
-		return "downArrow"
-	case "star5", "star":
-		return "star"
-	case "pentagon", "pentagon-right":
-		return "chevron"
+	case "pentagon":
+		return "pentagon"
 	case "hexagon":
 		return "hexagon"
+	case "octagon":
+		return "octagon"
 	case "parallelogram":
 		return "parallelogram"
 	case "trapezoid":
 		return "trapezoid"
 	case "cross":
 		return "plus"
+	case "pentagon-right":
+		return "chevron"
+	case "can":
+		return "can"
+	case "cube":
+		return "cube"
+	case "ring":
+		return "donut"
+	case "forbidden":
+		return "noSmoking"
+	case "heart":
+		return "heart"
+	case "lightning":
+		return "lightningBolt"
+	case "sun":
+		return "sun"
+	case "moon":
+		return "moon"
+	case "cloud":
+		return "cloud"
+	case "smiley":
+		return "smileyFace"
+	case "paper":
+		return "foldedCorner"
+	case "block-arc":
+		return "blockArc"
+	case "arc":
+		return "arc"
+	case "pie":
+		return "pie"
+	case "frame":
+		return "frame"
+	case "right-arrow":
+		return "rightArrow"
+	case "left-arrow":
+		return "leftArrow"
+	case "up-arrow":
+		return "upArrow"
+	case "down-arrow":
+		return "downArrow"
+	case "left-right-arrow":
+		return "leftRightArrow"
+	case "up-down-arrow":
+		return "upDownArrow"
+	case "quad-arrow":
+		return "quadArrow"
+	case "notched-right-arrow":
+		return "notchedRightArrow"
+	case "striped-right-arrow":
+		return "stripedRightArrow"
+	case "circular-arrow":
+		return "circularArrow"
+	case "right-arrow-callout":
+		return "rightArrowCallout"
+	case "left-arrow-callout":
+		return "leftArrowCallout"
+	case "up-arrow-callout":
+		return "upArrowCallout"
+	case "down-arrow-callout":
+		return "downArrowCallout"
+	case "left-right-arrow-callout":
+		return "leftRightArrowCallout"
+	case "star4":
+		return "star4"
+	case "star5":
+		return "star5"
+	case "star6":
+		return "star6"
+	case "star8":
+		return "star8"
+	case "star12":
+		return "star12"
+	case "star24":
+		return "star24"
+	case "bang":
+		return "irregularSeal1"
+	case "rectangular-callout":
+		return "wedgeRectCallout"
+	case "round-rectangular-callout":
+		return "wedgeRoundRectCallout"
+	case "round-callout":
+		return "wedgeEllipseCallout"
+	case "cloud-callout":
+		return "cloudCallout"
+	case "left-bracket":
+		return "leftBracket"
+	case "right-bracket":
+		return "rightBracket"
+	case "bracket-pair":
+		return "bracketPair"
+	case "left-brace":
+		return "leftBrace"
+	case "right-brace":
+		return "rightBrace"
+	case "brace-pair":
+		return "bracePair"
+	case "mathplus":
+		return "mathPlus"
+	case "mathminus":
+		return "mathMinus"
+	case "mathmultiply":
+		return "mathMultiply"
+	case "mathdivide":
+		return "mathDivide"
+	case "mathequal":
+		return "mathEqual"
+	case "mathnotequal":
+		return "mathNotEqual"
+	case "flowchart-process":
+		return "flowChartProcess"
+	case "flowchart-alternate-process":
+		return "flowChartAlternateProcess"
+	case "flowchart-decision":
+		return "flowChartDecision"
+	case "flowchart-data":
+		return "flowChartInputOutput"
+	case "flowchart-predefined-process":
+		return "flowChartPredefinedProcess"
+	case "flowchart-internal-storage":
+		return "flowChartInternalStorage"
+	case "flowchart-document":
+		return "flowChartDocument"
+	case "flowchart-multidocument":
+		return "flowChartMultidocument"
+	case "flowchart-terminator":
+		return "flowChartTerminator"
+	case "flowchart-preparation":
+		return "flowChartPreparation"
+	case "flowchart-manual-input":
+		return "flowChartManualInput"
+	case "flowchart-manual-operation":
+		return "flowChartManualOperation"
+	case "flowchart-connector":
+		return "flowChartConnector"
+	case "flowchart-off-page-connector":
+		return "flowChartOffpageConnector"
+	case "flowchart-card":
+		return "flowChartPunchedCard"
+	case "flowchart-punched-tape":
+		return "flowChartPunchedTape"
+	case "flowchart-summing-junction":
+		return "flowChartSummingJunction"
+	case "flowchart-or":
+		return "flowChartOr"
+	case "flowchart-collate":
+		return "flowChartCollate"
+	case "flowchart-sort":
+		return "flowChartSort"
+	case "flowchart-extract":
+		return "flowChartExtract"
+	case "flowchart-merge":
+		return "flowChartMerge"
+	case "flowchart-delay":
+		return "flowChartDelay"
+	case "flowchart-magnetic-disk":
+		return "flowChartMagneticDisk"
+	case "flowchart-display":
+		return "flowChartDisplay"
 	}
 	return "rect"
 }

+ 94 - 16
src/mod/office/odp_writer.go

@@ -317,22 +317,100 @@ func (b *odpBuilder) emitImage(body *strings.Builder, o *Object, src string) {
 }
 
 // odpShapeTypes maps the editor's shape kinds onto ODF enhanced-geometry
-// types, for everything a plain draw:rect / draw:ellipse cannot express
+// types, for everything a plain draw:rect / draw:ellipse cannot express.
+// A kind that is not here is written as a rectangle, which is closer to
+// right than dropping the shape. odpCustomShapeKind (odp_reader.go) reads
+// the same table back the other way.
 var odpShapeTypes = map[string]string{
-	"triangle":      "isosceles-triangle",
-	"rtTriangle":    "right-triangle",
-	"diamond":       "diamond",
-	"arrow":         "right-arrow",
-	"leftArrow":     "left-arrow",
-	"upArrow":       "up-arrow",
-	"downArrow":     "down-arrow",
-	"star":          "star5",
-	"chevron":       "pentagon-right",
-	"pentagon":      "pentagon-right",
-	"hexagon":       "hexagon",
-	"parallelogram": "parallelogram",
-	"trapezoid":     "trapezoid",
-	"plus":          "cross",
+	"triangle":                   "isosceles-triangle",
+	"rtTriangle":                 "right-triangle",
+	"diamond":                    "diamond",
+	"pentagon":                   "pentagon",
+	"hexagon":                    "hexagon",
+	"octagon":                    "octagon",
+	"parallelogram":              "parallelogram",
+	"trapezoid":                  "trapezoid",
+	"plus":                       "cross",
+	"chevron":                    "pentagon-right",
+	"homePlate":                  "pentagon-right",
+	"can":                        "can",
+	"cube":                       "cube",
+	"donut":                      "ring",
+	"noSmoking":                  "forbidden",
+	"heart":                      "heart",
+	"lightningBolt":              "lightning",
+	"sun":                        "sun",
+	"moon":                       "moon",
+	"cloud":                      "cloud",
+	"smileyFace":                 "smiley",
+	"foldedCorner":               "paper",
+	"blockArc":                   "block-arc",
+	"arc":                        "arc",
+	"pie":                        "pie",
+	"frame":                      "frame",
+	"rightArrow":                 "right-arrow",
+	"leftArrow":                  "left-arrow",
+	"upArrow":                    "up-arrow",
+	"downArrow":                  "down-arrow",
+	"leftRightArrow":             "left-right-arrow",
+	"upDownArrow":                "up-down-arrow",
+	"quadArrow":                  "quad-arrow",
+	"notchedRightArrow":          "notched-right-arrow",
+	"stripedRightArrow":          "striped-right-arrow",
+	"circularArrow":              "circular-arrow",
+	"rightArrowCallout":          "right-arrow-callout",
+	"leftArrowCallout":           "left-arrow-callout",
+	"upArrowCallout":             "up-arrow-callout",
+	"downArrowCallout":           "down-arrow-callout",
+	"leftRightArrowCallout":      "left-right-arrow-callout",
+	"star4":                      "star4",
+	"star5":                      "star5",
+	"star6":                      "star6",
+	"star8":                      "star8",
+	"star12":                     "star12",
+	"star24":                     "star24",
+	"irregularSeal1":             "bang",
+	"wedgeRectCallout":           "rectangular-callout",
+	"wedgeRoundRectCallout":      "round-rectangular-callout",
+	"wedgeEllipseCallout":        "round-callout",
+	"cloudCallout":               "cloud-callout",
+	"leftBracket":                "left-bracket",
+	"rightBracket":               "right-bracket",
+	"bracketPair":                "bracket-pair",
+	"leftBrace":                  "left-brace",
+	"rightBrace":                 "right-brace",
+	"bracePair":                  "brace-pair",
+	"mathPlus":                   "mathplus",
+	"mathMinus":                  "mathminus",
+	"mathMultiply":               "mathmultiply",
+	"mathDivide":                 "mathdivide",
+	"mathEqual":                  "mathequal",
+	"mathNotEqual":               "mathnotequal",
+	"flowChartProcess":           "flowchart-process",
+	"flowChartAlternateProcess":  "flowchart-alternate-process",
+	"flowChartDecision":          "flowchart-decision",
+	"flowChartInputOutput":       "flowchart-data",
+	"flowChartPredefinedProcess": "flowchart-predefined-process",
+	"flowChartInternalStorage":   "flowchart-internal-storage",
+	"flowChartDocument":          "flowchart-document",
+	"flowChartMultidocument":     "flowchart-multidocument",
+	"flowChartTerminator":        "flowchart-terminator",
+	"flowChartPreparation":       "flowchart-preparation",
+	"flowChartManualInput":       "flowchart-manual-input",
+	"flowChartManualOperation":   "flowchart-manual-operation",
+	"flowChartConnector":         "flowchart-connector",
+	"flowChartOffpageConnector":  "flowchart-off-page-connector",
+	"flowChartPunchedCard":       "flowchart-card",
+	"flowChartPunchedTape":       "flowchart-punched-tape",
+	"flowChartSummingJunction":   "flowchart-summing-junction",
+	"flowChartOr":                "flowchart-or",
+	"flowChartCollate":           "flowchart-collate",
+	"flowChartSort":              "flowchart-sort",
+	"flowChartExtract":           "flowchart-extract",
+	"flowChartMerge":             "flowchart-merge",
+	"flowChartDelay":             "flowchart-delay",
+	"flowChartMagneticDisk":      "flowchart-magnetic-disk",
+	"flowChartDisplay":           "flowchart-display",
 }
 
 func (b *odpBuilder) emitShape(body *strings.Builder, o *Object) {
@@ -376,7 +454,7 @@ func (b *odpBuilder) emitShape(body *strings.Builder, o *Object) {
 	switch o.Props.Kind {
 	case "ellipse":
 		tag = "draw:ellipse"
-	case "round":
+	case "roundRect", "round":
 		r := o.Props.Radius
 		if r <= 0 {
 			r = minF(o.W, o.H) * 0.15

+ 5 - 3
src/mod/office/office_test.go

@@ -169,11 +169,13 @@ func TestPptxRoundtrip(t *testing.T) {
 		t.Errorf("text X = %v, want ~80", textObj.X)
 	}
 
-	// shape kind + text survive
+	// shape kind + text survive. The fixture asks for "star", the name the
+	// editor used before its shape catalogue existed; a round trip through
+	// the preset geometry is what rewrites it as the preset's own name.
 	for _, o := range s1.Objects {
 		if o.Type == "shape" {
-			if o.Props.Kind != "star" {
-				t.Errorf("shape kind = %q, want star", o.Props.Kind)
+			if o.Props.Kind != "star5" {
+				t.Errorf("shape kind = %q, want star5", o.Props.Kind)
 			}
 			if o.Props.Text != "star text" {
 				t.Errorf("shape text = %q, want %q", o.Props.Text, "star text")

+ 87 - 5
src/mod/office/pptx_fidelity_test.go

@@ -416,8 +416,8 @@ func TestPptxPictureMaskRoundTrips(t *testing.T) {
 	// a shaped crop is a preset geometry on the picture; every kind the
 	// editor can draw must survive being written and read back
 	png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 32))
-	for _, kind := range []string{"ellipse", "triangle", "diamond", "star",
-		"chevron", "hexagon", "downArrow", "plus"} {
+	for _, kind := range []string{"ellipse", "triangle", "diamond", "star5",
+		"chevron", "hexagon", "downArrow", "plus", "roundRect", "heart"} {
 		t.Run(kind, func(t *testing.T) {
 			pres := &Presentation{Slides: []*Slide{{Objects: []*Object{{
 				Type: "image", X: 10, Y: 20, W: 200, H: 100, Z: 1,
@@ -526,7 +526,7 @@ func TestPptxRoundPictureKeepsItsRadius(t *testing.T) {
 	png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 32))
 	pres := &Presentation{Slides: []*Slide{{Objects: []*Object{{
 		Type: "image", X: 0, Y: 0, W: 200, H: 100, Z: 1,
-		Props: Props{Src: encodeDataURL(png, "png"), Mask: "round", Radius: 10},
+		Props: Props{Src: encodeDataURL(png, "png"), Mask: "roundRect", Radius: 10},
 	}}}}}
 	data, err := BuildPptx(pres)
 	if err != nil {
@@ -537,8 +537,8 @@ func TestPptxRoundPictureKeepsItsRadius(t *testing.T) {
 		t.Fatalf("ParsePptx: %v", err)
 	}
 	p := got.Slides[0].Objects[0].Props
-	if p.Mask != "round" {
-		t.Errorf("mask = %q, want round", p.Mask)
+	if p.Mask != "roundRect" {
+		t.Errorf("mask = %q, want roundRect", p.Mask)
 	}
 	// 10px of the 100px short side is a 10% adjust
 	if p.Radius < 9.5 || p.Radius > 10.5 {
@@ -787,6 +787,88 @@ func TestFontStackOffersShippedFonts(t *testing.T) {
 	}
 }
 
+/* ---------------- preset geometries ---------------- */
+
+// The editor's catalogue (web/Office/slides/slides_shapes.js) names its
+// shapes after the PresentationML presets, so a preset it draws must come
+// back as itself and go out as itself. A round trip through both tables is
+// what pins that down - and it is what stopped rightBrace from importing as
+// an outlined rectangle.
+func TestPresetGeometryRoundTrip(t *testing.T) {
+	for _, prst := range []string{
+		"rect", "roundRect", "ellipse", "triangle", "rtTriangle", "diamond",
+		"rightArrow", "leftArrow", "upArrow", "downArrow", "star5",
+		"chevron", "pentagon", "hexagon", "parallelogram", "trapezoid",
+		"rightBrace", "leftBrace", "bracePair", "leftBracket", "rightBracket",
+		"wedgeRectCallout", "wedgeRoundRectCallout", "cloudCallout",
+		"flowChartDecision", "flowChartDocument", "flowChartTerminator",
+		"can", "cube", "donut", "heart", "cloud", "quadArrow", "mathPlus",
+	} {
+		t.Run(prst, func(t *testing.T) {
+			kind, ok := prstToShapeKind[prst]
+			if !ok {
+				t.Fatalf("preset %q has no shape kind", prst)
+			}
+			if got := shapeKindPrst(kind); got != prst {
+				t.Errorf("%q -> kind %q -> %q, want %q back", prst, kind, got, prst)
+			}
+		})
+	}
+}
+
+// A preset the editor cannot draw still has to land on something, and
+// "rect" is the answer of last resort - never an empty geometry.
+// The editor had three names of its own before the catalogue: round, arrow
+// and star. They are gone from the catalogue, but a .ppta written back then
+// still says them, so export has to translate - and what comes back is the
+// preset's own name, which is how a deck gets rewritten by opening it.
+func TestLegacyShapeNamesStillExport(t *testing.T) {
+	for legacy, want := range map[string]string{
+		"round": "roundRect", "arrow": "rightArrow", "star": "star5",
+	} {
+		t.Run(legacy, func(t *testing.T) {
+			if got := shapeKindPrst(legacy); got != want {
+				t.Errorf("%q exports as %q, want %q", legacy, got, want)
+			}
+			if _, drawn := prstToShapeKind[legacy]; drawn {
+				t.Errorf("%q is still in the catalogue; it should only be a legacy alias", legacy)
+			}
+		})
+	}
+}
+
+func TestPresetGeometryFallback(t *testing.T) {
+	if got := shapeKindPrst("nothingLikeThisExists"); got != "rect" {
+		t.Errorf("unknown kind -> %q, want rect", got)
+	}
+	if got := shapeKindPrst(""); got != "rect" {
+		t.Errorf("empty kind -> %q, want rect", got)
+	}
+}
+
+// Every kind the ODF writer knows has to read back as the same kind, or a
+// deck loses its shapes on a .odp round trip.
+func TestOdpShapeTypesRoundTrip(t *testing.T) {
+	seen := map[string]string{}
+	for kind, typ := range odpShapeTypes {
+		if other, dup := seen[typ]; dup {
+			// two kinds may share an ODF type (chevron and homePlate do);
+			// the reader can only pick one, and that is fine
+			t.Logf("ODF type %q is shared by %q and %q", typ, other, kind)
+			continue
+		}
+		seen[typ] = kind
+	}
+	for typ, kind := range seen {
+		eg := &onode{name: "enhanced-geometry", attrs: map[string]string{"type": typ}}
+		n := &onode{name: "custom-shape", children: []onodeChild{{el: eg}}}
+		got := odpCustomShapeKind(n)
+		if got != kind && odpShapeTypes[got] != typ {
+			t.Errorf("ODF type %q read back as %q, want %q", typ, got, kind)
+		}
+	}
+}
+
 func TestFontWeightOf(t *testing.T) {
 	tests := []struct {
 		name string

+ 170 - 39
src/mod/office/pptx_reader.go

@@ -38,46 +38,177 @@ import (
 	"strings"
 )
 
-// prstToShapeKind maps DrawingML preset geometries onto the shape kinds
-// the Slides editor can draw. Anything absent falls back to a rectangle,
-// which is closer to right than dropping the shape.
+// prstToShapeKind maps a PresentationML preset geometry onto the shape the
+// editor draws for it. The editor's catalogue
+// (web/Office/slides/slides_shapes.js) uses these very names, so most of
+// this table is an identity - it exists to say which presets are covered,
+// and to send the ones that are not to the nearest outline that is. Keep
+// it in step with that file.
 var prstToShapeKind = map[string]string{
-	"rect":                  "rect",
-	"flowChartProcess":      "rect",
-	"snip1Rect":             "rect",
-	"snip2SameRect":         "rect",
-	"plaque":                "round",
-	"roundRect":             "round",
-	"round1Rect":            "round",
-	"round2SameRect":        "round",
-	"round2DiagRect":        "round",
-	"snipRoundRect":         "round",
-	"wedgeRoundRectCallout": "round",
-	"wedgeRectCallout":      "rect",
-	"ellipse":               "ellipse",
-	"circle":                "ellipse",
-	"flowChartConnector":    "ellipse",
-	"ovalCallout":           "ellipse",
-	"triangle":              "triangle",
-	"flowChartExtract":      "triangle",
-	"rtTriangle":            "rtTriangle",
-	"diamond":               "diamond",
-	"flowChartDecision":     "diamond",
-	"rightArrow":            "arrow",
-	"leftArrow":             "leftArrow",
-	"upArrow":               "upArrow",
-	"downArrow":             "downArrow",
-	"star5":                 "star",
-	"star4":                 "star",
-	"star6":                 "star",
-	"chevron":               "chevron",
-	"homePlate":             "chevron",
-	"pentagon":              "pentagon",
-	"hexagon":               "hexagon",
-	"parallelogram":         "parallelogram",
-	"trapezoid":             "trapezoid",
-	"mathPlus":              "plus",
-	"plus":                  "plus",
+	// drawn as themselves
+	"rect":                       "rect",
+	"roundRect":                  "roundRect",
+	"snip1Rect":                  "snip1Rect",
+	"snip2SameRect":              "snip2SameRect",
+	"snip2DiagRect":              "snip2DiagRect",
+	"round1Rect":                 "round1Rect",
+	"round2SameRect":             "round2SameRect",
+	"round2DiagRect":             "round2DiagRect",
+	"ellipse":                    "ellipse",
+	"triangle":                   "triangle",
+	"rtTriangle":                 "rtTriangle",
+	"parallelogram":              "parallelogram",
+	"trapezoid":                  "trapezoid",
+	"diamond":                    "diamond",
+	"pentagon":                   "pentagon",
+	"hexagon":                    "hexagon",
+	"heptagon":                   "heptagon",
+	"octagon":                    "octagon",
+	"decagon":                    "decagon",
+	"dodecagon":                  "dodecagon",
+	"plus":                       "plus",
+	"teardrop":                   "teardrop",
+	"frame":                      "frame",
+	"halfFrame":                  "halfFrame",
+	"corner":                     "corner",
+	"diagStripe":                 "diagStripe",
+	"plaque":                     "plaque",
+	"can":                        "can",
+	"cube":                       "cube",
+	"bevel":                      "bevel",
+	"donut":                      "donut",
+	"noSmoking":                  "noSmoking",
+	"blockArc":                   "blockArc",
+	"foldedCorner":               "foldedCorner",
+	"smileyFace":                 "smileyFace",
+	"heart":                      "heart",
+	"lightningBolt":              "lightningBolt",
+	"sun":                        "sun",
+	"moon":                       "moon",
+	"cloud":                      "cloud",
+	"arc":                        "arc",
+	"chord":                      "chord",
+	"pie":                        "pie",
+	"flowChartProcess":           "flowChartProcess",
+	"flowChartAlternateProcess":  "flowChartAlternateProcess",
+	"flowChartDecision":          "flowChartDecision",
+	"flowChartInputOutput":       "flowChartInputOutput",
+	"flowChartPredefinedProcess": "flowChartPredefinedProcess",
+	"flowChartInternalStorage":   "flowChartInternalStorage",
+	"flowChartDocument":          "flowChartDocument",
+	"flowChartMultidocument":     "flowChartMultidocument",
+	"flowChartTerminator":        "flowChartTerminator",
+	"flowChartPreparation":       "flowChartPreparation",
+	"flowChartManualInput":       "flowChartManualInput",
+	"flowChartManualOperation":   "flowChartManualOperation",
+	"flowChartConnector":         "flowChartConnector",
+	"flowChartOffpageConnector":  "flowChartOffpageConnector",
+	"flowChartPunchedCard":       "flowChartPunchedCard",
+	"flowChartPunchedTape":       "flowChartPunchedTape",
+	"flowChartSummingJunction":   "flowChartSummingJunction",
+	"flowChartOr":                "flowChartOr",
+	"flowChartCollate":           "flowChartCollate",
+	"flowChartSort":              "flowChartSort",
+	"flowChartExtract":           "flowChartExtract",
+	"flowChartMerge":             "flowChartMerge",
+	"flowChartDelay":             "flowChartDelay",
+	"flowChartMagneticDisk":      "flowChartMagneticDisk",
+	"flowChartDisplay":           "flowChartDisplay",
+	"rightArrow":                 "rightArrow",
+	"leftArrow":                  "leftArrow",
+	"upArrow":                    "upArrow",
+	"downArrow":                  "downArrow",
+	"leftRightArrow":             "leftRightArrow",
+	"upDownArrow":                "upDownArrow",
+	"quadArrow":                  "quadArrow",
+	"leftRightUpArrow":           "leftRightUpArrow",
+	"bentArrow":                  "bentArrow",
+	"uturnArrow":                 "uturnArrow",
+	"leftUpArrow":                "leftUpArrow",
+	"bentUpArrow":                "bentUpArrow",
+	"stripedRightArrow":          "stripedRightArrow",
+	"notchedRightArrow":          "notchedRightArrow",
+	"chevron":                    "chevron",
+	"homePlate":                  "homePlate",
+	"curvedRightArrow":           "curvedRightArrow",
+	"curvedLeftArrow":            "curvedLeftArrow",
+	"curvedUpArrow":              "curvedUpArrow",
+	"curvedDownArrow":            "curvedDownArrow",
+	"circularArrow":              "circularArrow",
+	"rightArrowCallout":          "rightArrowCallout",
+	"leftArrowCallout":           "leftArrowCallout",
+	"upArrowCallout":             "upArrowCallout",
+	"downArrowCallout":           "downArrowCallout",
+	"leftRightArrowCallout":      "leftRightArrowCallout",
+	"star4":                      "star4",
+	"star5":                      "star5",
+	"star6":                      "star6",
+	"star7":                      "star7",
+	"star8":                      "star8",
+	"star10":                     "star10",
+	"star12":                     "star12",
+	"star16":                     "star16",
+	"star24":                     "star24",
+	"star32":                     "star32",
+	"irregularSeal1":             "irregularSeal1",
+	"irregularSeal2":             "irregularSeal2",
+	"ribbon":                     "ribbon",
+	"ribbon2":                    "ribbon2",
+	"wave":                       "wave",
+	"doubleWave":                 "doubleWave",
+	"verticalScroll":             "verticalScroll",
+	"horizontalScroll":           "horizontalScroll",
+	"wedgeRectCallout":           "wedgeRectCallout",
+	"wedgeRoundRectCallout":      "wedgeRoundRectCallout",
+	"wedgeEllipseCallout":        "wedgeEllipseCallout",
+	"cloudCallout":               "cloudCallout",
+	"mathPlus":                   "mathPlus",
+	"mathMinus":                  "mathMinus",
+	"mathMultiply":               "mathMultiply",
+	"mathDivide":                 "mathDivide",
+	"mathEqual":                  "mathEqual",
+	"mathNotEqual":               "mathNotEqual",
+	"leftBracket":                "leftBracket",
+	"rightBracket":               "rightBracket",
+	"bracketPair":                "bracketPair",
+	"rightBrace":                 "rightBrace",
+	"leftBrace":                  "leftBrace",
+	"bracePair":                  "bracePair",
+
+	// not drawn, so sent to the nearest outline that is
+	"snipRoundRect":                    "roundRect",
+	"plaque2":                          "plaque",
+	"flowChartMagneticTape":            "flowChartConnector",
+	"flowChartMagneticDrum":            "can",
+	"flowChartOnlineStorage":           "flowChartDelay",
+	"flowChartSequentialAccessStorage": "flowChartConnector",
+	"flowChartOfflineStorage":          "flowChartExtract",
+	"mathMinus2":                       "mathMinus",
+	"bentConnector2":                   "rect",
+	"callout1":                         "wedgeRectCallout",
+	"callout2":                         "wedgeRectCallout",
+	"callout3":                         "wedgeRectCallout",
+	"accentCallout1":                   "wedgeRectCallout",
+	"borderCallout1":                   "wedgeRectCallout",
+	"borderCallout2":                   "wedgeRectCallout",
+	"borderCallout3":                   "wedgeRectCallout",
+	"swooshArrow":                      "curvedRightArrow",
+	"stripedRightArrow2":               "stripedRightArrow",
+	"circularArrow2":                   "circularArrow",
+	"leftCircularArrow":                "circularArrow",
+	"leftRightCircularArrow":           "circularArrow",
+	"upDownArrowCallout":               "upArrowCallout",
+	"quadArrowCallout":                 "quadArrow",
+	"curvedConnector2":                 "rect",
+	"gear6":                            "sun",
+	"gear9":                            "sun",
+	"funnel":                           "trapezoid",
+	"pieWedge":                         "pie",
+	"chartPlus":                        "mathPlus",
+	"chartStar":                        "star4",
+	"chartX":                           "mathMultiply",
+	"triangleRight":                    "rtTriangle",
+	"halfFrame2":                       "halfFrame",
 }
 
 // pptxDoc is the whole package plus everything shared across slides

+ 21 - 28
src/mod/office/pptx_writer.go

@@ -32,27 +32,26 @@ import (
 
 const nsDecl = `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"`
 
-// shapeKindToPrst is the inverse of prstToShapeKind (pptx_reader.go) for
-// the kinds the editor can draw, so a shape or a shaped crop written here
-// reads back as the same thing
+// shapeKindToPrst carries the three names the editor used before its shape
+// catalogue existed. A .ppta written back then still says them, and export
+// has to keep working; everything else is its own preset name already, and
+// shapeKindPrst falls through to that.
 var shapeKindToPrst = map[string]string{
-	"rect":          "rect",
-	"round":         "roundRect",
-	"ellipse":       "ellipse",
-	"triangle":      "triangle",
-	"rtTriangle":    "rtTriangle",
-	"diamond":       "diamond",
-	"arrow":         "rightArrow",
-	"leftArrow":     "leftArrow",
-	"upArrow":       "upArrow",
-	"downArrow":     "downArrow",
-	"star":          "star5",
-	"chevron":       "chevron",
-	"pentagon":      "pentagon",
-	"hexagon":       "hexagon",
-	"parallelogram": "parallelogram",
-	"trapezoid":     "trapezoid",
-	"plus":          "mathPlus",
+	"round": "roundRect",
+	"arrow": "rightArrow",
+	"star":  "star5",
+}
+
+// shapeKindPrst is the preset to write for an editor shape kind, and
+// "rect" for one this package has never heard of.
+func shapeKindPrst(kind string) string {
+	if p, ok := shapeKindToPrst[kind]; ok {
+		return p
+	}
+	if _, ok := prstToShapeKind[kind]; ok {
+		return kind
+	}
+	return "rect"
 }
 
 // BuildPptx serializes a Presentation without a media resolver (video /
@@ -675,10 +674,7 @@ func buildTextSp(id int, o *Object, theme string) string {
 
 func buildShapeSp(id int, o *Object) string {
 	p := o.Props
-	prst, ok := shapeKindToPrst[p.Kind]
-	if !ok {
-		prst = "rect"
-	}
+	prst := shapeKindPrst(p.Kind)
 	geom := `<a:prstGeom prst="` + prst + `"><a:avLst/></a:prstGeom>`
 	if prst == "roundRect" && p.Radius > 0 && o.W > 0 && o.H > 0 {
 		adj := int(p.Radius / minF(o.W, o.H) * 100000)
@@ -831,10 +827,7 @@ func buildPicSp(id int, o *Object, rid string) string {
 			int(p.Crop[2]*100000), int(p.Crop[3]*100000))
 	}
 	// a shaped crop is a preset geometry on the picture itself
-	prst := "rect"
-	if q, ok := shapeKindToPrst[p.Mask]; ok {
-		prst = q
-	}
+	prst := shapeKindPrst(p.Mask)
 	geom := `<a:prstGeom prst="` + prst + `"><a:avLst/></a:prstGeom>`
 	if (prst == "roundRect" || p.Mask == "" || p.Mask == "rect") &&
 		p.Radius > 0 && o.W > 0 && o.H > 0 {

+ 76 - 4
src/web/Office/README.md

@@ -184,11 +184,11 @@ Every element goes in as the PDF object it should be:
 |---|---|
 | text | real `Tj` text, one show-text per line fragment, at the baseline the browser laid it out on |
 | image | the original JPEG/PNG bytes, embedded once and re-used |
-| crop / shaped crop / rounded corners | a real PDF **clip path** (`shapePathOps`, from the same `shapePoints()` the canvas draws) |
+| crop / shaped crop / rounded corners | a real PDF **clip path** (`shapePathOps`, from the same `SlidesShapes` geometry the canvas draws) |
 | flip | a negative scale in the transformation matrix |
 | transparency | an `ExtGState` with `/ca` |
 | re-colour, brightness, contrast | the picture re-encoded through a canvas — a pixel operation in any renderer |
-| shape | a real vector path, filled and stroked |
+| shape | a real vector path from the catalogue, filled and stroked (even-odd where the shape has holes; stroked only, where it is a brace or a bracket) |
 | line | a real vector polyline, arrow heads as filled triangles |
 | table | real vector cell fills and rules, plus text |
 | chart | the chart's own SVG, translated element by element into PDF vectors |
@@ -274,6 +274,73 @@ File > Export puts a small progress panel in the corner of the canvas
 (`OfficeApp.showProgress`) and works from a snapshot of the deck, so
 carrying on editing cannot change the file that comes out.
 
+### Slides: starting a slide
+
+New slide is a split control on the toolbar: the button adds one, the caret
+beside it opens `showLayoutPicker()` — every layout in `LAYOUTS` as a
+preview of itself. The previews are built by the same `newSlide()` and
+`renderSlideContent()` the document uses, at the scale the rail uses, so a
+preview cannot come to disagree with the slide choosing it makes. The
+layouts are skeletons of real text objects, not a placeholder system; there
+is nothing to "fill in" afterwards, which is why the preview can be the
+real thing. The previews outline every box (`.sl-layoutpick-mini .sl-obj`),
+stated at slide scale so the transform brings it down to a hairline -
+without it a layout is a few grey smudges and one looks like the next.
+
+**Delete belongs to whatever has the keyboard.** A thumbnail takes focus
+when it is clicked, and focusing one selects it, so the slide the keyboard
+is on and the slide being edited are never two different slides. Delete
+then removes the slide; with the canvas focused the same key removes the
+selected objects. Both are registered with `HK` and guarded by
+`railFocused()`, so neither has to know about the other.
+
+### Slides: the shape catalogue
+
+Every shape the editor draws lives in
+[`slides/slides_shapes.js`](slides/slides_shapes.js) (`SlidesShapes`) as
+geometry — about 130 of them, in the four groups the Insert > Shape picker
+offers: shapes, arrows, call outs and equation.
+
+**They are named after the PresentationML presets they are** (`rightBrace`,
+`flowChartDecision`, `wedgeRoundRectCallout`). That is the point of the
+file: an imported deck keeps its own vocabulary, and
+`prstToShapeKind` / `shapeKindPrst` (`mod/office/pptx_{reader,writer}.go`)
+are a lookup rather than a translation. The editor had three names of its
+own before the catalogue — `round`, `arrow`, `star` — and they are gone:
+`SlidesShapes.ALIASES` knows what they used to mean and `normalizeBody()`
+runs every kind through `canonical()` as a document loads, so a deck
+written back then is rewritten the first time it is opened. The Go writer
+keeps the same three for a `.ppta` that has not been through the editor
+yet. A preset the catalogue does not draw is sent to the nearest outline
+that it does, and only a completely unknown one becomes a rectangle. The symptom
+that prompted all this was a `rightBrace` importing as a thin outlined
+rectangle, rotated — two long diagonal lines where a brace should be.
+
+Every shape produces an SVG path using **only M, L, C and Z**. Arcs are
+converted to cubics once, by `Path.arc()`, so nothing downstream has to
+understand an arc flag — and three things get to share one geometry:
+
+- the canvas (`shapeSvg` in `slides.js`),
+- `clip-path: path(...)`, which is how a picture is cropped to a shape, and
+- the PDF exporter, whose translator (`svgPathOps`) is about fifteen lines
+  because that is all it has to parse.
+
+Three flags on a catalogue entry change how it is drawn, and all three
+paths honour them: `open` (a brace, a bracket, an arc — a line and not an
+area, so it is stroked and never filled, and gets a stroke of its own if the
+document did not give it one), `evenOdd` (the path has holes in it), and
+`detail` (markings such as the divider bars of a predefined process, drawn
+over the outline rather than filled).
+
+The picker itself is `showShapePicker()`: the categories down the left, the
+shapes of the one in hand as icons on the right. The icons come from
+`SlidesShapes.icon()` — the same geometry again — so a picker entry cannot
+come to disagree with what choosing it inserts.
+
+`MASK_KINDS` is deliberately *not* the whole catalogue: a shaped crop reads
+as a silhouette, so the crop-shape grid offers the couple of dozen outlines
+that still say something at thumbnail size.
+
 ### Slides: cropping a picture
 
 Cropping never touches the pixels. The object frame says which part of the
@@ -295,8 +362,8 @@ and the picture's context menu.
   at full strength over it. A grip moves the frame; dragging the picture
   moves the source behind it. Enter or a click outside applies, Esc restores.
   Crop and the crop shapes are one split control — the caret beside it opens
-  a **grid of shape icons**, drawn by `shapeIcon()` from the same
-  `shapePoints()` the canvas uses so an icon cannot drift from the mask it
+  a **grid of shape icons**, drawn by `SlidesShapes.icon()` from the same
+  geometry the canvas uses so an icon cannot drift from the mask it
   applies. Picking one sets `props.mask`, drawn as a `clip-path` and written
   to `.pptx` as a `prstGeom` on the picture.
 - **Format options** opens `#slFormatPanel`, docked right of the canvas:
@@ -632,6 +699,11 @@ sh ../scripts/check-conventions.sh --diff origin/master
   weirdly in a real Office app, generate a reference file with
   python-docx/python-pptx and **diff the XML part-by-part** — that's how
   both the pagination and media problems were cracked.
+- **Thumbnails scale themselves.** A preview is the whole 960x540 slide
+  shrunk by a transform, and the box it has to fit is whatever the rail can
+  spare once the scrollbar has taken its cut — which varies by platform. So
+  `fitThumbs()` measures the box and sets `--sl-thumb-scale`; a hard-coded
+  scale is how the right-hand edge of every preview came to be missing.
 - **Front-end smoke test without a full server**: the repo's
   `.claude/launch.json` has a `webroot-static` config that serves
   `src/web/` on `:8123`; the apps load standalone (AGI calls fail

+ 30 - 3
src/web/Office/common/CONTRACT.md

@@ -613,8 +613,10 @@ corrects the height to the source's own aspect ratio.
 
 `mask` maps onto a `prstGeom` on the `p:pic` in .pptx, so a shaped crop made
 here and one made in PowerPoint are the same thing; the renderer draws it as
-a `clip-path` built from `shapePoints()`, so every shape the editor can draw
-is also a mask it can cut.
+a `clip-path` built from `SlidesShapes.points()` - stated in percentages, so
+the clip follows the frame as it is dragged. That is why the crop shapes are
+the catalogue's polygons and not all of it: a curved outline would have to be
+restated in pixels at every size.
 
 ## Picture tools (slides/slides_image.js)
 
@@ -632,7 +634,7 @@ Three pieces:
   `.of-te-btn`) anchored under a selected image: crop, reset, format options.
   Crop and the crop shapes are one split control, the caret opening the grid.
 - the **shape grid** — the crop shapes as icons rather than names, drawn by
-  `shapeIcon()` from the same `shapePoints()` the canvas uses, so an icon
+  `shapeIcon()` from the same `SlidesShapes` geometry the canvas uses, so an icon
   cannot drift from the mask it applies.
 - the **format panel** — `#slFormatPanel`, docked to the right of `#slMain`
   (it is a flex sibling, so the canvas re-fits itself via `relayout`): size,
@@ -683,6 +685,31 @@ it exportable. This applies on the Go side too: `fontStackFor`
 (`mod/office/pptx_text.go`) appends the same list to every stack an import
 writes, and the two lists have to stay in step.
 
+## Slides shape geometry (slides/slides_shapes.js)
+
+`SlidesShapes` owns every shape the Slides editor draws, named after the
+PresentationML preset it is. Paths use only `M`, `L`, `C` and `Z`, which is
+what lets the canvas, `clip-path: path()` and the PDF exporter share one
+geometry.
+
+```js
+SlidesShapes.path(kind, w, h)      // "M0 0L100 0..." ("" if unknown)
+SlidesShapes.detail(kind, w, h)    // markings to stroke, or ""
+SlidesShapes.points(kind, w, h)    // polygon corners, or null for a curved one
+SlidesShapes.icon(kind, size)      // the same outline as a small SVG
+SlidesShapes.canonical(kind)       // preset spelling -> catalogue name
+SlidesShapes.isOpen / evenOdd / label / defaultSize / CATEGORIES
+```
+
+Adding a shape means adding it here and nowhere else - the picker, the
+crop-shape grid, the canvas and the export all read from this one table.
+Keep the names in step with `prstToShapeKind` (`mod/office/pptx_reader.go`).
+
+`ALIASES` holds the three names the editor used before this file existed
+(`round`, `arrow`, `star`). They are not shapes any more - `normalizeBody()`
+rewrites them through `canonical()` as a document loads, so nothing
+downstream has to know about them.
+
 ## Vendored libs (common/lib/, all MIT)
 
 - `marked.min.js` — Markdown → HTML (Docs import)

+ 2 - 2
src/web/Office/common/office.js

@@ -778,8 +778,8 @@ var OfficeApp = (function () {
         var what = f.fmt ? formatLabel(f.fmt) : (f.ext + " file");
         $b.find(".of-fmtbanner-msg").html(
             "This is a <b>" + escapeHtml(what) + "</b>, not an ArozOS Office " +
-            escapeHtml(cfg.fileTypeName.toLowerCase()) + ". Saving keeps that format, " +
-            "so anything " + escapeHtml(f.ext) + " cannot store is lost.");
+            escapeHtml(cfg.fileTypeName.toLowerCase()) + ". " +
+            "Anything " + escapeHtml(f.ext) + " cannot store is lost on save.");
         $b.css("display", "");
     }
     /*

+ 1 - 3
src/web/Office/slides/index.html

@@ -31,9 +31,6 @@
     <div class="of-workspace" id="workspace">
         <div id="slRail" class="of-noprint">
             <div id="slThumbs"></div>
-            <button id="slRailAdd" type="button" title="New slide">
-                <i class="plus icon"></i> New slide
-            </button>
         </div>
         <div id="slMain">
             <div id="slCanvasArea">
@@ -55,6 +52,7 @@
     </div>
     <div id="slPrintArea"></div>
     <input type="file" id="slDeviceImage" accept="image/*" multiple style="display:none;">
+    <script src="slides_shapes.js"></script>
     <script src="slides_image.js"></script>
     <script src="slides_pdf.js"></script>
     <script src="slides.js"></script>

+ 158 - 20
src/web/Office/slides/slides.css

@@ -28,20 +28,6 @@
     overflow-x: hidden;
     padding: 10px 8px 4px 8px;
 }
-#slRailAdd {
-    flex: 0 0 auto;
-    margin: 6px 8px 8px 8px;
-    padding: 6px 8px;
-    border: 1px dashed var(--of-border);
-    border-radius: 5px;
-    background: transparent;
-    color: var(--of-fg-soft);
-    cursor: pointer;
-    font-size: 12px;
-    font-family: inherit;
-}
-#slRailAdd:hover { background: var(--of-hover); color: var(--of-fg); }
-#slRailAdd i.icon { margin: 0 2px 0 0 !important; font-size: 11px !important; }
 
 .sl-thumb {
     display: flex;
@@ -59,16 +45,27 @@
     color: var(--of-fg-soft);
     padding-top: 2px;
 }
+/* The preview takes whatever width the rail can spare - which is not a
+   number this stylesheet can know, because the scrollbar's width is the
+   platform's business and it eats into it. So the box is fluid and holds
+   the slide's shape, and the scale that fits a 960px slide inside it is
+   measured and set as --sl-thumb-scale (fitThumbs() in slides.js). A
+   hard-coded scale here is how the right edge of every preview came to be
+   clipped by the width of a scrollbar. */
 .sl-thumb-view {
     position: relative;
-    width: 136px;
-    height: 76.5px;
+    flex: 1 1 auto;
+    min-width: 0;
+    aspect-ratio: 960 / 540;
     overflow: hidden;
     border: 2px solid var(--of-border);
     border-radius: 4px;
     background: #fff;
     box-sizing: content-box;
 }
+/* focusing a thumbnail selects it, so the active border is already the
+   focus indicator - the browser's own outline would only double it up */
+.sl-thumb:focus { outline: none; }
 .sl-thumb.active .sl-thumb-view {
     border-color: var(--of-accent);
     box-shadow: 0 0 0 1px var(--of-accent);
@@ -79,7 +76,7 @@
     top: 0;
     width: 960px;
     height: 540px;
-    transform: scale(0.141667);
+    transform: scale(var(--sl-thumb-scale, 0.141667));
     transform-origin: 0 0;
     pointer-events: none;
 }
@@ -373,7 +370,16 @@ body.dark .sl-h { background: #2b2d31; }
 .sl-h[data-h="e"], .sl-h[data-h="w"] { cursor: ew-resize; }
 .sl-h[data-h="nw"], .sl-h[data-h="se"] { cursor: nwse-resize; }
 .sl-h[data-h="ne"], .sl-h[data-h="sw"] { cursor: nesw-resize; }
-.sl-h[data-h="rot"] { cursor: grab; }
+/* The rotate knob gets a cursor that says rotate. There is no keyword for
+   it, so it is drawn here - a circular arrow with a white halo so it reads
+   on a dark picture as well as on the page, with grab as the fallback for
+   anything that will not take a custom cursor. .sl-rotating keeps it while
+   the drag runs, because the pointer leaves the knob almost immediately. */
+.sl-h[data-h="rot"],
+#slCanvas.sl-rotating,
+#slCanvas.sl-rotating * {
+    cursor: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%3E%3Cg%20fill='none'%20stroke='%23fff'%20stroke-width='4'%20stroke-linejoin='round'%20stroke-linecap='round'%3E%3Cpath%20d='M7.05%2016.95A7%207%200%201%201%2016.95%2016.95'/%3E%3Cpath%20d='M13.98%2019.92L18.93%2018.93L14.97%2014.97Z'/%3E%3C/g%3E%3Cpath%20d='M7.05%2016.95A7%207%200%201%201%2016.95%2016.95'%20fill='none'%20stroke='%23000'%20stroke-width='1.9'%20stroke-linecap='round'/%3E%3Cpath%20d='M13.98%2019.92L18.93%2018.93L14.97%2014.97Z'%20fill='%23000'/%3E%3C/svg%3E") 12 12, grab;
+}
 .sl-h[data-h="p1"], .sl-h[data-h="p2"] { cursor: move; }
 .sl-rot-stem {
     position: absolute;
@@ -611,8 +617,6 @@ body.dark .sl-h { background: #2b2d31; }
 /* ============ Small screens ============ */
 @media (max-width: 700px) {
     #slRail { flex: 0 0 120px; width: 120px; }
-    .sl-thumb-view { width: 88px; height: 49.5px; }
-    .sl-thumb-mini { transform: scale(0.091667); }
 }
 
 /* ============ Picture tools (slides_image.js) ============ */
@@ -636,6 +640,140 @@ body.dark .sl-h { background: #2b2d31; }
 .sl-imagebar-caret i.icon { font-size: 10px !important; }
 
 /* ---- crop shape grid ---- */
+/* ---- New slide layout picker ---- */
+/* Every layout as a preview of itself, at the scale the rail uses. The
+   previews are real slides, rendered by renderSlideContent. */
+.sl-layoutpick {
+    position: fixed;
+    z-index: 1500;
+    display: grid;
+    grid-template-columns: repeat(3, 140px);
+    gap: 4px;
+    padding: 8px;
+    max-height: calc(100vh - 90px);
+    overflow-y: auto;
+    background: var(--of-chrome-bg);
+    border: 1px solid var(--of-border);
+    border-radius: 6px;
+    box-shadow: 0 6px 22px var(--of-shadow);
+    user-select: none;
+}
+.sl-layoutpick-cell {
+    display: block;
+    padding: 6px;
+    border: none;
+    border-radius: 5px;
+    background: transparent;
+    color: var(--of-fg);
+    font-family: inherit;
+    font-size: 11.5px;
+    text-align: center;
+    cursor: pointer;
+}
+.sl-layoutpick-cell:hover { background: var(--of-hover); }
+.sl-layoutpick-view {
+    position: relative;
+    width: 128px;
+    height: 72px;
+    margin: 0 auto;
+    overflow: hidden;
+    border: 1px solid var(--of-border);
+    border-radius: 3px;
+    background: #fff;
+    box-sizing: content-box;
+}
+.sl-layoutpick-cell:hover .sl-layoutpick-view { border-color: var(--of-accent); }
+.sl-layoutpick-mini {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 960px;
+    height: 540px;
+    transform: scale(0.13333);
+    transform-origin: 0 0;
+    pointer-events: none;
+}
+.sl-layoutpick-label {
+    margin-top: 5px;
+    color: var(--of-fg-soft);
+    line-height: 1.25;
+}
+/* Outline every box in a preview, or the layout is a few grey smudges and
+   you cannot tell one from the next. The mini is the whole 960px slide
+   scaled to 128, so the outline has to be stated at slide scale: 7px lands
+   at about one. A mid grey rather than a theme colour, because what is
+   behind it is the deck's own background, which may be light or dark. */
+.sl-layoutpick-mini .sl-obj {
+    outline: 7px solid #b7bcc2;
+    outline-offset: -3px;
+}
+
+/* ---- Insert > Shape picker ---- */
+/* Categories down the left, the shapes of the one in hand as icons on the
+   right. Same chrome as the crop-shape grid above it, because they are the
+   same act of choosing an outline. */
+.sl-shapepick {
+    position: fixed;
+    z-index: 1500;
+    display: flex;
+    align-items: stretch;
+    padding: 5px;
+    gap: 5px;
+    background: var(--of-chrome-bg);
+    border: 1px solid var(--of-border);
+    border-radius: 6px;
+    box-shadow: 0 6px 22px var(--of-shadow);
+    user-select: none;
+}
+.sl-shapepick-cats {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    padding-right: 5px;
+    border-right: 1px solid var(--of-border);
+}
+.sl-shapepick-cat {
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    min-width: 132px;
+    padding: 6px 8px;
+    border: none;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--of-fg);
+    font-family: inherit;
+    font-size: 12.5px;
+    text-align: left;
+    cursor: pointer;
+}
+.sl-shapepick-cat i.icon { margin: 0 !important; font-size: 12px !important; opacity: 0.75; }
+.sl-shapepick-cat span { flex: 1 1 auto; }
+.sl-shapepick-cat .sl-shapepick-more { opacity: 0.4; }
+.sl-shapepick-cat:hover { background: var(--of-hover); }
+.sl-shapepick-cat.active { background: var(--of-accent-soft); color: var(--of-accent); }
+.sl-shapepick-grid {
+    display: grid;
+    grid-template-columns: repeat(10, 30px);
+    grid-auto-rows: 30px;
+    gap: 1px;
+    max-height: 300px;
+    overflow-y: auto;
+}
+.sl-shapepick-cell {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    border: none;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--of-fg);
+    cursor: pointer;
+}
+.sl-shapepick-cell:hover { background: var(--of-hover); color: var(--of-accent); }
+
 .sl-shapegrid {
     position: fixed;
     z-index: 1500;

+ 259 - 104
src/web/Office/slides/slides.js

@@ -26,8 +26,9 @@
                         props: { ... } // per type:
                         //  text : { html, fontSize, color, align, bold, italic, underline }
                         //  image: { src, fit: "contain"|"cover"|"fill" }
-                        //  shape: { kind: "rect"|"round"|"ellipse"|"triangle"|"diamond"|
-                        //                 "arrow"|"star"|"chevron",
+                        //  shape: { kind: a SlidesShapes name, which is the
+                        //                 PresentationML preset name -
+                        //                 "rect"|"roundRect"|"ellipse"|"rightBrace"|...
                         //           fill, stroke, strokeW, text, textColor, fontSize, bold }
                         //  line : { stroke, strokeW, dash, arrowEnd }
                         //  table: { rows: [["a","b"],...], headerRow, colW?, rowH?, fontSize, color }
@@ -106,25 +107,25 @@ var SlidesApp = (function () {
     ];
     var MEDIA_MAX_BYTES = 200 * 1024 * 1024;  // uploads stream to the workdir
 
-    var SHAPE_KINDS = [
-        { kind: "rect",          label: "Rectangle" },
-        { kind: "round",         label: "Rounded rectangle" },
-        { kind: "ellipse",       label: "Ellipse" },
-        { kind: "triangle",      label: "Triangle" },
-        { kind: "rtTriangle",    label: "Right triangle" },
-        { kind: "diamond",       label: "Diamond" },
-        { kind: "pentagon",      label: "Pentagon" },
-        { kind: "hexagon",       label: "Hexagon" },
-        { kind: "parallelogram", label: "Parallelogram" },
-        { kind: "trapezoid",     label: "Trapezoid" },
-        { kind: "plus",          label: "Cross" },
-        { kind: "star",          label: "Star" },
-        { kind: "chevron",       label: "Chevron" },
-        { kind: "arrow",         label: "Arrow (right)" },
-        { kind: "leftArrow",     label: "Arrow (left)" },
-        { kind: "upArrow",       label: "Arrow (up)" },
-        { kind: "downArrow",     label: "Arrow (down)" }
-    ];
+    /* What a picture can be masked to. Deliberately a short list and not
+       the whole catalogue, for two reasons. A mask reads as a silhouette,
+       so the outlines worth offering are the ones that still say something
+       at thumbnail size - and every one of these is a polygon, which is
+       what lets maskClipPath() state it in percentages so the clip follows
+       the frame while it is being dragged. A curved shape would have to be
+       restated in pixels at every size. Every shape the editor can draw
+       lives in SlidesShapes (slides_shapes.js) and is offered by the
+       Insert > Shape picker. */
+    var MASK_KINDS = [
+        "rect", "roundRect", "ellipse", "triangle", "rtTriangle", "diamond",
+        "pentagon", "hexagon", "heptagon", "octagon", "decagon", "dodecagon",
+        "parallelogram", "trapezoid", "plus", "star4", "star5", "star6",
+        "star8", "star12", "chevron", "homePlate", "rightArrow", "leftArrow",
+        "upArrow", "downArrow", "leftRightArrow", "upDownArrow",
+        "flowChartManualInput", "flowChartInputOutput", "irregularSeal1"
+    ].map(function (k) {
+        return { kind: k, label: SlidesShapes.label(k) };
+    });
 
     /* ================= state ================= */
     var body = null;          // document body (see schema above)
@@ -206,7 +207,21 @@ var SlidesApp = (function () {
             s.objects.push(newTextObj("Left content", 50, 130, 420, 360, 20, "left", textColor));
             s.objects.push(newTextObj("Right content", 490, 130, 420, 360, 20, "left", textColor));
         } else if (kind === "caption") {
-            s.objects.push(newTextObj("Caption", 80, 440, 800, 60, 20, "center", textColor));
+            s.objects.push(newTextObj("Title", 60, 60, 840, 300, 28, "left", textColor));
+            s.objects.push(newTextObj("Caption", 60, 400, 840, 60, 16, "left", textColor));
+        } else if (kind === "section") {
+            s.objects.push(newTextObj("Section header", 60, 230, 840, 80, 36, "left", textColor));
+        } else if (kind === "onecol") {
+            s.objects.push(newTextObj("Heading", 60, 60, 840, 60, 28, "left", textColor));
+            s.objects.push(newTextObj("Text", 60, 140, 840, 340, 18, "left", textColor));
+        } else if (kind === "mainpoint") {
+            s.objects.push(newTextObj("Main point", 80, 210, 800, 120, 44, "left", textColor));
+        } else if (kind === "sectiondesc") {
+            s.objects.push(newTextObj("Section title", 60, 120, 380, 90, 30, "left", textColor));
+            s.objects.push(newTextObj("Description", 60, 230, 380, 200, 16, "left", textColor));
+        } else if (kind === "bignumber") {
+            s.objects.push(newTextObj("100%", 80, 160, 800, 140, 88, "center", textColor));
+            s.objects.push(newTextObj("What it stands for", 80, 320, 800, 60, 18, "center", textColor));
         }
         s.objects.forEach(function (o, i) { o.z = i + 1; });
         return s;
@@ -238,6 +253,10 @@ var SlidesApp = (function () {
                 o.rot = Number(o.rot) || 0;
                 o.z = i + 1;
                 if (!o.props || typeof o.props !== "object") o.props = {};
+                // decks written before the shape catalogue carry three
+                // editor-invented names; rewrite them as they come in
+                if (o.props.kind) o.props.kind = SlidesShapes.canonical(o.props.kind);
+                if (o.props.mask) o.props.mask = SlidesShapes.canonical(o.props.mask);
             });
         });
         if (!Array.isArray(b.fonts)) delete b.fonts;
@@ -345,7 +364,7 @@ var SlidesApp = (function () {
     function maskClipPath(kind) {
         if (!kind || kind === "rect") return "";
         if (kind === "ellipse") return "ellipse(50% 50% at 50% 50%)";
-        if (kind === "round") return "";        // border-radius draws this one
+        if (kind === "roundRect") return "";    // border-radius draws this one
         var pts = shapePoints(kind, 100, 100);
         if (!pts) return "";
         return "polygon(" + pts.map(function (pt) {
@@ -353,86 +372,71 @@ var SlidesApp = (function () {
         }).join(",") + ")";
     }
 
+    /* shapePoints is the polygon form of a shape, for the one caller that
+       wants corners rather than a path: the CSS clip-path of a mask, which
+       states them as percentages so the clip follows the frame. A shape
+       with curves in it has none, and says so - which is why MASK_KINDS is
+       all polygons. */
     function shapePoints(kind, w, h) {
-        var pts;
-        switch (kind) {
-            case "triangle":
-                pts = [[w / 2, 0], [w, h], [0, h]]; break;
-            case "diamond":
-                pts = [[w / 2, 0], [w, h / 2], [w / 2, h], [0, h / 2]]; break;
-            case "arrow":
-                pts = [[0, h * 0.3], [w * 0.62, h * 0.3], [w * 0.62, 0], [w, h / 2],
-                       [w * 0.62, h], [w * 0.62, h * 0.7], [0, h * 0.7]]; break;
-            case "chevron":
-                pts = [[0, 0], [w * 0.72, 0], [w, h / 2], [w * 0.72, h], [0, h], [w * 0.28, h / 2]]; break;
-            case "leftArrow":
-                pts = [[w, h * 0.3], [w * 0.38, h * 0.3], [w * 0.38, 0], [0, h / 2],
-                       [w * 0.38, h], [w * 0.38, h * 0.7], [w, h * 0.7]]; break;
-            case "upArrow":
-                pts = [[w * 0.3, h], [w * 0.3, h * 0.38], [0, h * 0.38], [w / 2, 0],
-                       [w, h * 0.38], [w * 0.7, h * 0.38], [w * 0.7, h]]; break;
-            case "downArrow":
-                pts = [[w * 0.3, 0], [w * 0.3, h * 0.62], [0, h * 0.62], [w / 2, h],
-                       [w, h * 0.62], [w * 0.7, h * 0.62], [w * 0.7, 0]]; break;
-            case "rtTriangle":
-                pts = [[0, 0], [w, h], [0, h]]; break;
-            case "pentagon":
-                pts = [[w / 2, 0], [w, h * 0.38], [w * 0.81, h], [w * 0.19, h], [0, h * 0.38]]; break;
-            case "hexagon":
-                pts = [[w * 0.25, 0], [w * 0.75, 0], [w, h / 2],
-                       [w * 0.75, h], [w * 0.25, h], [0, h / 2]]; break;
-            case "parallelogram":
-                pts = [[w * 0.25, 0], [w, 0], [w * 0.75, h], [0, h]]; break;
-            case "trapezoid":
-                pts = [[w * 0.25, 0], [w * 0.75, 0], [w, h], [0, h]]; break;
-            case "plus":
-                pts = [[w * 0.35, 0], [w * 0.65, 0], [w * 0.65, h * 0.35], [w, h * 0.35],
-                       [w, h * 0.65], [w * 0.65, h * 0.65], [w * 0.65, h], [w * 0.35, h],
-                       [w * 0.35, h * 0.65], [0, h * 0.65], [0, h * 0.35], [w * 0.35, h * 0.35]];
-                break;
-            case "star":
-                pts = [];
-                var cx = w / 2, cy = h / 2, rx = w / 2, ry = h / 2, inner = 0.42;
-                for (var i = 0; i < 10; i++) {
-                    var ang = -Math.PI / 2 + i * Math.PI / 5;
-                    var f = (i % 2 === 0) ? 1 : inner;
-                    pts.push([cx + rx * f * Math.cos(ang), cy + ry * f * Math.sin(ang)]);
-                }
-                break;
-            default:
-                pts = null;
-        }
-        return pts;
+        return SlidesShapes.points(kind, w, h);
     }
 
+    /* shapeSvg draws one shape. The geometry comes from SlidesShapes, so
+       the canvas, the icon in the picker, a shaped crop and the PDF export
+       are all working from the same outline.
+
+       Two shapes are still drawn as SVG primitives rather than as a path:
+       a rectangle and an ellipse, because a rounded rectangle's radius is
+       a property the user sets and `rx` follows the box as it is resized. */
     function shapeSvg(o) {
         var w = Math.max(4, o.w), h = Math.max(4, o.h);
         var p = o.props;
+        var kind = SlidesShapes.canonical(p.kind || "rect");
         var sw = Number(p.strokeW) || 0;
+        var stroke = p.stroke || "";
         // an imported shape may legitimately have no fill (an outline-only
         // box); only an editor-made shape with nothing set gets the default
         var fill = p.fill || "#e07b1f";
+        // a bracket, a brace or an arc is a line and not an area: with no
+        // stroke there would be nothing on the slide at all
+        var open = SlidesShapes.isOpen(kind);
+        if (open) {
+            if (!stroke || stroke === "none") stroke = (fill && fill !== "none") ? fill : "#333333";
+            if (!sw) sw = 2;
+            fill = "none";
+        }
+        var hasStroke = sw > 0 && stroke && stroke !== "none";
         var attrs = 'fill="' + esc(fill) + '"' +
-            (sw > 0 ? ' stroke="' + esc(p.stroke || "#333333") + '" stroke-width="' + sw + '"' +
+            (SlidesShapes.evenOdd(kind) ? ' fill-rule="evenodd"' : "") +
+            (hasStroke ? ' stroke="' + esc(stroke) + '" stroke-width="' + sw + '"' +
                 (p.dash ? ' stroke-dasharray="' + (sw * 3) + " " + (sw * 2.4) + '"' : "") : ' stroke="none"') +
             ' stroke-linejoin="round" vector-effect="non-scaling-stroke"';
         var inner;
-        var i = sw > 0 ? Math.max(0.5, sw / 2) : 0;
-        if (o.props.kind === "rect" || o.props.kind === "round" || !o.props.kind) {
-            var rx = o.props.kind === "round"
+        var i = hasStroke ? Math.max(0.5, sw / 2) : 0;
+        if (kind === "rect" || kind === "roundRect") {
+            var rx = kind === "roundRect"
                 ? (p.radius !== undefined ? Number(p.radius) : Math.min(w, h) * 0.15) : 0;
             inner = '<rect x="' + i + '" y="' + i + '" width="' + (w - 2 * i) + '" height="' + (h - 2 * i) +
                 '" rx="' + rx + '" ' + attrs + "/>";
-        } else if (o.props.kind === "ellipse") {
+        } else if (kind === "ellipse") {
             inner = '<ellipse cx="' + (w / 2) + '" cy="' + (h / 2) + '" rx="' + (w / 2 - i) + '" ry="' + (h / 2 - i) + '" ' + attrs + "/>";
         } else {
-            var pts = shapePoints(o.props.kind, w, h) || [[0, 0], [w, 0], [w, h], [0, h]];
-            inner = '<polygon points="' + pts.map(function (pt) {
-                return pt[0].toFixed(1) + "," + pt[1].toFixed(1);
-            }).join(" ") + '" ' + attrs + "/>";
+            var d = SlidesShapes.path(kind, w, h);
+            if (!d) d = SlidesShapes.path("rect", w, h);
+            inner = '<path d="' + d + '" ' + attrs + "/>";
         }
+        // markings - the divider bars of a predefined process, the fold of a
+        // folded corner. They are drawn, never filled.
+        var det = SlidesShapes.detail(kind, w, h);
+        if (det) {
+            inner += '<path d="' + det + '" fill="none" stroke="' +
+                esc(hasStroke ? stroke : contrastText(fill)) + '" stroke-width="' +
+                (sw > 0 ? sw : 1) + '" vector-effect="non-scaling-stroke"/>';
+        }
+        // a stroke sits astride the outline, so half of it falls outside the
+        // box - which is what PowerPoint draws too
         return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + " " + h +
-            '" preserveAspectRatio="none">' + inner + "</svg>";
+            '" preserveAspectRatio="none" style="overflow:visible">' + inner + "</svg>";
     }
 
     function shapeTextDiv(o) {
@@ -946,13 +950,27 @@ var SlidesApp = (function () {
             delete o.props.radius;
         } else {
             o.props.mask = kind;
-            if (kind === "round") o.props.radius = Math.min(o.w, o.h) * 0.15;
+            if (kind === "roundRect") o.props.radius = Math.min(o.w, o.h) * 0.15;
             else delete o.props.radius;
         }
         commit();
     }
 
     /* ================= rendering: rail / thumbnails ================= */
+
+    /* A preview is the whole slide at 960x540, shrunk by a transform. The
+       box it has to fit inside is whatever the rail can spare once the
+       scrollbar has taken its cut, which varies by platform - so measure it
+       rather than assume it. Getting this wrong does not look like a wrong
+       scale, it looks like the right-hand edge of every slide is missing. */
+    function fitThumbs() {
+        var view = document.querySelector("#slThumbs .sl-thumb-view");
+        var thumbs = document.getElementById("slThumbs");
+        if (!view || !thumbs) return;
+        var w = view.clientWidth;
+        if (w > 0) thumbs.style.setProperty("--sl-thumb-scale", w / SLIDE_W);
+    }
+
     function renderThumb(i) {
         var $mini = $("#slThumbs .sl-thumb").eq(i).find(".sl-thumb-mini");
         if ($mini.length && body.slides[i]) renderSlideContent($mini[0], body.slides[i]);
@@ -966,16 +984,31 @@ var SlidesApp = (function () {
     }
 
     var dragSlideIdx = -1;
+
+    // railFocused answers whose Delete key it is: the rail's or the canvas's
+    function railFocused() {
+        var a = document.activeElement;
+        var rail = document.getElementById("slRail");
+        return !!(a && rail && rail.contains(a));
+    }
+
     function renderRail() {
+        // deleting a slide rebuilds the rail, and the keyboard should not
+        // have to be given back by hand to delete the next one
+        var refocus = railFocused();
         var $t = $("#slThumbs").empty();
         body.slides.forEach(function (s, i) {
-            var $th = $('<div class="sl-thumb" draggable="true"></div>');
+            var $th = $('<div class="sl-thumb" draggable="true" tabindex="0"></div>');
             if (i === cur) $th.addClass("active");
             $th.append('<div class="sl-thumb-num">' + (i + 1) + "</div>");
             var $view = $('<div class="sl-thumb-view"><div class="sl-thumb-mini sl-slidebase"></div></div>');
             $th.append($view);
             renderSlideContent($view.find(".sl-thumb-mini")[0], s);
-            $th.on("click", function () { selectSlide(i); });
+            // Taking focus is what lets Delete mean "this slide", and
+            // focusing selects - so the slide the keyboard is on and the
+            // slide being edited can never be two different slides.
+            $th.on("click", function () { $th.focus(); selectSlide(i); });
+            $th.on("focus", function () { selectSlide(i); });
             $th.on("contextmenu", function (e) {
                 e.preventDefault();
                 selectSlide(i);
@@ -1012,6 +1045,8 @@ var SlidesApp = (function () {
             });
             $t.append($th);
         });
+        fitThumbs();
+        if (refocus) $("#slThumbs .sl-thumb").eq(cur).focus();
     }
     function updateRailActive() {
         $("#slThumbs .sl-thumb").each(function (i) {
@@ -1555,12 +1590,66 @@ var SlidesApp = (function () {
             { x: 330, y: 240, w: 300, h: 60 });
         startEdit(o.id);
     }
+    /* showShapePicker is the Insert > Shape control: the categories on the
+       left, the shapes of the one in hand as icons on the right. Icons and
+       not a list of names, because the outline is the thing being chosen -
+       and they are drawn from the catalogue, so a picker entry cannot come
+       to disagree with what gets inserted. */
+    var $shapePicker = null;
+    function closeShapePicker() {
+        if ($shapePicker) { $shapePicker.remove(); $shapePicker = null; }
+        $(document).off("mousedown.slshapepick");
+    }
+    function showShapePicker(x, y) {
+        closeShapePicker();
+        var $m = $('<div class="sl-shapepick of-noprint"></div>');
+        var $cats = $('<div class="sl-shapepick-cats"></div>');
+        var $grid = $('<div class="sl-shapepick-grid"></div>');
+        SlidesShapes.CATEGORIES.forEach(function (c, idx) {
+            var $b = $('<button type="button" class="sl-shapepick-cat"></button>');
+            $b.append($('<i class="icon"></i>').addClass(c.icon));
+            $b.append($("<span></span>").text(c.label));
+            $b.append('<i class="caret right icon sl-shapepick-more"></i>');
+            function open() {
+                $cats.find(".sl-shapepick-cat").removeClass("active");
+                $b.addClass("active");
+                $grid.empty();
+                c.kinds.forEach(function (k) {
+                    var $cell = $('<button type="button" class="sl-shapepick-cell"></button>')
+                        .attr("title", SlidesShapes.label(k))
+                        .html(SlidesShapes.icon(k, 22));
+                    $cell.on("click", function () { closeShapePicker(); insertShape(k); });
+                    $grid.append($cell);
+                });
+            }
+            $b.on("mouseenter click", open);
+            if (idx === 0) open();
+            $cats.append($b);
+        });
+        $m.append($cats).append($grid);
+        $("body").append($m);
+        var mw = $m.outerWidth(), mh = $m.outerHeight();
+        $m.css({
+            left: Math.max(4, Math.min(x, window.innerWidth - mw - 6)) + "px",
+            top: Math.max(4, Math.min(y, window.innerHeight - mh - 6)) + "px"
+        });
+        $shapePicker = $m;
+        setTimeout(function () {
+            $(document).on("mousedown.slshapepick", function (e) {
+                if ($shapePicker && !$shapePicker[0].contains(e.target)) closeShapePicker();
+            });
+        }, 0);
+    }
+
     function insertShape(kind) {
         var th = themeOf();
+        // a brace or a bracket only reads as itself tall and narrow, so the
+        // catalogue gets to say what box its shapes want
+        var size = SlidesShapes.defaultSize(kind) || [200, 160];
         addObj("shape", {
             kind: kind, fill: /^#[0-9a-fA-F]{6}$/.test(th.accent) ? th.accent : "#e07b1f",
             stroke: "#333333", strokeW: 0, text: "", fontSize: 18
-        }, { x: 380, y: 190, w: 200, h: 160 });
+        }, { x: 480 - size[0] / 2, y: 270 - size[1] / 2, w: size[0], h: size[1] });
     }
     function armDraw(kind) {
         endEdit(true);
@@ -2342,6 +2431,9 @@ var SlidesApp = (function () {
                 h: hname, id: o.id, start: pt, moved: false,
                 g: { x: o.x, y: o.y, w: o.w, h: o.h, rot: o.rot || 0 }
             };
+            // the pointer leaves the knob as soon as the object turns, so
+            // the rotate cursor has to be put on the canvas for the drag
+            if (drag.mode === "rotate") canvasEl.classList.add("sl-rotating");
             try { canvasEl.setPointerCapture(e.pointerId); } catch (err) { }
             return;
         }
@@ -2603,6 +2695,7 @@ var SlidesApp = (function () {
         var d = drag;
         drag = null;
         lastPointerEvt = null;
+        canvasEl.classList.remove("sl-rotating");
         hideGuides();
         try { canvasEl.releasePointerCapture(e.pointerId); } catch (err) { }
 
@@ -2771,7 +2864,7 @@ var SlidesApp = (function () {
                         label: "None (rectangle)",
                         checked: function () { return !o.props.mask; },
                         action: function () { setImageMask(o, ""); }
-                    }, { sep: true }].concat(SHAPE_KINDS.filter(function (s) {
+                    }, { sep: true }].concat(MASK_KINDS.filter(function (s) {
                         return s.kind !== "rect";
                     }).map(function (s) {
                         return {
@@ -2870,6 +2963,16 @@ var SlidesApp = (function () {
             { id: "sl.delete", description: "Delete selection", group: GO, when: function () { return editorIdle() && sel.length > 0; } });
         HK.register("Backspace", function () { deleteSelection(); },
             { id: "sl.delete2", when: function () { return editorIdle() && sel.length > 0; } });
+        /* Delete means the slide when the rail has the keyboard and the
+           canvas when it does not. Registered after the object one so it
+           gets first look (the registry is LIFO), though the two guards are
+           mutually exclusive anyway - selecting a slide clears the object
+           selection. */
+        HK.register("Delete", function () { deleteSlide(cur); },
+            { id: "sl.deleteslide", description: "Delete slide", group: GS,
+                when: function () { return editorIdle() && railFocused(); } });
+        HK.register("Backspace", function () { deleteSlide(cur); },
+            { id: "sl.deleteslide2", when: function () { return editorIdle() && railFocused(); } });
         HK.register("Escape", function () {
             if (cropId) { endCrop(false); return; }
             if (editingId) { endEdit(true); return; }
@@ -3068,7 +3171,14 @@ var SlidesApp = (function () {
         $tb.append(tbtn("undo", "Undo (Ctrl+Z)", function () { doUndo(); }));
         $tb.append(tbtn("redo", "Redo (Ctrl+Y)", function () { doRedo(); }));
         $tb.append('<div class="of-tsep"></div>');
+        // New slide is a split control: the button adds one, the caret
+        // beside it picks the layout to start from
         $tb.append(tbtn("plus square outline", "New slide (Ctrl+M)", function () { addSlideAfter(cur); }));
+        $tb.append(tbtn("caret down", "New slide with layout", function (e) {
+            var r = e.currentTarget.getBoundingClientRect();
+            OfficeApp.closeAllMenus();
+            showLayoutPicker(r.left - 60, r.bottom + 4);
+        }, "slBtnLayout"));
         $tb.append('<div class="of-tsep"></div>');
 
         $tb.append(tbtn("font", "Insert text box", insertText));
@@ -3083,9 +3193,8 @@ var SlidesApp = (function () {
         $tb.append($imgBtn);
         var $shpBtn = tbtn("object group", "Insert shape", function (e) {
             var r = e.currentTarget.getBoundingClientRect();
-            OfficeApp.showContextMenu(r.left, r.bottom + 4, SHAPE_KINDS.map(function (s) {
-                return { label: s.label, action: function () { insertShape(s.kind); } };
-            }));
+            OfficeApp.closeAllMenus();
+            showShapePicker(r.left, r.bottom + 4);
         });
         $tb.append($shpBtn);
         $tb.append(tbtn("minus", "Draw line", function () {
@@ -3678,9 +3787,11 @@ var SlidesApp = (function () {
                 ]
             },
             {
-                label: "Shape", icon: "object group", sub: SHAPE_KINDS.map(function (s) {
-                    return { label: s.label, action: function () { insertShape(s.kind); } };
-                })
+                label: "Shape...", icon: "object group",
+                action: function (e) {
+                    var r = document.getElementById("toolbar").getBoundingClientRect();
+                    showShapePicker(r.left + 120, r.bottom + 4);
+                }
             },
             { label: "Line", icon: "minus", action: function () { armDraw("line"); } },
             { label: "Arrow", icon: "long arrow alternate right", action: function () { armDraw("arrow"); } },
@@ -3704,19 +3815,65 @@ var SlidesApp = (function () {
             { label: "New slide from layout", icon: "th large", sub: layoutMenuItems }
         ];
     }
+    /* The layouts a new slide can start from, in the order the picker
+       shows them. They are skeletons of real objects, not a placeholder
+       system - so what the preview draws is what the slide will be. */
     var LAYOUTS = [
-        { key: "blank", label: "Blank" },
         { key: "title", label: "Title slide" },
+        { key: "section", label: "Section header" },
+        { key: "content", label: "Title and body" },
+        { key: "two", label: "Title and two columns" },
         { key: "normal", label: "Title only" },
-        { key: "content", label: "Title and content" },
-        { key: "two", label: "Two content boxes" },
-        { key: "caption", label: "Caption (bottom text)" }
+        { key: "onecol", label: "One-column text" },
+        { key: "mainpoint", label: "Main point" },
+        { key: "sectiondesc", label: "Section title and description" },
+        { key: "caption", label: "Caption" },
+        { key: "bignumber", label: "Big number" },
+        { key: "blank", label: "Blank" }
     ];
     function layoutMenuItems() {
         return LAYOUTS.map(function (l) {
             return { label: l.label, action: function () { addSlideAfter(cur, l.key); } };
         });
     }
+
+    /* showLayoutPicker is the caret beside New slide: every layout as a
+       preview of itself. The previews are built by the same newSlide() and
+       renderSlideContent() the document uses, at the scale the rail uses,
+       so a preview cannot come to disagree with the slide it makes. */
+    var $layoutPicker = null;
+    function closeLayoutPicker() {
+        if ($layoutPicker) { $layoutPicker.remove(); $layoutPicker = null; }
+        $(document).off("mousedown.sllayoutpick");
+    }
+    function showLayoutPicker(x, y) {
+        closeLayoutPicker();
+        var $m = $('<div class="sl-layoutpick of-noprint"></div>');
+        LAYOUTS.forEach(function (l) {
+            var $c = $('<button type="button" class="sl-layoutpick-cell"></button>');
+            var $v = $('<div class="sl-layoutpick-view"><div class="sl-layoutpick-mini sl-slidebase"></div></div>');
+            $c.append($v);
+            $c.append($('<div class="sl-layoutpick-label"></div>').text(l.label));
+            $m.append($c);
+            renderSlideContent($v.find(".sl-layoutpick-mini")[0], newSlide(l.key));
+            $c.on("click", function () {
+                closeLayoutPicker();
+                addSlideAfter(cur, l.key);
+            });
+        });
+        $("body").append($m);
+        var mw = $m.outerWidth(), mh = $m.outerHeight();
+        $m.css({
+            left: Math.max(4, Math.min(x, window.innerWidth - mw - 6)) + "px",
+            top: Math.max(4, Math.min(y, window.innerHeight - mh - 6)) + "px"
+        });
+        $layoutPicker = $m;
+        setTimeout(function () {
+            $(document).on("mousedown.sllayoutpick", function (e) {
+                if ($layoutPicker && !$layoutPicker[0].contains(e.target)) closeLayoutPicker();
+            });
+        }, 0);
+    }
     function slideMenuItems() {
         return [
             { label: "New slide", icon: "plus", key: "Ctrl+M", action: function () { addSlideAfter(cur); } },
@@ -3883,7 +4040,6 @@ var SlidesApp = (function () {
             }
         });
 
-        $("#slRailAdd").on("click", function () { addSlideAfter(body.slides.length - 1); });
 
         $("#slDeviceImage").on("change", function () {
             var files = this.files;
@@ -3892,7 +4048,10 @@ var SlidesApp = (function () {
         });
 
         registerHotkeys();
-        window.addEventListener("resize", layoutCanvas);
+        window.addEventListener("resize", function () {
+            layoutCanvas();
+            fitThumbs();
+        });
         // live list-button state as the caret moves through the text box
         document.addEventListener("selectionchange", function () {
             if (editingId && editingKind === "text") syncListButtonState();
@@ -3912,8 +4071,7 @@ var SlidesApp = (function () {
                 isCropping: function () { return !!cropId; },
                 resetImage: resetImage,
                 setMask: setImageMask,
-                shapeKinds: SHAPE_KINDS,
-                shapePoints: shapePoints,
+                shapeKinds: MASK_KINDS,
                 slideSize: [SLIDE_W, SLIDE_H],
                 relayout: layoutCanvas
             });
@@ -4116,9 +4274,6 @@ var SlidesApp = (function () {
         getCurrentIndex: function () { return cur; },
         renderSlideContent: renderSlideContent,
         themeOf: themeOf,
-        // the PDF exporter builds its clip paths from the very same
-        // outlines the canvas draws, so the two cannot disagree
-        shapePoints: shapePoints,
         slideCount: function () { return body ? body.slides.length : 0; }
     };
 })();

+ 5 - 21
src/web/Office/slides/slides_image.js

@@ -21,7 +21,7 @@
             commit:   fn,                    // model changed, redraw + undo
             startCrop: fn(id), endCrop: fn(apply), isCropping: fn -> bool,
             resetImage: fn(obj), setMask: fn(obj, kind),
-            shapeKinds: [{kind,label}], shapePoints: fn(kind,w,h),
+            shapeKinds: [{kind,label}],
             slideSize: [w,h], relayout: fn
         });
 
@@ -92,27 +92,11 @@ var SlidesImageTools = (function () {
 
     /* ================= shape grid ================= */
 
-    // shapeIcon draws one crop shape as a small outline, using the very
-    // same geometry the canvas uses so the icon cannot drift from the mask
+    // shapeIcon draws one crop shape as a small outline. The catalogue draws
+    // it, from the very same geometry the canvas uses, so the icon cannot
+    // drift from the mask it stands for.
     function shapeIcon(kind, size) {
-        var s = size || 22, pad = 2, w = s - pad * 2, h = s - pad * 2;
-        var body;
-        if (kind === "rect" || !kind) {
-            body = '<rect x="' + pad + '" y="' + pad + '" width="' + w + '" height="' + h + '"/>';
-        } else if (kind === "round") {
-            body = '<rect x="' + pad + '" y="' + pad + '" width="' + w + '" height="' + h +
-                '" rx="' + (w * 0.22) + '"/>';
-        } else if (kind === "ellipse") {
-            body = '<ellipse cx="' + (s / 2) + '" cy="' + (s / 2) + '" rx="' + (w / 2) + '" ry="' + (h / 2) + '"/>';
-        } else {
-            var pts = host.shapePoints(kind, w, h) || [];
-            body = '<polygon points="' + pts.map(function (pt) {
-                return (pt[0] + pad).toFixed(1) + "," + (pt[1] + pad).toFixed(1);
-            }).join(" ") + '"/>';
-        }
-        return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 ' + s + " " + s + '" ' +
-            'fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round">' +
-            body + "</svg>";
+        return SlidesShapes.icon(kind || "rect", size || 22);
     }
 
     /* showShapeMenu opens the crop-shape picker under an element. The

+ 74 - 15
src/web/Office/slides/slides_pdf.js

@@ -459,6 +459,15 @@ var SlidesPdf = (function () {
         };
     }
     // parseColor is parseFill when only the colour is wanted
+    /* contrastOf answers "what colour shows up on this fill" - only used
+       for a shape's markings when it has no stroke colour of its own */
+    function contrastOf(css) {
+        var f = parseFill(css);
+        if (!f) return "#333333";
+        var lum = 0.299 * f.c.red + 0.587 * f.c.green + 0.114 * f.c.blue;
+        return lum > 0.6 ? "#333333" : "#ffffff";
+    }
+
     function parseColor(css) {
         var f = parseFill(css);
         return f ? f.c : null;
@@ -537,7 +546,7 @@ var SlidesPdf = (function () {
                 PDFLib.lineTo(X(w), Y(h)), PDFLib.lineTo(X(0), Y(h)), PDFLib.closePath());
             return ops;
         }
-        if (kind === "round") {
+        if (kind === "roundRect") {
             var r = Math.min(radius > 0 ? radius : Math.min(w, h) * 0.15, Math.min(w, h) / 2);
             var k = r * 0.5523;
             ops.push(PDFLib.moveTo(X(r), Y(0)));
@@ -562,15 +571,28 @@ var SlidesPdf = (function () {
             ops.push(PDFLib.closePath());
             return ops;
         }
-        var pts = (window.SlidesApp && SlidesApp.shapePoints)
-            ? SlidesApp.shapePoints(kind, w, h) : null;
-        if (!pts || !pts.length) {
-            return shapePathOps("rect", x, y, w, h, 0);
+        var d = (window.SlidesShapes) ? SlidesShapes.path(kind, w, h) : "";
+        if (!d) return shapePathOps("rect", x, y, w, h, 0);
+        return svgPathOps(d, X, Y);
+    }
+
+    /* svgPathOps turns one of the catalogue's paths into PDF path
+       operators. It only has to understand M, L, C and Z because that is
+       all slides_shapes.js ever writes - arcs arrive already converted to
+       cubics, which is the whole reason for that restriction. */
+    function svgPathOps(d, X, Y) {
+        var ops = [];
+        var re = /([MLCZ])([^MLCZ]*)/g;
+        var m;
+        while ((m = re.exec(d))) {
+            var cmd = m[1];
+            if (cmd === "Z") { ops.push(PDFLib.closePath()); continue; }
+            var v = m[2].trim().split(/[\s,]+/).map(Number);
+            if (cmd === "M") ops.push(PDFLib.moveTo(X(v[0]), Y(v[1])));
+            else if (cmd === "L") ops.push(PDFLib.lineTo(X(v[0]), Y(v[1])));
+            else ops.push(PDFLib.appendBezierCurve(X(v[0]), Y(v[1]),
+                X(v[2]), Y(v[3]), X(v[4]), Y(v[5])));
         }
-        pts.forEach(function (pt, i) {
-            ops.push(i === 0 ? PDFLib.moveTo(X(pt[0]), Y(pt[1])) : PDFLib.lineTo(X(pt[0]), Y(pt[1])));
-        });
-        ops.push(PDFLib.closePath());
         return ops;
     }
 
@@ -1248,21 +1270,58 @@ var SlidesPdf = (function () {
     /* ---- shape object ---- */
     function drawShapeObject(pg, o, el, origin, ctx) {
         var p = o.props || {};
-        var fill = (p.fill && p.fill !== "none") ? parseColor(p.fill) : null;
-        var stroke = (p.strokeW > 0 && p.stroke && p.stroke !== "none") ? parseColor(p.stroke) : null;
+        var kind = window.SlidesShapes ? SlidesShapes.canonical(p.kind || "rect") : (p.kind || "rect");
+        var open = window.SlidesShapes && SlidesShapes.isOpen(kind);
+        var evenOdd = window.SlidesShapes && SlidesShapes.evenOdd(kind);
+        var strokeW = Number(p.strokeW) || 0;
+        var fillCss = p.fill, strokeCss = p.stroke;
+        // the same rule the canvas follows: a bracket, brace or arc is a
+        // line, so it is stroked and never filled (see shapeSvg)
+        if (open) {
+            if (!strokeCss || strokeCss === "none") {
+                strokeCss = (fillCss && fillCss !== "none") ? fillCss : "#333333";
+            }
+            if (!strokeW) strokeW = 2;
+            fillCss = "none";
+        }
+        var fill = (fillCss && fillCss !== "none") ? parseColor(fillCss) : null;
+        var stroke = (strokeW > 0 && strokeCss && strokeCss !== "none") ? parseColor(strokeCss) : null;
         if (fill || stroke) {
-            var ops = shapePathOps(p.kind || "rect", o.x, o.y, o.w, o.h, p.radius);
+            var ops = shapePathOps(kind, o.x, o.y, o.w, o.h, p.radius);
             if (fill) ops.unshift(PDFLib.setFillingColor(fill));
             if (stroke) {
                 ops.unshift(PDFLib.setStrokingColor(stroke));
-                ops.unshift(PDFLib.setLineWidth(px(p.strokeW)));
-                if (p.dash) ops.unshift(PDFLib.setDashPattern([px(p.strokeW * 3), px(p.strokeW * 2.4)], 0));
+                ops.unshift(PDFLib.setLineWidth(px(strokeW)));
+                if (p.dash) ops.unshift(PDFLib.setDashPattern([px(strokeW * 3), px(strokeW * 2.4)], 0));
+            }
+            if (fill && stroke) {
+                ops.push(evenOdd ? PDFLib.PDFOperator.of(PDFLib.PDFOperatorNames.FillEvenOddAndStroke)
+                    : PDFLib.fillAndStroke());
+            } else if (fill) {
+                ops.push(evenOdd ? PDFLib.PDFOperator.of(PDFLib.PDFOperatorNames.FillEvenOdd)
+                    : PDFLib.fill());
+            } else {
+                ops.push(PDFLib.stroke());
             }
-            ops.push(fill && stroke ? PDFLib.fillAndStroke() : (fill ? PDFLib.fill() : PDFLib.stroke()));
             pg.save();
             pg.ops(ops);
             pg.restore();
         }
+        // markings the canvas draws over the outline - the bars of a
+        // predefined process, the fold of a folded corner
+        var det = window.SlidesShapes ? SlidesShapes.detail(kind, o.w, o.h) : "";
+        if (det) {
+            var dc = stroke || parseColor(contrastOf(fillCss)) || PDFLib.rgb(0.2, 0.2, 0.2);
+            var X = function (v) { return px(o.x + v); };
+            var Y = function (v) { return px(SLIDE_H - (o.y + v)); };
+            var dops = svgPathOps(det, X, Y);
+            dops.unshift(PDFLib.setLineWidth(px(strokeW > 0 ? strokeW : 1)));
+            dops.unshift(PDFLib.setStrokingColor(dc));
+            dops.push(PDFLib.stroke());
+            pg.save();
+            pg.ops(dops);
+            pg.restore();
+        }
         // the caption rides on the same text rules as a text box
         var inner = el ? el.querySelector(".sl-shape-text") : null;
         if (!inner) return Promise.resolve();

+ 1334 - 0
src/web/Office/slides/slides_shapes.js

@@ -0,0 +1,1334 @@
+/*
+    ArozOS Office - Slides shape catalogue
+    ======================================
+    Every shape the editor can draw, as geometry rather than as a picture.
+
+    The names are the ones PresentationML uses for its preset geometries
+    (`<a:prstGeom prst="rightBrace"/>`), so an imported deck keeps its own
+    vocabulary and the converters on the Go side are a lookup rather than a
+    translation. A handful of older editor names (`round`, `arrow`, `star`,
+    ...) are kept as aliases, because documents already written carry them.
+
+    Each shape produces an SVG path, and deliberately only ever uses M, L, C
+    and Z:
+
+      - `clip-path: path(...)` takes it, which is how a picture is cropped
+        to a shape,
+      - the PDF exporter turns it into path operators with a translator of
+        about thirty lines (slides_pdf.js), and
+      - there is nothing to get wrong in an arc flag.
+
+    Circles and arcs therefore arrive as cubic curves; `arc()` below does
+    that conversion once so no shape has to think about it.
+
+    Some shapes have a second path - the divider bars of a predefined
+    process, the cross of a summing junction. Those are *drawn*, not filled:
+    they are the shape's markings, not its outline, so they come back
+    separately from `detail()` and the renderer strokes them.
+
+    Usage:
+        SlidesShapes.path(kind, w, h)   // "M0 0L100 0..." ("" if unknown)
+        SlidesShapes.detail(kind, w, h) // markings to stroke, or ""
+        SlidesShapes.points(kind, w, h) // polygon corners, or null
+        SlidesShapes.evenOdd(kind)      // does the path need even-odd fill
+        SlidesShapes.canonical(kind)    // legacy name -> catalogue name
+        SlidesShapes.has(kind)
+        SlidesShapes.label(kind)
+        SlidesShapes.CATEGORIES         // [{ id, label, kinds: [...] }]
+*/
+
+var SlidesShapes = (function () {
+    "use strict";
+
+    function r2(v) { return Math.round(v * 1000) / 1000; }
+
+    /* ---------------- the path builder ---------------- */
+
+    function Path() { this.s = ""; }
+    Path.prototype.M = function (x, y) { this.s += "M" + r2(x) + " " + r2(y); return this; };
+    Path.prototype.L = function (x, y) { this.s += "L" + r2(x) + " " + r2(y); return this; };
+    Path.prototype.C = function (x1, y1, x2, y2, x, y) {
+        this.s += "C" + r2(x1) + " " + r2(y1) + " " + r2(x2) + " " + r2(y2) +
+            " " + r2(x) + " " + r2(y);
+        return this;
+    };
+    Path.prototype.Z = function () { this.s += "Z"; return this; };
+    Path.prototype.poly = function (pts) {
+        for (var i = 0; i < pts.length; i++) {
+            if (i === 0) this.M(pts[i][0], pts[i][1]);
+            else this.L(pts[i][0], pts[i][1]);
+        }
+        return this.Z();
+    };
+    /* arc appends an elliptical arc from a0 to a1 (radians, y down). It
+       moves to the start when the path is empty or `move` is asked for,
+       and otherwise draws a line to it first, which is what every shape
+       below wants. Cut into quarter turns, each a cubic - the standard
+       approximation, exact to about one part in ten thousand. */
+    Path.prototype.arc = function (cx, cy, rx, ry, a0, a1, move) {
+        var span = a1 - a0;
+        var steps = Math.max(1, Math.ceil(Math.abs(span) / (Math.PI / 2)));
+        var step = span / steps;
+        var k = 4 / 3 * Math.tan(step / 4);
+        var a = a0;
+        var x0 = cx + rx * Math.cos(a), y0 = cy + ry * Math.sin(a);
+        if (move || this.s === "") this.M(x0, y0);
+        else this.L(x0, y0);
+        for (var i = 0; i < steps; i++) {
+            var b = a + step;
+            var cosA = Math.cos(a), sinA = Math.sin(a);
+            var cosB = Math.cos(b), sinB = Math.sin(b);
+            var p0x = cx + rx * cosA, p0y = cy + ry * sinA;
+            var p3x = cx + rx * cosB, p3y = cy + ry * sinB;
+            this.C(p0x - k * rx * sinA, p0y + k * ry * cosA,
+                p3x + k * rx * sinB, p3y - k * ry * cosB, p3x, p3y);
+            a = b;
+        }
+        return this;
+    };
+    Path.prototype.circle = function (cx, cy, rx, ry) {
+        return this.arc(cx, cy, rx, ry, 0, 2 * Math.PI, true).Z();
+    };
+    Path.prototype.toString = function () { return this.s; };
+
+    function P() { return new Path(); }
+
+    // regular polygon inscribed in the box, first corner at the top
+    function ngon(w, h, n, rotate) {
+        var pts = [];
+        var cx = w / 2, cy = h / 2;
+        var off = (rotate || 0) - Math.PI / 2;
+        for (var i = 0; i < n; i++) {
+            var a = off + i * 2 * Math.PI / n;
+            pts.push([cx + cx * Math.cos(a), cy + cy * Math.sin(a)]);
+        }
+        return pts;
+    }
+
+    // a star with n points; inner is the inner radius as a fraction
+    function starPts(w, h, n, inner) {
+        var pts = [];
+        var cx = w / 2, cy = h / 2;
+        for (var i = 0; i < n * 2; i++) {
+            var a = -Math.PI / 2 + i * Math.PI / n;
+            var f = (i % 2 === 0) ? 1 : inner;
+            pts.push([cx + cx * f * Math.cos(a), cy + cy * f * Math.sin(a)]);
+        }
+        return pts;
+    }
+
+    // rounded rectangle, with a radius per corner (tl, tr, br, bl)
+    function roundRectPath(w, h, tl, tr, br, bl) {
+        var p = P();
+        p.M(tl, 0).L(w - tr, 0);
+        if (tr) p.arc(w - tr, tr, tr, tr, -Math.PI / 2, 0);
+        p.L(w, h - br);
+        if (br) p.arc(w - br, h - br, br, br, 0, Math.PI / 2);
+        p.L(bl, h);
+        if (bl) p.arc(bl, h - bl, bl, bl, Math.PI / 2, Math.PI);
+        p.L(0, tl);
+        if (tl) p.arc(tl, tl, tl, tl, Math.PI, Math.PI * 1.5);
+        return p.Z().toString();
+    }
+
+    // the straight-sided arrow every four directions share, drawn right
+    // and then mapped onto the direction asked for
+    function arrowPts(w, h, headLen, shaft) {
+        var hx = w * (1 - headLen);
+        var t = h * (1 - shaft) / 2;
+        return [[0, t], [hx, t], [hx, 0], [w, h / 2], [hx, h], [hx, h - t], [0, h - t]];
+    }
+    function mapPts(pts, fn) { return pts.map(fn); }
+    function rotPts(pts, w, h, quarter) {
+        // quarter turns clockwise about the centre of a w x h box, with the
+        // box itself turning too - so an arrow drawn right becomes one
+        // drawn down in the same box
+        return mapPts(pts, function (p) {
+            var x = p[0] / w, y = p[1] / h;
+            if (quarter === 1) return [(1 - y) * w, x * h];
+            if (quarter === 2) return [(1 - x) * w, (1 - y) * h];
+            if (quarter === 3) return [y * w, (1 - x) * h];
+            return [x * w, y * h];
+        });
+    }
+
+    /* ---------------- the catalogue ---------------- */
+    /* Each entry is either `pts` (a polygon, which also answers points())
+       or `path`. `detail` is stroked rather than filled; `evenOdd` says the
+       path has holes in it. */
+
+    var DEFS = {
+
+        /* ---------- basic shapes ---------- */
+
+        rect: {
+            cat: "shape", label: "Rectangle",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w, h], [0, h]]; }
+        },
+        roundRect: {
+            cat: "shape", label: "Rounded rectangle",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.15;
+                return roundRectPath(w, h, r, r, r, r);
+            }
+        },
+        snip1Rect: {
+            cat: "shape", label: "Snip single corner",
+            pts: function (w, h) {
+                var s = Math.min(w, h) * 0.18;
+                return [[0, 0], [w - s, 0], [w, s], [w, h], [0, h]];
+            }
+        },
+        snip2SameRect: {
+            cat: "shape", label: "Snip same side corners",
+            pts: function (w, h) {
+                var s = Math.min(w, h) * 0.18;
+                return [[s, 0], [w - s, 0], [w, s], [w, h], [0, h], [0, s]];
+            }
+        },
+        snip2DiagRect: {
+            cat: "shape", label: "Snip diagonal corners",
+            pts: function (w, h) {
+                var s = Math.min(w, h) * 0.18;
+                return [[s, 0], [w, 0], [w, h - s], [w - s, h], [0, h], [0, s]];
+            }
+        },
+        round1Rect: {
+            cat: "shape", label: "Round single corner",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.28;
+                return roundRectPath(w, h, 0, r, 0, 0);
+            }
+        },
+        round2SameRect: {
+            cat: "shape", label: "Round same side corners",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.28;
+                return roundRectPath(w, h, r, r, 0, 0);
+            }
+        },
+        round2DiagRect: {
+            cat: "shape", label: "Round diagonal corners",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.28;
+                return roundRectPath(w, h, r, 0, r, 0);
+            }
+        },
+        ellipse: {
+            cat: "shape", label: "Ellipse",
+            path: function (w, h) { return P().circle(w / 2, h / 2, w / 2, h / 2).toString(); }
+        },
+        triangle: {
+            cat: "shape", label: "Triangle",
+            pts: function (w, h) { return [[w / 2, 0], [w, h], [0, h]]; }
+        },
+        rtTriangle: {
+            cat: "shape", label: "Right triangle",
+            pts: function (w, h) { return [[0, 0], [w, h], [0, h]]; }
+        },
+        parallelogram: {
+            cat: "shape", label: "Parallelogram",
+            pts: function (w, h) { return [[w * 0.25, 0], [w, 0], [w * 0.75, h], [0, h]]; }
+        },
+        trapezoid: {
+            cat: "shape", label: "Trapezoid",
+            pts: function (w, h) { return [[w * 0.25, 0], [w * 0.75, 0], [w, h], [0, h]]; }
+        },
+        diamond: {
+            cat: "shape", label: "Diamond",
+            pts: function (w, h) { return [[w / 2, 0], [w, h / 2], [w / 2, h], [0, h / 2]]; }
+        },
+        pentagon: {
+            cat: "shape", label: "Pentagon",
+            pts: function (w, h) { return ngon(w, h, 5); }
+        },
+        hexagon: {
+            cat: "shape", label: "Hexagon",
+            pts: function (w, h) {
+                return [[w * 0.25, 0], [w * 0.75, 0], [w, h / 2],
+                    [w * 0.75, h], [w * 0.25, h], [0, h / 2]];
+            }
+        },
+        heptagon: {
+            cat: "shape", label: "Heptagon",
+            pts: function (w, h) { return ngon(w, h, 7); }
+        },
+        octagon: {
+            cat: "shape", label: "Octagon",
+            pts: function (w, h) {
+                var s = Math.min(w, h) * 0.29;
+                return [[s, 0], [w - s, 0], [w, s], [w, h - s],
+                    [w - s, h], [s, h], [0, h - s], [0, s]];
+            }
+        },
+        decagon: {
+            cat: "shape", label: "Decagon",
+            pts: function (w, h) { return ngon(w, h, 10); }
+        },
+        dodecagon: {
+            cat: "shape", label: "Dodecagon",
+            pts: function (w, h) { return ngon(w, h, 12); }
+        },
+        plus: {
+            cat: "shape", label: "Cross",
+            pts: function (w, h) {
+                return [[w * 0.35, 0], [w * 0.65, 0], [w * 0.65, h * 0.35], [w, h * 0.35],
+                    [w, h * 0.65], [w * 0.65, h * 0.65], [w * 0.65, h], [w * 0.35, h],
+                    [w * 0.35, h * 0.65], [0, h * 0.65], [0, h * 0.35], [w * 0.35, h * 0.35]];
+            }
+        },
+        teardrop: {
+            cat: "shape", label: "Teardrop",
+            // three quarters of a circle, the last quarter pulled out to the
+            // corner it points at
+            path: function (w, h) {
+                return P().arc(w / 2, h / 2, w / 2, h / 2, 0, Math.PI * 1.5, true)
+                    .L(w, 0).Z().toString();
+            }
+        },
+        frame: {
+            cat: "shape", label: "Frame",
+            evenOdd: true,
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.14;
+                return P().poly([[0, 0], [w, 0], [w, h], [0, h]]).toString() +
+                    P().poly([[t, t], [t, h - t], [w - t, h - t], [w - t, t]]).toString();
+            }
+        },
+        halfFrame: {
+            cat: "shape", label: "Half frame",
+            pts: function (w, h) {
+                var t = Math.min(w, h) * 0.16;
+                return [[0, 0], [w, 0], [w - t, t], [t, t], [t, h - t], [0, h]];
+            }
+        },
+        corner: {
+            cat: "shape", label: "L shape",
+            pts: function (w, h) {
+                var t = Math.min(w, h) * 0.33;
+                return [[0, 0], [t, 0], [t, h - t], [w, h - t], [w, h], [0, h]];
+            }
+        },
+        diagStripe: {
+            cat: "shape", label: "Diagonal stripe",
+            pts: function (w, h) { return [[0, h * 0.5], [w * 0.5, 0], [w, 0], [0, h]]; }
+        },
+        plaque: {
+            cat: "shape", label: "Plaque",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.16;
+                var p = P();
+                p.M(r, 0).L(w - r, 0);
+                p.arc(w, 0, r, r, Math.PI * 0.5, Math.PI);
+                p.L(w, h - r);
+                p.arc(w, h, r, r, Math.PI, Math.PI * 1.5);
+                p.L(r, h);
+                p.arc(0, h, r, r, Math.PI * 1.5, Math.PI * 2);
+                p.L(0, r);
+                p.arc(0, 0, r, r, 0, Math.PI * 0.5);
+                return p.Z().toString();
+            }
+        },
+        can: {
+            cat: "shape", label: "Cylinder",
+            path: function (w, h) {
+                var ry = Math.min(h * 0.18, w * 0.5);
+                var p = P();
+                p.M(0, ry).arc(w / 2, ry, w / 2, ry, Math.PI, Math.PI * 2)
+                    .L(w, h - ry)
+                    .arc(w / 2, h - ry, w / 2, ry, 0, Math.PI)
+                    .Z();
+                return p.toString();
+            },
+            detail: function (w, h) {
+                var ry = Math.min(h * 0.18, w * 0.5);
+                return P().arc(w / 2, ry, w / 2, ry, 0, Math.PI, true).toString();
+            }
+        },
+        cube: {
+            cat: "shape", label: "Cube",
+            path: function (w, h) {
+                var d = Math.min(w, h) * 0.25;
+                return P().poly([[0, d], [d, 0], [w, 0], [w, h - d], [w - d, h], [0, h]]).toString();
+            },
+            detail: function (w, h) {
+                var d = Math.min(w, h) * 0.25;
+                return P().M(0, d).L(w - d, d).L(w, 0).toString() +
+                    P().M(w - d, d).L(w - d, h).toString();
+            }
+        },
+        bevel: {
+            cat: "shape", label: "Bevel",
+            evenOdd: true,
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.14;
+                return P().poly([[0, 0], [w, 0], [w, h], [0, h]]).toString() +
+                    P().poly([[t, t], [w - t, t], [w - t, h - t], [t, h - t]]).toString();
+            },
+            detail: function (w, h) {
+                var t = Math.min(w, h) * 0.14;
+                return P().M(0, 0).L(t, t).toString() +
+                    P().M(w, 0).L(w - t, t).toString() +
+                    P().M(w, h).L(w - t, h - t).toString() +
+                    P().M(0, h).L(t, h - t).toString();
+            }
+        },
+        donut: {
+            cat: "shape", label: "Donut",
+            evenOdd: true,
+            path: function (w, h) {
+                return P().circle(w / 2, h / 2, w / 2, h / 2).toString() +
+                    P().circle(w / 2, h / 2, w * 0.3, h * 0.3).toString();
+            }
+        },
+        noSmoking: {
+            cat: "shape", label: "\"No\" symbol",
+            /* Not even-odd: the ring's hole comes from winding the inner
+               circle the other way, which leaves the bar free to fill right
+               across it. Even-odd would punch the bar out again wherever it
+               crosses the ring. */
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.09;
+                var a = Math.PI / 4;
+                var dx = t * Math.sin(a), dy = t * Math.cos(a);
+                var rx = w * 0.36, ry = h * 0.36;
+                var x0 = w / 2 - rx * Math.cos(a), y0 = h / 2 + ry * Math.sin(a);
+                var x1 = w / 2 + rx * Math.cos(a), y1 = h / 2 - ry * Math.sin(a);
+                return P().circle(w / 2, h / 2, w / 2, h / 2).toString() +
+                    P().arc(w / 2, h / 2, rx, ry, 2 * Math.PI, 0, true).Z().toString() +
+                    P().poly([[x0 - dx, y0 - dy], [x1 - dx, y1 - dy],
+                        [x1 + dx, y1 + dy], [x0 + dx, y0 + dy]]).toString();
+            }
+        },
+        blockArc: {
+            cat: "shape", label: "Block arc",
+            path: function (w, h) {
+                var a0 = Math.PI, a1 = 2 * Math.PI;
+                var p = P();
+                p.arc(w / 2, h / 2, w / 2, h / 2, a0, a1, true);
+                p.arc(w / 2, h / 2, w * 0.3, h * 0.3, a1, a0);
+                return p.Z().toString();
+            }
+        },
+        foldedCorner: {
+            cat: "shape", label: "Folded corner",
+            path: function (w, h) {
+                var f = Math.min(w, h) * 0.22;
+                return P().poly([[0, 0], [w, 0], [w, h - f], [w - f, h], [0, h]]).toString();
+            },
+            detail: function (w, h) {
+                var f = Math.min(w, h) * 0.22;
+                return P().M(w, h - f).L(w - f, h - f).L(w - f, h).toString();
+            }
+        },
+        smileyFace: {
+            cat: "shape", label: "Smiley face",
+            evenOdd: true,
+            path: function (w, h) {
+                var eyeR = Math.min(w, h) * 0.055;
+                var mouth = P();
+                mouth.arc(w / 2, h * 0.44, w * 0.29, h * 0.29, Math.PI * 0.22, Math.PI * 0.78, true);
+                mouth.arc(w / 2, h * 0.40, w * 0.29, h * 0.29, Math.PI * 0.78, Math.PI * 0.22);
+                return P().circle(w / 2, h / 2, w / 2, h / 2).toString() +
+                    P().circle(w * 0.33, h * 0.36, eyeR, eyeR).toString() +
+                    P().circle(w * 0.67, h * 0.36, eyeR, eyeR).toString() +
+                    mouth.Z().toString();
+            }
+        },
+        heart: {
+            cat: "shape", label: "Heart",
+            path: function (w, h) {
+                return P().M(w / 2, h)
+                    .C(w * -0.06, h * 0.6, w * 0.08, h * -0.1, w / 2, h * 0.24)
+                    .C(w * 0.92, h * -0.1, w * 1.06, h * 0.6, w / 2, h)
+                    .Z().toString();
+            }
+        },
+        lightningBolt: {
+            cat: "shape", label: "Lightning bolt",
+            pts: function (w, h) {
+                return [[w * 0.44, 0], [w * 0.84, h * 0.40], [w * 0.60, h * 0.44],
+                    [w * 0.93, h * 0.75], [w * 0.68, h * 0.74], [w * 0.83, h],
+                    [w * 0.30, h * 0.56], [w * 0.53, h * 0.52], [w * 0.19, h * 0.24],
+                    [w * 0.42, h * 0.26], [w * 0.16, h * 0.03]];
+            }
+        },
+        sun: {
+            cat: "shape", label: "Sun",
+            // eight rays off a disc: the ring of "mid" points is the disc
+            pts: function (w, h) {
+                var cx = w / 2, cy = h / 2, mid = 0.62;
+                var pts = [];
+                for (var i = 0; i < 8; i++) {
+                    var a = -Math.PI / 2 + i * Math.PI / 4;
+                    var a1 = a - Math.PI / 10, a2 = a + Math.PI / 10;
+                    pts.push([cx + cx * mid * Math.cos(a1), cy + cy * mid * Math.sin(a1)]);
+                    pts.push([cx + cx * Math.cos(a), cy + cy * Math.sin(a)]);
+                    pts.push([cx + cx * mid * Math.cos(a2), cy + cy * mid * Math.sin(a2)]);
+                }
+                return pts;
+            }
+        },
+        moon: {
+            cat: "shape", label: "Crescent moon",
+            path: function (w, h) {
+                return P().M(w, 0)
+                    .C(w * 0.2, h * 0.1, w * 0.2, h * 0.9, w, h)
+                    .C(w * 0.55, h * 0.78, w * 0.55, h * 0.22, w, 0)
+                    .Z().toString();
+            }
+        },
+        cloud: {
+            cat: "shape", label: "Cloud",
+            path: function (w, h) {
+                var p = P();
+                p.M(w * 0.22, h * 0.92)
+                    .C(w * 0.02, h * 0.92, w * -0.05, h * 0.58, w * 0.13, h * 0.5)
+                    .C(w * 0.06, h * 0.28, w * 0.26, h * 0.1, w * 0.42, h * 0.2)
+                    .C(w * 0.5, h * -0.03, w * 0.8, h * -0.03, w * 0.86, h * 0.22)
+                    .C(w * 1.04, h * 0.26, w * 1.04, h * 0.6, w * 0.87, h * 0.66)
+                    .C(w * 0.93, h * 0.88, w * 0.7, h * 1.02, w * 0.57, h * 0.9)
+                    .C(w * 0.48, h * 1.0, w * 0.3, h * 1.0, w * 0.22, h * 0.92)
+                    .Z();
+                return p.toString();
+            }
+        },
+        arc: {
+            cat: "shape", label: "Arc",
+            open: true,
+            path: function (w, h) {
+                return P().arc(w / 2, h / 2, w / 2, h / 2, Math.PI * 1.5, Math.PI * 2, true).toString();
+            }
+        },
+        chord: {
+            cat: "shape", label: "Chord",
+            path: function (w, h) {
+                return P().arc(w / 2, h / 2, w / 2, h / 2, Math.PI * 1.25, Math.PI * 2.25, true)
+                    .Z().toString();
+            }
+        },
+        pie: {
+            cat: "shape", label: "Pie",
+            path: function (w, h) {
+                return P().M(w / 2, h / 2)
+                    .arc(w / 2, h / 2, w / 2, h / 2, Math.PI * 1.5, Math.PI * 3)
+                    .Z().toString();
+            }
+        },
+
+        /* ---------- flow chart ---------- */
+
+        flowChartProcess: {
+            cat: "shape", label: "Flowchart: process",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w, h], [0, h]]; }
+        },
+        flowChartAlternateProcess: {
+            cat: "shape", label: "Flowchart: alternate process",
+            path: function (w, h) {
+                var r = Math.min(w, h) * 0.17;
+                return roundRectPath(w, h, r, r, r, r);
+            }
+        },
+        flowChartDecision: {
+            cat: "shape", label: "Flowchart: decision",
+            pts: function (w, h) { return [[w / 2, 0], [w, h / 2], [w / 2, h], [0, h / 2]]; }
+        },
+        flowChartInputOutput: {
+            cat: "shape", label: "Flowchart: data",
+            pts: function (w, h) { return [[w * 0.2, 0], [w, 0], [w * 0.8, h], [0, h]]; }
+        },
+        flowChartPredefinedProcess: {
+            cat: "shape", label: "Flowchart: predefined process",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w, h], [0, h]]; },
+            detail: function (w, h) {
+                return P().M(w * 0.12, 0).L(w * 0.12, h).toString() +
+                    P().M(w * 0.88, 0).L(w * 0.88, h).toString();
+            }
+        },
+        flowChartInternalStorage: {
+            cat: "shape", label: "Flowchart: internal storage",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w, h], [0, h]]; },
+            detail: function (w, h) {
+                return P().M(w * 0.14, 0).L(w * 0.14, h).toString() +
+                    P().M(0, h * 0.14).L(w, h * 0.14).toString();
+            }
+        },
+        flowChartDocument: {
+            cat: "shape", label: "Flowchart: document",
+            path: function (w, h) {
+                return P().M(0, 0).L(w, 0).L(w, h * 0.83)
+                    .C(w * 0.72, h * 1.07, w * 0.28, h * 0.6, 0, h * 0.86)
+                    .Z().toString();
+            }
+        },
+        flowChartMultidocument: {
+            cat: "shape", label: "Flowchart: multidocument",
+            path: function (w, h) {
+                return P().M(0, h * 0.13).L(w * 0.9, h * 0.13).L(w * 0.9, h * 0.9)
+                    .C(w * 0.65, h * 1.1, w * 0.25, h * 0.68, 0, h * 0.92).Z().toString() +
+                    P().M(w * 0.05, h * 0.07).L(w * 0.95, h * 0.07).L(w * 0.95, h * 0.8)
+                    .L(w * 0.9, h * 0.8).L(w * 0.9, h * 0.13).L(w * 0.05, h * 0.13).Z().toString() +
+                    P().M(w * 0.1, 0).L(w, 0).L(w, h * 0.72).L(w * 0.95, h * 0.72)
+                    .L(w * 0.95, h * 0.07).L(w * 0.1, h * 0.07).Z().toString();
+            }
+        },
+        flowChartTerminator: {
+            cat: "shape", label: "Flowchart: terminator",
+            path: function (w, h) {
+                var r = Math.min(w / 2, h / 2);
+                return roundRectPath(w, h, r, r, r, r);
+            }
+        },
+        flowChartPreparation: {
+            cat: "shape", label: "Flowchart: preparation",
+            pts: function (w, h) {
+                return [[w * 0.2, 0], [w * 0.8, 0], [w, h / 2],
+                    [w * 0.8, h], [w * 0.2, h], [0, h / 2]];
+            }
+        },
+        flowChartManualInput: {
+            cat: "shape", label: "Flowchart: manual input",
+            pts: function (w, h) { return [[0, h * 0.25], [w, 0], [w, h], [0, h]]; }
+        },
+        flowChartManualOperation: {
+            cat: "shape", label: "Flowchart: manual operation",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w * 0.8, h], [w * 0.2, h]]; }
+        },
+        flowChartConnector: {
+            cat: "shape", label: "Flowchart: connector",
+            path: function (w, h) { return P().circle(w / 2, h / 2, w / 2, h / 2).toString(); }
+        },
+        flowChartOffpageConnector: {
+            cat: "shape", label: "Flowchart: off-page connector",
+            pts: function (w, h) {
+                return [[0, 0], [w, 0], [w, h * 0.8], [w / 2, h], [0, h * 0.8]];
+            }
+        },
+        flowChartPunchedCard: {
+            cat: "shape", label: "Flowchart: card",
+            pts: function (w, h) {
+                var s = Math.min(w, h) * 0.22;
+                return [[s, 0], [w, 0], [w, h], [0, h], [0, s]];
+            }
+        },
+        flowChartPunchedTape: {
+            cat: "shape", label: "Flowchart: punched tape",
+            path: function (w, h) {
+                return P().M(0, h * 0.12)
+                    .C(w * 0.25, h * -0.1, w * 0.75, h * 0.34, w, h * 0.12)
+                    .L(w, h * 0.88)
+                    .C(w * 0.75, h * 1.1, w * 0.25, h * 0.66, 0, h * 0.88)
+                    .Z().toString();
+            }
+        },
+        flowChartSummingJunction: {
+            cat: "shape", label: "Flowchart: summing junction",
+            path: function (w, h) { return P().circle(w / 2, h / 2, w / 2, h / 2).toString(); },
+            detail: function (w, h) {
+                var k = 0.1464;   // where the diagonal meets the circle
+                return P().M(w * k, h * k).L(w * (1 - k), h * (1 - k)).toString() +
+                    P().M(w * (1 - k), h * k).L(w * k, h * (1 - k)).toString();
+            }
+        },
+        flowChartOr: {
+            cat: "shape", label: "Flowchart: or",
+            path: function (w, h) { return P().circle(w / 2, h / 2, w / 2, h / 2).toString(); },
+            detail: function (w, h) {
+                return P().M(w / 2, 0).L(w / 2, h).toString() +
+                    P().M(0, h / 2).L(w, h / 2).toString();
+            }
+        },
+        flowChartCollate: {
+            cat: "shape", label: "Flowchart: collate",
+            pts: function (w, h) { return [[0, 0], [w, 0], [0, h], [w, h]]; }
+        },
+        flowChartSort: {
+            cat: "shape", label: "Flowchart: sort",
+            pts: function (w, h) { return [[w / 2, 0], [w, h / 2], [w / 2, h], [0, h / 2]]; },
+            detail: function (w, h) { return P().M(0, h / 2).L(w, h / 2).toString(); }
+        },
+        flowChartExtract: {
+            cat: "shape", label: "Flowchart: extract",
+            pts: function (w, h) { return [[w / 2, 0], [w, h], [0, h]]; }
+        },
+        flowChartMerge: {
+            cat: "shape", label: "Flowchart: merge",
+            pts: function (w, h) { return [[0, 0], [w, 0], [w / 2, h]]; }
+        },
+        flowChartDelay: {
+            cat: "shape", label: "Flowchart: delay",
+            path: function (w, h) {
+                var r = Math.min(w / 2, h / 2);
+                return roundRectPath(w, h, 0, r, r, 0);
+            }
+        },
+        flowChartMagneticDisk: {
+            cat: "shape", label: "Flowchart: stored data",
+            path: function (w, h) {
+                var ry = Math.min(h * 0.18, w * 0.5);
+                return P().M(0, ry).arc(w / 2, ry, w / 2, ry, Math.PI, Math.PI * 2)
+                    .L(w, h - ry).arc(w / 2, h - ry, w / 2, ry, 0, Math.PI).Z().toString();
+            },
+            detail: function (w, h) {
+                var ry = Math.min(h * 0.18, w * 0.5);
+                return P().arc(w / 2, ry, w / 2, ry, 0, Math.PI, true).toString();
+            }
+        },
+        flowChartDisplay: {
+            cat: "shape", label: "Flowchart: display",
+            path: function (w, h) {
+                return P().M(0, h / 2).L(w * 0.17, 0).L(w * 0.83, 0)
+                    .arc(w * 0.83, h / 2, w * 0.17, h / 2, -Math.PI / 2, Math.PI / 2)
+                    .L(w * 0.17, h).Z().toString();
+            }
+        },
+
+        /* ---------- arrows ---------- */
+
+        rightArrow: {
+            cat: "arrow", label: "Arrow: right",
+            pts: function (w, h) { return arrowPts(w, h, 0.38, 0.4); }
+        },
+        leftArrow: {
+            cat: "arrow", label: "Arrow: left",
+            pts: function (w, h) { return rotPts(arrowPts(w, h, 0.38, 0.4), w, h, 2); }
+        },
+        upArrow: {
+            cat: "arrow", label: "Arrow: up",
+            pts: function (w, h) { return rotPts(arrowPts(w, h, 0.38, 0.4), w, h, 3); }
+        },
+        downArrow: {
+            cat: "arrow", label: "Arrow: down",
+            pts: function (w, h) { return rotPts(arrowPts(w, h, 0.38, 0.4), w, h, 1); }
+        },
+        leftRightArrow: {
+            cat: "arrow", label: "Arrow: left-right",
+            pts: function (w, h) {
+                var t = h * 0.3, a = w * 0.25;
+                return [[0, h / 2], [a, 0], [a, t], [w - a, t], [w - a, 0], [w, h / 2],
+                    [w - a, h], [w - a, h - t], [a, h - t], [a, h]];
+            }
+        },
+        upDownArrow: {
+            cat: "arrow", label: "Arrow: up-down",
+            pts: function (w, h) {
+                var t = w * 0.3, a = h * 0.25;
+                return [[w / 2, 0], [w, a], [w - t, a], [w - t, h - a], [w, h - a], [w / 2, h],
+                    [0, h - a], [t, h - a], [t, a], [0, a]];
+            }
+        },
+        quadArrow: {
+            cat: "arrow", label: "Arrow: quad",
+            pts: function (w, h) {
+                var m = Math.min(w, h);
+                var t = m * 0.13, hw = m * 0.26, hl = m * 0.26;
+                var cx = w / 2, cy = h / 2;
+                return [[cx, 0], [cx + hw, hl], [cx + t, hl], [cx + t, cy - t],
+                    [w - hl, cy - t], [w - hl, cy - hw], [w, cy], [w - hl, cy + hw],
+                    [w - hl, cy + t], [cx + t, cy + t], [cx + t, h - hl], [cx + hw, h - hl],
+                    [cx, h], [cx - hw, h - hl], [cx - t, h - hl], [cx - t, cy + t],
+                    [hl, cy + t], [hl, cy + hw], [0, cy], [hl, cy - hw],
+                    [hl, cy - t], [cx - t, cy - t], [cx - t, hl], [cx - hw, hl]];
+            }
+        },
+        leftRightUpArrow: {
+            cat: "arrow", label: "Arrow: left-right-up",
+            pts: function (w, h) {
+                var cy = h * 0.78, t = h * 0.2, hh = h * 0.22, hl = w * 0.14;
+                var t2 = w * 0.12, vh = h * 0.3, vhw = w * 0.18;
+                return [[w / 2, 0], [w / 2 + vhw, vh], [w / 2 + t2 / 2, vh],
+                    [w / 2 + t2 / 2, cy - t / 2], [w - hl, cy - t / 2], [w - hl, cy - hh],
+                    [w, cy], [w - hl, cy + hh], [w - hl, cy + t / 2],
+                    [hl, cy + t / 2], [hl, cy + hh], [0, cy], [hl, cy - hh],
+                    [hl, cy - t / 2], [w / 2 - t2 / 2, cy - t / 2],
+                    [w / 2 - t2 / 2, vh], [w / 2 - vhw, vh]];
+            }
+        },
+        bentArrow: {
+            cat: "arrow", label: "Arrow: bent",
+            // runs right along the bottom, turns up, head at the top
+            pts: function (w, h) {
+                var cx = w * 0.7, hw = w * 0.3, t = h * 0.26, hh = h * 0.32;
+                return [[0, h], [0, h - t], [cx - t / 2, h - t], [cx - t / 2, hh],
+                    [cx - hw, hh], [cx, 0], [cx + hw, hh], [cx + t / 2, hh],
+                    [cx + t / 2, h]];
+            }
+        },
+        uturnArrow: {
+            cat: "arrow", label: "Arrow: U-turn",
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.22;
+                var cx = w * 0.62, r = w * 0.38;
+                return P().M(0, h)
+                    .L(0, h * 0.55)
+                    .C(0, h * 0.2, cx - r * 0.1, h * 0.05, cx, h * 0.28)
+                    .L(cx, h * 0.1).L(w, h * 0.34).L(cx, h * 0.58).L(cx, h * 0.42)
+                    .C(cx - r * 0.1, h * 0.32, t, h * 0.35, t, h * 0.62)
+                    .L(t, h).Z().toString();
+            }
+        },
+        leftUpArrow: {
+            cat: "arrow", label: "Arrow: left-up",
+            // an L with a head on each end: one points up, one points left
+            pts: function (w, h) {
+                var m = Math.min(w, h);
+                var hw = m * 0.22, t = hw * 0.9, hh = m * 0.32;
+                var vx = w - hw, hy = h - hw;
+                return [[vx, 0], [w, hh], [vx + t / 2, hh], [vx + t / 2, hy + t / 2],
+                    [hh, hy + t / 2], [hh, h], [0, hy], [hh, hy - hw * 2 + hw],
+                    [hh, hy - t / 2], [vx - t / 2, hy - t / 2], [vx - t / 2, hh],
+                    [vx - hw, hh]];
+            }
+        },
+        bentUpArrow: {
+            cat: "arrow", label: "Arrow: bent up",
+            pts: function (w, h) {
+                var t = h * 0.28;
+                return [[0, h - t], [w * 0.62, h - t], [w * 0.62, h * 0.3],
+                    [w * 0.45, h * 0.3], [w * 0.72, 0], [w, h * 0.3],
+                    [w * 0.83, h * 0.3], [w * 0.83, h], [0, h]];
+            }
+        },
+        stripedRightArrow: {
+            cat: "arrow", label: "Arrow: striped right",
+            evenOdd: true,
+            path: function (w, h) {
+                var pts = arrowPts(w, h, 0.38, 0.4);
+                var t = h * 0.3;
+                return P().poly(pts).toString() +
+                    P().poly([[w * 0.05, t], [w * 0.09, t], [w * 0.09, h - t], [w * 0.05, h - t]]).toString() +
+                    P().poly([[w * 0.13, t], [w * 0.2, t], [w * 0.2, h - t], [w * 0.13, h - t]]).toString();
+            }
+        },
+        notchedRightArrow: {
+            cat: "arrow", label: "Arrow: notched right",
+            pts: function (w, h) {
+                var hx = w * 0.62, t = h * 0.3;
+                return [[0, t], [hx, t], [hx, 0], [w, h / 2], [hx, h], [hx, h - t],
+                    [0, h - t], [t * 0.6, h / 2]];
+            }
+        },
+        chevron: {
+            cat: "arrow", label: "Chevron",
+            pts: function (w, h) {
+                return [[0, 0], [w * 0.72, 0], [w, h / 2], [w * 0.72, h], [0, h], [w * 0.28, h / 2]];
+            }
+        },
+        homePlate: {
+            cat: "arrow", label: "Pentagon arrow",
+            pts: function (w, h) {
+                return [[0, 0], [w * 0.72, 0], [w, h / 2], [w * 0.72, h], [0, h]];
+            }
+        },
+        curvedRightArrow: {
+            cat: "arrow", label: "Arrow: curved right",
+            path: function (w, h) {
+                var t = h * 0.2;
+                return P().M(0, h * 0.08)
+                    .C(w * 0.6, h * 0.0, w * 0.86, h * 0.28, w * 0.78, h * 0.56)
+                    .L(w, h * 0.56).L(w * 0.7, h).L(w * 0.42, h * 0.56).L(w * 0.62, h * 0.56)
+                    .C(w * 0.68, h * 0.36, w * 0.5, h * 0.22, 0, h * 0.08 + t)
+                    .Z().toString();
+            }
+        },
+        curvedLeftArrow: {
+            cat: "arrow", label: "Arrow: curved left",
+            path: function (w, h) {
+                var t = h * 0.2;
+                return P().M(w, h * 0.08)
+                    .C(w * 0.4, h * 0.0, w * 0.14, h * 0.28, w * 0.22, h * 0.56)
+                    .L(0, h * 0.56).L(w * 0.3, h).L(w * 0.58, h * 0.56).L(w * 0.38, h * 0.56)
+                    .C(w * 0.32, h * 0.36, w * 0.5, h * 0.22, w, h * 0.08 + t)
+                    .Z().toString();
+            }
+        },
+        curvedUpArrow: {
+            cat: "arrow", label: "Arrow: curved up",
+            path: function (w, h) {
+                var t = w * 0.2;
+                return P().M(w * 0.08, h)
+                    .C(w * 0.0, h * 0.4, w * 0.28, h * 0.14, w * 0.56, h * 0.22)
+                    .L(w * 0.56, 0).L(w, h * 0.3).L(w * 0.56, h * 0.58).L(w * 0.56, h * 0.38)
+                    .C(w * 0.36, h * 0.32, w * 0.22, h * 0.5, w * 0.08 + t, h)
+                    .Z().toString();
+            }
+        },
+        curvedDownArrow: {
+            cat: "arrow", label: "Arrow: curved down",
+            path: function (w, h) {
+                var t = w * 0.2;
+                return P().M(w * 0.08, 0)
+                    .C(w * 0.0, h * 0.6, w * 0.28, h * 0.86, w * 0.56, h * 0.78)
+                    .L(w * 0.56, h).L(w, h * 0.7).L(w * 0.56, h * 0.42).L(w * 0.56, h * 0.62)
+                    .C(w * 0.36, h * 0.68, w * 0.22, h * 0.5, w * 0.08 + t, 0)
+                    .Z().toString();
+            }
+        },
+        circularArrow: {
+            cat: "arrow", label: "Arrow: circular",
+            path: function (w, h) {
+                var cx = w / 2, cy = h / 2;
+                var ro = Math.min(w, h) * 0.46, ri = Math.min(w, h) * 0.28;
+                var a0 = Math.PI * 0.75, a1 = Math.PI * 2.15;
+                var p = P();
+                p.arc(cx, cy, ro, ro, a0, a1, true);
+                var ax = cx + ((ro + ri) / 2) * Math.cos(a1);
+                var ay = cy + ((ro + ri) / 2) * Math.sin(a1);
+                var hx = Math.cos(a1 + Math.PI / 2), hy = Math.sin(a1 + Math.PI / 2);
+                var hl = Math.min(w, h) * 0.2;
+                p.L(cx + (ro + hl * 0.5) * Math.cos(a1), cy + (ro + hl * 0.5) * Math.sin(a1));
+                p.L(ax + hx * hl, ay + hy * hl);
+                p.L(cx + (ri - hl * 0.5) * Math.cos(a1), cy + (ri - hl * 0.5) * Math.sin(a1));
+                p.L(cx + ri * Math.cos(a1), cy + ri * Math.sin(a1));
+                p.arc(cx, cy, ri, ri, a1, a0);
+                return p.Z().toString();
+            }
+        },
+        rightArrowCallout: {
+            cat: "arrow", label: "Callout: right arrow",
+            pts: function (w, h) {
+                var bx = w * 0.6, t = h * 0.22;
+                return [[0, 0], [bx, 0], [bx, t], [w * 0.78, t], [w * 0.78, 0],
+                    [w, h / 2], [w * 0.78, h], [w * 0.78, h - t], [bx, h - t], [bx, h], [0, h]];
+            }
+        },
+        leftArrowCallout: {
+            cat: "arrow", label: "Callout: left arrow",
+            pts: function (w, h) {
+                var bx = w * 0.4, t = h * 0.22;
+                return [[w, 0], [bx, 0], [bx, t], [w * 0.22, t], [w * 0.22, 0],
+                    [0, h / 2], [w * 0.22, h], [w * 0.22, h - t], [bx, h - t], [bx, h], [w, h]];
+            }
+        },
+        upArrowCallout: {
+            cat: "arrow", label: "Callout: up arrow",
+            pts: function (w, h) {
+                var by = h * 0.4, t = w * 0.22;
+                return [[0, h], [0, by], [t, by], [t, h * 0.22], [0, h * 0.22],
+                    [w / 2, 0], [w, h * 0.22], [w - t, h * 0.22], [w - t, by], [w, by], [w, h]];
+            }
+        },
+        downArrowCallout: {
+            cat: "arrow", label: "Callout: down arrow",
+            pts: function (w, h) {
+                var by = h * 0.6, t = w * 0.22;
+                return [[0, 0], [0, by], [t, by], [t, h * 0.78], [0, h * 0.78],
+                    [w / 2, h], [w, h * 0.78], [w - t, h * 0.78], [w - t, by], [w, by], [w, 0]];
+            }
+        },
+        leftRightArrowCallout: {
+            cat: "arrow", label: "Callout: left-right arrow",
+            pts: function (w, h) {
+                var t = h * 0.22, a = w * 0.2, b = w * 0.28;
+                return [[b, 0], [w - b, 0], [w - b, t], [w - a, t], [w - a, 0],
+                    [w, h / 2], [w - a, h], [w - a, h - t], [w - b, h - t], [w - b, h],
+                    [b, h], [b, h - t], [a, h - t], [a, h], [0, h / 2], [a, 0], [a, t], [b, t]];
+            }
+        },
+
+        /* ---------- call outs: stars, banners, bubbles ---------- */
+
+        star4: { cat: "callout", label: "4-point star", pts: function (w, h) { return starPts(w, h, 4, 0.3); } },
+        star5: { cat: "callout", label: "5-point star", pts: function (w, h) { return starPts(w, h, 5, 0.42); } },
+        star6: { cat: "callout", label: "6-point star", pts: function (w, h) { return starPts(w, h, 6, 0.5); } },
+        star7: { cat: "callout", label: "7-point star", pts: function (w, h) { return starPts(w, h, 7, 0.55); } },
+        star8: { cat: "callout", label: "8-point star", pts: function (w, h) { return starPts(w, h, 8, 0.6); } },
+        star10: { cat: "callout", label: "10-point star", pts: function (w, h) { return starPts(w, h, 10, 0.68); } },
+        star12: { cat: "callout", label: "12-point star", pts: function (w, h) { return starPts(w, h, 12, 0.72); } },
+        star16: { cat: "callout", label: "16-point star", pts: function (w, h) { return starPts(w, h, 16, 0.78); } },
+        star24: { cat: "callout", label: "24-point star", pts: function (w, h) { return starPts(w, h, 24, 0.84); } },
+        star32: { cat: "callout", label: "32-point star", pts: function (w, h) { return starPts(w, h, 32, 0.88); } },
+        irregularSeal1: {
+            cat: "callout", label: "Explosion",
+            pts: function (w, h) {
+                var f = [[0.22, 0.14], [0.35, 0.30], [0.28, 0.0], [0.47, 0.24], [0.55, 0.06],
+                    [0.60, 0.28], [0.79, 0.10], [0.74, 0.32], [1.0, 0.28], [0.83, 0.46],
+                    [0.98, 0.56], [0.79, 0.60], [0.90, 0.83], [0.66, 0.70], [0.64, 1.0],
+                    [0.50, 0.74], [0.36, 0.94], [0.34, 0.70], [0.12, 0.82], [0.22, 0.60],
+                    [0.0, 0.56], [0.16, 0.44], [0.04, 0.30]];
+                return f.map(function (p) { return [p[0] * w, p[1] * h]; });
+            }
+        },
+        irregularSeal2: {
+            cat: "callout", label: "Explosion 2",
+            pts: function (w, h) {
+                var f = [[0.12, 0.26], [0.28, 0.18], [0.22, 0.02], [0.42, 0.14], [0.50, 0.0],
+                    [0.58, 0.16], [0.74, 0.06], [0.74, 0.24], [0.94, 0.18], [0.86, 0.36],
+                    [1.0, 0.44], [0.84, 0.54], [0.96, 0.70], [0.76, 0.68], [0.80, 0.88],
+                    [0.62, 0.76], [0.54, 1.0], [0.44, 0.80], [0.28, 0.92], [0.30, 0.72],
+                    [0.10, 0.76], [0.20, 0.58], [0.0, 0.50], [0.18, 0.40]];
+                return f.map(function (p) { return [p[0] * w, p[1] * h]; });
+            }
+        },
+        ribbon: {
+            cat: "callout", label: "Ribbon",
+            pts: function (w, h) {
+                var e = w * 0.14, t = h * 0.28;
+                return [[0, 0], [e, h * 0.2], [0, h * 0.2], [w * 0.18, h * 0.5],
+                    [0, h * 0.8], [e, h], [w * 0.18, h * 0.8],
+                    [w * 0.82, h * 0.8], [w - e, h], [w, h * 0.8],
+                    [w * 0.82, h * 0.5], [w, h * 0.2], [w - e, h * 0.2], [w, 0],
+                    [w * 0.82, h * 0.2], [w * 0.18, h * 0.2]];
+            }
+        },
+        ribbon2: {
+            cat: "callout", label: "Banner",
+            pts: function (w, h) {
+                var e = w * 0.14;
+                return [[0, h * 0.16], [w * 0.18, 0], [w * 0.82, 0], [w, h * 0.16],
+                    [w - e, h * 0.16], [w, h * 0.4], [w * 0.82, h * 0.34],
+                    [w * 0.82, h], [w * 0.5, h * 0.78], [w * 0.18, h],
+                    [w * 0.18, h * 0.34], [0, h * 0.4], [e, h * 0.16]];
+            }
+        },
+        wave: {
+            cat: "callout", label: "Wave",
+            path: function (w, h) {
+                return P().M(0, h * 0.18)
+                    .C(w * 0.25, h * -0.08, w * 0.75, h * 0.44, w, h * 0.18)
+                    .L(w, h * 0.82)
+                    .C(w * 0.75, h * 1.08, w * 0.25, h * 0.56, 0, h * 0.82)
+                    .Z().toString();
+            }
+        },
+        doubleWave: {
+            cat: "callout", label: "Double wave",
+            path: function (w, h) {
+                return P().M(0, h * 0.2)
+                    .C(w * 0.12, h * -0.06, w * 0.38, h * 0.4, w * 0.5, h * 0.2)
+                    .C(w * 0.62, h * 0.0, w * 0.88, h * 0.4, w, h * 0.2)
+                    .L(w, h * 0.8)
+                    .C(w * 0.88, h * 1.06, w * 0.62, h * 0.6, w * 0.5, h * 0.8)
+                    .C(w * 0.38, h * 1.0, w * 0.12, h * 0.6, 0, h * 0.8)
+                    .Z().toString();
+            }
+        },
+        verticalScroll: {
+            cat: "callout", label: "Vertical scroll",
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.16;
+                return P().M(t, 0).L(w - t / 2, 0)
+                    .arc(w - t / 2, t / 2, t / 2, t / 2, -Math.PI / 2, Math.PI / 2)
+                    .L(w - t, t).L(w - t, h).L(t / 2, h)
+                    .arc(t / 2, h - t / 2, t / 2, t / 2, Math.PI / 2, Math.PI * 1.5)
+                    .L(t, h - t).Z().toString();
+            }
+        },
+        horizontalScroll: {
+            cat: "callout", label: "Horizontal scroll",
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.16;
+                return P().M(0, t).L(0, h - t / 2)
+                    .arc(t / 2, h - t / 2, t / 2, t / 2, Math.PI, Math.PI * 2)
+                    .L(t, h - t).L(w, h - t).L(w, t / 2)
+                    .arc(w - t / 2, t / 2, t / 2, t / 2, 0, Math.PI)
+                    .L(w - t, t).Z().toString();
+            }
+        },
+        wedgeRectCallout: {
+            cat: "callout", label: "Speech bubble: rectangle",
+            pts: function (w, h) {
+                var b = h * 0.72;
+                return [[0, 0], [w, 0], [w, b], [w * 0.42, b], [w * 0.2, h],
+                    [w * 0.28, b], [0, b]];
+            }
+        },
+        wedgeRoundRectCallout: {
+            cat: "callout", label: "Speech bubble: rounded",
+            path: function (w, h) {
+                var b = h * 0.72;
+                var r = Math.min(w, b) * 0.18;
+                var p = P();
+                p.M(r, 0).L(w - r, 0).arc(w - r, r, r, r, -Math.PI / 2, 0)
+                    .L(w, b - r).arc(w - r, b - r, r, r, 0, Math.PI / 2)
+                    .L(w * 0.42, b).L(w * 0.2, h).L(w * 0.28, b)
+                    .L(r, b).arc(r, b - r, r, r, Math.PI / 2, Math.PI)
+                    .L(0, r).arc(r, r, r, r, Math.PI, Math.PI * 1.5);
+                return p.Z().toString();
+            }
+        },
+        wedgeEllipseCallout: {
+            cat: "callout", label: "Speech bubble: oval",
+            path: function (w, h) {
+                // all the way round from one side of the tail's base to the
+                // other, then out to the tip and back
+                var cy = h * 0.36, ry = h * 0.36;
+                var a1 = Math.PI * 0.60, a2 = Math.PI * 0.80;
+                return P().arc(w / 2, cy, w / 2, ry, a2, a1 + Math.PI * 2, true)
+                    .L(w * 0.16, h).Z().toString();
+            }
+        },
+        cloudCallout: {
+            cat: "callout", label: "Speech bubble: cloud",
+            path: function (w, h) {
+                var bw = w, bh = h * 0.74;
+                var p = P();
+                p.M(bw * 0.22, bh * 0.92)
+                    .C(bw * 0.02, bh * 0.92, bw * -0.05, bh * 0.58, bw * 0.13, bh * 0.5)
+                    .C(bw * 0.06, bh * 0.28, bw * 0.26, bh * 0.1, bw * 0.42, bh * 0.2)
+                    .C(bw * 0.5, bh * -0.03, bw * 0.8, bh * -0.03, bw * 0.86, bh * 0.22)
+                    .C(bw * 1.04, bh * 0.26, bw * 1.04, bh * 0.6, bw * 0.87, bh * 0.66)
+                    .C(bw * 0.93, bh * 0.88, bw * 0.7, bh * 1.02, bw * 0.57, bh * 0.9)
+                    .C(bw * 0.48, bh * 1.0, bw * 0.3, bh * 1.0, bw * 0.22, bh * 0.92)
+                    .Z();
+                p.circle(w * 0.22, h * 0.85, w * 0.07, h * 0.07);
+                p.circle(w * 0.13, h * 0.96, w * 0.045, h * 0.045);
+                return p.toString();
+            }
+        },
+
+        /* ---------- equation ---------- */
+
+        mathPlus: {
+            cat: "equation", label: "Plus",
+            pts: function (w, h) {
+                var t = Math.min(w, h) * 0.18, m = w * 0.11, n = h * 0.11;
+                var cx = w / 2, cy = h / 2;
+                return [[cx - t / 2, n], [cx + t / 2, n], [cx + t / 2, cy - t / 2],
+                    [w - m, cy - t / 2], [w - m, cy + t / 2], [cx + t / 2, cy + t / 2],
+                    [cx + t / 2, h - n], [cx - t / 2, h - n], [cx - t / 2, cy + t / 2],
+                    [m, cy + t / 2], [m, cy - t / 2], [cx - t / 2, cy - t / 2]];
+            }
+        },
+        mathMinus: {
+            cat: "equation", label: "Minus",
+            pts: function (w, h) {
+                var t = Math.min(w, h) * 0.18, m = w * 0.11, cy = h / 2;
+                return [[m, cy - t / 2], [w - m, cy - t / 2], [w - m, cy + t / 2], [m, cy + t / 2]];
+            }
+        },
+        mathMultiply: {
+            cat: "equation", label: "Multiply",
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.16;
+                var cx = w / 2, cy = h / 2;
+                var ax = w * 0.36, ay = h * 0.36;
+                var d = t / Math.SQRT2;
+                return P().poly([
+                    [cx - ax, cy - ay + d], [cx - ax + d, cy - ay], [cx, cy - d * 1.4],
+                    [cx + ax - d, cy - ay], [cx + ax, cy - ay + d], [cx + d * 1.4, cy],
+                    [cx + ax, cy + ay - d], [cx + ax - d, cy + ay], [cx, cy + d * 1.4],
+                    [cx - ax + d, cy + ay], [cx - ax, cy + ay - d], [cx - d * 1.4, cy]
+                ]).toString();
+            }
+        },
+        mathDivide: {
+            cat: "equation", label: "Divide",
+            evenOdd: true,
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.14, m = w * 0.11, cy = h / 2;
+                var r = Math.min(w, h) * 0.09;
+                return P().poly([[m, cy - t / 2], [w - m, cy - t / 2],
+                    [w - m, cy + t / 2], [m, cy + t / 2]]).toString() +
+                    P().circle(w / 2, h * 0.22, r, r).toString() +
+                    P().circle(w / 2, h * 0.78, r, r).toString();
+            }
+        },
+        mathEqual: {
+            cat: "equation", label: "Equal",
+            evenOdd: true,
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.14, m = w * 0.11;
+                var y1 = h * 0.36, y2 = h * 0.64;
+                return P().poly([[m, y1 - t / 2], [w - m, y1 - t / 2],
+                    [w - m, y1 + t / 2], [m, y1 + t / 2]]).toString() +
+                    P().poly([[m, y2 - t / 2], [w - m, y2 - t / 2],
+                        [w - m, y2 + t / 2], [m, y2 + t / 2]]).toString();
+            }
+        },
+        mathNotEqual: {
+            cat: "equation", label: "Not equal",
+            evenOdd: true,
+            path: function (w, h) {
+                var t = Math.min(w, h) * 0.14, m = w * 0.11;
+                var y1 = h * 0.36, y2 = h * 0.64;
+                var sw = Math.min(w, h) * 0.12;
+                return P().poly([[m, y1 - t / 2], [w - m, y1 - t / 2],
+                    [w - m, y1 + t / 2], [m, y1 + t / 2]]).toString() +
+                    P().poly([[m, y2 - t / 2], [w - m, y2 - t / 2],
+                        [w - m, y2 + t / 2], [m, y2 + t / 2]]).toString() +
+                    P().poly([[w * 0.56, h * 0.08], [w * 0.56 + sw, h * 0.08],
+                        [w * 0.44 + sw, h * 0.92], [w * 0.44, h * 0.92]]).toString();
+            }
+        },
+        leftBracket: {
+            cat: "equation", size: [70, 220], label: "Left bracket",
+            open: true,
+            path: function (w, h) {
+                var r = Math.min(w, h * 0.5) * 0.9;
+                return P().M(w, 0)
+                    .C(w - r * 0.55, 0, 0, r * 0.45, 0, r)
+                    .L(0, h - r)
+                    .C(0, h - r * 0.45, w - r * 0.55, h, w, h).toString();
+            }
+        },
+        rightBracket: {
+            cat: "equation", size: [70, 220], label: "Right bracket",
+            open: true,
+            path: function (w, h) {
+                var r = Math.min(w, h * 0.5) * 0.9;
+                return P().M(0, 0)
+                    .C(r * 0.55, 0, w, r * 0.45, w, r)
+                    .L(w, h - r)
+                    .C(w, h - r * 0.45, r * 0.55, h, 0, h).toString();
+            }
+        },
+        bracketPair: {
+            cat: "equation", size: [70, 220], label: "Bracket pair",
+            open: true,
+            path: function (w, h) {
+                var bw = Math.min(w * 0.25, h * 0.3);
+                var r = Math.min(bw, h * 0.5) * 0.9;
+                return P().M(bw, 0).C(bw - r * 0.55, 0, 0, r * 0.45, 0, r)
+                    .L(0, h - r).C(0, h - r * 0.45, bw - r * 0.55, h, bw, h).toString() +
+                    P().M(w - bw, 0).C(w - bw + r * 0.55, 0, w, r * 0.45, w, r)
+                    .L(w, h - r).C(w, h - r * 0.45, w - bw + r * 0.55, h, w - bw, h).toString();
+            }
+        },
+        rightBrace: {
+            cat: "equation", size: [70, 220], label: "Right brace",
+            open: true,
+            path: function (w, h) { return bracePath(w, h, false); }
+        },
+        leftBrace: {
+            cat: "equation", size: [70, 220], label: "Left brace",
+            open: true,
+            path: function (w, h) { return bracePath(w, h, true); }
+        },
+        bracePair: {
+            cat: "equation", size: [70, 220], label: "Brace pair",
+            open: true,
+            path: function (w, h) {
+                var bw = Math.min(w * 0.3, h * 0.3);
+                return shiftPath(bracePath(bw, h, true), 0, 0) +
+                    shiftPath(bracePath(bw, h, false), w - bw, 0);
+            }
+        }
+    };
+
+    /* A brace is two quarter turns out to the middle and two back, which is
+       exactly what PresentationML's rightBrace draws and what a text-set
+       "}" looks like. `left` mirrors it. */
+    function bracePath(w, h, left) {
+        var r = Math.min(w * 0.5, h * 0.24);
+        var x0 = left ? w : 0;          // the open side
+        var x1 = left ? 0 : w;          // the point
+        var dir = left ? -1 : 1;
+        var p = P();
+        p.M(x0, 0);
+        p.C(x0 + dir * r * 0.55, 0, x0 + dir * r, r * 0.45, x0 + dir * r, r);
+        p.L(x0 + dir * r, h / 2 - r);
+        p.C(x0 + dir * r, h / 2 - r * 0.45, x1 - dir * r * 0.45, h / 2, x1, h / 2);
+        p.C(x1 - dir * r * 0.45, h / 2, x0 + dir * r, h / 2 + r * 0.45, x0 + dir * r, h / 2 + r);
+        p.L(x0 + dir * r, h - r);
+        p.C(x0 + dir * r, h - r * 0.45, x0 + dir * r * 0.55, h, x0, h);
+        return p.toString();
+    }
+
+    // shiftPath translates a finished path; only M/L/C coordinates exist in
+    // what this file produces, so a straight number walk is enough
+    function shiftPath(d, dx, dy) {
+        var i = 0;
+        return d.replace(/([MLC])([^MLCZ]*)/g, function (all, cmd, nums) {
+            var v = nums.trim().split(/[\s,]+/).map(Number);
+            for (var k = 0; k < v.length; k += 2) {
+                v[k] = r2(v[k] + dx);
+                v[k + 1] = r2(v[k + 1] + dy);
+            }
+            return cmd + v.join(" ");
+        });
+    }
+
+    /* ---------------- names documents may still carry ---------------- */
+
+    /* Three shapes had editor-invented names before the catalogue existed.
+       They are gone; these are what a deck written back then says, and the
+       only place that still knows. normalizeBody() in slides.js runs every
+       kind through canonical() as a document loads, so a deck is rewritten
+       to the catalogue's names the first time it is opened and these never
+       have to be thought about again. */
+    var ALIASES = {
+        round: "roundRect",
+        arrow: "rightArrow",
+        star: "star5"
+    };
+
+    function canonical(kind) {
+        var k = String(kind || "").trim();
+        if (!k) return "rect";
+        if (DEFS[k]) return k;
+        if (ALIASES[k]) return ALIASES[k];
+        return k;
+    }
+
+    function def(kind) { return DEFS[canonical(kind)] || null; }
+
+    function points(kind, w, h) {
+        var d = def(kind);
+        return d && d.pts ? d.pts(w, h) : null;
+    }
+
+    function path(kind, w, h) {
+        var d = def(kind);
+        if (!d) return "";
+        if (d.pts) return P().poly(d.pts(w, h)).toString();
+        return d.path(w, h);
+    }
+
+    function detail(kind, w, h) {
+        var d = def(kind);
+        return d && d.detail ? d.detail(w, h) : "";
+    }
+
+    /* defaultSize is the box a shape is inserted at. Most want the editor's
+       usual one; a brace or a bracket only reads as itself when it is tall
+       and narrow, so those say so. */
+    function defaultSize(kind) {
+        var d = def(kind);
+        return (d && d.size) ? d.size.slice() : null;
+    }
+
+    /* icon draws a shape as a small outline for a picker, from the very
+       same geometry the canvas uses - so an icon cannot come to disagree
+       with what choosing it actually inserts. */
+    function icon(kind, size) {
+        var s = size || 22, pad = 2;
+        var w = s - pad * 2, h = s - pad * 2;
+        var k = canonical(kind);
+        var body = '<path d="' + path(k, w, h) + '"' +
+            (evenOdd(k) ? ' fill-rule="evenodd"' : "") + "/>";
+        var det = detail(k, w, h);
+        if (det) body += '<path d="' + det + '"/>';
+        return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 ' + s + " " + s + '" ' +
+            'fill="none" stroke="currentColor" stroke-width="1.3" ' +
+            'stroke-linejoin="round"><g transform="translate(' + pad + "," + pad + ')">' +
+            body + "</g></svg>";
+    }
+
+    function evenOdd(kind) { var d = def(kind); return !!(d && d.evenOdd); }
+
+    var CATEGORIES = [
+        { id: "shape", label: "Shapes", icon: "square outline" },
+        { id: "arrow", label: "Arrows", icon: "long arrow alternate right" },
+        { id: "callout", label: "Call outs", icon: "comment outline" },
+        { id: "equation", label: "Equation", icon: "plus" }
+    ];
+    CATEGORIES.forEach(function (c) {
+        c.kinds = Object.keys(DEFS).filter(function (k) { return DEFS[k].cat === c.id; });
+    });
+
+    return {
+        CATEGORIES: CATEGORIES,
+        canonical: canonical,
+        has: function (kind) { return !!def(kind); },
+        label: function (kind) { var d = def(kind); return d ? d.label : "Shape"; },
+        category: function (kind) { var d = def(kind); return d ? d.cat : ""; },
+        evenOdd: evenOdd,
+        icon: icon,
+        isOpen: function (kind) { var d = def(kind); return !!(d && d.open); },
+        points: points,
+        path: path,
+        detail: detail,
+        defaultSize: defaultSize
+    };
+})();