3 Revīzijas 54fbab9269 ... d6555567a2

Autors SHA1 Ziņojums Datums
  Toby Chui d6555567a2 Merge branch 'claude/modest-bhabha-12961e' into v3.0.4 1 nedēļu atpakaļ
  Toby Chui f44a0c8f50 Fix weighted face handling in Office text/PDF 1 nedēļu atpakaļ
  Toby Chui ac78b5e668 Enhance slide fidelity and shape adjustments 1 nedēļu atpakaļ

+ 6 - 0
src/mod/office/internal/fonts/fonts.go

@@ -80,6 +80,12 @@ func StackFor(latin, ea string) string {
 	return strings.Join(parts, ",")
 }
 
+// BulletFallback is the face a list marker falls back to when its own font
+// has no glyph for it: Arimo, the shipped metric twin of Arial, which is
+// what PowerPoint and Google Slides draw a missing ● ○ ■ in. The CJK
+// fallbacks behind it draw those shapes full-width, twice the size.
+const BulletFallback = "Arimo"
+
 // shippedFontFallbacks are the families in web/Office/common/fonts, in the
 // order fonts.js offers them to the browser. Keep the two lists in step.
 var shippedFontFallbacks = []string{

+ 4 - 0
src/mod/office/model/presentation.go

@@ -199,6 +199,10 @@ type Props struct {
 	// (adj1, adj2 ... in 1/100000 of the frame): where a callout's tip
 	// points, how round a corner is
 	Adj map[string]float64 `json:"adj,omitempty"`
+	// a text object that is a placeholder: "title", "subtitle" or "body".
+	// Empty, the editor shows its prompt ("Click to add title"); the
+	// .pptx writer makes it a <p:ph> again, so PowerPoint does too.
+	Ph string `json:"ph,omitempty"`
 	// elbow / rotated connectors imported from pptx: the polyline the
 	// connector actually follows, relative to the object's x/y. Absent on
 	// a plain two-point line, which is the only kind the editor draws.

+ 4 - 7
src/mod/office/odf/odp_reader.go

@@ -1137,14 +1137,11 @@ func odpRunCSS(st odpStyle) string {
 	if st.Font != "" {
 		sb.WriteString("font-family:" + fonts.StackFor(st.Font, st.Font) + ";")
 	}
-	weight := 0
+	// only fo:font-weight sets a weight; a weight word in the family name
+	// belongs to that face, not to the CJK text the run falls back for
+	// (see runCSS in the pptx reader)
 	if st.Bold {
-		weight = 700
-	} else if w := fonts.WeightOf(st.Font); w != 0 {
-		weight = w
-	}
-	if weight != 0 {
-		sb.WriteString("font-weight:" + strconv.Itoa(weight) + ";")
+		sb.WriteString("font-weight:700;")
 	}
 	if st.Italic {
 		sb.WriteString("font-style:italic;")

+ 29 - 0
src/mod/office/odf/odp_test.go

@@ -1,9 +1,38 @@
 package odf
 
 import (
+	"strings"
 	"testing"
 )
 
+// Only fo:font-weight makes a run heavy; a weight word in the family name
+// belongs to that face, not to the CJK text the run falls back for
+func TestOdpRunCSSWeight(t *testing.T) {
+	cases := []struct {
+		name       string
+		st         odpStyle
+		wantWeight string // "" when the run must carry no font-weight
+	}{
+		{"semibold face", odpStyle{SizePt: 24, Font: "Open Sans SemiBold"}, ""},
+		{"extrabold face", odpStyle{SizePt: 24, Font: "Noto Sans ExtraBold"}, ""},
+		{"plain face", odpStyle{SizePt: 24, Font: "Arial"}, ""},
+		{"bold on a plain face", odpStyle{SizePt: 24, Font: "Arial", Bold: true}, "font-weight:700;"},
+		{"bold on a semibold face", odpStyle{SizePt: 24, Font: "Open Sans SemiBold", Bold: true}, "font-weight:700;"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			css := odpRunCSS(c.st)
+			if c.wantWeight == "" {
+				if strings.Contains(css, "font-weight") {
+					t.Errorf("odpRunCSS = %q, want no font-weight", css)
+				}
+			} else if !strings.Contains(css, c.wantWeight) {
+				t.Errorf("odpRunCSS = %q, want %s", css, c.wantWeight)
+			}
+		})
+	}
+}
+
 // 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) {

+ 84 - 0
src/mod/office/pptx/fidelity_test.go

@@ -554,6 +554,49 @@ func TestPptxRoundPictureKeepsItsRadius(t *testing.T) {
 	}
 }
 
+// A shaped crop reshaped with its handles keeps the guides it was given;
+// one left at its defaults states none
+func TestPptxShapedCropKeepsItsAdjustments(t *testing.T) {
+	png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 32))
+	tests := []struct {
+		name string
+		mask string
+		adj  map[string]float64
+	}{
+		{"adjusted arrow", "rightArrow", map[string]float64{"adj1": 30000, "adj2": 80000}},
+		{"adjusted star", "star5", map[string]float64{"adj": 12000}},
+		{"hexagon at its defaults", "hexagon", nil},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			pres := &model.Presentation{Slides: []*model.Slide{{Objects: []*model.Object{{
+				Type: "image", X: 0, Y: 0, W: 200, H: 100, Z: 1,
+				Props: model.Props{Src: media.EncodeDataURL(png, "png"), Mask: tc.mask, Adj: tc.adj},
+			}}}}}
+			data, err := Build(pres)
+			if err != nil {
+				t.Fatalf("BuildPptx: %v", err)
+			}
+			got, err := Parse(data)
+			if err != nil {
+				t.Fatalf("ParsePptx: %v", err)
+			}
+			p := got.Slides[0].Objects[0].Props
+			if p.Mask != tc.mask {
+				t.Errorf("mask = %q, want %q", p.Mask, tc.mask)
+			}
+			if len(p.Adj) != len(tc.adj) {
+				t.Fatalf("adj = %v, want %v", p.Adj, tc.adj)
+			}
+			for k, v := range tc.adj {
+				if p.Adj[k] != v {
+					t.Errorf("adj[%s] = %v, want %v", k, p.Adj[k], v)
+				}
+			}
+		})
+	}
+}
+
 func TestPptxBentConnectorKeepsItsElbow(t *testing.T) {
 	cxn := `<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="2" name="c"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr>` +
 		`<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm>` +
@@ -788,3 +831,44 @@ func TestPresetGeometryFallback(t *testing.T) {
 		t.Errorf("empty kind -> %q, want rect", got)
 	}
 }
+
+// Placeholders go out as <p:ph> and come back with their role; an empty
+// one is kept (the editor shows its prompt) and is still a valid body
+func TestPptxPlaceholderRoles(t *testing.T) {
+	pres := &model.Presentation{Slides: []*model.Slide{{Objects: []*model.Object{
+		{Type: "text", X: 10, Y: 10, W: 400, H: 60, Z: 1, Props: model.Props{Ph: "title", FontSize: 40}},
+		{Type: "text", X: 10, Y: 80, W: 400, H: 40, Z: 2, Props: model.Props{Ph: "subtitle", HTML: "<div>Sub</div>", FontSize: 20}},
+		{Type: "text", X: 10, Y: 130, W: 400, H: 200, Z: 3, Props: model.Props{Ph: "body", FontSize: 18}},
+		{Type: "text", X: 10, Y: 340, W: 400, H: 40, Z: 4, Props: model.Props{HTML: "<div>Plain</div>", FontSize: 18}},
+	}}}}
+	data, err := Build(pres)
+	if err != nil {
+		t.Fatalf("BuildPptx: %v", err)
+	}
+	slide := packagePart(t, data, "ppt/slides/slide1.xml")
+	for _, w := range []string{`<p:ph type="title"/>`, `<p:ph type="subTitle"`, `<p:ph type="body"`, `txBox="1"`} {
+		if !strings.Contains(slide, w) {
+			t.Errorf("written slide lacks %s", w)
+		}
+	}
+	if strings.Contains(slide, `<a:lstStyle/></p:txBody>`) {
+		t.Errorf("an empty placeholder was written with no paragraph:\n%s", slide)
+	}
+	got, err := Parse(data)
+	if err != nil {
+		t.Fatalf("ParsePptx: %v", err)
+	}
+	objs := got.Slides[0].Objects
+	want := []string{"title", "subtitle", "body", ""}
+	if len(objs) != len(want) {
+		t.Fatalf("got %d objects, want %d", len(objs), len(want))
+	}
+	for i, w := range want {
+		if objs[i].Type != "text" || objs[i].Props.Ph != w {
+			t.Errorf("object %d: %s ph %q, want text ph %q", i, objs[i].Type, objs[i].Props.Ph, w)
+		}
+	}
+	if objs[0].Props.FontSize < 39 || objs[0].Props.FontSize > 41 {
+		t.Errorf("empty title came back at %vpx, want its 40px", objs[0].Props.FontSize)
+	}
+}

+ 60 - 1
src/mod/office/pptx/reader.go

@@ -746,6 +746,25 @@ func findPh(root *xmltree.Node, want phKey) *xmltree.Node {
 	return byIdx
 }
 
+// phRole is what a text placeholder is for, in the editor's terms (props.ph):
+// "title", "subtitle" or "body"; "" for anything else, a date or a slide
+// number placeholder included
+func phRole(node *xmltree.Node) string {
+	key, ok := phOf(node)
+	if !ok {
+		return ""
+	}
+	switch key.typ {
+	case "title", "ctrTitle":
+		return "title"
+	case "subTitle":
+		return "subtitle"
+	case "body", "obj":
+		return "body"
+	}
+	return ""
+}
+
 // phTypeAlike treats the title variants as one kind, the way PowerPoint does
 func phTypeAlike(a, b string) bool {
 	norm := func(s string) string {
@@ -1092,6 +1111,16 @@ func (sc *slideCtx) buildTextBody(tx *xmltree.Node, chain []*xmltree.Node, bp bo
 					bs.Latin, bs.EastAsian = firstRun.Latin, firstRun.EastAsian
 					bs.SizePt *= k
 				}
+				// a geometric bullet (● ○ ■) the bullet font has no glyph
+				// for is drawn by PowerPoint and Google Slides in Arial,
+				// where it is a small shape. Left to the shipped fallbacks
+				// it lands on the CJK face, whose full-width circle is twice
+				// that size - so Arimo (Arial's metric twin, shipped and
+				// embeddable in a PDF) comes right after the bullet font,
+				// ahead of any East Asian font the deck names
+				if ps.BuType == "char" {
+					bs.EastAsian = fonts.BulletFallback
+				}
 				bs.Underline, bs.Strike, bs.Highlight = false, false, ""
 				left := (ps.MarLeft + ps.Indent) * cm.kx
 				// the outer span is a line of the paragraph's own text -
@@ -1371,6 +1400,14 @@ func (sc *slideCtx) parseSp(node *xmltree.Node, cm coordMap) *model.Object {
 		layoutSp.Path("txBody", "bodyPr"),
 		node.Path("txBody", "bodyPr"),
 	}, cm)
+	// Google Slides writes spcFirstLastPara="1" on every shape it exports
+	// but never draws the first paragraph's space before (or the last
+	// one's space after) itself - its decks would open lower than they
+	// were made. PowerPoint does honour the flag, so only Google's own
+	// shapes are read the way Google draws them.
+	if isGoogleShape(node) {
+		bp.SpcFirstLast = false
+	}
 	tr := sc.buildTextBody(node.First("txBody"), chain, bp, cm)
 
 	kind, known := prstToShapeKind[prst]
@@ -1403,12 +1440,15 @@ func (sc *slideCtx) parseSp(node *xmltree.Node, cm coordMap) *model.Object {
 	}
 
 	if !isShape {
-		if tr.HTML == "" {
+		role := phRole(node)
+		// an empty placeholder is kept: the editor shows its prompt
+		if tr.HTML == "" && role == "" {
 			return nil
 		}
 		return &model.Object{
 			Type: "text", X: box.X, Y: box.Y, W: box.W, H: box.H, Rot: box.Rot,
 			Props: model.Props{
+				Ph:   role,
 				HTML: tr.HTML, FontSize: units.Round2(tr.FontSize), Color: tr.First.Color,
 				Align: tr.Align, Bold: tr.Bold, Italic: tr.Italic,
 				Underline: tr.Underline, FontFamily: tr.FontCSS,
@@ -1704,6 +1744,11 @@ func (sc *slideCtx) parsePic(node *xmltree.Node, cm coordMap) *model.Object {
 	// a picture drawn through a preset geometry is a shaped crop
 	if kind, ok := prstToShapeKind[prst]; ok && kind != "rect" {
 		props.Mask = kind
+		// the guides it states reshape it, when the editor draws it as
+		// itself (roundRect's corner is props.Radius instead)
+		if kind == prst && kind != "roundRect" {
+			props.Adj = statedAdj(spPr)
+		}
 	}
 	if rad := sc.cornerRadius(spPr, prst, box); rad > 0 {
 		props.Radius = units.Round2(rad)
@@ -2169,6 +2214,20 @@ func transitionOf(sld *xmltree.Node) string {
 	return ""
 }
 
+// isGoogleShape reports whether Google Slides made this shape: its export
+// names every one "Google Shape;<id>;<page>", and no other producer does
+func isGoogleShape(node *xmltree.Node) bool {
+	for i := range node.Nodes {
+		if strings.HasPrefix(node.Nodes[i].XMLName.Local, "nv") {
+			if c := node.Nodes[i].First("cNvPr"); c != nil {
+				return strings.HasPrefix(c.Attr("name"), "Google Shape;")
+			}
+			return false
+		}
+	}
+	return false
+}
+
 // withLink reads a shape's click action into props.link: a jump to another
 // slide becomes "#N", an external address stays as it is (http(s) only)
 func (sc *slideCtx) withLink(node *xmltree.Node, obj *model.Object) *model.Object {

+ 15 - 0
src/mod/office/pptx/render_test.go

@@ -289,6 +289,13 @@ func TestPptxBulletsAndNumbers(t *testing.T) {
 		{"an empty paragraph has no bullet",
 			`<a:p><a:pPr marL="342900" indent="-342900"><a:buChar char="&#8226;"/></a:pPr><a:endParaRPr sz="2000"/></a:p>`,
 			nil, []string{"•"}},
+		{"a geometric bullet falls back to Arial's twin, not the CJK face",
+			para(`<a:buFont typeface="Open Sans"/><a:buChar char="●"/>`, "one"),
+			[]string{"●", "font-family:'Open Sans',Arimo,"}, nil},
+		{"a Wingdings bullet skips the deck's East Asian font",
+			`<a:p><a:pPr marL="342900" indent="-342900"><a:buFont typeface="Wingdings"/><a:buChar char="l"/></a:pPr>` +
+				`<a:r><a:rPr sz="2000"><a:latin typeface="Arial"/><a:ea typeface="Microsoft JhengHei"/></a:rPr><a:t>one</a:t></a:r></a:p>`,
+			[]string{"font-family:Arial,Arimo,"}, nil},
 		{"a number is never set in a symbol font",
 			para(`<a:buFont typeface="Wingdings"/><a:buAutoNum type="arabicPeriod"/>`, "one"),
 			[]string{"1."}, []string{"Wingdings"}},
@@ -336,6 +343,14 @@ func TestPptxTextLayoutRules(t *testing.T) {
 				`<p:txBody><a:bodyPr spcFirstLastPara="1"/><a:lstStyle/>` +
 				`<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>a</a:t></a:r></a:p></p:txBody></p:sp>`,
 			[]string{"margin-top:8px"}, nil},
+		{"Google Slides draws no space before the first paragraph",
+			`<p:sp><p:nvSpPr><p:cNvPr id="56" name="Google Shape;56;p13"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
+				`<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="4000000" cy="2000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
+				`<p:txBody><a:bodyPr spcFirstLastPara="1"/><a:lstStyle/>` +
+				`<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>a</a:t></a:r></a:p>` +
+				`<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>b</a:t></a:r></a:p></p:txBody></p:sp>`,
+			// the second paragraph keeps its 6pt = 8px; the first has none
+			[]string{"margin-top:8px"}, []string{`<div style="text-align:left;line-height:1.2;margin-top:8px`}},
 		{"a percentage space is of the line",
 			textBox(0, 0, 4000000, 2000000, `<a:p><a:r><a:rPr sz="2000"/><a:t>a</a:t></a:r></a:p>`+
 				`<a:p><a:pPr><a:spcBef><a:spcPct val="20000"/></a:spcBef></a:pPr><a:r><a:rPr sz="2000"/><a:t>b</a:t></a:r></a:p>`),

+ 6 - 7
src/mod/office/pptx/text.go

@@ -265,14 +265,13 @@ func runCSS(rs runStyle, scale float64) string {
 	var sb strings.Builder
 	sb.WriteString("font-size:" + units.FmtPx(units.PtToPx(rs.SizePt)*scale) + ";")
 	sb.WriteString("font-family:" + fonts.StackFor(rs.Latin, rs.EastAsian) + ";")
-	weight := 0
+	// only b="1" sets a weight. A weight word in the Latin typeface ("Open
+	// Sans SemiBold") belongs to that face alone - PowerPoint draws the
+	// run's East Asian text at regular weight in the EA font - and the
+	// Latin glyphs already get the heavy face from the family name
+	// (fonts.js substitute())
 	if rs.Bold {
-		weight = 700
-	} else if w := fonts.WeightOf(rs.Latin); w != 0 {
-		weight = w
-	}
-	if weight != 0 {
-		sb.WriteString("font-weight:" + strconv.Itoa(weight) + ";")
+		sb.WriteString("font-weight:700;")
 	}
 	if rs.Italic {
 		sb.WriteString("font-style:italic;")

+ 48 - 0
src/mod/office/pptx/textsave_test.go

@@ -68,6 +68,8 @@ func TestPptxWriterRunWeight(t *testing.T) {
 	}{
 		// a SemiBold face at its own weight is the face, not bold on top
 		{"semibold face", "font-family:" + stackOpenSansSemi + ";font-weight:600;", `b="0"`},
+		// what the reader writes for a SemiBold face now: no weight at all
+		{"semibold face, no weight", "font-family:" + stackOpenSansSemi + ";", `b="0"`},
 		{"bold on a semibold face", "font-family:" + stackOpenSansSemi + ";font-weight:700;", `b="1"`},
 		{"600 on a plain face", "font-family:Arial;font-weight:600;", `b="1"`},
 		{"bold keyword", "font-family:Arial;font-weight:bold;", `b="1"`},
@@ -84,6 +86,52 @@ func TestPptxWriterRunWeight(t *testing.T) {
 	}
 }
 
+// The weight a Latin typeface name implies belongs to that face, not to
+// the run: the run's East Asian text is drawn at regular weight
+func TestRunCSSWeight(t *testing.T) {
+	cases := []struct {
+		name       string
+		rs         runStyle
+		wantWeight string // "" when the run must carry no font-weight
+	}{
+		{"semibold face", runStyle{SizePt: 24, Latin: "Open Sans SemiBold", EastAsian: "MS PGothic"}, ""},
+		{"black face", runStyle{SizePt: 24, Latin: "Lato Black"}, ""},
+		{"light face", runStyle{SizePt: 24, Latin: "Open Sans Light"}, ""},
+		{"plain face", runStyle{SizePt: 24, Latin: "Arial"}, ""},
+		{"b=1 on a plain face", runStyle{SizePt: 24, Latin: "Arial", Bold: true}, "font-weight:700;"},
+		{"b=1 on a semibold face", runStyle{SizePt: 24, Latin: "Open Sans SemiBold", Bold: true}, "font-weight:700;"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			css := runCSS(c.rs, 1)
+			if c.wantWeight == "" {
+				if strings.Contains(css, "font-weight") {
+					t.Errorf("runCSS = %q, want no font-weight", css)
+				}
+			} else if !strings.Contains(css, c.wantWeight) {
+				t.Errorf("runCSS = %q, want %s", css, c.wantWeight)
+			}
+		})
+	}
+}
+
+// A SemiBold deck saved by the suite reads back as the same face at the
+// same (unset) weight, so a round trip neither drops nor adds bold
+func TestPptxSemiBoldRoundTrip(t *testing.T) {
+	html := `<div><span style="font-size:24px;font-family:` + stackOpenSansSemi + `;">動手做一台 USB-KVM</span></div>`
+	slide, back := writeOneObject(t, textObj(html, nil))
+	if !strings.Contains(slide, `<a:latin typeface="Open Sans SemiBold"/>`) || !strings.Contains(slide, `b="0"`) {
+		t.Fatalf("saved run is not a non-bold SemiBold face:\n%s", slide)
+	}
+	got := back.Props.HTML
+	if strings.Contains(got, "font-weight") {
+		t.Errorf("read back with a weight: %s", got)
+	}
+	if !strings.Contains(got, "Open Sans SemiBold") {
+		t.Errorf("read back without the SemiBold face: %s", got)
+	}
+}
+
 func TestPptxWriterKeepsTypefaceCase(t *testing.T) {
 	html := `<div><span style="font-size:24px;font-family:'Open Sans Medium','Open Sans',sans-serif;">Hi</span></div>`
 	slide, _ := writeOneObject(t, textObj(html, nil))

+ 34 - 2
src/mod/office/pptx/writer.go

@@ -702,9 +702,16 @@ func buildRichBody(o *model.Object, fallbackColor string) string {
 		if para.End != nil {
 			// an empty line keeps its size and font (the reader sizes it by this)
 			sb.WriteString(runProps("a:endParaRPr", *para.End, base))
+		} else if len(para.Runs) == 0 {
+			// so does a box with nothing in it - an empty placeholder
+			sb.WriteString(runProps("a:endParaRPr", slidetext.Run{}, base))
 		}
 		sb.WriteString(`</a:p>`)
 	}
+	if len(paras) == 0 {
+		// a text body needs a paragraph, and the box's size rides on it
+		sb.WriteString(`<a:p>` + runProps("a:endParaRPr", slidetext.Run{}, base) + `</a:p>`)
+	}
 	return sb.String()
 }
 
@@ -785,6 +792,27 @@ func bodyPrFor(p model.Props, defaultAnchor string) string {
 	return `<a:bodyPr wrap="square" anchor="` + anchor + `" anchorCtr="0" spcFirstLastPara="1" ` + ins + `><a:noAutofit/></a:bodyPr>`
 }
 
+// textSpNv is a text object's non-visual properties: a plain text box, or
+// a placeholder of the kind props.ph names, so PowerPoint shows its own
+// prompt while it is empty. The layout this writer produces has no
+// placeholders to inherit from, so the shape keeps every look it states.
+func textSpNv(id int, role string) string {
+	ph, name := "", "TextBox"
+	switch role {
+	case "title":
+		ph, name = `<p:ph type="title"/>`, "Title"
+	case "subtitle":
+		ph, name = fmt.Sprintf(`<p:ph type="subTitle" idx="%d"/>`, id), "Subtitle"
+	case "body":
+		ph, name = fmt.Sprintf(`<p:ph type="body" idx="%d"/>`, id), "Text Placeholder"
+	}
+	if ph == "" {
+		return fmt.Sprintf(`<p:cNvPr id="%d" name="TextBox %d"/><p:cNvSpPr txBox="1"/><p:nvPr/>`, id, id)
+	}
+	return fmt.Sprintf(`<p:cNvPr id="%d" name="%s %d"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr>%s</p:nvPr>`,
+		id, name, id, ph)
+}
+
 func buildTextSp(id int, o *model.Object, theme string) string {
 	p := o.Props
 	color := p.Color
@@ -792,10 +820,10 @@ func buildTextSp(id int, o *model.Object, theme string) string {
 		color = "#" + themeTextColor(theme)
 	}
 	return fmt.Sprintf(
-		`<p:sp><p:nvSpPr><p:cNvPr id="%d" name="TextBox %d"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>`+
+		`<p:sp><p:nvSpPr>%s</p:nvSpPr>`+
 			`<p:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>`+
 			`<p:txBody>%s<a:lstStyle/>%s</p:txBody></p:sp>`,
-		id, id,
+		textSpNv(id, p.Ph),
 		xfrm(o.X, o.Y, o.W, o.H, o.Rot, false, false),
 		bodyPrFor(p, "t"),
 		buildRichBody(o, color))
@@ -971,6 +999,10 @@ func buildPicSp(id int, o *model.Object, rid string) string {
 	// a shaped crop is a preset geometry on the picture itself
 	prst := shapeKindPrst(p.Mask)
 	geom := `<a:prstGeom prst="` + prst + `"><a:avLst/></a:prstGeom>`
+	if prst == p.Mask && len(p.Adj) > 0 {
+		// the shape's handles were dragged: its guides, as for a shape
+		geom = `<a:prstGeom prst="` + prst + `">` + avLstXML(p.Adj) + `</a:prstGeom>`
+	}
 	if (prst == "roundRect" || p.Mask == "" || p.Mask == "rect") &&
 		p.Radius > 0 && o.W > 0 && o.H > 0 {
 		adj := int(p.Radius / minF(o.W, o.H) * 100000)

+ 68 - 9
src/web/Office/README.md

@@ -523,8 +523,33 @@ Three answers, tried in that order, per character (`resolveChar`):
    [the note there](common/fonts/README.md)) — fetched, parsed for glyph
    coverage, and embedded subset to the glyphs the deck actually used. This
    is what carries CJK.
-3. nothing covers it — emoji, a script we do not ship — and only then does
-   **that one text box** come in as a picture of itself.
+3. nothing covers it — emoji, a script we do not ship — and only then do
+   **those characters** come in as a picture of themselves, in their place
+   in the line (`planRun` / `rasterText` in `pdfcore.js`), drawn by the
+   page's own canvas so the picture is set in the page's fonts. The text
+   around them stays text. (This used to rasterize the whole text box or
+   table; see the next point for why that mattered so much.)
+
+Characters that draw nothing — zero-width spaces and joiners, direction
+marks, variation selectors, a soft hyphen (`ignorableCp`) — are never
+offered to a font at all. The importers write a zero-width space into every
+empty line to give it its height, so before this rule a text box or table
+with one blank line went to the PDF as a picture of itself, cropped to its
+stored frame.
+
+What the browser does to text *when it paints* has to be repeated, because
+the DOM does not show it: `text-transform` (an all-caps title is "Agenda"
+in its text node), `letter-spacing` (character spacing `Tc`, after every
+character as CSS adds it), and **synthetic bold and italic** for a shipped
+face without that style (the CJK faces ship Regular only). How much a
+browser emboldens or slants is its own choice — Chrome strokes the outline
+by 1/24 to 1/32 of the size and slants by 1/4, Firefox smears sideways —
+so `synthStyle` measures it on a canvas: the requested weight against
+regular-weight text filled and stroked with a growing pen (text rendering
+mode 2, what the PDF does) until the ink matches, and the slant from how
+far the ink's centre moves per unit of its height. In Chrome that lands on
+Skia's own figure at every size; a browser that does not synthesize
+measures as nothing, and the PDF follows it.
 
 A system font that is none of the above is stepped over rather than used,
 and the character lands on the shipped face the document's own font stack
@@ -547,8 +572,12 @@ Two things about placing the text that are easy to get wrong, and were:
   ascent below the top of that rect. Treating it as the line box puts every
   line a couple of pixels high at 30px type.
 
-When a box does have to be rasterized, the picture is taken through an SVG
-`<foreignObject>`, so the *browser* lays it out and paints it. html2canvas
+When a whole object does have to be rasterized (only a chart whose SVG the
+translator cannot express, now), the picture is taken through an SVG
+`<foreignObject>`, so the *browser* lays it out and paints it, over the
+area the object actually paints — its own box plus anything that overflows
+it (`paintedBoxOf`), since objects do not clip. An SVG image cannot use the
+page's web fonts, which is why text never goes this way any more. html2canvas
 was tried first and is wrong for this: it re-implements layout over a
 clone, and on mixed CJK/Latin text with `pre-wrap` it breaks lines
 somewhere the browser did not — exactly the drift this rework removes. It
@@ -652,12 +681,24 @@ one, the label under it (a separate button) opens `showLayoutPicker()` — every
 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`),
+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.
 
+**A layout's text boxes are placeholders, as in Google Slides.** `newPh()`
+makes them empty, with `props.ph` saying what they are for (`title`,
+`subtitle` or `body`). Any empty text box - a placeholder, a text box left
+empty, an emptied one, an empty placeholder from a `.pptx` - shows a prompt
+("Click to add title", `PH_PROMPTS`) in its own text style and a thin grey
+frame (`isBlankText()` decides, `renderObjectEl` adds `.sl-empty` and a
+`.sl-ph` sibling of the text). A single click on one goes straight into
+editing. Only surfaces marked `.sl-showph` show the prompt (the canvas, the
+rail, the slide sorter, the layout picker) and `.sl-phframe` the frame (the
+canvas and the picker), so present mode, print and every export draw an
+empty box as nothing. The `.pptx` writer turns `ph` back into a `<p:ph>`
+(an empty one keeps its size on an `a:endParaRPr`), so PowerPoint prompts
+in it too, and the reader sets `ph` from the placeholders it imports.
+
 **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
@@ -695,8 +736,26 @@ ends. A callout made before adjustments existed has no `adj` and keeps its old
 drawing (body in the top 72%, tip at the bottom) until its handle is first
 dragged, when the frame shrinks to the body and the tip is stated where it
 already was (`adoptAdjustments`); the `.pptx` writer converts it the same way
-([`mod/office/pptx/adjust.go`](../../mod/office/pptx/adjust.go)). Other presets
-still draw at their default adjustments.
+([`mod/office/pptx/adjust.go`](../../mod/office/pptx/adjust.go)).
+
+**Every preset with a feature to tune has its handles too** - one yellow
+diamond per adjustment (`adjHandles` in `slides.js`, geometry in
+`SlidesShapes.handles` / `dragHandle`): the snipped and rounded rectangles,
+the polygons PowerPoint adjusts, frame, half frame, L shape, diagonal stripe,
+plaque, can, cube, bevel, donut, "no" symbol, teardrop, moon, sun, smiley,
+folded corner, pie / chord / arc / block arc (their angles, round the
+ellipse), every block, bent, U-turn, curved, circular and striped arrow,
+the arrow callouts, the stars, the waves, the brackets and braces and the
+equation shapes. Those all draw to PowerPoint's own formulas
+(`presetShapeDefinitions`), angles and `arcTo` included, so a reshaped
+shape opens in PowerPoint as it looks here. Only the ribbons and the
+scrolls still draw at fixed proportions. A picture cropped to one of those
+shapes gets the same handles - its `props.adj` belongs to its `mask`, goes
+into the clip path and the PDF clip, and is written to the picture's
+`prstGeom` - and one cropped to a rounded rectangle (or imported with
+rounded corners) gets the radius handle. Dragging one redraws only that
+object (`refreshObjEl`). Every object the pointer would move when dragged
+shows the move cursor; an object being edited shows the caret instead.
 
 ### Slides: the shape catalogue
 

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

@@ -819,7 +819,8 @@ back to the stylesheet, so nothing here changes how a new deck looks.
 | `fill` | shape, image | may be `#rrggbbaa` (a fill with alpha); on an image it is painted behind the picture |
 | `fillGrad` | shape, image | a gradient fill `{kind, angle, cx, cy, stops}` (`fill` then holds its first colour) |
 | `geom` | shape (`kind: "custom"`) | an imported freeform: `{paths: [{w, h, d, noFill, noStroke}]}`, M/L/C/Z in each path's own space |
-| `adj` | shape | the preset's guides, for the presets drawn to PowerPoint's formulas |
+| `adj` | shape, image | the preset's guides, for the presets drawn to PowerPoint's formulas; on an image, those of its `mask` (written to the picture's `prstGeom`) |
+| `ph` | text | a placeholder: `title`, `subtitle` or `body`. Empty, the canvas prompts in it; written to and read from .pptx as `<p:ph>` |
 
 A slide may carry `bgImage` (`{src, x, y, w, h, tile, opacity}`, placed in
 slide px) or `bgGrad` (as `fillGrad`) besides `bg`. They are painted by the
@@ -855,14 +856,15 @@ toolbar's crop button) the object itself is hidden — `.sl-obj.sl-cropping` —
 and `#slCrop` draws the whole picture ghosted with the kept part at full
 strength on top. Dragging a grip moves the *frame*; dragging the picture
 moves the *source underneath it*. Enter or a click outside applies, Esc
-restores. **Reset image** clears `crop`, `mask` and `radius`, puts the frame
+restores. **Reset image** clears `crop`, `mask`, `adj` and `radius`, puts the frame
 back to `props.orig` (stamped the first time a picture is trimmed) and
 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 `SlidesShapes.points()` - stated in percentages, so
-the clip follows the frame as it is dragged. That is why the crop shapes are
+a `clip-path` built from `SlidesShapes.points()` at the frame's own size and
+with the picture's `adj` - 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.
 
@@ -971,8 +973,10 @@ geometry.
 ```js
 SlidesShapes.path(kind, w, h, adj) // "M0 0L100 0..." ("" if unknown)
 SlidesShapes.customPath(geom, w, h, which) // a "custom" shape's paths, stretched over w x h
-SlidesShapes.detail(kind, w, h)    // markings to stroke, or ""
-SlidesShapes.points(kind, w, h)    // polygon corners, or null for a curved one
+SlidesShapes.detail(kind, w, h, adj) // markings to stroke, or ""
+SlidesShapes.points(kind, w, h, adj) // polygon corners, or null for a curved one
+SlidesShapes.handles(kind, w, h, adj)  // [[x, y], ...] its adjustment handles
+SlidesShapes.dragHandle(kind, i, w, h, x, y, adj) // every guide, with handle i at (x, y)
 SlidesShapes.icon(kind, size)      // the same outline as a small SVG
 SlidesShapes.canonical(kind)       // preset spelling -> catalogue name
 SlidesShapes.isOpen / evenOdd / label / defaultSize / CATEGORIES
@@ -985,10 +989,27 @@ Keep the names in step with `prstToShapeKind` (`mod/office/pptx/reader.go`).
 The common presets follow PowerPoint's own formulas (`presetShapeDefinitions`):
 their features are sized from the shorter side and read the guides a deck
 states (`av(adj, "adj", default)`), so a long flat chevron keeps a shallow
-point and a deck's own `<a:avLst>` is honoured - the triangle, parallelogram,
-trapezoid, hexagon, octagon, cross, the block arrows, chevron, pentagon arrow
-and can so far. A geometry function is called as `pts(w, h, adj)` /
-`path(w, h, adj)`; one that ignores `adj` draws at its defaults.
+point and a deck's own `<a:avLst>` is honoured - every preset that has
+adjustments except the ribbons and scrolls. Angle guides (pie,
+chord, arc, block arc, circular arrow) are 60000ths of a degree, clockwise
+from three o'clock, and *visual*: `Path.arcTo(wR, hR, stAng, swAng)` is
+PresentationML's own arc, continuing from the pen, and converts them to the
+ellipse's parameter itself. A geometry function is called as `pts(w, h, adj)` /
+`path(w, h, adj)` (and `detail(w, h, adj)`); one that ignores `adj` draws
+at its defaults.
+
+An entry with `adj: { defaults, handles }` can be reshaped on the canvas:
+each handle is `{ pos(w, h, a), set(w, h, x, y) }` - where its yellow
+diamond sits for the resolved guides `a`, and the guides that put it at a
+dragged point. `ssLen()` builds the usual one (a length in the shorter side
+along one axis), `share()` a share of one side, `polar()` an angle on the
+frame's ellipse, and `fit()` any other: it only needs the handle's position
+and finds the guide value that puts it nearest the pointer, which is how the
+curved arrows, braces and arrow callouts (guides with no simple inverse) get
+theirs. The defaults must
+be PowerPoint's, because `dragHandle` writes every guide and the `.pptx`
+writer passes them on as they are; the stars are the one exception (they
+keep the editor's older, rounder default until a handle is dragged).
 
 `ALIASES` holds the three names the editor used before this file existed
 (`round`, `arrow`, `star`). They are not shapes any more - `normalizeBody()`

+ 8 - 5
src/web/Office/common/fonts.js

@@ -50,8 +50,9 @@ var OfficeFonts = (function () {
 
     /* The shipped families. The CJK faces carry Regular only: bold is
        synthesized, by the browser on screen and by a stroked text rendering
-       mode in the PDF, which is the same smear without the advance widths
-       moving - and saves shipping four more multi-megabyte files. */
+       mode in the PDF (as wide as the browser's own stroke, measured by
+       OfficePdfCore.synthStyle), which is the same smear without the advance
+       widths moving - and saves shipping four more multi-megabyte files. */
     var SHIPPED = {
         "noto sans": {
             family: "Noto Sans",
@@ -687,7 +688,7 @@ var OfficeFonts = (function () {
                     finalLoads.push(STYLE_DESC[s2].css + ' 16px "' + pl.name.replace(/["\\]/g, "") + '"');
                 }
                 substituted[pl.key] = { via: pl.via || "Noto Sans", adjust: adj, kind: pl.kind,
-                    twin: !!(pl.twin || pl.exact) };
+                    twin: !!(pl.twin || pl.exact), heavy: !!pl.heavy };
             });
             probeEl.parentNode.removeChild(probeEl);
             if (!styleEl) {
@@ -723,8 +724,10 @@ var OfficeFonts = (function () {
 
     /* the stand-in a family was given, for exporters that draw text
        themselves: { via: "Arial", adjust: [r, b, i, bi], kind: "sans",
-       twin: false } or null. twin: via is a shipped metric twin, which an
-       exporter can embed in place of the original. */
+       twin: false, heavy: false } or null. twin: via is a shipped metric
+       twin, which an exporter can embed in place of the original. heavy:
+       a weighted name ("Open Sans SemiBold") drawn from via's bold file in
+       every style, whatever weight the text asks for. */
     function substituteOf(family) {
         return substituted[norm(family)] || null;
     }

+ 6 - 2
src/web/Office/common/fonts/README.md

@@ -104,8 +104,12 @@ Latin-only document never pulls the CJK files.
 Only the Latin family ships bold and italic. The CJK faces ship Regular and
 let bold be synthesized — the browser smears the outline without moving the
 advance widths, and the exporter does the same with a stroked text rendering
-mode, so the two still agree. Real bold CJK would double an already large
-payload for a difference neither side can see once it is on the page.
+mode, so the two still agree. How far it smears differs between browsers, so
+the exporter measures the stroke against the browser's own synthetic bold on
+a canvas (`synthStyle` in `../pdfcore.js`) rather than assuming one; a
+synthetic italic is measured and slanted the same way. Real bold CJK would
+double an already large payload for a difference neither side can see once
+it is on the page.
 
 ## Three constraints on any replacement
 

+ 2 - 1
src/web/Office/common/fonts/fonts.css

@@ -29,7 +29,8 @@
     Weights: the Latin family ships all four styles. The CJK faces ship
     Regular only and let bold be synthesized - the browser smears the outline
     without changing the advance, and the PDF exporter does the same with a
-    stroked text rendering mode, so the two still agree. Shipping real bold
+    stroked text rendering mode, as wide as the browser's own stroke
+    (measured, see ../pdfcore.js), so the two still agree. Shipping real bold
     CJK would double an already large payload for a difference neither side
     can see once it is on the page.
 */

+ 388 - 38
src/web/Office/common/pdfcore.js

@@ -50,6 +50,48 @@ var OfficePdfCore = (function () {
     function winAnsiCp(cp) {
         return cp >= 32 && cp <= 255;
     }
+
+    /* Characters that draw nothing: zero-width spaces and joiners,
+       direction marks, variation selectors, the byte order mark, a soft
+       hyphen. The editors write a zero-width space into every empty line
+       to give it its height, so these are everywhere - and offering one to
+       the font stack found no face with a glyph for it, which used to send
+       the whole text box or table to a picture. They are simply not drawn. */
+    function ignorableCp(cp) {
+        return cp === 0xAD || cp === 0x34F || cp === 0x61C ||
+            (cp >= 0x180B && cp <= 0x180F) || (cp >= 0x200B && cp <= 0x200F) ||
+            (cp >= 0x202A && cp <= 0x202E) || (cp >= 0x2060 && cp <= 0x206F) ||
+            (cp >= 0xFE00 && cp <= 0xFE0F) || cp === 0xFEFF ||
+            (cp >= 0xE0000 && cp <= 0xE0FFF);
+    }
+    // visibleText is the text with the characters that draw nothing removed
+    function visibleText(text) {
+        var out = "";
+        for (var i = 0; i < text.length; i++) {
+            var cp = text.codePointAt(i);
+            if (cp > 0xFFFF) i++;
+            if (!ignorableCp(cp)) out += String.fromCodePoint(cp);
+        }
+        return out;
+    }
+
+    /* text-transform is applied by the browser when it paints, so the text
+       node still holds what was typed: an all-caps title says "Agenda" in
+       the DOM and AGENDA on the slide. The PDF has to apply it the same way. */
+    var capitalizeRe = null;
+    try {
+        // a word starts after anything that is not a letter, a digit or an
+        // apostrophe ("don't" stays one word)
+        capitalizeRe = new RegExp("(^|[^\\p{L}\\p{N}'\\u2019])(\\p{Ll})", "gu");
+    } catch (e) { /* no Unicode property escapes: capitalize is left as typed */ }
+    function transformText(text, tt) {
+        if (tt === "uppercase") return text.toUpperCase();
+        if (tt === "lowercase") return text.toLowerCase();
+        if (tt === "capitalize" && capitalizeRe) {
+            return text.replace(capitalizeRe, function (m, a, b) { return a + b.toUpperCase(); });
+        }
+        return text;
+    }
     /* haveFamily reports whether a family is actually installed. It has to
        be measured: document.fonts.check() answers "is it loaded", and for a
        local family Chrome says yes whatever name you give it. The reliable
@@ -237,6 +279,10 @@ var OfficePdfCore = (function () {
                       the answer is not known until it is
          null         nothing here can show this character
 
+       bold: true on any of them means the face is bold whatever the run
+       asks for: a weighted family name ("Open Sans SemiBold") that the
+       screen draws from its base family's bold file (OfficeFonts.substitute).
+
        A family that is neither - a system font - is stepped over rather
        than used: its bytes are unreadable, so the character goes to the
        next entry, which is the shipped face for its script. */
@@ -264,18 +310,19 @@ var OfficePdfCore = (function () {
             var sub = OfficeFonts.substituteOf ? OfficeFonts.substituteOf(name) : null;
             // a metric twin the suite ships stood in on screen: the PDF
             // embeds that same face
+            var sb = !!(sub && sub.heavy);
             if (sub && sub.twin) {
-                var tw = fonts.shipped(sub.via, bold, italic);
+                var tw = fonts.shipped(sub.via, bold || sb, italic);
                 if (!tw) {
-                    if (!fonts.tried(sub.via, bold, italic)) return { need: sub.via };
+                    if (!fonts.tried(sub.via, bold || sb, italic)) return { need: sub.via, bold: sb };
                 } else if (!(tw.kit && tw.kit.hasGlyphForCodePoint && !tw.kit.hasGlyphForCodePoint(cp))) {
-                    return { shipped: sub.via };
+                    return { shipped: sub.via, bold: sb };
                 }
             }
             if (sub && winAnsiCp(cp)) {
                 var std = STD_FAMILIES[String(sub.via).toLowerCase()] ||
                     { serif: "TimesRoman", mono: "Courier" }[sub.kind] || "Helvetica";
-                return { std: std };
+                return { std: std, bold: sb };
             }
         }
         return null;
@@ -283,26 +330,36 @@ var OfficePdfCore = (function () {
 
     /* segmentText cuts a fragment into the pieces that share one font, the
        way a browser does per character. Returns null when any character has
-       nowhere to go, which is the signal to rasterize instead. */
+       nowhere to go, which is the signal to rasterize instead; an empty list
+       when nothing in it draws anything. */
     function segmentText(text, names, fonts, bold, italic) {
         var segs = [], cur = null;
         for (var i = 0; i < text.length; i++) {
             var cp = text.codePointAt(i);
             var ch = String.fromCodePoint(cp);
             if (ch.length > 1) i++;          // a surrogate pair
+            if (ignorableCp(cp)) continue;
             var res = resolveChar(cp, names, fonts, bold, italic);
             if (!res || res.need) return null;
-            var key = res.std ? "s:" + res.std : "f:" + res.shipped;
+            var key = (res.std ? "s:" + res.std : "f:" + res.shipped) + (res.bold ? ":b" : "");
             if (cur && cur.key === key) cur.text += ch;
             else { cur = { key: key, res: res, text: ch }; segs.push(cur); }
         }
         return segs;
     }
 
+    // faceOf: the font a resolved piece is drawn in, and what of its style
+    // has to be synthesized (family is the name the page declared it under)
     function faceOf(res, fonts, bold, italic) {
-        if (res.std) return { font: fonts.std(res.std, bold, italic), synthBold: false };
+        bold = bold || !!res.bold;
+        if (res.std) return { font: fonts.std(res.std, bold, italic), synthBold: false, synthItalic: false };
         var rec = fonts.shipped(res.shipped, bold, italic);
-        return rec ? { font: rec.font, synthBold: rec.synthBold } : null;
+        if (!rec) return null;
+        var face = OfficeFonts.faceFor(res.shipped, bold, italic);
+        return {
+            font: rec.font, synthBold: rec.synthBold, synthItalic: rec.synthItalic,
+            family: face ? face.family : res.shipped
+        };
     }
 
     /* Where the baseline sits is the browser's decision, and the exporter
@@ -340,6 +397,162 @@ var OfficePdfCore = (function () {
         return m;
     }
 
+    /* ---------------- synthetic bold and italic ----------------
+
+       A face the suite ships without a bold (the CJK ones) or an italic is
+       emboldened or slanted by the browser, and how much is the browser's
+       business: Chrome strokes the outline by 1/24 to 1/32 of the size,
+       Firefox smears it sideways, other engines differ again. A fixed
+       stroke in the PDF was therefore heavier than the screen in one
+       browser and lighter in the next, so the amount is measured rather
+       than assumed.
+
+       Bold: the run's text is drawn on a canvas in the weight it asked for
+       (which the browser synthesizes exactly as it does on the page) and in
+       regular weight filled and stroked with a growing pen - the very thing
+       the PDF does with text rendering mode 2 - until the two carry the
+       same ink. Both drawings go through the same rasterizer, so its
+       anti-aliasing cancels out; in Chrome this lands on Skia's own figure
+       to the hundredth of a pixel at every size.
+
+       Italic: the slant moves every bit of ink sideways in proportion to
+       its height above the baseline, so the shift of the ink's centre over
+       the height of that centre is the slant. A browser that does not
+       synthesize at all measures as no stroke and no slant, and the PDF
+       follows it. */
+    var SYNTH_K = 2;                 // device pixels per css px while measuring
+    var synthCache = {};
+    var synthCtx = null;
+    function synthCanvas(w, h) {
+        if (!synthCtx) {
+            var c = document.createElement("canvas");
+            synthCtx = c.getContext("2d", { willReadFrequently: true });
+        }
+        var cv = synthCtx.canvas;
+        if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
+        return synthCtx;
+    }
+    function quoteFamily(f) {
+        return '"' + String(f).replace(/["\\]/g, "") + '"';
+    }
+    // skiaBold is Chrome's own figure, used when nothing can be measured
+    function skiaBold(size) {
+        var t = clamp((size - 9) / 27, 0, 1);
+        return size * (1 / 24 + (1 / 32 - 1 / 24) * t);
+    }
+    function inkStats(g, w, h) {
+        var d = g.getImageData(0, 0, w, h).data;
+        var sum = 0, sx = 0, sy = 0;
+        for (var i = 3, p = 0; i < d.length; i += 4, p++) {
+            var a = d[i];
+            if (!a) continue;
+            sum += a;
+            sx += a * (p % w);
+            sy += a * ((p / w) | 0);
+        }
+        return { ink: sum / 255, cx: sum ? sx / sum : 0, cy: sum ? sy / sum : 0 };
+    }
+
+    /* synthStyle answers how much the browser emboldens (stroke width, css
+       px) and slants (horizontal shift per unit of height) one face at one
+       size, for the faces faceFor() says are synthesized. sample is text
+       set in that face: what is measured is the same glyphs. */
+    function synthStyle(family, weight, italic, sizePx, sample, wantBold, wantItalic) {
+        var out = { bold: 0, skew: 0 };
+        var text = visibleText(String(sample || "")).replace(/\s+/g, "").substring(0, 12);
+        weight = parseInt(weight, 10) || (wantBold ? 700 : 400);
+        if (wantBold) out.bold = measureBold(family, weight, italic, sizePx, text);
+        if (wantItalic) out.skew = measureSkew(family, weight, text);
+        return out;
+    }
+    function measureBold(family, weight, italic, size, text) {
+        var key = "b|" + family + "|" + weight + "|" + (italic ? 1 : 0) + "|" + size.toFixed(2);
+        if (synthCache[key] !== undefined) return synthCache[key];
+        var w = skiaBold(size);
+        try {
+            if (!text) throw new Error("nothing to measure");
+            var style = italic ? "italic " : "";
+            var fam = quoteFamily(family);
+            var g = synthCanvas(1, 1);
+            g.setTransform(1, 0, 0, 1, 0, 0);
+            g.font = style + "400 " + size + "px " + fam;
+            var tw = g.measureText(text).width;
+            var W = Math.ceil((tw + size * 1.5) * SYNTH_K), H = Math.ceil(size * 2.2 * SYNTH_K);
+            // a measurement this large is not worth its cost; Chrome's
+            // figure is proportional above 36px, so measure smaller and scale
+            var scale = 1;
+            if (W * H > 1600000) {
+                scale = Math.sqrt(1600000 / (W * H));
+                W = Math.ceil(W * scale); H = Math.ceil(H * scale);
+            }
+            var k = SYNTH_K * scale;
+            g = synthCanvas(W, H);
+            var draw = function (wt, pen) {
+                g.setTransform(1, 0, 0, 1, 0, 0);
+                g.clearRect(0, 0, W, H);
+                g.setTransform(k, 0, 0, k, 0, 0);
+                g.font = style + wt + " " + size + "px " + fam;
+                g.fillStyle = "#000";
+                g.textBaseline = "alphabetic";
+                g.fillText(text, size * 0.5, size * 1.5);
+                if (pen > 0) {
+                    g.strokeStyle = "#000";
+                    g.lineWidth = pen;
+                    g.lineJoin = "miter";
+                    g.miterLimit = 10;
+                    g.strokeText(text, size * 0.5, size * 1.5);
+                }
+                return inkStats(g, W, H).ink;
+            };
+            var base = draw(400, 0);
+            var target = draw(weight, 0);
+            if (!(base > 0)) throw new Error("no ink");
+            if (target <= base * 1.002) {
+                w = 0;                       // this browser does not embolden
+            } else {
+                var lo = 0, hi = size / 6;
+                for (var it = 0; it < 14; it++) {
+                    var mid = (lo + hi) / 2;
+                    if (draw(400, mid) < target) lo = mid; else hi = mid;
+                }
+                w = (lo + hi) / 2;
+            }
+        } catch (e) { /* keep Chrome's figure */ }
+        synthCache[key] = w;
+        return w;
+    }
+    function measureSkew(family, weight, text) {
+        var key = "i|" + family + "|" + weight;
+        if (synthCache[key] !== undefined) return synthCache[key];
+        var s = 0.25;                       // Chrome's and Safari's slant
+        try {
+            if (!text) throw new Error("nothing to measure");
+            var size = 64;
+            var fam = quoteFamily(family);
+            var g = synthCanvas(1, 1);
+            g.setTransform(1, 0, 0, 1, 0, 0);
+            g.font = weight + " " + size + "px " + fam;
+            var W = Math.ceil(g.measureText(text).width + size * 2), H = Math.ceil(size * 2);
+            var baseY = size * 1.4;
+            g = synthCanvas(W, H);
+            var draw = function (style) {
+                g.setTransform(1, 0, 0, 1, 0, 0);
+                g.clearRect(0, 0, W, H);
+                g.font = style + weight + " " + size + "px " + fam;
+                g.fillStyle = "#000";
+                g.textBaseline = "alphabetic";
+                g.fillText(text, size * 0.5, baseY);
+                return inkStats(g, W, H);
+            };
+            var up = draw(""), it = draw("italic ");
+            var rise = baseY - up.cy;
+            if (!(up.ink > 0) || !(rise > 1)) throw new Error("no ink");
+            s = clamp((it.cx - up.cx) / rise, 0, 0.5);
+        } catch (e) { /* keep the usual slant */ }
+        synthCache[key] = s;
+        return s;
+    }
+
     /* eachTextNode is the walk both the font pre-pass and the run collector
        make, kept in one place so they cannot disagree about what counts as
        text on the slide. */
@@ -348,7 +561,7 @@ var OfficePdfCore = (function () {
         var node;
         while ((node = walker.nextNode())) {
             var text = node.nodeValue;
-            if (!text || !text.trim()) continue;
+            if (!text || !visibleText(text).trim()) continue;
             var parent = node.parentElement;
             if (!parent) continue;
             var cs = window.getComputedStyle(parent);
@@ -485,15 +698,20 @@ var OfficePdfCore = (function () {
             range.selectNodeContents(node);
             var rects = Array.prototype.slice.call(range.getClientRects());
             if (!rects.length) return;
+            var ls = parseFloat(cs.letterSpacing);
             var shared = {
                 origin: origin,
+                node: node,
                 names: familyList(cs.fontFamily),
+                family: cs.fontFamily,
                 metrics: fontMetricsOf(cs),
                 size: parseFloat(cs.fontSize) || 12,
                 weight: parseInt(cs.fontWeight, 10) || (cs.fontWeight === "bold" ? 700 : 400),
                 italic: cs.fontStyle === "italic" || cs.fontStyle === "oblique",
                 underline: cs.textDecorationLine.indexOf("underline") >= 0,
                 strike: cs.textDecorationLine.indexOf("line-through") >= 0,
+                transform: cs.textTransform,
+                letterSpacing: isFinite(ls) ? ls : 0,
                 color: cs.color,
                 background: cs.backgroundColor
             };
@@ -501,6 +719,7 @@ var OfficePdfCore = (function () {
                 var r = Object.create(shared);
                 r.text = ln.text;
                 r.rect = ln.rect;
+                r.start = ln.start;
                 runs.push(r);
             });
         });
@@ -510,14 +729,16 @@ var OfficePdfCore = (function () {
     /* splitByLine maps a text node's client rects back onto the substrings
        that produced them, so each line can be drawn at its own baseline.
        Character-by-character is the only reliable way: the browser decides
-       where the break went, and only it knows. */
+       where the break went, and only it knows. start is where each line's
+       text begins in the node. */
     function splitByLine(node, text, lineRects) {
         if (lineRects.length === 1) {
-            return [{ text: text, rect: lineRects[0] }];
+            return [{ text: text, rect: lineRects[0], start: 0 }];
         }
         var range = document.createRange();
         var out = [];
         var cur = "";
+        var curStart = 0;
         var curTop = null;
         var curRect = null;
         for (var i = 0; i < text.length; i++) {
@@ -531,14 +752,15 @@ var OfficePdfCore = (function () {
                 else { curRect.right = Math.max(curRect.right, r.right); }
                 cur += text[i];
             } else {
-                out.push({ text: cur, rect: curRect });
+                out.push({ text: cur, rect: curRect, start: curStart });
                 cur = text[i];
+                curStart = i;
                 curTop = top;
                 curRect = { left: r.left, top: r.top, bottom: r.bottom, right: r.right };
             }
         }
-        if (cur !== "" && curRect) out.push({ text: cur, rect: curRect });
-        return out.length ? out : [{ text: text, rect: lineRects[0] }];
+        if (cur !== "" && curRect) out.push({ text: cur, rect: curRect, start: curStart });
+        return out.length ? out : [{ text: text, rect: lineRects[0], start: 0 }];
     }
 
     // canDrawAsText is the whole fallback decision, in one place: every
@@ -546,11 +768,102 @@ var OfficePdfCore = (function () {
     function canDrawAsText(runs, fonts) {
         for (var i = 0; i < runs.length; i++) {
             var r = runs[i];
-            if (!segmentText(r.text, r.names, fonts, r.weight >= 600, r.italic)) return false;
+            if (!segmentText(transformText(r.text, r.transform), r.names, fonts, r.weight >= 600, r.italic)) return false;
         }
         return true;
     }
 
+    /* planRun decides how one line fragment is drawn: as text wherever a
+       font can show it, and as a picture only for the characters nothing
+       can (an emoji, a script the suite does not ship). Each piece comes
+       back with its place in the line, measured off the DOM, so a picture
+       in the middle of a sentence lands between the words around it:
+           [{ text, dx, w, fit, raster }]
+       dx is from the run's left edge; fit is the width to fit text to, 0
+       when the piece begins or ends on a space (the browser collapses
+       those, the measurement does not). */
+    function planRun(r, fonts) {
+        var bold = r.weight >= 600;
+        var whole = r.rect.right - r.rect.left;
+        var shown = transformText(r.text, r.transform);
+        if (segmentText(shown, r.names, fonts, bold, r.italic)) {
+            return [{ text: shown, dx: 0, w: whole, fit: /^\s|\s$/.test(r.text) ? 0 : whole, raster: false }];
+        }
+        // cut where drawability changes; a character that draws nothing
+        // (a joiner inside an emoji) stays with what is around it
+        var parts = [], cur = null;
+        for (var i = 0; i < r.text.length;) {
+            var cp = r.text.codePointAt(i), n = cp > 0xFFFF ? 2 : 1;
+            var ok;
+            if (ignorableCp(cp)) {
+                ok = cur ? cur.ok : true;
+            } else {
+                var shownCp = transformText(String.fromCodePoint(cp), r.transform).codePointAt(0);
+                var res = resolveChar(shownCp, r.names, fonts, bold, r.italic);
+                ok = !!(res && !res.need);
+            }
+            if (cur && cur.ok === ok) cur.end = i + n;
+            else { cur = { ok: ok, start: i, end: i + n }; parts.push(cur); }
+            i += n;
+        }
+        /* Each piece is measured against the whole line at the same moment,
+           so the place comes out right even when the page is no longer at
+           the zoom it was collected at (Docs hands the page back before it
+           writes its display list). A line whose text node has changed
+           since cannot be measured: it goes in whole, as a picture. */
+        var range = document.createRange();
+        var now = null;
+        try {
+            range.setStart(r.node, r.start);
+            range.setEnd(r.node, r.start + r.text.length);
+            now = range.getBoundingClientRect();
+        } catch (e) { now = null; }
+        if (!now || !(now.width > 0) || r.node.nodeValue.substr(r.start, r.text.length) !== r.text) {
+            return [{ text: shown, dx: 0, w: whole, fit: 0, raster: true }];
+        }
+        var k = whole / now.width;
+        var out = [];
+        parts.forEach(function (p) {
+            var text = r.text.substring(p.start, p.end);
+            range.setStart(r.node, r.start + p.start);
+            range.setEnd(r.node, r.start + p.end);
+            var b = range.getBoundingClientRect();
+            var dx = (b.left - now.left) * k, w = b.width * k;
+            if (!(w > 0) && !visibleText(text).trim()) return;
+            out.push({
+                text: transformText(text, r.transform), dx: dx, w: w,
+                fit: /^\s|\s$/.test(text) ? 0 : w, raster: !p.ok
+            });
+        });
+        return out;
+    }
+
+    /* rasterText draws a piece of a run as a picture of itself, through
+       the page's own canvas - which has the page's fonts, so the picture is
+       set in the face the screen used, unlike an SVG image of the element.
+       Returns { src, x, y, w, h } relative to the piece's left edge and the
+       line's top, or null. */
+    function rasterText(r, text, w, lineH, baseline) {
+        try {
+            var pad = Math.ceil(r.size * 0.3);
+            var cw = Math.max(1, w) + pad * 2, ch = lineH + pad * 2;
+            var k = RASTER_SCALE;
+            var c = document.createElement("canvas");
+            c.width = Math.ceil(cw * k);
+            c.height = Math.ceil(ch * k);
+            var g = c.getContext("2d");
+            g.scale(k, k);
+            g.font = (r.italic ? "italic " : "") + r.weight + " " + r.size + "px " + r.family;
+            if (r.letterSpacing && "letterSpacing" in g) g.letterSpacing = r.letterSpacing + "px";
+            g.fillStyle = r.color;
+            g.textBaseline = "alphabetic";
+            g.fillText(text, pad, pad + baseline);
+            return { src: c.toDataURL("image/png"), x: -pad, y: -pad, w: cw, h: ch };
+        } catch (e) {
+            return null;
+        }
+    }
+
     /* drawFragment puts one line fragment on the page as real text, in as
        many pieces as it takes fonts to spell it.
 
@@ -561,19 +874,29 @@ var OfficePdfCore = (function () {
        could not embed - from pushing the rest of the line out of place.
 
        Bold that a shipped face does not have is stroked rather than filled,
-       at the width the browser smears it by. Neither side moves the advance
-       widths, so the two stay in step. */
+       and italic it does not have is slanted, both by as much as the
+       browser does it (synthStyle). Neither moves the advance widths, so
+       the two stay in step.
+
+       letterSpacing (css px) goes in as character spacing, which is how
+       CSS adds it too: after every character, the last one included. */
     function drawFragment(pg, spec) {
         var fonts = pg.fonts;
         var segs = segmentText(spec.text, spec.names, fonts, spec.bold, spec.italic);
         if (!segs || !segs.length) return 0;
 
+        var ls = spec.letterSpacing || 0;
         var total = 0;
         for (var i = 0; i < segs.length; i++) {
             var face = faceOf(segs[i].res, fonts, spec.bold, spec.italic);
             if (!face) return 0;
             segs[i].face = face;
-            segs[i].w = face.font.widthOfTextAtSize(segs[i].text, spec.sizePx);
+            segs[i].w = face.font.widthOfTextAtSize(segs[i].text, spec.sizePx) +
+                (ls ? ls * Array.from(segs[i].text).length : 0);
+            if (face.synthBold || face.synthItalic) {
+                segs[i].synth = synthStyle(face.family, spec.weight || (spec.bold ? 700 : 400), spec.italic,
+                    spec.sizePx, segs[i].text, face.synthBold, face.synthItalic);
+            }
             total += segs[i].w;
         }
 
@@ -590,17 +913,23 @@ var OfficePdfCore = (function () {
         segs.forEach(function (seg) {
             pg.save();
             var ops = [];
+            var synth = seg.synth || { bold: 0, skew: 0 };
             if (scale !== 1) ops.push(PDFLib.setCharacterSqueeze(scale * 100));
-            if (seg.face.synthBold) {
+            if (ls) ops.push(PDFLib.setCharacterSpacing(px(ls)));
+            if (synth.bold > 0.01) {
                 ops.push(PDFLib.setTextRenderingMode(PDFLib.TextRenderingMode.FillAndOutline));
-                ops.push(PDFLib.setLineWidth(px(spec.sizePx / 28)));
+                ops.push(PDFLib.setLineWidth(px(synth.bold)));
                 ops.push(PDFLib.setStrokingColor(col));
             }
             if (ops.length) pg.ops(ops);
-            pg.p.drawText(seg.text, {
+            var opt = {
                 x: px(cursor), y: pg.y(spec.baselinePx),
                 size: px(spec.sizePx), font: seg.face.font, color: col
-            });
+            };
+            // the text matrix's horizontal shear by height (pdf-lib calls
+            // that angle ySkew)
+            if (synth.skew > 0.001) opt.ySkew = PDFLib.radians(Math.atan(synth.skew));
+            pg.p.drawText(seg.text, opt);
             pg.restore();
             cursor += seg.w * scale;
         });
@@ -608,8 +937,11 @@ var OfficePdfCore = (function () {
     }
 
     /* drawRuns puts every run on the page at the baseline the browser laid
-       it out on, in the width the browser gave it. */
-    function drawRuns(pg, runs) {
+       it out on, in the width the browser gave it. A character no font can
+       show comes in as a picture of itself, in its own place in the line
+       (planRun); drawImage(src, x, y, w, h) puts that picture on the page,
+       and without it such characters are left out. */
+    function drawRuns(pg, runs, drawImage) {
         runs.forEach(function (r) {
             var x = r.origin.x + (r.rect.left - r.origin.left);
             var top = r.origin.y + (r.rect.top - r.origin.top);
@@ -620,17 +952,24 @@ var OfficePdfCore = (function () {
             var col = parseColor(r.color) || PDFLib.rgb(0, 0, 0);
             var bg = parseFill(r.background);
             if (bg) pg.rect(x, top, domW, lineH, { fill: bg.c, fillOpacity: bg.a });
-            // a fragment that starts or ends on a space cannot be fitted to
-            // its rect: the browser collapses those, the measurement does not
-            var w = drawFragment(pg, {
-                text: r.text, names: r.names,
-                bold: r.weight >= 600, italic: r.italic,
-                sizePx: r.size, xPx: x, baselinePx: top + baseline,
-                color: col, fitPx: /^\s|\s$/.test(r.text) ? 0 : domW
+            var drawn = 0;
+            planRun(r, pg.fonts).forEach(function (pc) {
+                if (pc.raster) {
+                    var pic = drawImage ? rasterText(r, pc.text, pc.w, lineH, baseline) : null;
+                    if (pic) drawImage(pic.src, x + pc.dx + pic.x, top + pic.y, pic.w, pic.h);
+                    drawn += pc.w;
+                    return;
+                }
+                drawn += drawFragment(pg, {
+                    text: pc.text, names: r.names,
+                    bold: r.weight >= 600, weight: r.weight, italic: r.italic,
+                    sizePx: r.size, xPx: x + pc.dx, baselinePx: top + baseline,
+                    color: col, fitPx: pc.fit, letterSpacing: r.letterSpacing
+                });
             });
             if (r.underline || r.strike) {
                 var yOff = r.underline ? baseline + r.size * 0.11 : baseline - r.size * 0.28;
-                pg.rect(x, top + yOff, w || domW, Math.max(0.7, r.size * 0.06), { fill: col });
+                pg.rect(x, top + yOff, drawn || domW, Math.max(0.7, r.size * 0.06), { fill: col });
             }
         });
     }
@@ -665,27 +1004,32 @@ var OfficePdfCore = (function () {
        The cost is that computed styles have to be inlined onto the clone
        (an SVG image cannot reach the page's stylesheets) and every
        resource must already be a data URL - which, in a slide, it is. */
-    function rasterizeElement(el, w, h) {
+    /* ext, when given, is the area to capture relative to the element's own
+       box ({ x, y, w, h }, x and y usually <= 0): what overflows the box -
+       a table whose rows grew past its frame, text running out of its box -
+       is on the slide, so it belongs in the picture too. */
+    function rasterizeElement(el, w, h, ext) {
         try {
             var clone = el.cloneNode(true);
             inlineStyles(el, clone);
+            ext = ext || { x: 0, y: 0, w: w, h: h };
             // the foreignObject supplies the box, so the clone must not
             // carry the absolute placement it had on the slide
-            clone.style.position = "static";
-            clone.style.left = "auto";
-            clone.style.top = "auto";
+            clone.style.position = "absolute";
+            clone.style.left = (-ext.x) + "px";
+            clone.style.top = (-ext.y) + "px";
             clone.style.transform = "none";
             clone.style.margin = "0";
             clone.style.width = w + "px";
             clone.style.height = h + "px";
 
-            var cw = Math.max(1, Math.ceil(w)), ch = Math.max(1, Math.ceil(h));
+            var cw = Math.max(1, Math.ceil(ext.w)), ch = Math.max(1, Math.ceil(ext.h));
             /* The markup inside a foreignObject has to be well-formed XML,
                and innerHTML is not: it writes <br> unclosed, which makes the
                whole SVG fail to parse and the element vanish from the page.
                XMLSerializer writes real XHTML, so it cannot. */
             var wrap = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
-            wrap.setAttribute("style", "width:" + cw + "px;height:" + ch + "px;");
+            wrap.setAttribute("style", "position:relative;overflow:hidden;width:" + cw + "px;height:" + ch + "px;");
             wrap.appendChild(clone);
             var xhtml = new XMLSerializer().serializeToString(wrap);
             var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + cw +
@@ -810,6 +1154,10 @@ var OfficePdfCore = (function () {
         RASTER_SCALE: RASTER_SCALE,
         STD_FAMILIES: STD_FAMILIES,
         winAnsiCp: winAnsiCp,
+        ignorableCp: ignorableCp,
+        visibleText: visibleText,
+        transformText: transformText,
+        synthStyle: synthStyle,
         haveFamily: haveFamily,
         familyList: familyList,
         loadFontkit: loadFontkit,
@@ -829,6 +1177,8 @@ var OfficePdfCore = (function () {
         collectRuns: collectRuns,
         splitByLine: splitByLine,
         canDrawAsText: canDrawAsText,
+        planRun: planRun,
+        rasterText: rasterText,
         drawFragment: drawFragment,
         drawRuns: drawRuns,
         rasterizeElement: rasterizeElement,

+ 67 - 19
src/web/Office/common/pdfdraw.js

@@ -21,9 +21,13 @@
     Coordinates are px from the page's top-left corner. Ops are arrays:
         ["rect", x, y, w, h, rgb, alpha]
         ["image", id, x, y, w, h, crop]       crop {t,r,b,l} fractions | null
-        ["text", x, baseline, size, rgb, fitW, segs, deco]
-              segs [[text, font, synthBold]]  font "s:Helvetica:bold:italic" | "f:<url>"
+        ["text", x, baseline, size, rgb, fitW, segs, deco, letterSpacing]
+              segs [[text, font, bold, skew]]  font "s:Helvetica:bold:italic" | "f:<url>"
+                   bold: synthetic bold stroke width in px (0: none), skew:
+                   synthetic italic shear (0: upright) - both as measured
+                   in the page (OfficePdfCore.synthStyle)
               deco [y, h, w] underline/strike bar, w used when nothing drew
+              letterSpacing px after every character, optional
         ["leader", x, w, baseline, size, rgb, ch, font]
         ["ellipse", cx, cy, rx, ry, rgb, strokeW]      strokeW 0 = filled
         ["poly", [x, y, ...], rgb, strokeW]
@@ -42,6 +46,7 @@
         var rec = OfficePdfDraw.recorder();   (in the page)
             rec.addPage([w, h]) -> a stand-in for a pdf-lib PDFPage
             rec.embed(src)      -> Promise<picture handle> for drawImage
+            rec.embedNow(src)   -> the same handle, synchronously
             rec.job(title)      -> the job
 */
 
@@ -73,6 +78,32 @@ var OfficePdfDraw = (function () {
         return null;
     }
 
+    /* decodeToPng re-encodes a picture pdf-lib cannot embed (GIF, WebP,
+       BMP) through the browser's own decoder. It works in the worker
+       (createImageBitmap + OffscreenCanvas) and in the page alike; null
+       when the bytes are nothing the browser can draw. */
+    function decodeToPng(bytes) {
+        if (typeof createImageBitmap !== "function" || typeof Blob === "undefined") return Promise.resolve(null);
+        return createImageBitmap(new Blob([bytes])).then(function (bmp) {
+            var w = bmp.width, h = bmp.height;
+            if (typeof OffscreenCanvas !== "undefined") {
+                var oc = new OffscreenCanvas(w, h);
+                oc.getContext("2d").drawImage(bmp, 0, 0);
+                bmp.close();
+                return oc.convertToBlob({ type: "image/png" });
+            }
+            var c = document.createElement("canvas");
+            c.width = w; c.height = h;
+            c.getContext("2d").drawImage(bmp, 0, 0);
+            bmp.close();
+            return new Promise(function (res) { c.toBlob(res, "image/png"); });
+        }).then(function (blob) {
+            return blob ? blob.arrayBuffer() : null;
+        }).then(function (buf) {
+            return buf ? new Uint8Array(buf) : null;
+        }).catch(function () { return null; });
+    }
+
     function render(job, opts) {
         opts = opts || {};
         var pdfDoc;
@@ -127,8 +158,11 @@ var OfficePdfDraw = (function () {
                 var kind = sniff(bytes);
                 if (kind === "jpg") return pdfDoc.embedJpg(bytes);
                 if (kind === "png") return pdfDoc.embedPng(bytes);
-                // a recording names what it could not check: try it as a PNG
-                return spec.guess ? pdfDoc.embedPng(bytes) : null;
+                // a GIF, WebP or BMP: the picture the browser decodes from
+                // it (a GIF's first frame), as a PNG
+                return decodeToPng(bytes).then(function (png) {
+                    return png ? pdfDoc.embedPng(png) : null;
+                });
             }).catch(function () { return null; }).then(function (img) {
                 imageReady[id] = img;
                 return img;
@@ -230,14 +264,17 @@ var OfficePdfDraw = (function () {
            it, squeezed or stretched to the width the browser gave it */
         function drawText(page, op, y, push) {
             var x = op[1], baseline = op[2], size = op[3], col = rgb(op[4]), fitW = op[5];
+            var ls = op[8] || 0;
             var segs = [];
             var total = 0;
             for (var i = 0; i < op[6].length; i++) {
                 var s = op[6][i];
                 var font = fontReady[s[1]];
                 if (!font) return;
-                var w = font.widthOfTextAtSize(s[0], size);
-                segs.push({ text: s[0], font: font, synthBold: s[2], w: w });
+                var w = font.widthOfTextAtSize(s[0], size) + (ls ? ls * Array.from(s[0]).length : 0);
+                // s[2]: the synthetic bold stroke in px (true: the old fixed one)
+                var bw = typeof s[2] === "number" ? s[2] : (s[2] ? size / 28 : 0);
+                segs.push({ text: s[0], font: font, bold: bw, skew: s[3] || 0, w: w });
                 total += w;
             }
             var scale = 1;
@@ -251,13 +288,17 @@ var OfficePdfDraw = (function () {
             segs.forEach(function (seg) {
                 var ops = [PDFLib.pushGraphicsState()];
                 if (scale !== 1) ops.push(PDFLib.setCharacterSqueeze(scale * 100));
-                if (seg.synthBold) {
+                if (ls) ops.push(PDFLib.setCharacterSpacing(ls * PX));
+                if (seg.bold > 0.01) {
                     ops.push(PDFLib.setTextRenderingMode(PDFLib.TextRenderingMode.FillAndOutline));
-                    ops.push(PDFLib.setLineWidth(size / 28 * PX));
+                    ops.push(PDFLib.setLineWidth(seg.bold * PX));
                     ops.push(PDFLib.setStrokingColor(col));
                 }
                 push(ops);
-                page.drawText(seg.text, { x: cursor * PX, y: y(baseline), size: size * PX, font: seg.font, color: col });
+                var opt = { x: cursor * PX, y: y(baseline), size: size * PX, font: seg.font, color: col };
+                // a synthetic italic: the text matrix's shear by height
+                if (seg.skew > 0.001) opt.ySkew = PDFLib.radians(Math.atan(seg.skew));
+                page.drawText(seg.text, opt);
                 push([PDFLib.popGraphicsState()]);
                 cursor += seg.w * scale;
             });
@@ -412,6 +453,18 @@ var OfficePdfDraw = (function () {
             this.calls.push(["ops", list]);
         };
         RecPage.prototype.recordAlpha = function (key, a) { this.calls.push(["alpha", key, a]); };
+        function pictureRef(src) {
+            if (!src) return null;
+            if (bySrc[src]) return bySrc[src];
+            var id = "p" + (seq++);
+            var abs = src;
+            if (src.indexOf("data:") !== 0) {
+                try { abs = new URL(src, document.baseURI).href; } catch (e) { abs = src; }
+            }
+            images[id] = { src: abs };
+            bySrc[src] = { __img: id };
+            return bySrc[src];
+        }
 
         return {
             addPage: function (size) {
@@ -421,16 +474,11 @@ var OfficePdfDraw = (function () {
             },
             // a picture is named, not embedded: the worker embeds it once
             embed: function (src) {
-                if (!src) return Promise.resolve(null);
-                if (bySrc[src]) return Promise.resolve(bySrc[src]);
-                var id = "p" + (seq++);
-                var abs = src;
-                if (src.indexOf("data:") !== 0) {
-                    try { abs = new URL(src, document.baseURI).href; } catch (e) { abs = src; }
-                }
-                images[id] = { src: abs, guess: true };
-                bySrc[src] = { __img: id };
-                return Promise.resolve(bySrc[src]);
+                return Promise.resolve(pictureRef(src));
+            },
+            // the same, for a caller that has to draw it on the spot
+            embedNow: function (src) {
+                return pictureRef(src);
             },
             job: function (title) {
                 return { title: title || "", images: images, pages: pages };

+ 48 - 64
src/web/Office/docs/docs_pdf.js

@@ -10,8 +10,8 @@
                   browser's baseline (OfficePdfCore.drawRuns - the font rules
                   are the ones Slides uses: standard PDF fonts for families
                   metric-identical to them, the shipped Noto faces embedded
-                  and subset for everything else, a picture of the run only
-                  when nothing can show a character)
+                  and subset for everything else, and a picture only of the
+                  characters nothing can show, in their place in the line)
       background  a filled rectangle - per line fragment for highlighted text
       borders     filled strips; a collapsed table rule is centred on the
                   cell edge the way the browser draws it, at the width the
@@ -114,8 +114,9 @@ var DocsPdf = (function () {
                 }
             }
 
-            // borders
-            if (cs.display !== "inline") {
+            // borders (an inline picture is one box, so its outline is drawn
+            // too; inline text with a border would need a box per line)
+            if (cs.display !== "inline" || el.tagName === "IMG") {
                 var bb = frame.box(el.getBoundingClientRect());
                 var cell = (el.tagName === "TD" || el.tagName === "TH") && cs.borderCollapse === "collapse";
                 ["Top", "Right", "Bottom", "Left"].forEach(function (side) {
@@ -167,6 +168,7 @@ var DocsPdf = (function () {
                     ch: leader === "hyphen" ? "-" : (leader === "underscore" ? "_" : "."),
                     names: C.familyList(cs.fontFamily),
                     size: parseFloat(cs.fontSize) || 14.67,
+                    weight: parseInt(cs.fontWeight, 10) || 400,
                     bold: (parseInt(cs.fontWeight, 10) || 400) >= 600,
                     color: parseColor(cs.color) || PDFLib.rgb(0, 0, 0),
                     baseline: lb.y + (lb.h - (met.ascent + met.descent)) / 2 + met.ascent
@@ -189,7 +191,7 @@ var DocsPdf = (function () {
         // text, as line fragments at their baselines
         var origin = { left: frame.left, top: frame.top, x: 0, y: 0 };
         var runs = C.collectRuns(o.pageEl, origin).filter(function (run) {
-            return run.text.trim() !== "";
+            return C.visibleText(run.text).trim() !== "";
         });
         runs.forEach(function (run) {
             var top = run.rect.top - frame.top;
@@ -235,6 +237,7 @@ var DocsPdf = (function () {
             kind: "marker", text: text, x: left, baseline: baseline, size: size,
             shape: bulletShape(text, before, size, left, baseline),
             names: C.familyList(before.fontFamily),
+            weight: parseInt(before.fontWeight, 10) || 400,
             bold: (parseInt(before.fontWeight, 10) || 400) >= 600,
             italic: before.fontStyle === "italic",
             color: parseColor(before.color) || PDFLib.rgb(0, 0, 0)
@@ -287,11 +290,14 @@ var DocsPdf = (function () {
             for (var i = 0; i < text.length; i++) {
                 var cp = text.codePointAt(i);
                 if (cp > 0xFFFF) i++;
-                fn(C.resolveChar(cp, names, fonts, bold, italic), bold, italic);
+                var res = C.resolveChar(cp, names, fonts, bold, italic);
+                fn(res, bold || !!(res && res.bold), italic);
             }
         }
         function eachChar(fn) {
-            runs.forEach(function (r) { each(r.text, r.names, r.weight >= 600, r.italic, fn); });
+            runs.forEach(function (r) {
+                each(C.visibleText(C.transformText(r.text, r.transform)), r.names, r.weight >= 600, r.italic, fn);
+            });
             markers.forEach(function (m) { if (!m.shape) each(m.text, m.names, m.bold, m.italic, fn); });
             leaders.forEach(function (l) { each(l.ch, l.names, l.bold, false, fn); });
         }
@@ -310,19 +316,26 @@ var DocsPdf = (function () {
         try { return new URL(u, document.baseURI).href; } catch (e) { return u; }
     }
 
-    // the pieces a line fragment is spelled in, as display-list fonts
-    function segsFor(text, names, fonts, bold, italic) {
+    /* the pieces a line fragment is spelled in, as display-list fonts:
+       [text, font, synthetic bold stroke px, synthetic italic shear]. null
+       when a character has no font, an empty list when nothing in it draws
+       anything. st is { weight, size }: how much the browser emboldens or
+       slants a face depends on both, and is measured (synthStyle). */
+    function segsFor(text, names, fonts, bold, italic, st) {
         var segs = C.segmentText(text, names, fonts, bold, italic);
-        if (!segs || !segs.length) return null;
+        if (!segs) return null;
         var out = [];
         for (var i = 0; i < segs.length; i++) {
             var res = segs[i].res;
+            var b = bold || !!res.bold;
             if (res.std) {
-                out.push([segs[i].text, "s:" + res.std + ":" + (bold ? 1 : 0) + ":" + (italic ? 1 : 0), false]);
+                out.push([segs[i].text, "s:" + res.std + ":" + (b ? 1 : 0) + ":" + (italic ? 1 : 0), 0, 0]);
             } else {
-                var face = OfficeFonts.faceFor(res.shipped, bold, italic);
+                var face = OfficeFonts.faceFor(res.shipped, b, italic);
                 if (!face) return null;
-                out.push([segs[i].text, "f:" + absUrl(face.url), !!face.synthBold]);
+                var syn = (face.synthBold || face.synthItalic) ?
+                    C.synthStyle(face.family, st.weight, italic, st.size, segs[i].text, face.synthBold, face.synthItalic) : null;
+                out.push([segs[i].text, "f:" + absUrl(face.url), syn ? syn.bold : 0, syn ? syn.skew : 0]);
             }
         }
         return out;
@@ -426,27 +439,6 @@ var DocsPdf = (function () {
         });
     }
 
-    /* a run no font here can show (emoji, a script not shipped) is drawn
-       as a picture of its own text, by the browser's own text renderer */
-    function rasterRun(run) {
-        var w = run.rect.right - run.rect.left, h = run.rect.bottom - run.rect.top;
-        if (w <= 0 || h <= 0) return Promise.resolve(null);
-        var k = C.RASTER_SCALE;
-        var c = document.createElement("canvas");
-        c.width = Math.ceil(w * k);
-        c.height = Math.ceil(h * k);
-        var g = c.getContext("2d");
-        g.scale(k, k);
-        g.font = (run.italic ? "italic " : "") + run.weight + " " + run.size + "px " + run.names.map(function (n) {
-            return /[\s"']/.test(n) ? '"' + n.replace(/"/g, "") + '"' : n;
-        }).join(",");
-        g.fillStyle = run.color;
-        g.textBaseline = "alphabetic";
-        var glyphH = run.metrics.ascent + run.metrics.descent;
-        g.fillText(run.text, 0, (h - glyphH) / 2 + run.metrics.ascent);
-        return canvasBytes(c);
-    }
-
     function breathe() {
         return new Promise(function (res) { setTimeout(res, 0); });
     }
@@ -484,7 +476,6 @@ var DocsPdf = (function () {
                     var r = borderRect(b);
                     ops.push(["rect", r[0], r[1] - dy, r[2], r[3], rgbOf(b.fill.c), b.fill.a]);
                 });
-                var rasters = [];
                 items.texts.forEach(function (r) {
                     var x = r.origin.x + (r.rect.left - r.origin.left);
                     var top = r.origin.y + (r.rect.top - r.origin.top) - dy;
@@ -492,52 +483,45 @@ var DocsPdf = (function () {
                     var domW = r.rect.right - r.rect.left;
                     var bg = C.parseFill(r.background);
                     if (bg) ops.push(["rect", x, top, domW, lineH, rgbOf(bg.c), bg.a]);
-                    var segs = segsFor(r.text, r.names, fonts, r.weight >= 600, r.italic);
-                    if (!segs) {
-                        rasters.push({ run: r, at: ops.length, x: x, top: top, w: domW, h: lineH });
-                        ops.push(null);
-                        return;
-                    }
                     var glyphH = r.metrics.ascent + r.metrics.descent;
                     var baseline = (lineH - glyphH) / 2 + r.metrics.ascent;
-                    var deco = null;
+                    var col = rgbOf(C.parseColor(r.color));
+                    // text where a font can show it; a character none can
+                    // (an emoji) is a picture of itself in its place
+                    C.planRun(r, fonts).forEach(function (pc) {
+                        if (pc.raster) {
+                            var pic = C.rasterText(r, pc.text, pc.w, lineH, baseline);
+                            if (!pic) return;
+                            var id = "r" + (nextId++);
+                            job.images[id] = { src: pic.src };
+                            ops.push(["image", id, x + pc.dx + pic.x, top + pic.y, pic.w, pic.h, null]);
+                            return;
+                        }
+                        var segs = segsFor(pc.text, r.names, fonts, r.weight >= 600, r.italic, r);
+                        if (!segs || !segs.length) return;
+                        ops.push(["text", x + pc.dx, top + baseline, r.size, col, pc.fit, segs, null, r.letterSpacing || 0]);
+                    });
                     if (r.underline || r.strike) {
                         var yOff = r.underline ? baseline + r.size * 0.11 : baseline - r.size * 0.28;
-                        deco = [top + yOff, Math.max(0.7, r.size * 0.06), domW];
+                        ops.push(["rect", x, top + yOff, domW, Math.max(0.7, r.size * 0.06), col, 1]);
                     }
-                    // a fragment that starts or ends on a space cannot be fitted
-                    // to its rect: the browser collapses those, the measure does not
-                    ops.push(["text", x, top + baseline, r.size, rgbOf(C.parseColor(r.color)),
-                        /^\s|\s$/.test(r.text) ? 0 : domW, segs, deco]);
                 });
                 items.extras.forEach(function (x) {
                     if (x.kind === "marker") {
                         if (x.shape) {
                             ops.push(shapeOps(x.shape, x.color, dy));
                         } else {
-                            var ms = segsFor(x.text, x.names, fonts, x.bold, x.italic);
-                            if (ms) ops.push(["text", x.x, x.baseline - dy, x.size, rgbOf(x.color), 0, ms, null]);
+                            var ms = segsFor(x.text, x.names, fonts, x.bold, x.italic, x);
+                            if (ms && ms.length) ops.push(["text", x.x, x.baseline - dy, x.size, rgbOf(x.color), 0, ms, null]);
                         }
                     } else if (x.kind === "leader") {
-                        var ls = segsFor(x.ch, x.names, fonts, x.bold, false);
-                        if (ls) ops.push(["leader", x.box.x, x.box.w, x.baseline - dy, x.size, rgbOf(x.color), x.ch, ls[0][1]]);
+                        var ls = segsFor(x.ch, x.names, fonts, x.bold, false, x);
+                        if (ls && ls.length) ops.push(["leader", x.box.x, x.box.w, x.baseline - dy, x.size, rgbOf(x.color), x.ch, ls[0][1]]);
                     } else if (x.kind === "rule") {
                         ops.push(["rect", x.box.x, x.box.y - dy, x.box.w, x.box.h, rgbOf(x.fill.c), x.fill.a]);
                     }
                 });
-                // text no font can show becomes a picture of itself, in place
-                var rs = rasters.map(function (it) {
-                    return rasterRun(it.run).then(function (bytes) {
-                        if (!bytes) return;
-                        var id = "r" + (nextId++);
-                        job.images[id] = { bytes: bytes };
-                        ops[it.at] = ["image", id, it.x, it.top, it.w, it.h, null];
-                    });
-                });
-                return Promise.all(rs).then(function () {
-                    for (var k = ops.length - 1; k >= 0; k--) if (!ops[k]) ops.splice(k, 1);
-                    return breathe();
-                });
+                return breathe();
             });
         });
         return chain.then(function () {

+ 40 - 1
src/web/Office/slides/slides.css

@@ -221,6 +221,16 @@
 #slSlideLayer .sl-obj.sl-type-line,
 #slSlideLayer .sl-obj.sl-type-line svg * { pointer-events: none; }
 #slSlideLayer .sl-obj.sl-type-line svg > polyline:first-child { pointer-events: stroke; }
+/* Pressing on an object and dragging moves it, so that is what the cursor
+   says - everywhere on it, text included, until it is being edited (the
+   caret then owns the text; see .sl-editing below). An armed shape or line
+   tool draws over objects, so it keeps its crosshair. */
+#slSlideLayer .sl-obj:not(.sl-editing),
+#slSlideLayer .sl-obj:not(.sl-editing) *,
+#slCanvas.sl-moving,
+#slCanvas.sl-moving * { cursor: move; }
+#slCanvas.sl-drawmode .sl-obj,
+#slCanvas.sl-drawmode .sl-obj * { cursor: crosshair; }
 .sl-obj > svg { display: block; width: 100%; height: 100%; overflow: visible; }
 .sl-obj img { display: block; width: 100%; height: 100%; pointer-events: none; }
 
@@ -243,6 +253,34 @@
     padding: 2px 4px;
     cursor: default;
 }
+/* An empty text box (props.html with nothing in it) shows a prompt -
+   "Click to add title" - and a thin grey frame, as Google Slides and
+   PowerPoint do. Only a surface that asks for them shows them: the canvas
+   and the layout picker (.sl-showph.sl-phframe), the rail and the slide
+   sorter (prompt only). Present mode, print and every export render the
+   same objects without either, so an empty box is simply not there. The
+   prompt is laid out with the box's own text style (slides.js) and is
+   never the text itself, which is why editing hides it. */
+.sl-ph {
+    display: none;
+    position: absolute;
+    inset: 0;
+    box-sizing: border-box;
+    flex-direction: column;
+    padding: 2px 4px;
+    line-height: 1.3;
+    white-space: pre-wrap;
+    overflow: hidden;
+    pointer-events: none;
+    opacity: 0.55;
+}
+.sl-ph.sl-ph-title { opacity: 0.85; }
+.sl-showph .sl-obj.sl-empty > .sl-ph { display: flex; }
+.sl-showph .sl-obj.sl-empty.sl-editing > .sl-ph { display: none; }
+/* inset, so it never pokes out past the box, and grey at half strength so
+   it reads on a dark slide as well as a light one */
+.sl-phframe .sl-obj.sl-empty { box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.55); }
+
 /* imported paragraphs carry their own spacing; keep the browser from
    adding any of its own on top */
 .sl-text-in > div { margin: 0; }
@@ -515,7 +553,8 @@ body.of-narrow .sl-present-btn { padding: 0 9px; }
 .sl-viewbtn .of-ic { width: 15px; height: 15px; }
 
 /* the format painter is armed */
-#slCanvas.sl-painting, #slCanvas.sl-painting .sl-obj { cursor: copy !important; }
+#slCanvas.sl-painting, #slCanvas.sl-painting .sl-obj,
+#slCanvas.sl-painting .sl-obj * { cursor: copy !important; }
 
 /* Find and replace */
 .sl-findrep .ps-check { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: var(--of-fg); }

+ 146 - 59
src/web/Office/slides/slides.js

@@ -84,6 +84,8 @@
 
     An image also carries what the picture tools put on it:
       crop[l,t,r,b] / mask / radius  the crop and the shaped crop
+      adj                            the shaped crop's adjustments (its
+                                     handles; same guides as a shape's)
       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)
@@ -220,38 +222,61 @@ var SlidesApp = (function () {
             props: { html: html, fontSize: fontSize, color: color, align: align || "left" }
         };
     }
+    /* A layout's text boxes are placeholders: empty, with props.ph saying
+       what they are for, so the canvas prompts "Click to add title" in them
+       (renderObjectEl) and they vanish from the show and every export
+       until something is typed. The .pptx writer makes them <p:ph> again. */
+    function newPh(role, x, y, w, h, fontSize, align, color) {
+        var o = newTextObj("", x, y, w, h, fontSize, align, color);
+        o.props.ph = role;
+        return o;
+    }
+    var PH_PROMPTS = {
+        title: "Click to add title",
+        subtitle: "Click to add subtitle",
+        body: "Click to add text"
+    };
+    // a text object with nothing in it: no characters (the zero-width
+    // space an imported empty line carries does not count) and no picture
+    function isBlankText(o) {
+        if (!o || o.type !== "text") return false;
+        var html = String((o.props && o.props.html) || "");
+        if (/<(img|svg|video|audio|iframe|object)\b/i.test(html)) return false;
+        return html.replace(/<[^>]*>/g, "").replace(/&nbsp;|&#8203;|&#x200b;|&zwnj;|\u200b|\u00a0/gi, "")
+            .trim() === "";
+    }
     function newSlide(kind) {
         var th = themeOf ? themeOf() : THEMES.clean;
         var textColor = (body ? themeOf() : th).text;
         var s = { id: genId("s"), bg: null, notes: "", transition: "none", objects: [] };
         if (kind === "title") {
-            s.objects.push(newTextObj("Presentation title", 80, 190, 800, 90, 44, "center", textColor));
-            s.objects.push(newTextObj("Subtitle", 180, 300, 600, 50, 20, "center", textColor));
+            s.objects.push(newPh("title", 80, 190, 800, 90, 44, "center", textColor));
+            s.objects.push(newPh("subtitle", 180, 300, 600, 50, 20, "center", textColor));
         } else if (kind === "normal") {
-            s.objects.push(newTextObj("Slide title", 50, 34, 860, 66, 32, "left", textColor));
+            s.objects.push(newPh("title", 50, 34, 860, 66, 32, "left", textColor));
         } else if (kind === "content") {
-            s.objects.push(newTextObj("Slide title", 50, 34, 860, 66, 32, "left", textColor));
-            s.objects.push(newTextObj("Content", 50, 130, 860, 360, 22, "left", textColor));
+            s.objects.push(newPh("title", 50, 34, 860, 66, 32, "left", textColor));
+            s.objects.push(newPh("body", 50, 130, 860, 360, 22, "left", textColor));
         } else if (kind === "two") {
-            s.objects.push(newTextObj("Slide title", 50, 34, 860, 66, 32, "left", textColor));
-            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));
+            s.objects.push(newPh("title", 50, 34, 860, 66, 32, "left", textColor));
+            s.objects.push(newPh("body", 50, 130, 420, 360, 20, "left", textColor));
+            s.objects.push(newPh("body", 490, 130, 420, 360, 20, "left", textColor));
         } else if (kind === "caption") {
-            s.objects.push(newTextObj("Title", 60, 60, 840, 300, 28, "left", textColor));
-            s.objects.push(newTextObj("Caption", 60, 400, 840, 60, 16, "left", textColor));
+            s.objects.push(newPh("title", 60, 60, 840, 300, 28, "left", textColor));
+            s.objects.push(newPh("body", 60, 400, 840, 60, 16, "left", textColor));
         } else if (kind === "section") {
-            s.objects.push(newTextObj("Section header", 60, 230, 840, 80, 36, "left", textColor));
+            s.objects.push(newPh("title", 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));
+            s.objects.push(newPh("title", 60, 60, 840, 60, 28, "left", textColor));
+            s.objects.push(newPh("body", 60, 140, 840, 340, 18, "left", textColor));
         } else if (kind === "mainpoint") {
-            s.objects.push(newTextObj("Main point", 80, 210, 800, 120, 44, "left", textColor));
+            s.objects.push(newPh("title", 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));
+            s.objects.push(newPh("title", 60, 120, 380, 90, 30, "left", textColor));
+            s.objects.push(newPh("body", 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.push(newPh("title", 80, 160, 800, 140, 88, "center", textColor));
+            s.objects.push(newPh("body", 80, 320, 800, 60, 18, "center", textColor));
         }
         // the layouts are drawn for 960x540; a deck of another shape gets
         // them spread over its own height
@@ -396,7 +421,7 @@ var SlidesApp = (function () {
         if (under) wrapS += "background:" + under + ";";
         if (p.radius) wrapS += "border-radius:" + (Number(p.radius) || 0) + "px;";
         if (p.opacity) wrapS += "opacity:" + clamp(Number(p.opacity) || 1, 0, 1) + ";";
-        var clip = maskClipPath(p.mask);
+        var clip = maskClipPath(p.mask, p.adj, w, h);
         if (clip) wrapS += "clip-path:" + clip + ";-webkit-clip-path:" + clip + ";";
         // a picture's outline (pptx <a:ln> on the picture) is centred on the
         // frame like PowerPoint draws it; a shaped crop gets it along the
@@ -411,7 +436,7 @@ var SlidesApp = (function () {
             } else if (w > 0 && h > 0) {
                 // CSS has no dash-dot, and a clipped wrapper cannot draw
                 // along its own clip: an SVG does both
-                var d = clip && window.SlidesShapes ? SlidesShapes.path(p.mask, w, h) : "";
+                var d = clip && window.SlidesShapes ? SlidesShapes.path(p.mask, w, h, p.adj) : "";
                 var r = Number(p.radius) || 0;
                 var geo = d ? '<path d="' + esc(d) + '"'
                     : '<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + r + '"';
@@ -466,16 +491,20 @@ var SlidesApp = (function () {
     }
 
     /* A shaped crop ("mask image"): the picture is clipped to one of the
-       editor's shape outlines. shapePoints() already defines every polygon
-       shape, so asking it for a 100x100 box yields percentages directly. */
-    function maskClipPath(kind) {
+       editor's shape outlines, with the adjustments its handles gave it
+       (props.adj on a picture belongs to its mask). The corners are worked
+       out at the frame's real size, because most presets measure their
+       features from the shorter side, and stated as percentages so the
+       clip still follows the frame while it is being dragged. */
+    function maskClipPath(kind, adj, w, h) {
         if (!kind || kind === "rect") return "";
         if (kind === "ellipse") return "ellipse(50% 50% at 50% 50%)";
         if (kind === "roundRect") return "";    // border-radius draws this one
-        var pts = shapePoints(kind, 100, 100);
+        var fw = w > 0 ? w : 100, fh = h > 0 ? h : 100;
+        var pts = shapePoints(kind, fw, fh, adj);
         if (!pts) return "";
         return "polygon(" + pts.map(function (pt) {
-            return pt[0].toFixed(2) + "% " + pt[1].toFixed(2) + "%";
+            return (pt[0] / fw * 100).toFixed(2) + "% " + (pt[1] / fh * 100).toFixed(2) + "%";
         }).join(",") + ")";
     }
 
@@ -484,8 +513,8 @@ var SlidesApp = (function () {
        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) {
-        return SlidesShapes.points(kind, w, h);
+    function shapePoints(kind, w, h, adj) {
+        return SlidesShapes.points(kind, w, h, adj);
     }
 
     /* shapeSvg draws one shape. The geometry comes from SlidesShapes, so
@@ -553,7 +582,7 @@ var SlidesApp = (function () {
         }
         // 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);
+        var det = SlidesShapes.detail(kind, w, h, p.adj);
         if (det) {
             inner += '<path d="' + det + '" fill="none" stroke="' +
                 esc(hasStroke ? stroke : contrastText(fill)) + '" stroke-width="' +
@@ -809,6 +838,15 @@ var SlidesApp = (function () {
             case "text":
                 d.innerHTML = '<div class="sl-text-in" style="' + textStyle(o.props) + '">' +
                     (o.props.html || "") + "</div>";
+                if (isBlankText(o)) {
+                    // the prompt is a sibling of the text, never in it: the
+                    // PDF exporter and the editor only ever read .sl-text-in,
+                    // and only a surface marked .sl-showph shows it
+                    d.className += " sl-empty";
+                    d.insertAdjacentHTML("beforeend", '<div class="sl-ph' +
+                        (o.props.ph === "title" ? " sl-ph-title" : "") + '" style="' + textStyle(o.props) +
+                        '">' + esc(PH_PROMPTS[o.props.ph] || PH_PROMPTS.body) + "</div>");
+                }
                 break;
             case "image":
                 d.innerHTML = imageHtml(o.props, o.w, o.h);
@@ -917,24 +955,44 @@ var SlidesApp = (function () {
         return h;
     }
 
-    /* ---- shape adjustments: a callout's tip, a rounded corner ----
-       The yellow handle sits where the adjustment is: on the tip of a
+    /* ---- shape adjustments: a callout's tip, a corner, an arrow's head ----
+       Each yellow handle sits where its adjustment is: on the tip of a
        speech bubble, on the top edge where a rounded rectangle's corner
-       ends. Positions are in the frame's own (unrotated) box. */
-    function adjHandlePos(o) {
-        if (o.type !== "shape" || !o.props) return null;
-        var kind = SlidesShapes.canonical(o.props.kind || "rect");
-        var how = SlidesShapes.adjustable(kind);
+       ends, where an arrow's head meets its shaft. A picture's shaped crop
+       has the handles of its shape. Positions are in the frame's own
+       (unrotated) box; `how` says what dragging one changes ("tip",
+       "radius", or "adj": handle i of SlidesShapes.handles). */
+    function adjGeomKind(o) {
+        if (!o.props) return "";
+        if (o.type === "shape") return SlidesShapes.canonical(o.props.kind || "rect");
+        if (o.type === "image") {
+            if (o.props.mask) return SlidesShapes.canonical(o.props.mask);
+            // an unmasked picture with rounded corners is a rounded rectangle
+            return Number(o.props.radius) > 0 ? "roundRect" : "";
+        }
+        return "";
+    }
+    function adjHandles(o) {
+        var kind = adjGeomKind(o);
+        if (!kind) return [];
+        var how = o.type === "shape" ? SlidesShapes.adjustable(kind) : (kind === "roundRect" ? "radius" : "");
         if (how === "tip") {
-            if (o.props.adj) return SlidesShapes.tipPoint(kind, o.w, o.h, o.props.adj);
-            var lt = SlidesShapes.legacyTip(kind);
-            return lt ? [lt.tip[0] * o.w, lt.tip[1] * o.h] : null;
+            var tp = null;
+            if (o.props.adj) tp = SlidesShapes.tipPoint(kind, o.w, o.h, o.props.adj);
+            else {
+                var lt = SlidesShapes.legacyTip(kind);
+                if (lt) tp = [lt.tip[0] * o.w, lt.tip[1] * o.h];
+            }
+            return tp ? [{ how: "tip", i: 0, at: tp, title: "Drag to move the tip" }] : [];
         }
         if (how === "radius") {
             var r = o.props.radius !== undefined ? Number(o.props.radius) : Math.min(o.w, o.h) * 0.15;
-            return [clamp(r, 0, Math.min(o.w, o.h) / 2), 0];
+            return [{ how: "radius", i: 0, at: [clamp(r, 0, Math.min(o.w, o.h) / 2), 0],
+                title: "Drag to round the corners" }];
         }
-        return null;
+        return SlidesShapes.handles(kind, o.w, o.h, o.props.adj).map(function (pt, i) {
+            return { how: "adj", i: i, at: pt, title: "Drag to adjust the shape" };
+        });
     }
     /* A callout made before adjustments existed drew its body in the top of
        the frame and its tip at the bottom. The first drag of its handle
@@ -1003,14 +1061,13 @@ var SlidesApp = (function () {
                     stem.style.height = stemH + "px";
                     fr.appendChild(stem);
                     fr.appendChild(mkHandle("rot", w / 2, -stemH, hs));
-                    var ah = adjHandlePos(o);
-                    if (ah) {
-                        var knob = mkHandle("adj", ah[0], ah[1], hs * 0.95);
+                    adjHandles(o).forEach(function (ah) {
+                        var knob = mkHandle("adj", ah.at[0], ah.at[1], hs * 0.95);
                         knob.className += " sl-h-adj";
-                        knob.title = SlidesShapes.adjustable(o.props.kind) === "tip"
-                            ? "Drag to move the tip" : "Drag to round the corners";
+                        knob.setAttribute("data-i", ah.i);
+                        knob.title = ah.title;
                         fr.appendChild(knob);
-                    }
+                    });
                 }
             }
             framesEl.appendChild(fr);
@@ -1171,7 +1228,7 @@ var SlidesApp = (function () {
         var full = fullImageRect(o);
         // 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",
+        ["crop", "mask", "adj", "radius", "flipH", "flipV",
          "recolor", "bright", "contrast", "opacity"].forEach(function (k) {
             delete o.props[k];
         });
@@ -1205,6 +1262,8 @@ var SlidesApp = (function () {
     function setImageMask(o, kind) {
         if (!o || o.type !== "image") return;
         if (cropId === o.id) endCrop(true);
+        // the old shape's adjustments mean nothing to the new one
+        delete o.props.adj;
         if (!kind || kind === "rect") {
             delete o.props.mask;
             delete o.props.radius;
@@ -1265,7 +1324,7 @@ var SlidesApp = (function () {
             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>');
+            var $view = $('<div class="sl-thumb-view"><div class="sl-thumb-mini sl-slidebase sl-showph"></div></div>');
             $th.append($view);
             renderSlideContent($view.find(".sl-thumb-mini")[0], s);
             // Taking focus is what lets Delete mean "this slide", and
@@ -1912,7 +1971,8 @@ var SlidesApp = (function () {
     /* ================= object insertion ================= */
     function insertText() {
         var th = themeOf();
-        var o = addObj("text", { html: "Text", fontSize: 24, color: th.text, align: "left" },
+        // empty, caret in it: left empty it prompts "Click to add text"
+        var o = addObj("text", { html: "", fontSize: 24, color: th.text, align: "left" },
             { x: 330, y: 240, w: 300, h: 60 });
         startEdit(o.id);
     }
@@ -2682,6 +2742,17 @@ var SlidesApp = (function () {
     }
 
     /* ================= live element update (during drag) ================= */
+    /* refreshObjEl redraws an object's contents in place, for a change to
+       its outline (an adjustment) rather than to its frame. The rest of
+       the slide is left alone, so a drag stays cheap. */
+    function refreshObjEl(o) {
+        var el = objEl(o.id);
+        if (!el || !el.parentNode) return;
+        var fresh = renderObjectEl(o, (parseInt(el.style.zIndex, 10) || 1) - 1);
+        fresh.className = el.className;
+        el.parentNode.replaceChild(fresh, el);
+    }
+
     function updateObjEl(o) {
         var el = objEl(o.id);
         if (!el) return;
@@ -2821,8 +2892,12 @@ var SlidesApp = (function () {
                 // where the handle starts; an old callout is converted on the
                 // first real move (adoptAdjustments), which leaves the tip -
                 // and so this point - exactly where it is
-                drag.at = adjHandlePos(o) || [0, 0];
-                drag.how = SlidesShapes.adjustable(o.props.kind);
+                var ahi = parseInt(e.target.getAttribute("data-i"), 10) || 0;
+                var ah = adjHandles(o)[ahi];
+                drag.at = ah ? ah.at : [0, 0];
+                drag.how = ah ? ah.how : "";
+                drag.i = ahi;
+                drag.kind = adjGeomKind(o);
             }
             // 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
@@ -2857,6 +2932,9 @@ var SlidesApp = (function () {
                 pendingToggle: pendingToggle, pendingCollapse: pendingCollapse,
                 clickedId: id, wasSelected: wasSelected
             };
+            // keep the move cursor for the whole drag, wherever the pointer
+            // gets ahead of the object
+            canvasEl.classList.add("sl-moving");
             try { canvasEl.setPointerCapture(e.pointerId); } catch (err) { }
             return;
         }
@@ -2967,14 +3045,17 @@ var SlidesApp = (function () {
                 var ly = drag.at[1] + dx * Math.sin(arad) + dy * Math.cos(arad);
                 if (drag.how === "radius") {
                     o.props.radius = Math.round(clamp(lx, 0, Math.min(o.w, o.h) / 2) * 100) / 100;
-                } else {
+                } else if (drag.how === "tip") {
                     var adj = {};
                     Object.keys(o.props.adj || {}).forEach(function (k) { adj[k] = o.props.adj[k]; });
                     adj.adj1 = Math.round((lx - o.w / 2) / Math.max(1, o.w) * 100000);
                     adj.adj2 = Math.round((ly - o.h / 2) / Math.max(1, o.h) * 100000);
                     o.props.adj = adj;
+                } else if (drag.how === "adj") {
+                    o.props.adj = SlidesShapes.dragHandle(drag.kind, drag.i, o.w, o.h, lx, ly, o.props.adj);
                 }
-                updateObjEl(o);
+                // the outline itself changes, not just the frame
+                refreshObjEl(o);
                 renderOverlay();
                 break;
             }
@@ -3131,6 +3212,7 @@ var SlidesApp = (function () {
         drag = null;
         lastPointerEvt = null;
         canvasEl.classList.remove("sl-rotating");
+        canvasEl.classList.remove("sl-moving");
         hideGuides();
         try { canvasEl.releasePointerCapture(e.pointerId); } catch (err) { }
 
@@ -3185,10 +3267,13 @@ var SlidesApp = (function () {
                 setSel(sel.filter(function (id) { return id !== d.pendingToggle; }));
             } else if (d.pendingCollapse) {
                 setSel(expandGroups([d.pendingCollapse]));
-            } else if (d.wasSelected) {
-                // second click on an already-selected object enters text edit
+            } else {
+                // second click on an already-selected object enters text
+                // edit - and the first one does on an empty text box, whose
+                // prompt says "Click to add"
                 var co = objById(d.clickedId);
-                if (co && (co.type === "text" || co.type === "shape" || co.type === "table")) {
+                if (co && (d.wasSelected || (isBlankText(co) && sel.length === 1)) &&
+                    (co.type === "text" || co.type === "shape" || co.type === "table")) {
                     startEdit(co.id);
                 }
             }
@@ -4491,7 +4576,7 @@ var SlidesApp = (function () {
         body.slides.forEach(function (s, i) {
             var $c = $('<div class="sl-ov-card" tabindex="0" draggable="true"></div>').attr("data-i", i);
             if (i === cur) $c.addClass("active");
-            var $v = $('<div class="sl-ov-view"><div class="sl-ov-mini sl-slidebase"></div></div>');
+            var $v = $('<div class="sl-ov-view"><div class="sl-ov-mini sl-slidebase sl-showph"></div></div>');
             $c.append($v);
             var $meta = $('<div class="sl-ov-meta"></div>').append('<span class="sl-ov-num">' + (i + 1) + "</span>");
             if (s.transition && s.transition !== "none") {
@@ -5123,7 +5208,8 @@ var SlidesApp = (function () {
             $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));
+            var $lmini = $v.find(".sl-layoutpick-mini").addClass("sl-showph sl-phframe");
+            renderSlideContent($lmini[0], newSlide(l.key));
             $c.on("click", function () {
                 closeLayoutPicker();
                 addSlideAfter(cur, l.key);
@@ -5146,7 +5232,8 @@ var SlidesApp = (function () {
     function initDomRefs() {
         canvasEl = document.getElementById("slCanvas");
         layerEl = document.getElementById("slSlideLayer");
-        layerEl.className = "sl-slidebase";
+        // the canvas shows empty text boxes' prompts and frames
+        layerEl.className = "sl-slidebase sl-showph sl-phframe";
         var overlay = document.getElementById("slOverlay");
         overlay.innerHTML = '<div id="slFrames"></div>' +
             '<div id="slCrop"></div>' +

+ 86 - 27
src/web/Office/slides/slides_pdf.js

@@ -10,10 +10,12 @@
     What each element becomes:
 
       text      real PDF text (Tj), one show-text per line fragment, placed
-                at the baseline the browser laid it out on - when the glyphs
-                and the font allow it (see canDrawAsText). Otherwise that
-                one text box is rasterized, nothing else.
-      image     the original JPEG/PNG bytes, embedded once and re-used;
+                at the baseline the browser laid it out on. A character no
+                font here can show (an emoji) comes in as a picture of
+                itself in its place in the line (OfficePdfCore.planRun);
+                the text around it stays text.
+      image     the original JPEG/PNG bytes, embedded once and re-used (a
+                GIF, WebP, BMP or SVG goes in as a PNG of its first frame);
                 crops, shaped crops and rounded corners are real PDF clip
                 paths, flips are a negative scale in the matrix, and
                 transparency is an ExtGState. A re-coloured picture is
@@ -42,7 +44,8 @@
          is what carries CJK, and it is why those families are in every
          font stack the editor writes.
       3. nothing covers it - emoji, a script we do not ship - and only then
-         does that one text box come in as a picture of itself.
+         do those characters come in as a picture of themselves, drawn by
+         the page's own canvas in the page's own fonts.
 
     A system font that is none of the above is substituted by the shipped
     face the document's own font stack names next, which is the same thing
@@ -81,7 +84,7 @@ var SlidesPdf = (function () {
     var resolveChar = C.resolveChar, segmentText = C.segmentText, faceOf = C.faceOf;
     var eachTextNode = C.eachTextNode, px = C.px, clamp = C.clamp;
     var parseFill = C.parseFill, parseColor = C.parseColor, contrastOf = C.contrastOf;
-    var collectRuns = C.collectRuns, canDrawAsText = C.canDrawAsText;
+    var collectRuns = C.collectRuns;
     var drawFragment = C.drawFragment, drawRuns = C.drawRuns;
     var rasterizeElement = C.rasterizeElement, rasterizeFallback = C.rasterizeFallback;
     var filteredImageData = C.filteredImageData;
@@ -104,10 +107,14 @@ var SlidesPdf = (function () {
                 var names = familyList(cs.fontFamily);
                 var bold = (parseInt(cs.fontWeight, 10) || 400) >= 600;
                 var italic = cs.fontStyle === "italic" || cs.fontStyle === "oblique";
+                // what is drawn: the text as the browser shows it, without
+                // the characters that draw nothing (no face is asked for those)
+                text = C.visibleText(C.transformText(text, cs.textTransform));
                 for (var i = 0; i < text.length; i++) {
                     var cp = text.codePointAt(i);
                     if (cp > 0xFFFF) i++;
-                    fn(resolveChar(cp, names, fonts, bold, italic), bold, italic);
+                    var res = resolveChar(cp, names, fonts, bold, italic);
+                    fn(res, bold || !!(res && res.bold), italic);
                 }
             });
         }
@@ -147,6 +154,14 @@ var SlidesPdf = (function () {
 
     /* ---------------- geometry -> PDF path operators ---------------- */
 
+    // the outline a picture is clipped to: its shaped crop, or a rounded
+    // rectangle for an unmasked one with rounded corners (border-radius
+    // on the canvas)
+    function imageClipKind(p) {
+        if (p.mask) return p.mask;
+        return Number(p.radius) > 0 ? "roundRect" : "rect";
+    }
+
     // shapePathOps turns one of the editor's shape outlines into path
     // operators in page space. Used for both shape objects and the clip
     // path of a shaped crop, so the two cannot disagree.
@@ -219,25 +234,59 @@ var SlidesPdf = (function () {
         return { x: r.left - s.left, y: r.top - s.top, w: r.width, h: r.height };
     }
 
-    /* drawAsRaster puts one element in as an image of itself, at the box it
-       occupies on the slide. A rotated object is drawn unrotated inside the
+    /* paintedBoxOf is the area an element actually paints: its own box plus
+       whatever of its content overflows it (objects do not clip - a table's
+       rows grow past its frame, text runs out of its box), within the
+       slide, which does clip. */
+    function paintedBoxOf(el, stageEl) {
+        var b = boxOf(el, stageEl);
+        var s = stageEl.getBoundingClientRect();
+        var x0 = b.x, y0 = b.y, x1 = b.x + b.w, y1 = b.y + b.h;
+        var all = el.getElementsByTagName("*");
+        for (var i = 0; i < all.length; i++) {
+            var r = all[i].getBoundingClientRect();
+            if (!(r.width > 0) || !(r.height > 0)) continue;
+            x0 = Math.min(x0, r.left - s.left); y0 = Math.min(y0, r.top - s.top);
+            x1 = Math.max(x1, r.right - s.left); y1 = Math.max(y1, r.bottom - s.top);
+        }
+        x0 = Math.max(x0, 0); y0 = Math.max(y0, 0);
+        x1 = Math.min(x1, SLIDE_W); y1 = Math.min(y1, SLIDE_H);
+        return { x: x0, y: y0, w: Math.max(1, x1 - x0), h: Math.max(1, y1 - y0) };
+    }
+
+    /* drawAsRaster puts one element in as an image of itself, at the area
+       it paints on the slide. A rotated object is drawn unrotated inside the
        rotation matrix its caller set up, so the picture stays crisp
        rather than being a picture of something already turned. */
     function drawAsRaster(pg, o, el, ctx) {
         var b = boxOf(el, ctx.stage);
-        return rasterizeElement(el, b.w, b.h).then(function (src) {
-            return src || rasterizeFallback(el, b.w, b.h);
+        var area = paintedBoxOf(el, ctx.stage);
+        var ext = { x: area.x - b.x, y: area.y - b.y, w: area.w, h: area.h };
+        return rasterizeElement(el, b.w, b.h, ext).then(function (src) {
+            if (src) return src;
+            area = b;      // html2canvas draws the element's own box
+            return rasterizeFallback(el, b.w, b.h);
         }).then(function (src) {
             if (!src) return;
             return ctx.embed(src).then(function (img) {
                 if (!img) return;
                 pg.p.drawImage(img, {
-                    x: px(b.x), y: pg.y(b.y + b.h), width: px(b.w), height: px(b.h)
+                    x: px(area.x), y: pg.y(area.y + area.h), width: px(area.w), height: px(area.h)
                 });
             });
         });
     }
 
+    /* textPicture is what drawRuns calls for the few characters no font
+       can show: the picture is named in the recording on the spot, so it
+       lands in the drawing order exactly where the text around it does */
+    function textPicture(pg, ctx) {
+        return function (src, x, y, w, h) {
+            var img = ctx.embedNow(src);
+            if (img) pg.p.drawImage(img, { x: px(x), y: pg.y(y + h), width: px(w), height: px(h) });
+        };
+    }
+
     /* ---------------- SVG (charts) -> PDF vectors ---------------- */
 
     /* OfficeCharts draws with rect / line / polyline / polygon / path /
@@ -379,6 +428,7 @@ var SlidesPdf = (function () {
                         else if (anchor === "end") tx -= twPx;
                         drawFragment(pg, {
                             text: str, names: names, bold: bold, italic: false,
+                            weight: parseInt(cs.fontWeight, 10) || 400,
                             sizePx: sizePx, xPx: tx,
                             baselinePx: Y(parseFloat(c.getAttribute("y") || 0)),
                             color: fillCol || PDFLib.rgb(0, 0, 0), fitPx: 0
@@ -443,17 +493,16 @@ var SlidesPdf = (function () {
         return [];
     }
 
+    /* Text no longer sends its object to a picture: a character no font
+       can show is drawn as a picture of itself in its place in the line
+       (OfficePdfCore.planRun), and the rest stays text. Only a chart whose
+       SVG the translator cannot express goes in whole. */
     function needsRaster(o, el, fonts) {
         if (!el) return false;
         if (o.type === "chart") {
             var svg = el.querySelector("svg");
             return !svg || !svgTranslatable(svg, fonts);
         }
-        var roots = textRootsOf(o, el);
-        for (var i = 0; i < roots.length; i++) {
-            var runs = collectRuns(roots[i], { left: 0, top: 0, x: 0, y: 0 });
-            if (runs.length && !canDrawAsText(runs, fonts)) return true;
-        }
         return false;
     }
 
@@ -497,7 +546,7 @@ var SlidesPdf = (function () {
         var inner = el ? el.querySelector(".sl-text-in") : null;
         if (!inner) return Promise.resolve();
         var runs = collectRuns(inner, origin);
-        if (runs.length) drawRuns(pg, runs);
+        if (runs.length) drawRuns(pg, runs, textPicture(pg, ctx));
         return Promise.resolve();
     }
 
@@ -510,6 +559,16 @@ var SlidesPdf = (function () {
         if (!src) return Promise.resolve();
         var filter = (window.SlidesImageTools ? SlidesImageTools.imageFilter(p) : "");
         var prep = filter ? filteredImageData(src, filter) : Promise.resolve(src);
+        // an SVG picture is drawn to a PNG here, at the size it is shown
+        // (with its crop) - a worker has no way to draw one. GIF, WebP and
+        // BMP are decoded where the file is written (pdfdraw.js).
+        if (/^data:image\/svg/i.test(src)) {
+            var sc = p.crop && p.crop.length === 4 ? p.crop.map(function (v) { return Number(v) || 0; }) : [0, 0, 0, 0];
+            var sw = Math.max(1, o.w / Math.max(0.001, 1 - sc[0] - sc[2]));
+            var sh = Math.max(1, o.h / Math.max(0.001, 1 - sc[1] - sc[3]));
+            var k = Math.min(C.RASTER_SCALE, 4096 / Math.max(sw, sh));
+            prep = filteredImageData(src, filter || "none", Math.round(sw * k), Math.round(sh * k));
+        }
         // a fill on the frame shows through the picture's transparent parts
         var under = null, underImg = null;
         if (o.type === "image" && p.fillGrad && p.fillGrad.stops && p.fillGrad.stops.length) {
@@ -538,7 +597,7 @@ var SlidesPdf = (function () {
 
             pg.save();
             // clip to the frame, in the shape the picture is masked to
-            var clipOps = shapePathOps(p.mask || "rect", o.x, o.y, o.w, o.h, p.radius);
+            var clipOps = shapePathOps(imageClipKind(p), o.x, o.y, o.w, o.h, p.radius, p.adj);
             clipOps.push(PDFLib.clip(), PDFLib.endPath());
             pg.ops(clipOps);
             if (p.opacity && p.opacity < 1) {
@@ -569,7 +628,7 @@ var SlidesPdf = (function () {
             var sw = Number(p.strokeW) || 0;
             var sc = (sw > 0 && p.stroke && p.stroke !== "none") ? parseColor(p.stroke) : null;
             if (sc && o.type === "image") {
-                var sops = shapePathOps(p.mask || "rect", o.x, o.y, o.w, o.h, p.radius);
+                var sops = shapePathOps(imageClipKind(p), o.x, o.y, o.w, o.h, p.radius, p.adj);
                 sops.unshift(PDFLib.setStrokingColor(sc), PDFLib.setLineWidth(px(sw)));
                 var idash = SlidesLines.dashArray(p, sw);
                 if (idash) sops.unshift(PDFLib.setDashPattern(idash.map(px), 0));
@@ -589,12 +648,12 @@ var SlidesPdf = (function () {
         // outline (gradientPng); that picture has to be in hand first
         if (g && g.stops && g.stops.length && ctx && ctx.embed) {
             return ctx.embed(gradientPng(g, Math.max(1, o.w), Math.max(1, o.h))).then(function (img) {
-                return drawShapeBody(pg, o, el, origin, img);
+                return drawShapeBody(pg, o, el, origin, img, ctx);
             });
         }
-        return drawShapeBody(pg, o, el, origin, null);
+        return drawShapeBody(pg, o, el, origin, null, ctx);
     }
-    function drawShapeBody(pg, o, el, origin, gradImg) {
+    function drawShapeBody(pg, o, el, origin, gradImg, ctx) {
         var p = o.props || {};
         var kind = window.SlidesShapes ? SlidesShapes.canonical(p.kind || "rect") : (p.kind || "rect");
         var open = window.SlidesShapes && SlidesShapes.isOpen(kind);
@@ -663,7 +722,7 @@ var SlidesPdf = (function () {
         }
         // 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) : "";
+        var det = window.SlidesShapes ? SlidesShapes.detail(kind, o.w, o.h, p.adj) : "";
         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); };
@@ -680,7 +739,7 @@ var SlidesPdf = (function () {
         var inner = el ? el.querySelector(".sl-shape-text") : null;
         if (!inner) return Promise.resolve();
         var runs = collectRuns(inner, origin || { left: 0, top: 0, x: o.x, y: o.y });
-        if (runs.length) drawRuns(pg, runs);
+        if (runs.length) drawRuns(pg, runs, ctx ? textPicture(pg, ctx) : null);
         return Promise.resolve();
     }
 
@@ -757,7 +816,7 @@ var SlidesPdf = (function () {
                         { stroke: bd.c, strokeW: bw, strokeOpacity: bd.a });
                 }
                 var runs = collectRuns(cell, origin);
-                if (runs.length) drawRuns(pg, runs);
+                if (runs.length) drawRuns(pg, runs, textPicture(pg, ctx));
             })(cells[i]);
         }
         return Promise.all(jobs);
@@ -921,7 +980,7 @@ var SlidesPdf = (function () {
                         return withStage(slide, drawSlide);
                     });
                     function drawSlide(stageEl) {
-                        var ctx = { stage: stageEl, embed: embedImage };
+                        var ctx = { stage: stageEl, embed: embedImage, embedNow: rec.embedNow };
                         // every face this slide needs is in hand before a
                         // single object is measured, so the drawing below
                         // can stay synchronous

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 799 - 44
src/web/Office/slides/slides_shapes.js


+ 41 - 1
src/web/Office/slides/test_lines.js

@@ -1,7 +1,7 @@
 /*
     node web/Office/slides/test_lines.js
 
-    Checks the line styling (slides_lines.js) and the callout adjustments
+    Checks the line styling (slides_lines.js) and the shape adjustments
     (slides_shapes.js) without a browser: the canvas and the PDF exporter
     both draw from these, so a wrong number here is wrong everywhere.
 */
@@ -91,6 +91,46 @@ check("rect has no adjustment", SlidesShapes.adjustable("rect") === "");
 var below = SlidesShapes.path("wedgeRectCallout", 120, 60, { adj1: -20833, adj2: 62500 });
 check("tail on the bottom side", below.indexOf("L50 60L25") > 0 || below.indexOf("L50 60") > 0, below);
 
+/* ---- preset handles ---- */
+// every handle lands back where it was dragged from, and the outline it
+// leads to is a real path
+SlidesShapes.CATEGORIES.forEach(function (c) {
+    c.kinds.forEach(function (k) {
+        [[200, 100], [80, 240]].forEach(function (sz) {
+            SlidesShapes.handles(k, sz[0], sz[1], null).forEach(function (pt, i) {
+                var adj = SlidesShapes.dragHandle(k, i, sz[0], sz[1], pt[0], pt[1], null);
+                var back = SlidesShapes.handles(k, sz[0], sz[1], adj)[i];
+                check(k + " handle " + i + " round trip at " + sz,
+                    near(back[0], pt[0], 0.5) && near(back[1], pt[1], 0.5), JSON.stringify([pt, back, adj]));
+                var d = SlidesShapes.path(k, sz[0], sz[1], adj);
+                check(k + " adjusted path", d && !/NaN/.test(d), d);
+            });
+        });
+    });
+});
+// a right arrow: the shaft's thickness at the tail, the head's base on top
+var ra = SlidesShapes.handles("rightArrow", 200, 100, null);
+check("arrow handles", ra.length === 2 && near(ra[0][0], 0) && near(ra[0][1], 25) &&
+    near(ra[1][0], 150) && near(ra[1][1], 0), JSON.stringify(ra));
+var thin = SlidesShapes.dragHandle("rightArrow", 0, 200, 100, 0, 40, null);
+check("arrow shaft drag", thin.adj1 === 20000 && thin.adj2 === 50000, JSON.stringify(thin));
+var longHead = SlidesShapes.dragHandle("rightArrow", 1, 200, 100, 100, 0, thin);
+check("arrow head drag keeps the shaft", longHead.adj1 === 20000 && longHead.adj2 === 100000, JSON.stringify(longHead));
+check("arrow head is capped by the length",
+    SlidesShapes.dragHandle("rightArrow", 1, 200, 100, -500, 0, null).adj2 === 200000);
+// the same drag on an up arrow, whose head is at the top
+var ua = SlidesShapes.dragHandle("upArrow", 1, 100, 200, 0, 100, null);
+check("up arrow head drag", ua.adj2 === 100000, JSON.stringify(ua));
+// a rounded single corner: the handle is where the corner ends
+var r1 = SlidesShapes.dragHandle("round1Rect", 0, 200, 100, 170, 0, null);
+check("corner drag", r1.adj === 30000, JSON.stringify(r1));
+check("corner is capped at half", SlidesShapes.dragHandle("round1Rect", 0, 200, 100, 0, 0, null).adj === 50000);
+check("star inner radius", SlidesShapes.dragHandle("star5", 0, 100, 100, 50, 25, null).adj === 25000);
+check("points follow adjustments",
+    JSON.stringify(SlidesShapes.points("triangle", 100, 50, { adj: 0 })[0]) === "[0,0]");
+check("rect has no handles", SlidesShapes.handles("rect", 100, 100, null).length === 0);
+check("roundRect keeps its radius handle in slides.js", SlidesShapes.handles("roundRect", 100, 100, null).length === 0);
+
 if (failures) {
     console.log(failures + " check(s) failed");
     process.exit(1);

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels