1
0

3 Коммиты 54fbab9269 ... d6555567a2

Автор SHA1 Сообщение Дата
  Toby Chui d6555567a2 Merge branch 'claude/modest-bhabha-12961e' into v3.0.4 1 неделя назад
  Toby Chui f44a0c8f50 Fix weighted face handling in Office text/PDF 1 неделя назад
  Toby Chui ac78b5e668 Enhance slide fidelity and shape adjustments 1 неделя назад

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

@@ -80,6 +80,12 @@ func StackFor(latin, ea string) string {
 	return strings.Join(parts, ",")
 	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
 // 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.
 // order fonts.js offers them to the browser. Keep the two lists in step.
 var shippedFontFallbacks = []string{
 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
 	// (adj1, adj2 ... in 1/100000 of the frame): where a callout's tip
 	// points, how round a corner is
 	// points, how round a corner is
 	Adj map[string]float64 `json:"adj,omitempty"`
 	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
 	// elbow / rotated connectors imported from pptx: the polyline the
 	// connector actually follows, relative to the object's x/y. Absent on
 	// 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.
 	// 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 != "" {
 	if st.Font != "" {
 		sb.WriteString("font-family:" + fonts.StackFor(st.Font, 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 {
 	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 {
 	if st.Italic {
 		sb.WriteString("font-style:italic;")
 		sb.WriteString("font-style:italic;")

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

@@ -1,9 +1,38 @@
 package odf
 package odf
 
 
 import (
 import (
+	"strings"
 	"testing"
 	"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
 // 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.
 // deck loses its shapes on a .odp round trip.
 func TestOdpShapeTypesRoundTrip(t *testing.T) {
 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) {
 func TestPptxBentConnectorKeepsItsElbow(t *testing.T) {
 	cxn := `<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="2" name="c"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr>` +
 	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>` +
 		`<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)
 		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
 	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
 // phTypeAlike treats the title variants as one kind, the way PowerPoint does
 func phTypeAlike(a, b string) bool {
 func phTypeAlike(a, b string) bool {
 	norm := func(s string) string {
 	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.Latin, bs.EastAsian = firstRun.Latin, firstRun.EastAsian
 					bs.SizePt *= k
 					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, ""
 				bs.Underline, bs.Strike, bs.Highlight = false, false, ""
 				left := (ps.MarLeft + ps.Indent) * cm.kx
 				left := (ps.MarLeft + ps.Indent) * cm.kx
 				// the outer span is a line of the paragraph's own text -
 				// 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"),
 		layoutSp.Path("txBody", "bodyPr"),
 		node.Path("txBody", "bodyPr"),
 		node.Path("txBody", "bodyPr"),
 	}, cm)
 	}, 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)
 	tr := sc.buildTextBody(node.First("txBody"), chain, bp, cm)
 
 
 	kind, known := prstToShapeKind[prst]
 	kind, known := prstToShapeKind[prst]
@@ -1403,12 +1440,15 @@ func (sc *slideCtx) parseSp(node *xmltree.Node, cm coordMap) *model.Object {
 	}
 	}
 
 
 	if !isShape {
 	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 nil
 		}
 		}
 		return &model.Object{
 		return &model.Object{
 			Type: "text", X: box.X, Y: box.Y, W: box.W, H: box.H, Rot: box.Rot,
 			Type: "text", X: box.X, Y: box.Y, W: box.W, H: box.H, Rot: box.Rot,
 			Props: model.Props{
 			Props: model.Props{
+				Ph:   role,
 				HTML: tr.HTML, FontSize: units.Round2(tr.FontSize), Color: tr.First.Color,
 				HTML: tr.HTML, FontSize: units.Round2(tr.FontSize), Color: tr.First.Color,
 				Align: tr.Align, Bold: tr.Bold, Italic: tr.Italic,
 				Align: tr.Align, Bold: tr.Bold, Italic: tr.Italic,
 				Underline: tr.Underline, FontFamily: tr.FontCSS,
 				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
 	// a picture drawn through a preset geometry is a shaped crop
 	if kind, ok := prstToShapeKind[prst]; ok && kind != "rect" {
 	if kind, ok := prstToShapeKind[prst]; ok && kind != "rect" {
 		props.Mask = kind
 		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 {
 	if rad := sc.cornerRadius(spPr, prst, box); rad > 0 {
 		props.Radius = units.Round2(rad)
 		props.Radius = units.Round2(rad)
@@ -2169,6 +2214,20 @@ func transitionOf(sld *xmltree.Node) string {
 	return ""
 	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
 // 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)
 // 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 {
 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",
 		{"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>`,
 			`<a:p><a:pPr marL="342900" indent="-342900"><a:buChar char="&#8226;"/></a:pPr><a:endParaRPr sz="2000"/></a:p>`,
 			nil, []string{"•"}},
 			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",
 		{"a number is never set in a symbol font",
 			para(`<a:buFont typeface="Wingdings"/><a:buAutoNum type="arabicPeriod"/>`, "one"),
 			para(`<a:buFont typeface="Wingdings"/><a:buAutoNum type="arabicPeriod"/>`, "one"),
 			[]string{"1."}, []string{"Wingdings"}},
 			[]string{"1."}, []string{"Wingdings"}},
@@ -336,6 +343,14 @@ func TestPptxTextLayoutRules(t *testing.T) {
 				`<p:txBody><a:bodyPr spcFirstLastPara="1"/><a:lstStyle/>` +
 				`<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>`,
 				`<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},
 			[]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",
 		{"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>`+
 			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>`),
 				`<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
 	var sb strings.Builder
 	sb.WriteString("font-size:" + units.FmtPx(units.PtToPx(rs.SizePt)*scale) + ";")
 	sb.WriteString("font-size:" + units.FmtPx(units.PtToPx(rs.SizePt)*scale) + ";")
 	sb.WriteString("font-family:" + fonts.StackFor(rs.Latin, rs.EastAsian) + ";")
 	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 {
 	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 {
 	if rs.Italic {
 		sb.WriteString("font-style: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
 		// a SemiBold face at its own weight is the face, not bold on top
 		{"semibold face", "font-family:" + stackOpenSansSemi + ";font-weight:600;", `b="0"`},
 		{"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"`},
 		{"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"`},
 		{"600 on a plain face", "font-family:Arial;font-weight:600;", `b="1"`},
 		{"bold keyword", "font-family:Arial;font-weight:bold;", `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) {
 func TestPptxWriterKeepsTypefaceCase(t *testing.T) {
 	html := `<div><span style="font-size:24px;font-family:'Open Sans Medium','Open Sans',sans-serif;">Hi</span></div>`
 	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))
 	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 {
 		if para.End != nil {
 			// an empty line keeps its size and font (the reader sizes it by this)
 			// an empty line keeps its size and font (the reader sizes it by this)
 			sb.WriteString(runProps("a:endParaRPr", *para.End, base))
 			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>`)
 		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()
 	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>`
 	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 {
 func buildTextSp(id int, o *model.Object, theme string) string {
 	p := o.Props
 	p := o.Props
 	color := p.Color
 	color := p.Color
@@ -792,10 +820,10 @@ func buildTextSp(id int, o *model.Object, theme string) string {
 		color = "#" + themeTextColor(theme)
 		color = "#" + themeTextColor(theme)
 	}
 	}
 	return fmt.Sprintf(
 	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:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>`+
 			`<p:txBody>%s<a:lstStyle/>%s</p:txBody></p:sp>`,
 			`<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),
 		xfrm(o.X, o.Y, o.W, o.H, o.Rot, false, false),
 		bodyPrFor(p, "t"),
 		bodyPrFor(p, "t"),
 		buildRichBody(o, color))
 		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
 	// a shaped crop is a preset geometry on the picture itself
 	prst := shapeKindPrst(p.Mask)
 	prst := shapeKindPrst(p.Mask)
 	geom := `<a:prstGeom prst="` + prst + `"><a:avLst/></a:prstGeom>`
 	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") &&
 	if (prst == "roundRect" || p.Mask == "" || p.Mask == "rect") &&
 		p.Radius > 0 && o.W > 0 && o.H > 0 {
 		p.Radius > 0 && o.W > 0 && o.H > 0 {
 		adj := int(p.Radius / minF(o.W, o.H) * 100000)
 		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
    [the note there](common/fonts/README.md)) — fetched, parsed for glyph
    coverage, and embedded subset to the glyphs the deck actually used. This
    coverage, and embedded subset to the glyphs the deck actually used. This
    is what carries CJK.
    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,
 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
 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
   ascent below the top of that rect. Treating it as the line box puts every
   line a couple of pixels high at 30px type.
   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
 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
 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
 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
 preview of itself. The previews are built by the same `newSlide()` and
 `renderSlideContent()` the document uses, at the scale the rail uses, so a
 `renderSlideContent()` the document uses, at the scale the rail uses, so a
 preview cannot come to disagree with the slide choosing it makes. The
 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 -
 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.
 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
 **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
 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
 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
 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
 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
 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
 ### 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 |
 | `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) |
 | `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 |
 | `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
 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
 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
 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
 strength on top. Dragging a grip moves the *frame*; dragging the picture
 moves the *source underneath it*. Enter or a click outside applies, Esc
 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
 back to `props.orig` (stamped the first time a picture is trimmed) and
 corrects the height to the source's own aspect ratio.
 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
 `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
 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
 the catalogue's polygons and not all of it: a curved outline would have to be
 restated in pixels at every size.
 restated in pixels at every size.
 
 
@@ -971,8 +973,10 @@ geometry.
 ```js
 ```js
 SlidesShapes.path(kind, w, h, adj) // "M0 0L100 0..." ("" if unknown)
 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.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.icon(kind, size)      // the same outline as a small SVG
 SlidesShapes.canonical(kind)       // preset spelling -> catalogue name
 SlidesShapes.canonical(kind)       // preset spelling -> catalogue name
 SlidesShapes.isOpen / evenOdd / label / defaultSize / CATEGORIES
 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`):
 The common presets follow PowerPoint's own formulas (`presetShapeDefinitions`):
 their features are sized from the shorter side and read the guides a deck
 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
 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
 `ALIASES` holds the three names the editor used before this file existed
 (`round`, `arrow`, `star`). They are not shapes any more - `normalizeBody()`
 (`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
     /* The shipped families. The CJK faces carry Regular only: bold is
        synthesized, by the browser on screen and by a stroked text rendering
        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 = {
     var SHIPPED = {
         "noto sans": {
         "noto sans": {
             family: "Noto Sans",
             family: "Noto Sans",
@@ -687,7 +688,7 @@ var OfficeFonts = (function () {
                     finalLoads.push(STYLE_DESC[s2].css + ' 16px "' + pl.name.replace(/["\\]/g, "") + '"');
                     finalLoads.push(STYLE_DESC[s2].css + ' 16px "' + pl.name.replace(/["\\]/g, "") + '"');
                 }
                 }
                 substituted[pl.key] = { via: pl.via || "Noto Sans", adjust: adj, kind: pl.kind,
                 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);
             probeEl.parentNode.removeChild(probeEl);
             if (!styleEl) {
             if (!styleEl) {
@@ -723,8 +724,10 @@ var OfficeFonts = (function () {
 
 
     /* the stand-in a family was given, for exporters that draw text
     /* the stand-in a family was given, for exporters that draw text
        themselves: { via: "Arial", adjust: [r, b, i, bi], kind: "sans",
        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) {
     function substituteOf(family) {
         return substituted[norm(family)] || null;
         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
 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
 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
 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
 ## 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
     Weights: the Latin family ships all four styles. The CJK faces ship
     Regular only and let bold be synthesized - the browser smears the outline
     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
     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
     CJK would double an already large payload for a difference neither side
     can see once it is on the page.
     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) {
     function winAnsiCp(cp) {
         return cp >= 32 && cp <= 255;
         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
     /* haveFamily reports whether a family is actually installed. It has to
        be measured: document.fonts.check() answers "is it loaded", and for a
        be measured: document.fonts.check() answers "is it loaded", and for a
        local family Chrome says yes whatever name you give it. The reliable
        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
                       the answer is not known until it is
          null         nothing here can show this character
          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
        A family that is neither - a system font - is stepped over rather
        than used: its bytes are unreadable, so the character goes to the
        than used: its bytes are unreadable, so the character goes to the
        next entry, which is the shipped face for its script. */
        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;
             var sub = OfficeFonts.substituteOf ? OfficeFonts.substituteOf(name) : null;
             // a metric twin the suite ships stood in on screen: the PDF
             // a metric twin the suite ships stood in on screen: the PDF
             // embeds that same face
             // embeds that same face
+            var sb = !!(sub && sub.heavy);
             if (sub && sub.twin) {
             if (sub && sub.twin) {
-                var tw = fonts.shipped(sub.via, bold, italic);
+                var tw = fonts.shipped(sub.via, bold || sb, italic);
                 if (!tw) {
                 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))) {
                 } 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)) {
             if (sub && winAnsiCp(cp)) {
                 var std = STD_FAMILIES[String(sub.via).toLowerCase()] ||
                 var std = STD_FAMILIES[String(sub.via).toLowerCase()] ||
                     { serif: "TimesRoman", mono: "Courier" }[sub.kind] || "Helvetica";
                     { serif: "TimesRoman", mono: "Courier" }[sub.kind] || "Helvetica";
-                return { std: std };
+                return { std: std, bold: sb };
             }
             }
         }
         }
         return null;
         return null;
@@ -283,26 +330,36 @@ var OfficePdfCore = (function () {
 
 
     /* segmentText cuts a fragment into the pieces that share one font, the
     /* segmentText cuts a fragment into the pieces that share one font, the
        way a browser does per character. Returns null when any character has
        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) {
     function segmentText(text, names, fonts, bold, italic) {
         var segs = [], cur = null;
         var segs = [], cur = null;
         for (var i = 0; i < text.length; i++) {
         for (var i = 0; i < text.length; i++) {
             var cp = text.codePointAt(i);
             var cp = text.codePointAt(i);
             var ch = String.fromCodePoint(cp);
             var ch = String.fromCodePoint(cp);
             if (ch.length > 1) i++;          // a surrogate pair
             if (ch.length > 1) i++;          // a surrogate pair
+            if (ignorableCp(cp)) continue;
             var res = resolveChar(cp, names, fonts, bold, italic);
             var res = resolveChar(cp, names, fonts, bold, italic);
             if (!res || res.need) return null;
             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;
             if (cur && cur.key === key) cur.text += ch;
             else { cur = { key: key, res: res, text: ch }; segs.push(cur); }
             else { cur = { key: key, res: res, text: ch }; segs.push(cur); }
         }
         }
         return segs;
         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) {
     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);
         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
     /* Where the baseline sits is the browser's decision, and the exporter
@@ -340,6 +397,162 @@ var OfficePdfCore = (function () {
         return m;
         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
     /* 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
        make, kept in one place so they cannot disagree about what counts as
        text on the slide. */
        text on the slide. */
@@ -348,7 +561,7 @@ var OfficePdfCore = (function () {
         var node;
         var node;
         while ((node = walker.nextNode())) {
         while ((node = walker.nextNode())) {
             var text = node.nodeValue;
             var text = node.nodeValue;
-            if (!text || !text.trim()) continue;
+            if (!text || !visibleText(text).trim()) continue;
             var parent = node.parentElement;
             var parent = node.parentElement;
             if (!parent) continue;
             if (!parent) continue;
             var cs = window.getComputedStyle(parent);
             var cs = window.getComputedStyle(parent);
@@ -485,15 +698,20 @@ var OfficePdfCore = (function () {
             range.selectNodeContents(node);
             range.selectNodeContents(node);
             var rects = Array.prototype.slice.call(range.getClientRects());
             var rects = Array.prototype.slice.call(range.getClientRects());
             if (!rects.length) return;
             if (!rects.length) return;
+            var ls = parseFloat(cs.letterSpacing);
             var shared = {
             var shared = {
                 origin: origin,
                 origin: origin,
+                node: node,
                 names: familyList(cs.fontFamily),
                 names: familyList(cs.fontFamily),
+                family: cs.fontFamily,
                 metrics: fontMetricsOf(cs),
                 metrics: fontMetricsOf(cs),
                 size: parseFloat(cs.fontSize) || 12,
                 size: parseFloat(cs.fontSize) || 12,
                 weight: parseInt(cs.fontWeight, 10) || (cs.fontWeight === "bold" ? 700 : 400),
                 weight: parseInt(cs.fontWeight, 10) || (cs.fontWeight === "bold" ? 700 : 400),
                 italic: cs.fontStyle === "italic" || cs.fontStyle === "oblique",
                 italic: cs.fontStyle === "italic" || cs.fontStyle === "oblique",
                 underline: cs.textDecorationLine.indexOf("underline") >= 0,
                 underline: cs.textDecorationLine.indexOf("underline") >= 0,
                 strike: cs.textDecorationLine.indexOf("line-through") >= 0,
                 strike: cs.textDecorationLine.indexOf("line-through") >= 0,
+                transform: cs.textTransform,
+                letterSpacing: isFinite(ls) ? ls : 0,
                 color: cs.color,
                 color: cs.color,
                 background: cs.backgroundColor
                 background: cs.backgroundColor
             };
             };
@@ -501,6 +719,7 @@ var OfficePdfCore = (function () {
                 var r = Object.create(shared);
                 var r = Object.create(shared);
                 r.text = ln.text;
                 r.text = ln.text;
                 r.rect = ln.rect;
                 r.rect = ln.rect;
+                r.start = ln.start;
                 runs.push(r);
                 runs.push(r);
             });
             });
         });
         });
@@ -510,14 +729,16 @@ var OfficePdfCore = (function () {
     /* splitByLine maps a text node's client rects back onto the substrings
     /* 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.
        that produced them, so each line can be drawn at its own baseline.
        Character-by-character is the only reliable way: the browser decides
        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) {
     function splitByLine(node, text, lineRects) {
         if (lineRects.length === 1) {
         if (lineRects.length === 1) {
-            return [{ text: text, rect: lineRects[0] }];
+            return [{ text: text, rect: lineRects[0], start: 0 }];
         }
         }
         var range = document.createRange();
         var range = document.createRange();
         var out = [];
         var out = [];
         var cur = "";
         var cur = "";
+        var curStart = 0;
         var curTop = null;
         var curTop = null;
         var curRect = null;
         var curRect = null;
         for (var i = 0; i < text.length; i++) {
         for (var i = 0; i < text.length; i++) {
@@ -531,14 +752,15 @@ var OfficePdfCore = (function () {
                 else { curRect.right = Math.max(curRect.right, r.right); }
                 else { curRect.right = Math.max(curRect.right, r.right); }
                 cur += text[i];
                 cur += text[i];
             } else {
             } else {
-                out.push({ text: cur, rect: curRect });
+                out.push({ text: cur, rect: curRect, start: curStart });
                 cur = text[i];
                 cur = text[i];
+                curStart = i;
                 curTop = top;
                 curTop = top;
                 curRect = { left: r.left, top: r.top, bottom: r.bottom, right: r.right };
                 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
     // canDrawAsText is the whole fallback decision, in one place: every
@@ -546,11 +768,102 @@ var OfficePdfCore = (function () {
     function canDrawAsText(runs, fonts) {
     function canDrawAsText(runs, fonts) {
         for (var i = 0; i < runs.length; i++) {
         for (var i = 0; i < runs.length; i++) {
             var r = runs[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;
         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
     /* drawFragment puts one line fragment on the page as real text, in as
        many pieces as it takes fonts to spell it.
        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.
        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,
        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) {
     function drawFragment(pg, spec) {
         var fonts = pg.fonts;
         var fonts = pg.fonts;
         var segs = segmentText(spec.text, spec.names, fonts, spec.bold, spec.italic);
         var segs = segmentText(spec.text, spec.names, fonts, spec.bold, spec.italic);
         if (!segs || !segs.length) return 0;
         if (!segs || !segs.length) return 0;
 
 
+        var ls = spec.letterSpacing || 0;
         var total = 0;
         var total = 0;
         for (var i = 0; i < segs.length; i++) {
         for (var i = 0; i < segs.length; i++) {
             var face = faceOf(segs[i].res, fonts, spec.bold, spec.italic);
             var face = faceOf(segs[i].res, fonts, spec.bold, spec.italic);
             if (!face) return 0;
             if (!face) return 0;
             segs[i].face = face;
             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;
             total += segs[i].w;
         }
         }
 
 
@@ -590,17 +913,23 @@ var OfficePdfCore = (function () {
         segs.forEach(function (seg) {
         segs.forEach(function (seg) {
             pg.save();
             pg.save();
             var ops = [];
             var ops = [];
+            var synth = seg.synth || { bold: 0, skew: 0 };
             if (scale !== 1) ops.push(PDFLib.setCharacterSqueeze(scale * 100));
             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.setTextRenderingMode(PDFLib.TextRenderingMode.FillAndOutline));
-                ops.push(PDFLib.setLineWidth(px(spec.sizePx / 28)));
+                ops.push(PDFLib.setLineWidth(px(synth.bold)));
                 ops.push(PDFLib.setStrokingColor(col));
                 ops.push(PDFLib.setStrokingColor(col));
             }
             }
             if (ops.length) pg.ops(ops);
             if (ops.length) pg.ops(ops);
-            pg.p.drawText(seg.text, {
+            var opt = {
                 x: px(cursor), y: pg.y(spec.baselinePx),
                 x: px(cursor), y: pg.y(spec.baselinePx),
                 size: px(spec.sizePx), font: seg.face.font, color: col
                 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();
             pg.restore();
             cursor += seg.w * scale;
             cursor += seg.w * scale;
         });
         });
@@ -608,8 +937,11 @@ var OfficePdfCore = (function () {
     }
     }
 
 
     /* drawRuns puts every run on the page at the baseline the browser laid
     /* 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) {
         runs.forEach(function (r) {
             var x = r.origin.x + (r.rect.left - r.origin.left);
             var x = r.origin.x + (r.rect.left - r.origin.left);
             var top = r.origin.y + (r.rect.top - r.origin.top);
             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 col = parseColor(r.color) || PDFLib.rgb(0, 0, 0);
             var bg = parseFill(r.background);
             var bg = parseFill(r.background);
             if (bg) pg.rect(x, top, domW, lineH, { fill: bg.c, fillOpacity: bg.a });
             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) {
             if (r.underline || r.strike) {
                 var yOff = r.underline ? baseline + r.size * 0.11 : baseline - r.size * 0.28;
                 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
        The cost is that computed styles have to be inlined onto the clone
        (an SVG image cannot reach the page's stylesheets) and every
        (an SVG image cannot reach the page's stylesheets) and every
        resource must already be a data URL - which, in a slide, it is. */
        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 {
         try {
             var clone = el.cloneNode(true);
             var clone = el.cloneNode(true);
             inlineStyles(el, clone);
             inlineStyles(el, clone);
+            ext = ext || { x: 0, y: 0, w: w, h: h };
             // the foreignObject supplies the box, so the clone must not
             // the foreignObject supplies the box, so the clone must not
             // carry the absolute placement it had on the slide
             // 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.transform = "none";
             clone.style.margin = "0";
             clone.style.margin = "0";
             clone.style.width = w + "px";
             clone.style.width = w + "px";
             clone.style.height = h + "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,
             /* The markup inside a foreignObject has to be well-formed XML,
                and innerHTML is not: it writes <br> unclosed, which makes the
                and innerHTML is not: it writes <br> unclosed, which makes the
                whole SVG fail to parse and the element vanish from the page.
                whole SVG fail to parse and the element vanish from the page.
                XMLSerializer writes real XHTML, so it cannot. */
                XMLSerializer writes real XHTML, so it cannot. */
             var wrap = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
             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);
             wrap.appendChild(clone);
             var xhtml = new XMLSerializer().serializeToString(wrap);
             var xhtml = new XMLSerializer().serializeToString(wrap);
             var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + cw +
             var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + cw +
@@ -810,6 +1154,10 @@ var OfficePdfCore = (function () {
         RASTER_SCALE: RASTER_SCALE,
         RASTER_SCALE: RASTER_SCALE,
         STD_FAMILIES: STD_FAMILIES,
         STD_FAMILIES: STD_FAMILIES,
         winAnsiCp: winAnsiCp,
         winAnsiCp: winAnsiCp,
+        ignorableCp: ignorableCp,
+        visibleText: visibleText,
+        transformText: transformText,
+        synthStyle: synthStyle,
         haveFamily: haveFamily,
         haveFamily: haveFamily,
         familyList: familyList,
         familyList: familyList,
         loadFontkit: loadFontkit,
         loadFontkit: loadFontkit,
@@ -829,6 +1177,8 @@ var OfficePdfCore = (function () {
         collectRuns: collectRuns,
         collectRuns: collectRuns,
         splitByLine: splitByLine,
         splitByLine: splitByLine,
         canDrawAsText: canDrawAsText,
         canDrawAsText: canDrawAsText,
+        planRun: planRun,
+        rasterText: rasterText,
         drawFragment: drawFragment,
         drawFragment: drawFragment,
         drawRuns: drawRuns,
         drawRuns: drawRuns,
         rasterizeElement: rasterizeElement,
         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:
     Coordinates are px from the page's top-left corner. Ops are arrays:
         ["rect", x, y, w, h, rgb, alpha]
         ["rect", x, y, w, h, rgb, alpha]
         ["image", id, x, y, w, h, crop]       crop {t,r,b,l} fractions | null
         ["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
               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]
         ["leader", x, w, baseline, size, rgb, ch, font]
         ["ellipse", cx, cy, rx, ry, rgb, strokeW]      strokeW 0 = filled
         ["ellipse", cx, cy, rx, ry, rgb, strokeW]      strokeW 0 = filled
         ["poly", [x, y, ...], rgb, strokeW]
         ["poly", [x, y, ...], rgb, strokeW]
@@ -42,6 +46,7 @@
         var rec = OfficePdfDraw.recorder();   (in the page)
         var rec = OfficePdfDraw.recorder();   (in the page)
             rec.addPage([w, h]) -> a stand-in for a pdf-lib PDFPage
             rec.addPage([w, h]) -> a stand-in for a pdf-lib PDFPage
             rec.embed(src)      -> Promise<picture handle> for drawImage
             rec.embed(src)      -> Promise<picture handle> for drawImage
+            rec.embedNow(src)   -> the same handle, synchronously
             rec.job(title)      -> the job
             rec.job(title)      -> the job
 */
 */
 
 
@@ -73,6 +78,32 @@ var OfficePdfDraw = (function () {
         return null;
         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) {
     function render(job, opts) {
         opts = opts || {};
         opts = opts || {};
         var pdfDoc;
         var pdfDoc;
@@ -127,8 +158,11 @@ var OfficePdfDraw = (function () {
                 var kind = sniff(bytes);
                 var kind = sniff(bytes);
                 if (kind === "jpg") return pdfDoc.embedJpg(bytes);
                 if (kind === "jpg") return pdfDoc.embedJpg(bytes);
                 if (kind === "png") return pdfDoc.embedPng(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) {
             }).catch(function () { return null; }).then(function (img) {
                 imageReady[id] = img;
                 imageReady[id] = img;
                 return img;
                 return img;
@@ -230,14 +264,17 @@ var OfficePdfDraw = (function () {
            it, squeezed or stretched to the width the browser gave it */
            it, squeezed or stretched to the width the browser gave it */
         function drawText(page, op, y, push) {
         function drawText(page, op, y, push) {
             var x = op[1], baseline = op[2], size = op[3], col = rgb(op[4]), fitW = op[5];
             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 segs = [];
             var total = 0;
             var total = 0;
             for (var i = 0; i < op[6].length; i++) {
             for (var i = 0; i < op[6].length; i++) {
                 var s = op[6][i];
                 var s = op[6][i];
                 var font = fontReady[s[1]];
                 var font = fontReady[s[1]];
                 if (!font) return;
                 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;
                 total += w;
             }
             }
             var scale = 1;
             var scale = 1;
@@ -251,13 +288,17 @@ var OfficePdfDraw = (function () {
             segs.forEach(function (seg) {
             segs.forEach(function (seg) {
                 var ops = [PDFLib.pushGraphicsState()];
                 var ops = [PDFLib.pushGraphicsState()];
                 if (scale !== 1) ops.push(PDFLib.setCharacterSqueeze(scale * 100));
                 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.setTextRenderingMode(PDFLib.TextRenderingMode.FillAndOutline));
-                    ops.push(PDFLib.setLineWidth(size / 28 * PX));
+                    ops.push(PDFLib.setLineWidth(seg.bold * PX));
                     ops.push(PDFLib.setStrokingColor(col));
                     ops.push(PDFLib.setStrokingColor(col));
                 }
                 }
                 push(ops);
                 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()]);
                 push([PDFLib.popGraphicsState()]);
                 cursor += seg.w * scale;
                 cursor += seg.w * scale;
             });
             });
@@ -412,6 +453,18 @@ var OfficePdfDraw = (function () {
             this.calls.push(["ops", list]);
             this.calls.push(["ops", list]);
         };
         };
         RecPage.prototype.recordAlpha = function (key, a) { this.calls.push(["alpha", key, a]); };
         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 {
         return {
             addPage: function (size) {
             addPage: function (size) {
@@ -421,16 +474,11 @@ var OfficePdfDraw = (function () {
             },
             },
             // a picture is named, not embedded: the worker embeds it once
             // a picture is named, not embedded: the worker embeds it once
             embed: function (src) {
             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) {
             job: function (title) {
                 return { title: title || "", images: images, pages: pages };
                 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
                   browser's baseline (OfficePdfCore.drawRuns - the font rules
                   are the ones Slides uses: standard PDF fonts for families
                   are the ones Slides uses: standard PDF fonts for families
                   metric-identical to them, the shipped Noto faces embedded
                   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
       background  a filled rectangle - per line fragment for highlighted text
       borders     filled strips; a collapsed table rule is centred on the
       borders     filled strips; a collapsed table rule is centred on the
                   cell edge the way the browser draws it, at the width 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 bb = frame.box(el.getBoundingClientRect());
                 var cell = (el.tagName === "TD" || el.tagName === "TH") && cs.borderCollapse === "collapse";
                 var cell = (el.tagName === "TD" || el.tagName === "TH") && cs.borderCollapse === "collapse";
                 ["Top", "Right", "Bottom", "Left"].forEach(function (side) {
                 ["Top", "Right", "Bottom", "Left"].forEach(function (side) {
@@ -167,6 +168,7 @@ var DocsPdf = (function () {
                     ch: leader === "hyphen" ? "-" : (leader === "underscore" ? "_" : "."),
                     ch: leader === "hyphen" ? "-" : (leader === "underscore" ? "_" : "."),
                     names: C.familyList(cs.fontFamily),
                     names: C.familyList(cs.fontFamily),
                     size: parseFloat(cs.fontSize) || 14.67,
                     size: parseFloat(cs.fontSize) || 14.67,
+                    weight: parseInt(cs.fontWeight, 10) || 400,
                     bold: (parseInt(cs.fontWeight, 10) || 400) >= 600,
                     bold: (parseInt(cs.fontWeight, 10) || 400) >= 600,
                     color: parseColor(cs.color) || PDFLib.rgb(0, 0, 0),
                     color: parseColor(cs.color) || PDFLib.rgb(0, 0, 0),
                     baseline: lb.y + (lb.h - (met.ascent + met.descent)) / 2 + met.ascent
                     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
         // text, as line fragments at their baselines
         var origin = { left: frame.left, top: frame.top, x: 0, y: 0 };
         var origin = { left: frame.left, top: frame.top, x: 0, y: 0 };
         var runs = C.collectRuns(o.pageEl, origin).filter(function (run) {
         var runs = C.collectRuns(o.pageEl, origin).filter(function (run) {
-            return run.text.trim() !== "";
+            return C.visibleText(run.text).trim() !== "";
         });
         });
         runs.forEach(function (run) {
         runs.forEach(function (run) {
             var top = run.rect.top - frame.top;
             var top = run.rect.top - frame.top;
@@ -235,6 +237,7 @@ var DocsPdf = (function () {
             kind: "marker", text: text, x: left, baseline: baseline, size: size,
             kind: "marker", text: text, x: left, baseline: baseline, size: size,
             shape: bulletShape(text, before, size, left, baseline),
             shape: bulletShape(text, before, size, left, baseline),
             names: C.familyList(before.fontFamily),
             names: C.familyList(before.fontFamily),
+            weight: parseInt(before.fontWeight, 10) || 400,
             bold: (parseInt(before.fontWeight, 10) || 400) >= 600,
             bold: (parseInt(before.fontWeight, 10) || 400) >= 600,
             italic: before.fontStyle === "italic",
             italic: before.fontStyle === "italic",
             color: parseColor(before.color) || PDFLib.rgb(0, 0, 0)
             color: parseColor(before.color) || PDFLib.rgb(0, 0, 0)
@@ -287,11 +290,14 @@ var DocsPdf = (function () {
             for (var i = 0; i < text.length; i++) {
             for (var i = 0; i < text.length; i++) {
                 var cp = text.codePointAt(i);
                 var cp = text.codePointAt(i);
                 if (cp > 0xFFFF) 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) {
         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); });
             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); });
             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; }
         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);
         var segs = C.segmentText(text, names, fonts, bold, italic);
-        if (!segs || !segs.length) return null;
+        if (!segs) return null;
         var out = [];
         var out = [];
         for (var i = 0; i < segs.length; i++) {
         for (var i = 0; i < segs.length; i++) {
             var res = segs[i].res;
             var res = segs[i].res;
+            var b = bold || !!res.bold;
             if (res.std) {
             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 {
             } else {
-                var face = OfficeFonts.faceFor(res.shipped, bold, italic);
+                var face = OfficeFonts.faceFor(res.shipped, b, italic);
                 if (!face) return null;
                 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;
         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() {
     function breathe() {
         return new Promise(function (res) { setTimeout(res, 0); });
         return new Promise(function (res) { setTimeout(res, 0); });
     }
     }
@@ -484,7 +476,6 @@ var DocsPdf = (function () {
                     var r = borderRect(b);
                     var r = borderRect(b);
                     ops.push(["rect", r[0], r[1] - dy, r[2], r[3], rgbOf(b.fill.c), b.fill.a]);
                     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) {
                 items.texts.forEach(function (r) {
                     var x = r.origin.x + (r.rect.left - r.origin.left);
                     var x = r.origin.x + (r.rect.left - r.origin.left);
                     var top = r.origin.y + (r.rect.top - r.origin.top) - dy;
                     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 domW = r.rect.right - r.rect.left;
                     var bg = C.parseFill(r.background);
                     var bg = C.parseFill(r.background);
                     if (bg) ops.push(["rect", x, top, domW, lineH, rgbOf(bg.c), bg.a]);
                     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 glyphH = r.metrics.ascent + r.metrics.descent;
                     var baseline = (lineH - glyphH) / 2 + r.metrics.ascent;
                     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) {
                     if (r.underline || r.strike) {
                         var yOff = r.underline ? baseline + r.size * 0.11 : baseline - r.size * 0.28;
                         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) {
                 items.extras.forEach(function (x) {
                     if (x.kind === "marker") {
                     if (x.kind === "marker") {
                         if (x.shape) {
                         if (x.shape) {
                             ops.push(shapeOps(x.shape, x.color, dy));
                             ops.push(shapeOps(x.shape, x.color, dy));
                         } else {
                         } 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") {
                     } 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") {
                     } 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]);
                         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 () {
         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,
 #slSlideLayer .sl-obj.sl-type-line svg * { pointer-events: none; }
 #slSlideLayer .sl-obj.sl-type-line svg * { pointer-events: none; }
 #slSlideLayer .sl-obj.sl-type-line svg > polyline:first-child { pointer-events: stroke; }
 #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 > svg { display: block; width: 100%; height: 100%; overflow: visible; }
 .sl-obj img { display: block; width: 100%; height: 100%; pointer-events: none; }
 .sl-obj img { display: block; width: 100%; height: 100%; pointer-events: none; }
 
 
@@ -243,6 +253,34 @@
     padding: 2px 4px;
     padding: 2px 4px;
     cursor: default;
     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
 /* imported paragraphs carry their own spacing; keep the browser from
    adding any of its own on top */
    adding any of its own on top */
 .sl-text-in > div { margin: 0; }
 .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; }
 .sl-viewbtn .of-ic { width: 15px; height: 15px; }
 
 
 /* the format painter is armed */
 /* 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 */
 /* Find and replace */
 .sl-findrep .ps-check { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: var(--of-fg); }
 .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:
     An image also carries what the picture tools put on it:
       crop[l,t,r,b] / mask / radius  the crop and the shaped crop
       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
       orig {x,y,w,h}                 the frame Reset image puts back
       flipH / flipV                  mirrored horizontally / vertically
       flipH / flipV                  mirrored horizontally / vertically
       recolor / bright / contrast    the colour treatment (slides_image.js)
       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" }
             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) {
     function newSlide(kind) {
         var th = themeOf ? themeOf() : THEMES.clean;
         var th = themeOf ? themeOf() : THEMES.clean;
         var textColor = (body ? themeOf() : th).text;
         var textColor = (body ? themeOf() : th).text;
         var s = { id: genId("s"), bg: null, notes: "", transition: "none", objects: [] };
         var s = { id: genId("s"), bg: null, notes: "", transition: "none", objects: [] };
         if (kind === "title") {
         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") {
         } 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") {
         } 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") {
         } 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") {
         } 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") {
         } 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") {
         } 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") {
         } 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") {
         } 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") {
         } 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
         // the layouts are drawn for 960x540; a deck of another shape gets
         // them spread over its own height
         // them spread over its own height
@@ -396,7 +421,7 @@ var SlidesApp = (function () {
         if (under) wrapS += "background:" + under + ";";
         if (under) wrapS += "background:" + under + ";";
         if (p.radius) wrapS += "border-radius:" + (Number(p.radius) || 0) + "px;";
         if (p.radius) wrapS += "border-radius:" + (Number(p.radius) || 0) + "px;";
         if (p.opacity) wrapS += "opacity:" + clamp(Number(p.opacity) || 1, 0, 1) + ";";
         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 + ";";
         if (clip) wrapS += "clip-path:" + clip + ";-webkit-clip-path:" + clip + ";";
         // a picture's outline (pptx <a:ln> on the picture) is centred on the
         // 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
         // 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) {
             } else if (w > 0 && h > 0) {
                 // CSS has no dash-dot, and a clipped wrapper cannot draw
                 // CSS has no dash-dot, and a clipped wrapper cannot draw
                 // along its own clip: an SVG does both
                 // 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 r = Number(p.radius) || 0;
                 var geo = d ? '<path d="' + esc(d) + '"'
                 var geo = d ? '<path d="' + esc(d) + '"'
                     : '<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + r + '"';
                     : '<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
     /* 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 || kind === "rect") return "";
         if (kind === "ellipse") return "ellipse(50% 50% at 50% 50%)";
         if (kind === "ellipse") return "ellipse(50% 50% at 50% 50%)";
         if (kind === "roundRect") return "";    // border-radius draws this one
         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 "";
         if (!pts) return "";
         return "polygon(" + pts.map(function (pt) {
         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(",") + ")";
         }).join(",") + ")";
     }
     }
 
 
@@ -484,8 +513,8 @@ var SlidesApp = (function () {
        states them as percentages so the clip follows the frame. A shape
        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
        with curves in it has none, and says so - which is why MASK_KINDS is
        all polygons. */
        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
     /* 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
         // markings - the divider bars of a predefined process, the fold of a
         // folded corner. They are drawn, never filled.
         // 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) {
         if (det) {
             inner += '<path d="' + det + '" fill="none" stroke="' +
             inner += '<path d="' + det + '" fill="none" stroke="' +
                 esc(hasStroke ? stroke : contrastText(fill)) + '" stroke-width="' +
                 esc(hasStroke ? stroke : contrastText(fill)) + '" stroke-width="' +
@@ -809,6 +838,15 @@ var SlidesApp = (function () {
             case "text":
             case "text":
                 d.innerHTML = '<div class="sl-text-in" style="' + textStyle(o.props) + '">' +
                 d.innerHTML = '<div class="sl-text-in" style="' + textStyle(o.props) + '">' +
                     (o.props.html || "") + "</div>";
                     (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;
                 break;
             case "image":
             case "image":
                 d.innerHTML = imageHtml(o.props, o.w, o.h);
                 d.innerHTML = imageHtml(o.props, o.w, o.h);
@@ -917,24 +955,44 @@ var SlidesApp = (function () {
         return h;
         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
        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 (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") {
         if (how === "radius") {
             var r = o.props.radius !== undefined ? Number(o.props.radius) : Math.min(o.w, o.h) * 0.15;
             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
     /* 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
        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";
                     stem.style.height = stemH + "px";
                     fr.appendChild(stem);
                     fr.appendChild(stem);
                     fr.appendChild(mkHandle("rot", w / 2, -stemH, hs));
                     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.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);
                         fr.appendChild(knob);
-                    }
+                    });
                 }
                 }
             }
             }
             framesEl.appendChild(fr);
             framesEl.appendChild(fr);
@@ -1171,7 +1228,7 @@ var SlidesApp = (function () {
         var full = fullImageRect(o);
         var full = fullImageRect(o);
         // everything the picture tools can put on a picture comes off: the
         // everything the picture tools can put on a picture comes off: the
         // crop, the shaped crop, the flips and the colour treatment
         // 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) {
          "recolor", "bright", "contrast", "opacity"].forEach(function (k) {
             delete o.props[k];
             delete o.props[k];
         });
         });
@@ -1205,6 +1262,8 @@ var SlidesApp = (function () {
     function setImageMask(o, kind) {
     function setImageMask(o, kind) {
         if (!o || o.type !== "image") return;
         if (!o || o.type !== "image") return;
         if (cropId === o.id) endCrop(true);
         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") {
         if (!kind || kind === "rect") {
             delete o.props.mask;
             delete o.props.mask;
             delete o.props.radius;
             delete o.props.radius;
@@ -1265,7 +1324,7 @@ var SlidesApp = (function () {
             var $th = $('<div class="sl-thumb" draggable="true" tabindex="0"></div>');
             var $th = $('<div class="sl-thumb" draggable="true" tabindex="0"></div>');
             if (i === cur) $th.addClass("active");
             if (i === cur) $th.addClass("active");
             $th.append('<div class="sl-thumb-num">' + (i + 1) + "</div>");
             $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);
             $th.append($view);
             renderSlideContent($view.find(".sl-thumb-mini")[0], s);
             renderSlideContent($view.find(".sl-thumb-mini")[0], s);
             // Taking focus is what lets Delete mean "this slide", and
             // Taking focus is what lets Delete mean "this slide", and
@@ -1912,7 +1971,8 @@ var SlidesApp = (function () {
     /* ================= object insertion ================= */
     /* ================= object insertion ================= */
     function insertText() {
     function insertText() {
         var th = themeOf();
         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 });
             { x: 330, y: 240, w: 300, h: 60 });
         startEdit(o.id);
         startEdit(o.id);
     }
     }
@@ -2682,6 +2742,17 @@ var SlidesApp = (function () {
     }
     }
 
 
     /* ================= live element update (during drag) ================= */
     /* ================= 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) {
     function updateObjEl(o) {
         var el = objEl(o.id);
         var el = objEl(o.id);
         if (!el) return;
         if (!el) return;
@@ -2821,8 +2892,12 @@ var SlidesApp = (function () {
                 // where the handle starts; an old callout is converted on the
                 // where the handle starts; an old callout is converted on the
                 // first real move (adoptAdjustments), which leaves the tip -
                 // first real move (adoptAdjustments), which leaves the tip -
                 // and so this point - exactly where it is
                 // 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 pointer leaves the knob as soon as the object turns, so
             // the rotate cursor has to be put on the canvas for the drag
             // the rotate cursor has to be put on the canvas for the drag
@@ -2857,6 +2932,9 @@ var SlidesApp = (function () {
                 pendingToggle: pendingToggle, pendingCollapse: pendingCollapse,
                 pendingToggle: pendingToggle, pendingCollapse: pendingCollapse,
                 clickedId: id, wasSelected: wasSelected
                 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) { }
             try { canvasEl.setPointerCapture(e.pointerId); } catch (err) { }
             return;
             return;
         }
         }
@@ -2967,14 +3045,17 @@ var SlidesApp = (function () {
                 var ly = drag.at[1] + dx * Math.sin(arad) + dy * Math.cos(arad);
                 var ly = drag.at[1] + dx * Math.sin(arad) + dy * Math.cos(arad);
                 if (drag.how === "radius") {
                 if (drag.how === "radius") {
                     o.props.radius = Math.round(clamp(lx, 0, Math.min(o.w, o.h) / 2) * 100) / 100;
                     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 = {};
                     var adj = {};
                     Object.keys(o.props.adj || {}).forEach(function (k) { adj[k] = o.props.adj[k]; });
                     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.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);
                     adj.adj2 = Math.round((ly - o.h / 2) / Math.max(1, o.h) * 100000);
                     o.props.adj = adj;
                     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();
                 renderOverlay();
                 break;
                 break;
             }
             }
@@ -3131,6 +3212,7 @@ var SlidesApp = (function () {
         drag = null;
         drag = null;
         lastPointerEvt = null;
         lastPointerEvt = null;
         canvasEl.classList.remove("sl-rotating");
         canvasEl.classList.remove("sl-rotating");
+        canvasEl.classList.remove("sl-moving");
         hideGuides();
         hideGuides();
         try { canvasEl.releasePointerCapture(e.pointerId); } catch (err) { }
         try { canvasEl.releasePointerCapture(e.pointerId); } catch (err) { }
 
 
@@ -3185,10 +3267,13 @@ var SlidesApp = (function () {
                 setSel(sel.filter(function (id) { return id !== d.pendingToggle; }));
                 setSel(sel.filter(function (id) { return id !== d.pendingToggle; }));
             } else if (d.pendingCollapse) {
             } else if (d.pendingCollapse) {
                 setSel(expandGroups([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);
                 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);
                     startEdit(co.id);
                 }
                 }
             }
             }
@@ -4491,7 +4576,7 @@ var SlidesApp = (function () {
         body.slides.forEach(function (s, i) {
         body.slides.forEach(function (s, i) {
             var $c = $('<div class="sl-ov-card" tabindex="0" draggable="true"></div>').attr("data-i", i);
             var $c = $('<div class="sl-ov-card" tabindex="0" draggable="true"></div>').attr("data-i", i);
             if (i === cur) $c.addClass("active");
             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);
             $c.append($v);
             var $meta = $('<div class="sl-ov-meta"></div>').append('<span class="sl-ov-num">' + (i + 1) + "</span>");
             var $meta = $('<div class="sl-ov-meta"></div>').append('<span class="sl-ov-num">' + (i + 1) + "</span>");
             if (s.transition && s.transition !== "none") {
             if (s.transition && s.transition !== "none") {
@@ -5123,7 +5208,8 @@ var SlidesApp = (function () {
             $c.append($v);
             $c.append($v);
             $c.append($('<div class="sl-layoutpick-label"></div>').text(l.label));
             $c.append($('<div class="sl-layoutpick-label"></div>').text(l.label));
             $m.append($c);
             $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 () {
             $c.on("click", function () {
                 closeLayoutPicker();
                 closeLayoutPicker();
                 addSlideAfter(cur, l.key);
                 addSlideAfter(cur, l.key);
@@ -5146,7 +5232,8 @@ var SlidesApp = (function () {
     function initDomRefs() {
     function initDomRefs() {
         canvasEl = document.getElementById("slCanvas");
         canvasEl = document.getElementById("slCanvas");
         layerEl = document.getElementById("slSlideLayer");
         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");
         var overlay = document.getElementById("slOverlay");
         overlay.innerHTML = '<div id="slFrames"></div>' +
         overlay.innerHTML = '<div id="slFrames"></div>' +
             '<div id="slCrop"></div>' +
             '<div id="slCrop"></div>' +

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

@@ -10,10 +10,12 @@
     What each element becomes:
     What each element becomes:
 
 
       text      real PDF text (Tj), one show-text per line fragment, placed
       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
                 crops, shaped crops and rounded corners are real PDF clip
                 paths, flips are a negative scale in the matrix, and
                 paths, flips are a negative scale in the matrix, and
                 transparency is an ExtGState. A re-coloured picture is
                 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
          is what carries CJK, and it is why those families are in every
          font stack the editor writes.
          font stack the editor writes.
       3. nothing covers it - emoji, a script we do not ship - and only then
       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
     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
     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 resolveChar = C.resolveChar, segmentText = C.segmentText, faceOf = C.faceOf;
     var eachTextNode = C.eachTextNode, px = C.px, clamp = C.clamp;
     var eachTextNode = C.eachTextNode, px = C.px, clamp = C.clamp;
     var parseFill = C.parseFill, parseColor = C.parseColor, contrastOf = C.contrastOf;
     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 drawFragment = C.drawFragment, drawRuns = C.drawRuns;
     var rasterizeElement = C.rasterizeElement, rasterizeFallback = C.rasterizeFallback;
     var rasterizeElement = C.rasterizeElement, rasterizeFallback = C.rasterizeFallback;
     var filteredImageData = C.filteredImageData;
     var filteredImageData = C.filteredImageData;
@@ -104,10 +107,14 @@ var SlidesPdf = (function () {
                 var names = familyList(cs.fontFamily);
                 var names = familyList(cs.fontFamily);
                 var bold = (parseInt(cs.fontWeight, 10) || 400) >= 600;
                 var bold = (parseInt(cs.fontWeight, 10) || 400) >= 600;
                 var italic = cs.fontStyle === "italic" || cs.fontStyle === "oblique";
                 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++) {
                 for (var i = 0; i < text.length; i++) {
                     var cp = text.codePointAt(i);
                     var cp = text.codePointAt(i);
                     if (cp > 0xFFFF) 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 ---------------- */
     /* ---------------- 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
     // shapePathOps turns one of the editor's shape outlines into path
     // operators in page space. Used for both shape objects and the clip
     // operators in page space. Used for both shape objects and the clip
     // path of a shaped crop, so the two cannot disagree.
     // 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 };
         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
        rotation matrix its caller set up, so the picture stays crisp
        rather than being a picture of something already turned. */
        rather than being a picture of something already turned. */
     function drawAsRaster(pg, o, el, ctx) {
     function drawAsRaster(pg, o, el, ctx) {
         var b = boxOf(el, ctx.stage);
         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) {
         }).then(function (src) {
             if (!src) return;
             if (!src) return;
             return ctx.embed(src).then(function (img) {
             return ctx.embed(src).then(function (img) {
                 if (!img) return;
                 if (!img) return;
                 pg.p.drawImage(img, {
                 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 ---------------- */
     /* ---------------- SVG (charts) -> PDF vectors ---------------- */
 
 
     /* OfficeCharts draws with rect / line / polyline / polygon / path /
     /* OfficeCharts draws with rect / line / polyline / polygon / path /
@@ -379,6 +428,7 @@ var SlidesPdf = (function () {
                         else if (anchor === "end") tx -= twPx;
                         else if (anchor === "end") tx -= twPx;
                         drawFragment(pg, {
                         drawFragment(pg, {
                             text: str, names: names, bold: bold, italic: false,
                             text: str, names: names, bold: bold, italic: false,
+                            weight: parseInt(cs.fontWeight, 10) || 400,
                             sizePx: sizePx, xPx: tx,
                             sizePx: sizePx, xPx: tx,
                             baselinePx: Y(parseFloat(c.getAttribute("y") || 0)),
                             baselinePx: Y(parseFloat(c.getAttribute("y") || 0)),
                             color: fillCol || PDFLib.rgb(0, 0, 0), fitPx: 0
                             color: fillCol || PDFLib.rgb(0, 0, 0), fitPx: 0
@@ -443,17 +493,16 @@ var SlidesPdf = (function () {
         return [];
         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) {
     function needsRaster(o, el, fonts) {
         if (!el) return false;
         if (!el) return false;
         if (o.type === "chart") {
         if (o.type === "chart") {
             var svg = el.querySelector("svg");
             var svg = el.querySelector("svg");
             return !svg || !svgTranslatable(svg, fonts);
             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;
         return false;
     }
     }
 
 
@@ -497,7 +546,7 @@ var SlidesPdf = (function () {
         var inner = el ? el.querySelector(".sl-text-in") : null;
         var inner = el ? el.querySelector(".sl-text-in") : null;
         if (!inner) return Promise.resolve();
         if (!inner) return Promise.resolve();
         var runs = collectRuns(inner, origin);
         var runs = collectRuns(inner, origin);
-        if (runs.length) drawRuns(pg, runs);
+        if (runs.length) drawRuns(pg, runs, textPicture(pg, ctx));
         return Promise.resolve();
         return Promise.resolve();
     }
     }
 
 
@@ -510,6 +559,16 @@ var SlidesPdf = (function () {
         if (!src) return Promise.resolve();
         if (!src) return Promise.resolve();
         var filter = (window.SlidesImageTools ? SlidesImageTools.imageFilter(p) : "");
         var filter = (window.SlidesImageTools ? SlidesImageTools.imageFilter(p) : "");
         var prep = filter ? filteredImageData(src, filter) : Promise.resolve(src);
         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
         // a fill on the frame shows through the picture's transparent parts
         var under = null, underImg = null;
         var under = null, underImg = null;
         if (o.type === "image" && p.fillGrad && p.fillGrad.stops && p.fillGrad.stops.length) {
         if (o.type === "image" && p.fillGrad && p.fillGrad.stops && p.fillGrad.stops.length) {
@@ -538,7 +597,7 @@ var SlidesPdf = (function () {
 
 
             pg.save();
             pg.save();
             // clip to the frame, in the shape the picture is masked to
             // 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());
             clipOps.push(PDFLib.clip(), PDFLib.endPath());
             pg.ops(clipOps);
             pg.ops(clipOps);
             if (p.opacity && p.opacity < 1) {
             if (p.opacity && p.opacity < 1) {
@@ -569,7 +628,7 @@ var SlidesPdf = (function () {
             var sw = Number(p.strokeW) || 0;
             var sw = Number(p.strokeW) || 0;
             var sc = (sw > 0 && p.stroke && p.stroke !== "none") ? parseColor(p.stroke) : null;
             var sc = (sw > 0 && p.stroke && p.stroke !== "none") ? parseColor(p.stroke) : null;
             if (sc && o.type === "image") {
             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)));
                 sops.unshift(PDFLib.setStrokingColor(sc), PDFLib.setLineWidth(px(sw)));
                 var idash = SlidesLines.dashArray(p, sw);
                 var idash = SlidesLines.dashArray(p, sw);
                 if (idash) sops.unshift(PDFLib.setDashPattern(idash.map(px), 0));
                 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
         // outline (gradientPng); that picture has to be in hand first
         if (g && g.stops && g.stops.length && ctx && ctx.embed) {
         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 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 p = o.props || {};
         var kind = window.SlidesShapes ? SlidesShapes.canonical(p.kind || "rect") : (p.kind || "rect");
         var kind = window.SlidesShapes ? SlidesShapes.canonical(p.kind || "rect") : (p.kind || "rect");
         var open = window.SlidesShapes && SlidesShapes.isOpen(kind);
         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
         // markings the canvas draws over the outline - the bars of a
         // predefined process, the fold of a folded corner
         // 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) {
         if (det) {
             var dc = stroke || parseColor(contrastOf(fillCss)) || PDFLib.rgb(0.2, 0.2, 0.2);
             var dc = stroke || parseColor(contrastOf(fillCss)) || PDFLib.rgb(0.2, 0.2, 0.2);
             var X = function (v) { return px(o.x + v); };
             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;
         var inner = el ? el.querySelector(".sl-shape-text") : null;
         if (!inner) return Promise.resolve();
         if (!inner) return Promise.resolve();
         var runs = collectRuns(inner, origin || { left: 0, top: 0, x: o.x, y: o.y });
         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();
         return Promise.resolve();
     }
     }
 
 
@@ -757,7 +816,7 @@ var SlidesPdf = (function () {
                         { stroke: bd.c, strokeW: bw, strokeOpacity: bd.a });
                         { stroke: bd.c, strokeW: bw, strokeOpacity: bd.a });
                 }
                 }
                 var runs = collectRuns(cell, origin);
                 var runs = collectRuns(cell, origin);
-                if (runs.length) drawRuns(pg, runs);
+                if (runs.length) drawRuns(pg, runs, textPicture(pg, ctx));
             })(cells[i]);
             })(cells[i]);
         }
         }
         return Promise.all(jobs);
         return Promise.all(jobs);
@@ -921,7 +980,7 @@ var SlidesPdf = (function () {
                         return withStage(slide, drawSlide);
                         return withStage(slide, drawSlide);
                     });
                     });
                     function drawSlide(stageEl) {
                     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
                         // every face this slide needs is in hand before a
                         // single object is measured, so the drawing below
                         // single object is measured, so the drawing below
                         // can stay synchronous
                         // can stay synchronous

Разница между файлами не показана из-за своего большого размера
+ 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
     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
     (slides_shapes.js) without a browser: the canvas and the PDF exporter
     both draw from these, so a wrong number here is wrong everywhere.
     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 });
 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);
 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) {
 if (failures) {
     console.log(failures + " check(s) failed");
     console.log(failures + " check(s) failed");
     process.exit(1);
     process.exit(1);

Некоторые файлы не были показаны из-за большого количества измененных файлов