Pārlūkot izejas kodu

Add Slides image format tools

Extract the Slides picture UI into a dedicated module with a floating image bar, crop-to-shape picker, and docked format panel for flips, position, recolor, brightness, contrast, and transparency. Update image rendering and reset behavior to apply those properties consistently across the editor.

On the backend, extend Office image props and PPTX import/export so picture flips and DrawingML image effects round-trip correctly, with coverage for recolor, luminance, and transparency. Refresh the Office docs and contract to describe the new picture tools and data fields.
Toby Chui 3 nedēļas atpakaļ
vecāks
revīzija
c81cf2bf0e

+ 11 - 1
src/mod/office/office.go

@@ -112,7 +112,17 @@ type Props struct {
 	Opacity float64   `json:"opacity,omitempty"` // 0..1, 0 = fully opaque
 	// a shaped crop: the picture is clipped to one of the shape kinds
 	// below ("" or "rect" = a plain rectangle)
-	Mask string `json:"mask,omitempty"`
+	Mask  string `json:"mask,omitempty"`
+	FlipH bool   `json:"flipH,omitempty"`
+	FlipV bool   `json:"flipV,omitempty"`
+	// picture colour: a re-colour preset key (see RECOLORS in
+	// slides/slides_image.js) and the brightness / contrast offsets, where
+	// 0 is "leave alone" and the range is roughly -0.9 .. 1
+	Recolor  string  `json:"recolor,omitempty"`
+	Bright   float64 `json:"bright,omitempty"`
+	Contrast float64 `json:"contrast,omitempty"`
+	// editor-only: the crop panel's aspect-ratio latch
+	LockAspect bool `json:"lockAspect,omitempty"`
 	// the frame the whole picture filled before it was first cropped, so
 	// Reset image can put it back. Editor state - no format stores it.
 	Orig *Rect `json:"orig,omitempty"`

+ 71 - 0
src/mod/office/pptx_fidelity_test.go

@@ -451,6 +451,77 @@ func TestPptxPictureMaskRoundTrips(t *testing.T) {
 	}
 }
 
+func TestPptxPictureEffectsRoundTrip(t *testing.T) {
+	png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 32))
+	tests := []struct {
+		name string
+		in   Props
+		want func(Props) string // "" when the value survived
+	}{
+		{"flips", Props{FlipH: true, FlipV: true}, func(p Props) string {
+			if !p.FlipH || !p.FlipV {
+				return fmt.Sprintf("flipH/flipV = %v/%v, want true/true", p.FlipH, p.FlipV)
+			}
+			return ""
+		}},
+		{"greyscale", Props{Recolor: "gray"}, func(p Props) string {
+			if p.Recolor != "gray" {
+				return "recolor = " + p.Recolor + ", want gray"
+			}
+			return ""
+		}},
+		{"black and white", Props{Recolor: "black-white"}, func(p Props) string {
+			if p.Recolor != "black-white" {
+				return "recolor = " + p.Recolor + ", want black-white"
+			}
+			return ""
+		}},
+		{"duotone tint", Props{Recolor: "teal"}, func(p Props) string {
+			if p.Recolor != "teal" {
+				return "recolor = " + p.Recolor + ", want teal"
+			}
+			return ""
+		}},
+		{"brightness and contrast", Props{Bright: 0.2, Contrast: -0.3}, func(p Props) string {
+			if p.Bright != 0.2 || p.Contrast != -0.3 {
+				return fmt.Sprintf("bright/contrast = %v/%v, want 0.2/-0.3", p.Bright, p.Contrast)
+			}
+			return ""
+		}},
+		{"transparency", Props{Opacity: 0.4}, func(p Props) string {
+			if p.Opacity != 0.4 {
+				return fmt.Sprintf("opacity = %v, want 0.4", p.Opacity)
+			}
+			return ""
+		}},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			props := tc.in
+			props.Src = encodeDataURL(png, "png")
+			props.Fit = "fill"
+			pres := &Presentation{Slides: []*Slide{{Objects: []*Object{{
+				Type: "image", X: 0, Y: 0, W: 200, H: 100, Z: 1, Props: props,
+			}}}}}
+			data, err := BuildPptx(pres)
+			if err != nil {
+				t.Fatalf("BuildPptx: %v", err)
+			}
+			got, err := ParsePptx(data)
+			if err != nil {
+				t.Fatalf("ParsePptx: %v", err)
+			}
+			objs := got.Slides[0].Objects
+			if len(objs) != 1 || objs[0].Type != "image" {
+				t.Fatalf("objects = %+v, want one image", objs)
+			}
+			if msg := tc.want(objs[0].Props); msg != "" {
+				t.Error(msg)
+			}
+		})
+	}
+}
+
 func TestPptxRoundPictureKeepsItsRadius(t *testing.T) {
 	png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 32))
 	pres := &Presentation{Slides: []*Slide{{Objects: []*Object{{

+ 70 - 7
src/mod/office/pptx_reader.go

@@ -1375,13 +1375,8 @@ func (sc *slideCtx) parsePic(node *xnode, cm coordMap) *Object {
 	if rad := sc.cornerRadius(spPr, prst, box); rad > 0 {
 		props.Radius = round2(rad)
 	}
-	if am := blip.first("alphaModFix"); am != nil {
-		if v := am.attr("amt"); v != "" {
-			if o := clamp01(atofDefault(v, 100000) / 100000); o < 1 {
-				props.Opacity = round2(o)
-			}
-		}
-	}
+	props.FlipH, props.FlipV = box.FlipH, box.FlipV
+	readPictureEffects(blip, &props)
 	return &Object{
 		Type: "image", X: box.X, Y: box.Y, W: box.W, H: box.H, Rot: box.Rot,
 		Props: props,
@@ -1693,3 +1688,71 @@ func (sc *slideCtx) parseChartFrame(gdata *xnode, box xfrmBox) *Object {
 		Props: Props{Spec: chartSpecJSON(spec)},
 	}
 }
+
+// readPictureEffects reads the colour effects DrawingML allows on a
+// picture's blip: transparency, a greyscale wash, a duotone tint and the
+// luminance (brightness / contrast) pair. They map onto the editor's
+// re-colour presets and adjustment offsets.
+func readPictureEffects(blip *xnode, props *Props) {
+	if blip == nil {
+		return
+	}
+	if am := blip.first("alphaModFix"); am != nil {
+		if v := am.attr("amt"); v != "" {
+			if o := clamp01(atofDefault(v, 100000) / 100000); o < 1 {
+				props.Opacity = round2(o)
+			}
+		}
+	}
+	if blip.first("grayscl") != nil {
+		props.Recolor = "gray"
+	}
+	if blip.first("biLevel") != nil {
+		props.Recolor = "black-white"
+	}
+	if dt := blip.first("duotone"); dt != nil {
+		// the second stop is the colour the picture is washed into
+		plain := colorCtx{}
+		var hexes []string
+		for i := range dt.Nodes {
+			if c := plain.resolveColor(&dt.Nodes[i]); c != "" {
+				hexes = append(hexes, c)
+			}
+		}
+		if len(hexes) > 0 {
+			props.Recolor = nearestRecolor(hexes[len(hexes)-1])
+		}
+	}
+	if lum := blip.first("lum"); lum != nil {
+		if v := lum.attr("bright"); v != "" {
+			props.Bright = round2(atofDefault(v, 0) / 100000)
+		}
+		if v := lum.attr("contrast"); v != "" {
+			props.Contrast = round2(atofDefault(v, 0) / 100000)
+		}
+	}
+}
+
+// recolorTints is the colour each re-colour preset washes a picture into,
+// shared by the reader (nearest match on a duotone) and the writer
+var recolorTints = map[string]string{
+	"blue-light": "#9fc5f8", "blue-dark": "#1c4587", "teal": "#12b5cb",
+	"green": "#34a853", "lime": "#b7e1a1", "yellow": "#ffd966",
+	"orange": "#f6b26b", "red": "#e06666", "purple": "#8e7cc3",
+	"pink": "#ea9999", "sepia": "#b5814f", "washout": "#d9d9d9",
+}
+
+// nearestRecolor picks the preset whose tint is closest to a colour
+func nearestRecolor(hex string) string {
+	r, g, b := hexToRGB(hex)
+	best, bestD := "gray", 0.0
+	first := true
+	for key, tint := range recolorTints {
+		tr, tg, tb := hexToRGB(tint)
+		d := (r-tr)*(r-tr) + (g-tg)*(g-tg) + (b-tb)*(b-tb)
+		if first || d < bestD {
+			best, bestD, first = key, d, false
+		}
+	}
+	return best
+}

+ 40 - 6
src/mod/office/pptx_writer.go

@@ -844,17 +844,51 @@ func buildPicSp(id int, o *Object, rid string) string {
 				`<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val %d"/></a:avLst></a:prstGeom>`, adj)
 		}
 	}
-	blip := `<a:blip r:embed="` + rid + `"/>`
-	if p.Opacity > 0 && p.Opacity < 1 {
-		blip = `<a:blip r:embed="` + rid + `"><a:alphaModFix amt="` +
-			fmt.Sprintf("%d", int(p.Opacity*100000)) + `"/></a:blip>`
-	}
+	blip := `<a:blip r:embed="` + rid + `">` + pictureEffects(p) + `</a:blip>`
 	return fmt.Sprintf(
 		`<p:pic><p:nvPicPr><p:cNvPr id="%d" name="Picture %d"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>`+
 			`<p:blipFill>%s%s<a:stretch><a:fillRect/></a:stretch></p:blipFill>`+
 			`<p:spPr>%s%s</p:spPr></p:pic>`,
 		id, id, blip, srcRect,
-		xfrm(o.X, o.Y, o.W, o.H, o.Rot, false, false), geom)
+		xfrm(o.X, o.Y, o.W, o.H, o.Rot, p.FlipH, p.FlipV), geom)
+}
+
+// pictureEffects renders the colour effects a picture carries as the
+// DrawingML children of its <a:blip>: transparency, a greyscale wash or a
+// duotone tint for the re-colour preset, and the luminance pair for the
+// brightness / contrast adjustments.
+func pictureEffects(p Props) string {
+	var sb strings.Builder
+	if p.Opacity > 0 && p.Opacity < 1 {
+		sb.WriteString(fmt.Sprintf(`<a:alphaModFix amt="%d"/>`, int(p.Opacity*100000)))
+	}
+	switch p.Recolor {
+	case "":
+		// nothing
+	case "gray":
+		sb.WriteString(`<a:grayscl/>`)
+	case "black-white":
+		sb.WriteString(`<a:biLevel thresh="50000"/>`)
+	case "negative":
+		// DrawingML has no image inversion; the nearest honest thing is to
+		// leave the picture alone rather than write something else
+	default:
+		if tint, ok := recolorTints[p.Recolor]; ok {
+			sb.WriteString(`<a:duotone><a:prstClr val="black"/><a:srgbClr val="` +
+				hexColor(tint, "808080") + `"/></a:duotone>`)
+		}
+	}
+	if p.Bright != 0 || p.Contrast != 0 {
+		lum := `<a:lum`
+		if p.Bright != 0 {
+			lum += fmt.Sprintf(` bright="%d"`, int(p.Bright*100000))
+		}
+		if p.Contrast != 0 {
+			lum += fmt.Sprintf(` contrast="%d"`, int(p.Contrast*100000))
+		}
+		sb.WriteString(lum + `/>`)
+	}
+	return sb.String()
 }
 
 func buildTableFrame(id int, o *Object) string {

+ 30 - 10
src/web/Office/README.md

@@ -172,21 +172,41 @@ editor and one made in PowerPoint are interchangeable. `fullImageRect()`
 in `slides.js` inverts the pair to find where the whole picture sits, and
 that is the whole of the crop tool's geometry.
 
-Three commands, on the picture's context menu and on the toolbar (which
-shows them only while a picture is selected):
+The tools live in [`slides/slides_image.js`](slides/slides_image.js)
+(`SlidesImageTools`) and reach the document only through a host object of
+callbacks, so `slides.js` stays about the document and the canvas. They are
+reachable three ways: a **floating picture bar** under the selected image
+(the text-edit bar's chrome, so the two feel like one family), the toolbar,
+and the picture's context menu.
 
 - **Crop image** (also a double-click) opens the tool: the object itself is
   hidden and the overlay draws the whole picture ghosted with the kept part
   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.
-- **Mask image** clips it to any of the editor's shape kinds (`props.mask`),
-  drawn as a `clip-path` built from the same `shapePoints()` the shape
-  objects use, and written to `.pptx` as a `prstGeom` on the picture.
-- **Reset image** clears the crop, the mask and the corner radius, puts the
-  frame back to `props.orig` — stamped the first time a picture is trimmed,
-  and holding the frame the *whole* picture filled — and corrects the height
-  to the source's own aspect ratio, so a picture stretched by dragging a
-  corner comes back undistorted too.
+  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
+  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:
+  size, rotation and flips, position and align-to-slide, re-colour, and the
+  brightness / contrast / transparency adjustments. `imageFilter()` is the
+  single place that turns `recolor` + `bright` + `contrast` into a CSS
+  filter, so the canvas, the thumbnails, present mode and the panel's own
+  swatches cannot disagree — the swatches show the picture itself through
+  each filter, so the preview *is* the result.
+- **Reset image** takes off everything the tools can put on: the crop, the
+  shaped crop, the flips and the colour treatment. It puts the frame back to
+  `props.orig` — stamped the first time a picture is trimmed, and holding the
+  frame the *whole* picture filled — and corrects the height to the source's
+  own aspect ratio, so a picture stretched by dragging a corner comes back
+  undistorted too.
+
+The colour work round-trips through `.pptx` as the DrawingML effects on the
+picture's `<a:blip>`: `a:alphaModFix` for transparency, `a:grayscl` /
+`a:biLevel` / `a:duotone` for the re-colour preset, and `a:lum` for
+brightness and contrast. The one preset with no DrawingML equivalent is
+*Negative*, which is written as no effect rather than as something else.
 
 ### Sheets formula engine
 

+ 31 - 0
src/web/Office/common/CONTRACT.md

@@ -557,6 +557,9 @@ back to the stylesheet, so nothing here changes how a new deck looks.
 | `crop` | image | `[left, top, right, bottom]` fractions clipped off the source |
 | `mask` | image | a shaped crop: a `SHAPE_KINDS` kind the picture is clipped to |
 | `orig` | image | `{x,y,w,h}` the frame before the first crop — Reset image |
+| `flipH` / `flipV` | image | mirrored horizontally / vertically |
+| `recolor` | image | a re-colour preset key (`RECOLORS` in `slides_image.js`) |
+| `bright` / `contrast` | image | adjustment offsets; 0 = leave alone, range ≈ -0.9..1 |
 | `radius` | image, shape | corner radius in px |
 | `opacity` | image | 0..1; 0 means fully opaque |
 | `points` | line | the polyline a bent connector follows, relative to `x`/`y` |
@@ -596,6 +599,34 @@ 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.
 
+## Picture tools (slides/slides_image.js)
+
+`SlidesImageTools` owns everything that acts on a selected picture, so
+`slides.js` stays about the document and the canvas. It never touches the
+model directly — `init()` takes a host object of callbacks (`getImage`,
+`commit`, `startCrop`, `setMask`, `resetImage`, …) and slides.js drives it
+with `sync()` / `reposition()` from `setSel`, `commit` and `renderOverlay`.
+**`init()` must run before `OfficeApp.init()`**: loading a document selects
+objects, and that syncs the tools.
+
+Three pieces:
+
+- the **floating picture bar** — the text-edit bar's chrome (`.of-textedit-bar`,
+  `.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
+  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,
+  rotation and flips, position and align-to-slide, re-colour and the
+  brightness / contrast / transparency adjustments.
+
+`imageFilter(props)` is the single place that turns `recolor` + `bright` +
+`contrast` into a CSS filter, so the canvas, the thumbnails, present mode and
+the panel's own swatches cannot disagree — the swatches are the picture
+itself seen through each filter, so the preview *is* the result.
+
 The presentation body may also carry `fonts`: `[{family, weight, style, src}]`
 where `src` is a font-file data URL taken from the source deck's embedded
 fonts. `normalizeBody` installs them as `@font-face` rules in a single

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

@@ -52,6 +52,7 @@
     </div>
     <div id="slPrintArea"></div>
     <input type="file" id="slDeviceImage" accept="image/*" multiple style="display:none;">
+    <script src="slides_image.js"></script>
     <script src="slides.js"></script>
     <script src="present.js"></script>
 </body>

+ 205 - 0
src/web/Office/slides/slides.css

@@ -591,3 +591,208 @@ body.dark .sl-h { background: #2b2d31; }
     .sl-thumb-view { width: 88px; height: 49.5px; }
     .sl-thumb-mini { transform: scale(0.091667); }
 }
+
+/* ============ Picture tools (slides_image.js) ============ */
+
+/* the floating picture bar reuses the text bar's chrome so the two feel
+   like one family; only the label and the split crop control differ */
+.sl-imagebar-label {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 0 6px 0 2px;
+    font-size: 12px;
+    color: var(--of-fg-soft);
+    white-space: nowrap;
+}
+.sl-imagebar-label i.icon { margin: 0 !important; font-size: 12px !important; }
+.sl-imagebar .of-te-btn.active { background: var(--of-accent-soft); color: var(--of-accent); }
+/* crop + caret read as one split button */
+.sl-imagebar-crop { border-radius: 4px 0 0 4px !important; }
+.sl-imagebar-caret { width: 16px !important; border-radius: 0 4px 4px 0 !important; }
+.sl-imagebar-caret i.icon { font-size: 10px !important; }
+
+/* ---- crop shape grid ---- */
+.sl-shapegrid {
+    position: fixed;
+    z-index: 1500;
+    padding: 6px;
+    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-shapegrid-none {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+    padding: 5px 8px;
+    margin-bottom: 4px;
+    border: none;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--of-fg);
+    font-family: inherit;
+    font-size: 12px;
+    text-align: left;
+    cursor: pointer;
+}
+.sl-shapegrid-cells {
+    display: grid;
+    grid-template-columns: repeat(6, 32px);
+    gap: 2px;
+    border-top: 1px solid var(--of-border);
+    padding-top: 5px;
+}
+.sl-shapegrid-cell {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 32px;
+    height: 32px;
+    border: none;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--of-fg);
+    cursor: pointer;
+}
+.sl-shapegrid-none:hover, .sl-shapegrid-cell:hover { background: var(--of-hover); }
+.sl-shapegrid-none.active, .sl-shapegrid-cell.active {
+    background: var(--of-accent-soft);
+    color: var(--of-accent);
+}
+
+/* ---- format side panel ---- */
+#slFormatPanel {
+    flex: 0 0 268px;
+    width: 268px;
+    display: flex;
+    flex-direction: column;
+    overflow-y: auto;
+    background: var(--of-chrome-bg);
+    border-left: 1px solid var(--of-border);
+    user-select: none;
+}
+.sl-fp-title {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 12px 12px 10px 14px;
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--of-fg);
+}
+.sl-fp-title i.icon { margin: 0 !important; color: var(--of-accent); }
+.sl-fp-title span { flex: 1 1 auto; }
+.sl-fp-close {
+    border: none;
+    background: transparent;
+    color: var(--of-fg-soft);
+    cursor: pointer;
+    padding: 2px;
+    border-radius: 4px;
+}
+.sl-fp-close:hover { background: var(--of-hover); color: var(--of-fg); }
+.sl-fp-close i.icon { margin: 0 !important; }
+.sl-fp-empty { padding: 10px 14px; color: var(--of-fg-soft); font-size: 12px; }
+
+.sl-fp-section { border-top: 1px solid var(--of-border); }
+.sl-fp-head {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 14px;
+    font-size: 13px;
+    color: var(--of-fg);
+    cursor: pointer;
+}
+.sl-fp-head:hover { background: var(--of-hover); }
+.sl-fp-head i.icon { margin: 0 !important; font-size: 11px !important; transition: transform 0.15s ease; }
+.sl-fp-section.closed .sl-fp-head i.icon { transform: rotate(-90deg); }
+.sl-fp-body { padding: 0 14px 12px 14px; }
+.sl-fp-section.closed .sl-fp-body { display: none; }
+
+.sl-fp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.sl-fp-field { margin-bottom: 8px; min-width: 0; }
+.sl-fp-field label {
+    display: block;
+    margin-bottom: 3px;
+    font-size: 11px;
+    color: var(--of-fg-soft);
+}
+.sl-fp-inputrow { display: flex; align-items: center; gap: 4px; }
+.sl-fp-num {
+    flex: 1 1 auto;
+    min-width: 0;
+    height: 28px;
+    padding: 0 6px;
+    border: 1px solid var(--of-border);
+    border-radius: 4px;
+    background: var(--of-chrome-bg2);
+    color: var(--of-fg);
+    font-family: inherit;
+    font-size: 12px;
+    outline: none;
+}
+.sl-fp-num:focus { border-color: var(--of-accent); }
+.sl-fp-unit { flex: 0 0 auto; font-size: 11px; color: var(--of-fg-soft); min-width: 26px; }
+.sl-fp-range { flex: 1 1 auto; min-width: 0; accent-color: var(--of-accent); }
+.sl-fp-check {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    margin: 2px 0 10px;
+    font-size: 12px;
+    color: var(--of-fg);
+    cursor: pointer;
+}
+.sl-fp-iconbtn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 28px;
+    border: 1px solid var(--of-border);
+    border-radius: 4px;
+    background: var(--of-chrome-bg2);
+    color: var(--of-fg);
+    cursor: pointer;
+}
+.sl-fp-iconbtn:hover { background: var(--of-hover); }
+.sl-fp-iconbtn.active { background: var(--of-accent-soft); color: var(--of-accent); border-color: var(--of-accent); }
+.sl-fp-iconbtn i.icon { margin: 0 !important; font-size: 12px !important; }
+
+/* re-colour swatches: the picture itself seen through each filter, so the
+   preview is literally the result */
+.sl-fp-swatches { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; }
+.sl-fp-swatch {
+    display: block;
+    padding: 0;
+    border: 1px solid var(--of-border);
+    border-radius: 4px;
+    background: var(--of-chrome-bg2);
+    color: var(--of-fg-soft);
+    cursor: pointer;
+    overflow: hidden;
+}
+.sl-fp-swatch img {
+    display: block;
+    width: 100%;
+    height: 42px;
+    object-fit: cover;
+    background: var(--of-chrome-bg2);
+}
+.sl-fp-swatch span {
+    display: block;
+    padding: 2px 3px 3px;
+    font-size: 9px;
+    line-height: 1.15;
+    text-align: center;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.sl-fp-swatch:hover { border-color: var(--of-accent); }
+.sl-fp-swatch.active { border-color: var(--of-accent); box-shadow: 0 0 0 1px var(--of-accent); color: var(--of-accent); }

+ 62 - 27
src/web/Office/slides/slides.js

@@ -58,10 +58,16 @@
       line         : points (a bent connector's polyline), arrowStart
       table        : cellFill[][], cellPad[t,r,b,l]
 
-    An image also carries crop / mask / orig when it has been cropped in
-    the editor - the same props, since a crop made here and one made in
-    PowerPoint mean the same thing. See the "image crop tool" section
-    below and CONTRACT.md for the frame-vs-source identity they obey.
+    An image also carries what the picture tools put on it:
+      crop[l,t,r,b] / mask / radius  the crop and the shaped crop
+      orig {x,y,w,h}                 the frame Reset image puts back
+      flipH / flipV                  mirrored horizontally / vertically
+      recolor / bright / contrast    the colour treatment (slides_image.js)
+      opacity                        1 - transparency
+    These are the same props the pptx reader writes, because a crop or a
+    tint made here and one made in PowerPoint mean the same thing. See the
+    "image crop tool" section below, slides_image.js for the tools that
+    edit them, and CONTRACT.md for the frame-vs-source identity they obey.
 */
 
 var SlidesApp = (function () {
@@ -305,6 +311,15 @@ var SlidesApp = (function () {
         if (p.opacity) wrapS += "opacity:" + clamp(Number(p.opacity) || 1, 0, 1) + ";";
         var clip = maskClipPath(p.mask);
         if (clip) wrapS += "clip-path:" + clip + ";-webkit-clip-path:" + clip + ";";
+        // re-colour and the brightness / contrast adjustments are one CSS
+        // filter, built by the picture tools so the canvas, the thumbnails,
+        // present mode and the panel's own swatches all agree
+        if (window.SlidesImageTools) {
+            var f = SlidesImageTools.imageFilter(p);
+            if (f) imgS += "filter:" + f + ";";
+        }
+        var flip = (p.flipH ? "scaleX(-1) " : "") + (p.flipV ? "scaleY(-1)" : "");
+        if (flip) imgS += "transform:" + flip.trim() + ";";
         return '<div class="sl-img-wrap" style="' + wrapS + '">' +
             '<img draggable="false" src="' + esc(p.src || "") +
             '" style="' + imgS + '" alt=""></div>';
@@ -687,6 +702,7 @@ var SlidesApp = (function () {
     function renderOverlay() {
         if (!framesEl) return;
         framesEl.innerHTML = "";
+        if (window.SlidesImageTools) SlidesImageTools.reposition();
         if (cropId) {
             // the crop tool replaces the selection frame while it is open
             renderCropOverlay();
@@ -889,9 +905,12 @@ var SlidesApp = (function () {
         if (!o || o.type !== "image") return;
         if (cropId === o.id) endCrop(false);
         var full = fullImageRect(o);
-        delete o.props.crop;
-        delete o.props.mask;
-        delete o.props.radius;
+        // everything the picture tools can put on a picture comes off: the
+        // crop, the shaped crop, the flips and the colour treatment
+        ["crop", "mask", "radius", "flipH", "flipV",
+         "recolor", "bright", "contrast", "opacity"].forEach(function (k) {
+            delete o.props[k];
+        });
         if (o.props.orig) {
             o.x = o.props.orig.x; o.y = o.props.orig.y;
             o.w = o.props.orig.w; o.h = o.props.orig.h;
@@ -1122,6 +1141,8 @@ var SlidesApp = (function () {
         renderOverlay();
         renderThumb(cur);
         updateStatus();
+        // the picture bar is anchored to a DOM node the re-render replaced
+        if (window.SlidesImageTools) SlidesImageTools.sync();
         OfficeApp.markDirty();
         undo.push(snap());
     }
@@ -3076,33 +3097,25 @@ var SlidesApp = (function () {
         $tb.append(tbtn("table", "Insert table", tableDialog));
         $tb.append(tbtn("chart bar", "Insert chart", function () { chartDialog(null); }));
         // picture tools: only meaningful with an image selected, so they
-        // are hidden until there is one (syncToolbarFromSel)
+        // are hidden until there is one (syncToolbarFromSel). Crop and the
+        // crop shapes are one split control - the button crops, the caret
+        // beside it picks the outline to crop to.
         $tb.append(tbtn("crop", "Crop image", function () {
             var io = selectedImage();
             if (io) { if (cropId === io.id) endCrop(true); else startCrop(io.id); }
         }, "slBtnCrop"));
-        $tb.append(tbtn("object ungroup outline", "Mask image to a shape", function (e) {
-            var io = selectedImage();
-            if (!io) return;
-            var r = e.currentTarget.getBoundingClientRect();
-            OfficeApp.showContextMenu(r.left, r.bottom + 4, [{
-                label: "None (rectangle)",
-                checked: function () { return !io.props.mask; },
-                action: function () { setImageMask(io, ""); }
-            }, { sep: true }].concat(SHAPE_KINDS.filter(function (s) {
-                return s.kind !== "rect";
-            }).map(function (s) {
-                return {
-                    label: s.label,
-                    checked: function () { return io.props.mask === s.kind; },
-                    action: function () { setImageMask(io, s.kind); }
-                };
-            })));
-        }, "slBtnMask"));
+        $tb.append(tbtn("caret down", "Crop to shape", function (e) {
+            if (selectedImage() && window.SlidesImageTools) {
+                SlidesImageTools.showShapeMenu(e.currentTarget);
+            }
+        }, "slBtnCropShape"));
         $tb.append(tbtn("history", "Reset image", function () {
             var io = selectedImage();
             if (io) resetImage(io);
         }, "slBtnResetImg"));
+        $tb.append(tbtn("sliders horizontal", "Image format options", function () {
+            if (window.SlidesImageTools) SlidesImageTools.togglePanel();
+        }, "slBtnImgFmt"));
         $tb.append('<div class="of-tsep"></div>');
 
         var $fs = $('<input type="number" class="of-tinput sl-num" id="slFontSize" min="6" max="200" step="1" title="Font size" value="24">');
@@ -3249,8 +3262,12 @@ var SlidesApp = (function () {
         var so = selObjs();
         var o = so.length ? so[0] : null;
         var isImg = !!selectedImage();
-        $("#slBtnCrop, #slBtnMask, #slBtnResetImg").toggle(isImg);
+        $("#slBtnCrop, #slBtnCropShape, #slBtnResetImg, #slBtnImgFmt").toggle(isImg);
         $("#slBtnCrop").toggleClass("active", !!cropId);
+        if (window.SlidesImageTools) {
+            $("#slBtnImgFmt").toggleClass("active", SlidesImageTools.panelOpen());
+            SlidesImageTools.sync();
+        }
         if (!o) return;
         var p = o.props;
         if (o.type === "text" || o.type === "shape" || o.type === "table") {
@@ -3817,6 +3834,24 @@ var SlidesApp = (function () {
     }
 
     function init() {
+        // the picture tools live in their own module and reach the document
+        // only through this host object
+        if (window.SlidesImageTools) {
+            SlidesImageTools.init({
+                getImage: selectedImage,
+                objEl: objEl,
+                commit: commit,
+                startCrop: startCrop,
+                endCrop: endCrop,
+                isCropping: function () { return !!cropId; },
+                resetImage: resetImage,
+                setMask: setImageMask,
+                shapeKinds: SHAPE_KINDS,
+                shapePoints: shapePoints,
+                slideSize: [SLIDE_W, SLIDE_H],
+                relayout: layoutCanvas
+            });
+        }
         snapGrid = false;
         undo = new OfficeUndoStack({ limit: 100, apply: applyUndoState });
 

+ 474 - 0
src/web/Office/slides/slides_image.js

@@ -0,0 +1,474 @@
+/*
+    ArozOS Office - Slides picture tools
+    ====================================
+    Everything that acts on a selected picture, kept out of slides.js so
+    that file stays about the document and the canvas:
+
+      - the floating picture bar, the same shape as the text-edit bar but
+        anchored to a selected image: crop, reset, format options
+      - the shape grid: the crop shapes drawn as icons rather than listed
+        as names, shared by the bar and the toolbar's crop button
+      - the format panel: a docked side panel for size, position, rotation,
+        alignment, re-colour and the brightness / contrast / transparency
+        adjustments
+
+    slides.js owns the document, so this module never touches the model
+    directly - it asks through the host object passed to init():
+
+        SlidesImageTools.init({
+            getImage: fn -> the lone selected image object, or null,
+            objEl:    fn(id) -> its DOM element,
+            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),
+            slideSize: [w,h], relayout: fn
+        });
+
+    and the host drives it with sync() / hide() / reposition().
+*/
+
+var SlidesImageTools = (function () {
+    "use strict";
+
+    var host = null;
+    var $bar = null;
+    var $panel = null;
+    var barObjId = null;
+
+    /* Re-colour presets. Each is a CSS filter chain, and the swatches in
+       the panel show the picture itself through that filter, so what is
+       previewed is exactly what is applied. The tint recipe (grayscale,
+       then sepia as a colour base, then rotate the hue) is the standard
+       way to wash an arbitrary photo into one hue with plain CSS. */
+    var RECOLORS = [
+        { key: "", label: "No recolour", filter: "" },
+        { key: "gray", label: "Greyscale", filter: "grayscale(1)" },
+        { key: "sepia", label: "Sepia", filter: "sepia(1)" },
+        { key: "washout", label: "Washed out", filter: "grayscale(0.4) brightness(1.35) contrast(0.7)" },
+        { key: "blue-light", label: "Light blue", filter: tint(210, 0.55, 1.25) },
+        { key: "blue-dark", label: "Dark blue", filter: tint(215, 1.1, 0.7) },
+        { key: "teal", label: "Teal", filter: tint(175, 1.0, 0.95) },
+        { key: "green", label: "Green", filter: tint(105, 0.95, 0.95) },
+        { key: "lime", label: "Lime", filter: tint(75, 1.3, 1.15) },
+        { key: "yellow", label: "Yellow", filter: tint(45, 1.4, 1.15) },
+        { key: "orange", label: "Orange", filter: tint(15, 1.3, 1.0) },
+        { key: "red", label: "Red", filter: tint(345, 1.2, 0.9) },
+        { key: "purple", label: "Purple", filter: tint(265, 0.9, 0.9) },
+        { key: "pink", label: "Pink", filter: tint(315, 0.9, 1.15) },
+        { key: "black-white", label: "Black and white", filter: "grayscale(1) contrast(2.6) brightness(1.05)" },
+        { key: "negative", label: "Negative", filter: "invert(1)" }
+    ];
+    // hue is where the wash lands, sat how strong it is, bright how light
+    function tint(hue, sat, bright) {
+        return "grayscale(1) sepia(1) hue-rotate(" + (hue - 40) + "deg) " +
+            "saturate(" + sat + ") brightness(" + bright + ")";
+    }
+    function recolorByKey(key) {
+        for (var i = 0; i < RECOLORS.length; i++) {
+            if (RECOLORS[i].key === (key || "")) return RECOLORS[i];
+        }
+        return RECOLORS[0];
+    }
+
+    /* imageFilter is the single place that turns a picture's re-colour and
+       adjustments into CSS, so the canvas, the thumbnails, present mode and
+       the panel's own swatches all agree. Exported for slides.js to use
+       while rendering. */
+    function imageFilter(p) {
+        var parts = [];
+        var rc = recolorByKey(p.recolor);
+        if (rc.filter) parts.push(rc.filter);
+        var b = Number(p.bright) || 0;
+        var c = Number(p.contrast) || 0;
+        if (b) parts.push("brightness(" + (1 + clamp(b, -0.9, 2)) + ")");
+        if (c) parts.push("contrast(" + (1 + clamp(c, -0.9, 2)) + ")");
+        return parts.join(" ");
+    }
+
+    function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
+    function esc(t) { return OfficeApp.escapeHtml(t); }
+    function num(v, d) { var n = Number(v); return isFinite(n) ? n : d; }
+
+    /* ================= 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
+    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>";
+    }
+
+    /* showShapeMenu opens the crop-shape picker under an element. The
+       shapes are icons in a grid rather than a list of names, because the
+       outline is the thing being chosen. */
+    function showShapeMenu(anchorEl) {
+        closeShapeMenu();
+        if (!host) return;
+        var o = host.getImage();
+        if (!o) return;
+        var $m = $('<div class="sl-shapegrid of-noprint"></div>');
+        var $none = $('<button type="button" class="sl-shapegrid-none"></button>')
+            .append($(shapeIcon("rect")))
+            .append($("<span></span>").text("No shape (rectangle)"));
+        if (!o.props.mask) $none.addClass("active");
+        $none.on("click", function () { closeShapeMenu(); host.setMask(o, ""); });
+        $m.append($none);
+
+        var $grid = $('<div class="sl-shapegrid-cells"></div>');
+        host.shapeKinds.forEach(function (s) {
+            if (s.kind === "rect") return;
+            var $b = $('<button type="button" class="sl-shapegrid-cell"></button>')
+                .attr("title", s.label)
+                .html(shapeIcon(s.kind));
+            if (o.props.mask === s.kind) $b.addClass("active");
+            $b.on("click", function () { closeShapeMenu(); host.setMask(o, s.kind); });
+            $grid.append($b);
+        });
+        $m.append($grid);
+        $("body").append($m);
+
+        var r = anchorEl.getBoundingClientRect();
+        var w = $m.outerWidth(), h = $m.outerHeight();
+        var x = Math.min(r.left, window.innerWidth - w - 6);
+        var y = r.bottom + 4;
+        if (y + h > window.innerHeight - 6) y = Math.max(6, r.top - h - 4);
+        $m.css({ left: Math.max(6, x) + "px", top: y + "px" });
+        setTimeout(function () {
+            $(document).on("mousedown.slshapegrid", function (e) {
+                if (!$m[0].contains(e.target)) closeShapeMenu();
+            });
+        }, 0);
+    }
+    function closeShapeMenu() {
+        $(".sl-shapegrid").remove();
+        $(document).off("mousedown.slshapegrid");
+    }
+
+    /* ================= floating picture bar ================= */
+
+    function barBtn(icon, title, fn) {
+        var $b = $('<button type="button" class="of-te-btn" title="' + esc(title) + '">' +
+            '<i class="' + icon + ' icon"></i></button>');
+        $b.on("mousedown", function (e) { e.preventDefault(); });
+        $b.on("click", fn);
+        return $b;
+    }
+
+    function buildBar() {
+        $bar = $('<div class="of-textedit-bar sl-imagebar of-noprint"></div>');
+        var $row = $('<div class="of-te-row"></div>');
+        $bar.append($row);
+
+        $row.append($('<span class="sl-imagebar-label"></span>')
+            .append('<i class="image outline icon"></i>')
+            .append(document.createTextNode("Edit image")));
+        $row.append('<div class="of-te-sep"></div>');
+
+        // crop and the crop shapes are one control: the button crops, the
+        // caret beside it picks the shape to crop to
+        var $crop = barBtn("crop", "Crop image", function () {
+            var o = host.getImage();
+            if (!o) return;
+            if (host.isCropping()) host.endCrop(true); else host.startCrop(o.id);
+        });
+        $crop.addClass("sl-imagebar-crop");
+        $row.append($crop);
+        var $caret = barBtn("caret down", "Crop to shape", function (e) {
+            showShapeMenu(e.currentTarget);
+        });
+        $caret.addClass("sl-imagebar-caret");
+        $row.append($caret);
+
+        $row.append(barBtn("history", "Reset image (clear crop, shape and colour)", function () {
+            var o = host.getImage();
+            if (o) host.resetImage(o);
+        }));
+        $row.append('<div class="of-te-sep"></div>');
+        var $fmt = barBtn("sliders horizontal", "Format options", function () {
+            togglePanel();
+        });
+        $fmt.addClass("sl-imagebar-fmt");
+        $row.append($fmt);
+
+        $("body").append($bar);
+    }
+
+    function syncBarState() {
+        if (!$bar) return;
+        $bar.find(".sl-imagebar-crop").toggleClass("active", !!host.isCropping());
+        $bar.find(".sl-imagebar-fmt").toggleClass("active", !!$panel);
+    }
+
+    function reposition() {
+        if (!$bar || !host) return;
+        var o = host.getImage();
+        var el = o ? host.objEl(o.id) : null;
+        if (!el) { hideBar(); return; }
+        var r = el.getBoundingClientRect();
+        var w = $bar.outerWidth(), h = $bar.outerHeight();
+        var x = r.left + (r.width - w) / 2;
+        // below the picture by default, like the reference: the space above
+        // is usually where the slide's own content is
+        var y = r.bottom + 8;
+        if (y + h > window.innerHeight - 4) y = Math.max(4, r.top - h - 8);
+        if (x + w > window.innerWidth - 4) x = window.innerWidth - w - 4;
+        if (x < 4) x = 4;
+        $bar.css({ left: x + "px", top: y + "px" });
+    }
+
+    function hideBar() {
+        if ($bar) { $bar.remove(); $bar = null; }
+        barObjId = null;
+        closeShapeMenu();
+    }
+
+    /* sync is called whenever the selection or the document changes: it
+       shows the bar for a lone picture and takes it away otherwise */
+    function sync() {
+        if (!host) return;
+        var o = host.getImage();
+        if (!o) {
+            hideBar();
+            if ($panel) renderPanel();
+            return;
+        }
+        if (!$bar) buildBar();
+        barObjId = o.id;
+        syncBarState();
+        reposition();
+        if ($panel) renderPanel();
+    }
+
+    /* ================= format panel ================= */
+
+    function togglePanel() {
+        if (!host) return;
+        if ($panel) closePanel(); else openPanel();
+    }
+    function openPanel() {
+        if ($panel) return;
+        $panel = $('<div id="slFormatPanel" class="of-noprint"></div>');
+        $("#slMain").after($panel);
+        renderPanel();
+        syncBarState();
+        if (host.relayout) host.relayout();
+    }
+    function closePanel() {
+        if (!$panel) return;
+        $panel.remove();
+        $panel = null;
+        syncBarState();
+        if (host.relayout) host.relayout();
+    }
+
+    function section(title, key, $body) {
+        var open = OfficeApp.getSetting("fmtSection." + key, true);
+        var $s = $('<div class="sl-fp-section"></div>').toggleClass("closed", !open);
+        var $h = $('<div class="sl-fp-head"><i class="chevron down icon"></i><span></span></div>');
+        $h.find("span").text(title);
+        $h.on("click", function () {
+            var nowClosed = $s.toggleClass("closed").hasClass("closed");
+            OfficeApp.setSetting("fmtSection." + key, !nowClosed);
+        });
+        return $s.append($h).append($('<div class="sl-fp-body"></div>').append($body));
+    }
+
+    // numField builds a labelled number input that writes straight through
+    // to the model, committing on change
+    function numField(label, unit, get, set, opts) {
+        opts = opts || {};
+        var $w = $('<div class="sl-fp-field"></div>');
+        $w.append($("<label></label>").text(label));
+        var $row = $('<div class="sl-fp-inputrow"></div>');
+        var $i = $('<input type="number" class="sl-fp-num">')
+            .attr("step", opts.step || 1).val(get());
+        if (opts.min !== undefined) $i.attr("min", opts.min);
+        if (opts.max !== undefined) $i.attr("max", opts.max);
+        $i.on("change", function () { set(num($i.val(), get())); });
+        $row.append($i);
+        if (unit) $row.append($('<span class="sl-fp-unit"></span>').text(unit));
+        return $w.append($row);
+    }
+
+    function slider(label, get, set) {
+        var $w = $('<div class="sl-fp-field"></div>');
+        var $lab = $("<label></label>").text(label);
+        $w.append($lab);
+        var $i = $('<input type="range" class="sl-fp-range" min="-90" max="100" step="1">')
+            .val(Math.round(get() * 100));
+        var $val = $('<span class="sl-fp-unit"></span>').text(Math.round(get() * 100) + "%");
+        $i.on("input", function () { $val.text($i.val() + "%"); });
+        $i.on("change", function () { set(num($i.val(), 0) / 100); });
+        return $w.append($('<div class="sl-fp-inputrow"></div>').append($i).append($val));
+    }
+
+    function renderPanel() {
+        if (!$panel || !host) return;
+        $panel.empty();
+        var $head = $('<div class="sl-fp-title"><i class="sliders horizontal icon"></i>' +
+            "<span>Format options</span></div>");
+        $head.append($('<button type="button" class="sl-fp-close" title="Close">' +
+            '<i class="times icon"></i></button>').on("click", closePanel));
+        $panel.append($head);
+
+        var o = host.getImage();
+        if (!o) {
+            $panel.append('<div class="sl-fp-empty">Select a picture to see its ' +
+                "format options.</div>");
+            return;
+        }
+        var p = o.props;
+        // commit() calls back into sync(), which re-renders this panel,
+        // so reading the fresh values back is not this function's job
+        var apply = function () { host.commit(); };
+
+        /* ---- size and rotation ---- */
+        var $size = $("<div></div>");
+        var $grid = $('<div class="sl-fp-grid"></div>');
+        $grid.append(numField("Width", "px", function () { return Math.round(o.w); },
+            function (v) {
+                v = Math.max(4, v);
+                if (p.lockAspect && o.w > 0) o.h = Math.max(4, o.h * v / o.w);
+                o.w = v;
+                apply();
+            }));
+        $grid.append(numField("Height", "px", function () { return Math.round(o.h); },
+            function (v) {
+                v = Math.max(4, v);
+                if (p.lockAspect && o.h > 0) o.w = Math.max(4, o.w * v / o.h);
+                o.h = v;
+                apply();
+            }));
+        $size.append($grid);
+        var $lock = $('<label class="sl-fp-check"><input type="checkbox"> ' +
+            "<span>Lock aspect ratio</span></label>");
+        $lock.find("input").prop("checked", !!p.lockAspect).on("change", function () {
+            p.lockAspect = this.checked;
+            if (!p.lockAspect) delete p.lockAspect;
+            host.commit();
+        });
+        $size.append($lock);
+
+        var $rot = $('<div class="sl-fp-grid"></div>');
+        $rot.append(numField("Angle", "deg", function () { return Math.round(o.rot || 0); },
+            function (v) { o.rot = ((v % 360) + 360) % 360; apply(); }, { min: 0, max: 359 }));
+        var $flips = $('<div class="sl-fp-field"><label>Rotate / flip</label></div>');
+        var $frow = $('<div class="sl-fp-inputrow"></div>');
+        $frow.append(iconBtn("redo", "Rotate 90 degrees", function () {
+            o.rot = (((o.rot || 0) + 90) % 360); apply();
+        }));
+        $frow.append(iconBtn("arrows alternate horizontal", "Flip horizontally", function () {
+            p.flipH = !p.flipH; if (!p.flipH) delete p.flipH; apply();
+        }, !!p.flipH));
+        $frow.append(iconBtn("arrows alternate vertical", "Flip vertically", function () {
+            p.flipV = !p.flipV; if (!p.flipV) delete p.flipV; apply();
+        }, !!p.flipV));
+        $rot.append($flips.append($frow));
+        $size.append($rot);
+        $panel.append(section("Size and rotation", "size", $size));
+
+        /* ---- position and alignment ---- */
+        var $pos = $("<div></div>");
+        var $pgrid = $('<div class="sl-fp-grid"></div>');
+        $pgrid.append(numField("X", "px", function () { return Math.round(o.x); },
+            function (v) { o.x = v; apply(); }));
+        $pgrid.append(numField("Y", "px", function () { return Math.round(o.y); },
+            function (v) { o.y = v; apply(); }));
+        $pos.append($pgrid);
+        var $al = $('<div class="sl-fp-field"><label>Align to slide</label></div>');
+        var $arow = $('<div class="sl-fp-inputrow"></div>');
+        [["align left", "Left", function () { o.x = 0; }],
+         ["align center", "Centre", function () { o.x = (host.slideSize[0] - o.w) / 2; }],
+         ["align right", "Right", function () { o.x = host.slideSize[0] - o.w; }],
+         ["angle up", "Top", function () { o.y = 0; }],
+         ["minus", "Middle", function () { o.y = (host.slideSize[1] - o.h) / 2; }],
+         ["angle down", "Bottom", function () { o.y = host.slideSize[1] - o.h; }]
+        ].forEach(function (a) {
+            $arow.append(iconBtn(a[0], a[1], function () { a[2](); apply(); }));
+        });
+        $pos.append($al.append($arow));
+        $panel.append(section("Position", "pos", $pos));
+
+        /* ---- re-colour ---- */
+        var $rc = $('<div class="sl-fp-swatches"></div>');
+        RECOLORS.forEach(function (r) {
+            var $b = $('<button type="button" class="sl-fp-swatch"></button>').attr("title", r.label);
+            $b.append($('<img alt="">').attr("src", p.src || "").css("filter", r.filter || "none"));
+            $b.append($("<span></span>").text(r.label));
+            if ((p.recolor || "") === r.key) $b.addClass("active");
+            $b.on("click", function () {
+                if (r.key) p.recolor = r.key; else delete p.recolor;
+                apply();
+            });
+            $rc.append($b);
+        });
+        $panel.append(section("Re-colour", "recolor", $rc));
+
+        /* ---- adjustments ---- */
+        var $adj = $("<div></div>");
+        $adj.append(slider("Transparency", function () {
+            return p.opacity ? 1 - clamp(Number(p.opacity), 0, 1) : 0;
+        }, function (v) {
+            v = clamp(v, 0, 1);
+            if (v <= 0.001) delete p.opacity; else p.opacity = Math.round((1 - v) * 1000) / 1000;
+            apply();
+        }));
+        $adj.append(slider("Brightness", function () { return Number(p.bright) || 0; },
+            function (v) {
+                if (Math.abs(v) < 0.005) delete p.bright; else p.bright = v;
+                apply();
+            }));
+        $adj.append(slider("Contrast", function () { return Number(p.contrast) || 0; },
+            function (v) {
+                if (Math.abs(v) < 0.005) delete p.contrast; else p.contrast = v;
+                apply();
+            }));
+        $panel.append(section("Adjustments", "adjust", $adj));
+    }
+
+    function iconBtn(icon, title, fn, active) {
+        var $b = $('<button type="button" class="sl-fp-iconbtn" title="' + esc(title) + '">' +
+            '<i class="' + icon + ' icon"></i></button>');
+        if (active) $b.addClass("active");
+        $b.on("click", fn);
+        return $b;
+    }
+
+    /* ================= public ================= */
+    function init(h) {
+        host = h;
+        $(window).on("resize.slimagebar", function () { reposition(); });
+    }
+
+    return {
+        init: init,
+        sync: sync,
+        hide: function () { hideBar(); closeShapeMenu(); },
+        reposition: reposition,
+        syncState: syncBarState,
+        togglePanel: togglePanel,
+        panelOpen: function () { return !!$panel; },
+        showShapeMenu: showShapeMenu,
+        shapeIcon: shapeIcon,
+        imageFilter: imageFilter,
+        recolorFilter: function (key) { return recolorByKey(key).filter; },
+        recolors: RECOLORS
+    };
+})();