前面两篇文章分别介绍了 Navbar 的后端和前端的制作过程。本文在现有的创建章节基础上,添加创建进度游标和进度条功能。

进度游标是一个图形,它可以示意当前 PPT 展示的进度信息。它可以分为按章节进度和按页面进度两种。按章节进度即每切换一章跳动一次,跟随着 Navbar 中的 section title 移动。而按页面即每翻一页前进一次。

游标在每个页面中只显示当前的位置。如果把对应所有页的游标都放在同一页面中,并对当前页、过去页、未来页使用不同的样式,就变成了进度条。

下面我们来创建这些元素。

创建游标

创建进度游标时,在每一页要有一个图形作为游标。下面我们来定义一个进度游标的样式。进度游标可以有两种选择:GeometricShape 和 Image 。尽管 Office.js 提供了插入 SVG 图片的功能,但是由于游标通常比较小,使用 SVG 的意义不大,就暂不提供这一功能了。下面我们在第一页 PPT 中分别来创建这两种图形。

GeometricShape 类型识别

GeometricShape 就是 PowerPoint 中自带的一些基本图形。目前的 PowerPoint 库中支持多数基本图形的创建,但是不支持黄色控件的编辑以及旋转。在创建时,有一个 GeometricShape 的枚举类型用于指定图形的形状。但是在读取属性的时候,这一枚举类型无法被读取。这就导致我们想在页面中先创建一个几何图形再来 match 它的 GeometricShape 信息变得困难。因此我们使用了一个 trick ,就是从 PowerPoint 对图形的自动命名中找到不同几何图形名称与图形类别的关系,从而推断出图形的类别。

前面提到,为了调试方便,我们创建了一个 Log 按钮,并绑定了一个 log() 方法。这里我们利用它来临时读取几何图形的信息,并打印在控制台中。

1
2
3
4
5
6
7
{
methods: {
load() {
tryCatch(logShapeName);
}
}
}
1
2
3
4
5
6
7
8
async function logShapeName() {
await PowerPoint.run(async context => {
const shapes = context.presentation.getSelectedShapes();
shapes.load("items/name");
await context.sync();
console.log(shapes.map(shape => shape.name));
})
}

这个函数我们作为测试使用,写得比较简单。这样我们绘制一个图形,再点击 Log 按钮,就可以看到它的 name 属性打印在控制台中了。如图所示:

logged shape name

可以看出,命名的方式就是 shape 的类别加上一个数字。这样我们只需要使用正则表达式把 shape 的类别读出来,再映射到 GeometricShape 的枚举类型中,就可以了。这里我列举了一些可能被用作游标的 shape 类别,如下图所示。

available shapes

然后写一个读取 name ,得到 GeometricShape 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
function getGeomShapeType(name) {
const mapper = {
"Rectangle":1,
"RoundedRectangle":1,
"SnipSingleCornerRectangle": 1,
"SnipSameSideCornerRectangle": 1,
"SnipDiagonalCornerReectangle": 1,
"SnipandRoundSingleCornerRectangle": 1,
"RoundSingleCornerRectangle": 1,
"RoundSameSideCornerRectangle": 1,
"RoundDiagnalCornerRectangle": 1,
"Oval": 1,
"IsoscelesTriangle":1,
"Parallelogram":1,
"Trapezoid": 1,
"Diamond": 1,
"RegularPentagon": 1,
"Hexagon": 1,
"Chord": 1,
"Teardrop": 1,
"Frame": 1,
"HalfFrame": 1,
"L-Shape": 1,
"DiagonalStripe": 1,
"Cross": 1,
"Plaque": 1,
"Can": 1,
"Cube": 1,
"Bevel": 1,
"Donut": 1,
"BlockArc": 1,
"FoldedCorner": 1,
"Heart": 1,
"LightningBolt": 1,
"Sun": 1,
"Moon": 1,
"Cloud": 1,
"DoubleBracket": 1,
"DoubleBrace": 1,
"LeftBracket": 1,
"RightBracket": 1,
"LeftBrace": 1,
"RightBrace": 1,
"RightArrow": 1,
"LeftArrow": 1,
"UpArrow": 1,
"DownArrow": 1,
"Left-RightArrow": 1,
"Up-DownArrow": 1,
"StripedRightArrow": 1,
"NotchedRightArrow": 1,
"Pentagon": 1,
"Chevron": 1,
"RightArrowCallout": 1,
"DownArrowCallout": 1,
"LeftArrowCallout": 1,
"UpArrowCallout": 1,
"Flowchart:PredefinedProcess": 1,
};
let key = name.replace(/\d+$/g, "").replace(" ", "")
if (key in mapper) {
return mapper[key];
} else {
return "";
}
}

以上代码中,首先创建了一个对象 mapper ,用于将形状名映射到 PowerPoint 的枚举类型名。然后使用正则表达式去掉形状名中的数字和空格,就可以得到形状对应的 GeometricShape 类别了。

Shape 属性匹配

在前文匹配文本框的格式时,我们读取了要匹配对象的 width height text 参数,但是在 setTextboxFormat() 中没有设置这些参数。这是因为对于文本框,它的长度和高度需要通过计算获取。而对于游标,它的 width height 是不变的,如果有文字 text 也是不变的。所以需要将这些属性也写到对新对象的操作中。这里我们写一个新的函数:

1
2
3
4
5
6
function setShapeFormat(shape, format) {
shape.width = format.width;
shape.height = format.height;
shape.textFrame.textRange.text = format.textFrame.textRange.text;
setTextboxFormat(shape, format);
}

这要,就把其他所需的参数都设置进去了。

图片插入

尽管 PowerPoint API 没有提供图片的导入功能,但是 Office 通用的 API 提供了相应的功能。在 Script Lab 的 Samples 中可以找到插入位图以及 svg 矢量图的方法。这里的图片是使用 base64 编码读取过来,然后插入到 Office 软件中的。遗憾的是没有找到从 PowerPoint 中直接以 base64 的方式直接读取图片的方法。因此这里只能选择从文件中读取。先写一个函数用于插入图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function insertImage(shapes, cursorConfig): {
await Office.context.document.setSelectedDataAsync(
cursorConfig.data,
{
coercionType: Office.CoercionType.Image,
imageLeft: 0,
imageTop: 0,
imageWidth: cursorConfig.width,
imageHeight: cursorConfig.height,
},
function(asyncResult) {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
console.log(asyncResult.error.message);
}
}
);
}

其中 cursorConfigdata 属性保存的是一个以 Base64 编码的字符串,代表一个位图图片。这个图片可以经前端设置由用户选取。两个属性 imageWidthimageHeight 如果指定,则图片会被压缩。如果不指定,图片会以原始大小放置。

位置计算

在位置计算时,我们分为两种游标来设置。第一种是根据章节来移动的游标,每变化一个章节移动一次。第二种是根据页面来移动的游标,每翻一页都移动一次,类似于一个进度条。下面我们对这两种游标分别进行位置计算。

按章节移动

在实现 sectionBar 的过程中,我们使用 computeSectionBarProp() 计算了每一个章节文本框的位置信息。这里可以利用这个位置信息来设置每个游标的位置。但是需要游标相对于章节文本框有一定的偏移。而且每一个文本框的大小不同,在对齐时,是与起始位置对齐,还是与终止位置对齐,还是居中,也是需要设置的要素。因此,把定义游标的数据结构设置为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function defaultCursorFormat() {

}

function defaultCursorConfig() {
return {
by: "Slide",
kind: "Shape",
data: "Rectangle",
width: 10,
height: 10,
topOffset: 5,
leftOffset: 5,
alignment: "Start",
format: defaultCursorFormat(),
}
}

下面来根据 sectionBarConfigsectionCursorConfig 来计算游标的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
async function computeCursorPropBySection(cursorConfig, sectionBarConfig) {
const { direction, slideSectionIds, sectionTitles } = sectionBarConfig;
const barProp = await computeSectionBarProp(sectionBarConfig);
const { alignment: align, format, leftOffset, topOffset, width, height } = cursorConfig;
let secProp;
if (direction === "Vertical") {
secProp = barProp.map((p) => {
const realHeight = height <= 0 ? p.height + height : height;
const alignOffset =
align === "Center" ? (p.height - realHeight) / 2 : align === "End" ? p.height - realHeight : 0;
return {
width: width,
height: realHeight,
top: p.top + topOffset + alignOffset,
left: p.left + leftOffset
};
});
} else if (direction === "Horizontal") {
secProp = barProp.map((p) => {
const realWidth = width <= 0 ? p.width + width : width;
const alignOffset = align === "Center" ? (p.width - realWidth) / 2 : align === "End" ? p.width - realWidth : 0;
return {
width: realWidth,
height: height,
top: p.top + topOffset,
left: p.left + leftOffset + alignOffset
};
});
} else {
throw new Error(`Invalid direction ${direction} in computeCursorPropBySection()`)
}
return slideSectionIds.map((i, n) => secProp[i]);
}

这里要注意的是, sectionBarConfig 的数据模型在上一篇文章中重构了一下,现在的数据模型为:

1
2
3
4
5
6
7
8
9
10
11
12
13
function defaultSectionBarConfig() {
return {
direction: "Horizontal", // enum in "Horizontal" || "Vertical"
distribution: "EqualSpacing", // enum in "EqualSpacing" || "Uniform" || "SlideNum"
rangeWidth: 960,
rangeHeight: 30,
rangeLeft: 0,
rangeTop: 0,
activeFormat: defaultActiveFormat(),
inactiveFormat: defaultInactiveFormat(),
showActiveOnly: false
}
}

因此在上面的代码中读取的是 sectionBarConfigdirection 属性了。

按页面移动

按页面移动的游标与按章节移动的类似,只不过数量上要多一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function computeCursorPropBySlide(cursorConfig, sectionBarConfig) {
const { slideSectionIds, direction, rangeWidth, rangeHeight, rangeLeft, rangeTop } = sectionBarConfig;
const { alignment: align, width, height } = cursorConfig;
const slideCount = slideSectionIds.length;
if (direction === "Vertical") {
const span = rangeHeight / slideCount;
return Array(slideCount)
.fill(0)
.map((_, i) => {
const realHeight = height <= 0 ? span + height : height;
const alignOffset = align === "Center" ? (span - realHeight) / 2 : align === "End" ? span - realHeight : 0;
return {
width: width,
height: realHeight,
left: rangeLeft,
top: rangeTop + span * i + alignOffset
};
});
} else if (direction === "Horizontal") {
const span = rangeWidth / slideCount;
return Array(slideCount)
.fill(0)
.map((_, i) => {
const realWidth = width <= 0 ? span + width : width;
const alignOffset = align === "Center" ? (span - realWidth) / 2 : align === "End" ? span - realWidth : 0;
return {
width: realWidth,
height: height,
left: rangeLeft + span * i + alignOffset,
top: rangeTop
};
});
} else {
throw new Error (`Invalid direction ${direction} in computeCursorPropBySlide()`)
}
}

这里设置了一个小的 trick 。以水平的进度条为例,如果把数值设置成0,通过计算使图形布满。如果设成负值,则将图形的长度设为相邻两个之间空隙的长度。

放置游标

以上完成了设置,我们就可以放置游标了。先写后端函数:

1
2
3
4
5
6
7
8
9
async function computeCursorProp(cursorConfig, sectionBarConfig) {
if (cursorConfig.by === "Slide") {
return await computeCursorPropBySlide(cursorConfig, sectionBarConfig);
} else if (cursorConfig.by === "Section") {
return await computeCursorPropBySection(cursorConfig, sectionBarConfig);
} else {
throw new Error(`Invalid cursorConfig.by ${cursorConfig.by} in computeCursorProp()`)
}
}

上面的工厂计算了游标的 Prop ,下面根据计算的结果放置游标。首先是放置 GeometricShape

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function createShapeCursor(cursorConfig, sectionBarConfig) {
await PowerPoint.run(async (context) => {
const { slideSectionIds, slideSkipped } = sectionBarConfig;
const prop = await computeCursorProp(cursorConfig, sectionBarConfig);
console.log(prop);
prop.map((p, n) => {
if (slideSkipped[n]) {
return
}
const shapes = context.presentation.slides.getItemAt(n).shapes;
const shape = shapes.addGeometricShape(cursorConfig.shape, p);
setTextboxFormat(shape, cursorConfig.format);
shape.name = `NavbarCursor-${n}`;
});
await context.sync();
});
}

将这一函数加载到 Log 按钮中,点击就会发现成功创建了游标。如下图所示:

section cursor

然后是放置图片:

1

设计前端页面

下面我们来设计前端页面,把 Cursor 需要的配置参数输入进来。

首先是数据模型,我们在 Vue 的 data 中创建一个新的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
data() {
return {
cursor: {
shape: "Rectangle",
width: 0,
height: 5,
leftOffset: 5,
topOffset: 5,
alignment: "Start",
format: defaultCursorFormat(),
}
}
}
}

然后在前端界面中再加入这些属性的输入框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<div class="row">
<div class="col s12">
<h6>Define cursor</h6>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<select v-model="cursor.shape">
<option value="Rectangle">Rectangle</option>
<option value="Circle">Circle</option>
</select>
<label>Shape</label>
</div>
<div class="input-field col s6">
<select v-model="cursor.alignment">
<option>Start</option>
<option>Center</option>
<option>End</option>
</select>
<label>Alignment</label>
</div>
</div>
<div class="row">
<div class="input-field col s3">
<input id="cursorLeftOffset" type="text" class="validate" v-model.number="cursor.leftOffset">
<label for="cursorLeftOffset">Left offset</label>
</div>
<div class="input-field col s3">
<input id="cursorTopOffset" type="text" class="validate" v-model.number="cursor.topOffset">
<label for="cursorTopOffset">Top offset</label>
</div>
<div class="input-field col s3">
<input id="cursorWidth" type="text" class="validate" v-model.number="cursor.width">
<label for="cursorWidth">Width</label>
</div>
<div class="input-field col s3">
<input id="cursorHeight" type="text" class="validate" v-model.number="cursor.height">
<label for="cursorHeight">Height</label>
</div>
</div>
<div class="row">
<div class="input-field col s2">
<label>Match format:</label>
</div>
<div class="input-field col s10">
<a class="waves-effect waves-light btn-flat" data-position="top" data-tooltip="Select a textbox first" @click="matchSelectedShapeFormat()">Cursor</a>
</div>
</div>

定义后的界面如下图所示:

cursor_menu

然后绑定匹配目标格式的方法:

1
2
3
4
5
6
7
8
9
10
11
12
{
methods: {
matchSelectedShapeFormat() {
readSelectedShapeFormat().then(format => {
this.cursor.shape = getGeomShapeType(format.name);
this.cursor.width = format.width;
this.cursor.height = format.height;
this.cursor.format = format;
})
}
}
}

这个 cursor 可以直接传入后端的 createCursor() 函数,所以不需要再使用 computed 属性来组装了。这时,把 createCursor() 函数与 Log 按钮绑定,点击后就可以看到页面中生成游标了,如图所示。

创建进度条

有时我们不仅仅希望页面有一个游标,还希望能有一个进度条,更清晰地展示当前页面所处的位置。如 LaTeX beamer 的 Frankfurt 主题就可以实现这一功能,如下图所示:

frankfurt theme

下面我们就在游标的逻辑上进行小的修改,从而实现进度条功能。

每页创建所有游标

前面的游标每一页只有一个。在创建按页的进度条时,我们只需要在每一页中都创建所有游标,再区别当前页、已完成页、未完成页的格式,就可以了。而在创建按章节的进度条时,同章节的游标会重合。这时我们就需要设置一个偏移量,使不同页面的游标错开,这样就可以在同一页中都显示了。下面我们来创建 computeProgressBarPropBySection() 函数,并定义了输入它的参数 progressBarConfig 的数据模型。其中有一个参数 offset ,表示同章节相邻两个游标之间的距离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function defaultProgressBarConfig() {
return {
bySlide: true,
offset: 20,
currentCursor: defaultCursorConfig(),
backCursor: defaultCursorConfig(),
foreCursor: defaultCursorConfig(),
}
}

function computeProgressBarProp(progressBarConfig, sectionBarConfig) {
const sectionBarProp = computeSectionBarProp(sectionBarConfig);
const {currentCursor, backCursor, foreCursor, offset} = progressBarConfig;
const currentProp = await computeCursorProp(currentCursor);
const backProp = await computeCursorProp(backCursor);
const foreProp = await computeCursorProp(foreCursor);
return {sectionBarProp, currentProp, backProp, foreProp}
}

function createProgressBar(progressBarConfig, sectionBarConfig) {
const { sectionBarProp, currentProp, backProp, foreProp } = computeProgressBarProp(progressBarConfig, sectionBarConfig);
const { slideSectionIds } = sectionBarProp;
slideSectionIds.map((_, n) => {

})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function createCursor(cursorConfig, sectionBarConfig) {
await PowerPoint.run(async context => {
if (cursorConfig.bySlide) {
const prop = await computeCursorPropBySlide(cursorConfig, sectionBarConfig);
prop.map((p, n) => {
const shapes = context.presentation.slides.getItemAt(n).shapes;
const shape = shapes.addGeometricShape(cursorConfig.shape, p);
setTextboxFormat(shape, cursorConfig.format);
shape.name = `NavbarCursor-${n}`;
})
} else {
const prop = await computeCursorPropBySection(cursorConfig, sectionBarConfig);
prop.map((p, n) => {
const shapes = context.presentation.slides.getItemAt(n).shapes;
const shape = shapes.addGeometricShape(cursorConfig.shape, p);
setTextboxFormat(shape, cursorConfig.format);
shape.name = `NavbarCursor-${n}`;
})
}
await context.sync();
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
async function computeProgressBarDiscreteGroupedProp(progressBarConfig, sectionBarConfig) {
const { slideSectionIds } = sectionBarConfig;
const {
docking,
slideCount,
startOffset,
endOffset,
edgeOffset,
width,
backColor,
foreColor,
discrete
} = progressBarConfig;
const { group, currentColor, length } = discrete;
const { spacing } = group;
const sectionBarProp = await computeSectionBarProp(sectionBarConfig);
const step = length + spacing;
if (docking === "Left" || docking === "Right") {
const left = docking === "Left" ? edgeOffset : SLIDEWIDTH - edgeOffset - width;
const step = length + spacing;
let currentSectionSlideCount = 0;
let oldId = -1;
const tops = slideSectionIds.map((id, n) => {
if (id !== oldId) {
currentSectionSlideCount = 0;
oldId = id;
}
currentSectionSlideCount += 1;
sectionBarProp[id].top + step * (currentSectionSlideCount - 1);
});
return Array(slideCount)
.fill(0)
.map((_, i) => {
return {
left: left,
top: tops[i],
width: length,
height: width
};
});
} else if (docking === "Top" || docking === "Bottom") {
const top = docking === "Top" ? edgeOffset : SLIDEHEIGHT - edgeOffset - width;
const step = length + spacing;
let currentSectionSlideCount = 0;
let oldId = -1;
const lefts = slideSectionIds.map((id, n) => {
if (id !== oldId) {
currentSectionSlideCount = 0;
oldId = id;
}
currentSectionSlideCount += 1;
sectionBarProp[id].left + step * (currentSectionSlideCount - 1);
});
return Array(slideCount)
.fill(0)
.map((_, i) => {
return {
left: lefts[i],
top: top,
width: length,
height: width
};
});
} else {
throw new Error(`Invalid docking ${docking}`);
}
}