小売チェーンの月次経営会議には決まった資料がある。今月はどれだけ売れたか、どの店が伸びてどの店が落ちたか、売れている物の構成が変わったか、そして会議で何を決めるか。データの出所も決まっている——営業企画部が毎月 BI から書き出す《店舗営業台帳》で、シートは 2 枚。店舗売上(店舗名/売上高/前年同期比/粗利率/客単価)と、品目構成(品目/売上高/構成比)である。台帳には文章が 1 行もなく、資料に載る結論はすべてこの 2 枚から導かれる。
台帳を資料に変える手作業は、表をスライドへ貼り、頁ごとにグラフを挿入し、種類を選び、データ範囲をドラッグで指定し、結論の文章を数字を見ながら打ち直す、という流れになる。問題は最後の段で、グラフ上の数字と文章中の数字が二系統になることにある。片方を直して片方を忘れれば「グラフは 550.8 万円、本文は 55.08 万円」という食い違いが起きる。店舗数が 6 から 7 に増えれば、グラフの参照範囲も結論の順位も全部引き直しになる。しかも毎月同じ版面を保つ必要があり、手作業では 30 分はかかる。
本記事では Free Spire.Presentation for Python を使い、この資料を「台帳から直接生成する」形にする。構成は会議で答える問いの順に沿わせる——全体規模は棒グラフ、伸び率は第 2 軸を使った複合グラフ、品目構成はドーナツグラフ、最後に要点 5 行を 1 頁へまとめる。グラフのデータと結論の数字は同じ読み込み結果から取るため、二つの口径が分岐しない。
pip install spire.presentation.free spire.xls.free
グラフのデータを Excel の台帳から取るので、spire.presentation.free と spire.xls.free の 2 つが要る。前者でプレゼン資料を作り、後者で台帳を読む。
台帳の口径と読み込み
サンプルの《店舗営業台帳 2026 年 9 月》は 2 枚のシートで構成される。
| シート | 構造 |
|---|---|
| 店舗売上 | 3 行目が見出し、4 行目から明細:店舗名/売上高(万円)/前年同期比/粗利率/客単価(円)。末尾は「合計 / 平均」 |
| 品目構成 | 3 行目が見出し、4 行目から明細:品目/売上高(万円)/構成比 |
読み込みでは範囲を推測せず、「見出しが 3 行目、合計行まで」という約束だけを手掛かりに順に読む。
from spire.xls import Workbook
from spire.presentation.common import RectangleF
from spire.presentation import (Presentation, FileFormat, ChartType, ShapeType,
TextParagraph, TextFont, TextAlignmentType,
FillFormatType, ChartDataPoint, Color)
TITLE_FONT = "Meiryo"
MONTH = "2026 年 9 月"
LEDGER = "店舗営業台帳_2026年9月.xlsx"
DECK = "店舗経営分析_2026年9月.pptx"
def read_ledger(path):
wb = Workbook()
wb.LoadFromFile(path)
s1, s2 = wb.Worksheets[0], wb.Worksheets[1]
stores = []
r = 4
while s1.Range[r, 1].Text and "合計" not in s1.Range[r, 1].Text:
stores.append((s1.Range[r, 1].Text,
s1.Range[r, 2].NumberValue,
s1.Range[r, 3].NumberValue,
s1.Range[r, 4].NumberValue))
r += 1
cates = []
r = 4
while s2.Range[r, 1].Text:
cates.append((s2.Range[r, 1].Text, s2.Range[r, 2].NumberValue))
r += 1
wb.Dispose()
return stores, cates
ループの終了条件に使うのは「セルが空」または「文字列に合計を含む」で、行番号は決め打ちしない。明細の行数は店舗数で決まるため、行番号を固定すると店舗が増減したときに必ず読み違える。内容で判定すれば自動で追随する。取り出すのは NumberValue(数値)で、前年同期比と粗利率は台帳上で小数として保持されている(0.128 は 12.8%)。書式はグラフと文章の側で整える。
プレゼン資料そのものを先に用意する。表紙は濃色の帯に表題を載せ、以降の頁はその後に追加していく。表紙の文字も塗りなしの矩形に置き、色と文字サイズを段落ごとに指定する。
pres = Presentation()
def cover_text(slide, text, rect, size, rgb):
sh = slide.Shapes.AppendShape(ShapeType.Rectangle, rect)
sh.Fill.FillType = FillFormatType.none
sh.Line.FillType = FillFormatType.none
para = TextParagraph()
para.Text = text
para.Alignment = TextAlignmentType.Center
para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
para.TextRanges[0].FontHeight = size
para.TextRanges[0].Fill.FillType = FillFormatType.Solid
para.TextRanges[0].Fill.SolidColor.Color = rgb
sh.TextFrame.Paragraphs.Append(para)
return sh
cover = pres.Slides[0]
band = cover.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(0, 150, 720, 390))
band.Fill.FillType = FillFormatType.Solid
band.Fill.SolidColor.Color = Color.FromArgb(255, 18, 38, 62)
band.Line.FillType = FillFormatType.none
cover_text(cover, "%s 店舗経営分析" % MONTH,
RectangleF.FromLTRB(40, 208, 680, 272), 36, Color.get_White())
cover_text(cover, "株式会社北斗リテール · 営業企画部 月次経営会議",
RectangleF.FromLTRB(40, 286, 680, 326), 17, Color.FromArgb(255, 170, 190, 210))
問い 1:今月は全体でどれだけ売れたか
1 頁目は規模を答える。6 店舗の売上高を集合縦棒グラフで並べ、各棒に数値ラベルを付ければ、棒の高さを目測する必要がなくなる。
グラフ作成とデータ投入の共通処理を関数に切り出し、後の 3 枚でも使い回す。
def fill_chart_data(chart, categories, series_names, series_values):
for c, name in enumerate(series_names):
chart.ChartData[0, c + 1].Text = name
for i, cat in enumerate(categories):
chart.ChartData[i + 1, 0].Text = cat
for c, values in enumerate(series_values):
chart.ChartData[i + 1, c + 1].NumberValue = values[i]
col = chr(ord("A") + len(series_names))
chart.Series.SeriesLabel = chart.ChartData["B1", "%s1" % col]
chart.Categories.CategoryLabels = chart.ChartData["A2", "A%d" % (len(categories) + 1)]
for c in range(len(series_names)):
letter = chr(ord("A") + c + 1)
chart.Series[c].Values = chart.ChartData["%s2" % letter, "%s%d" % (letter, len(categories) + 1)]
データの投入は 2 段階で、どちらも省けない。前半で分類名と各系列の数値を chart.ChartData という表オブジェクトへ書き込む(行・列は 0 起点)。後半でグラフと表の3 か所を結び付ける。系列名は 1 行目の 2 列目以降、分類ラベルは 0 列目の 2 行目以降、各系列の値は対応する列。ChartData に書いただけで結び付けを行わなければ、保存されるグラフは空になる。データ源がどの表を指すのか決まっていないためである。
グラフ頁の見出しはプレースホルダーではなく、塗りも枠線も無い矩形を文字枠として使う。プレースホルダーは版面をマスターから継承するため、位置と文字サイズが上書きされる。
def title_of(slide, text, subtitle=None):
box = slide.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(40, 20, 680, 96))
box.Fill.FillType = FillFormatType.none
box.Line.FillType = FillFormatType.none
para = TextParagraph()
para.Text = text
para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
para.TextRanges[0].FontHeight = 24
box.TextFrame.Paragraphs.Append(para)
if subtitle:
para2 = TextParagraph()
para2.Text = subtitle
para2.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
para2.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
para2.TextRanges[0].FontHeight = 13
box.TextFrame.Paragraphs.Append(para2)
return box
stores, cates = read_ledger(LEDGER)
names = [s[0] for s in stores]
sales = [s[1] for s in stores]
growth = [s[2] for s in stores]
margin = [s[3] for s in stores]
total = sum(sales)
top = max(stores, key=lambda x: x[1])
worst = min(stores, key=lambda x: x[1])
up = [s for s in stores if s[2] > 0]
neg = [s for s in stores if s[2] < 0]
s2 = pres.Slides.Append()
title_of(s2, "一、店舗別の売上高(万円)",
"合計 %.1f 万円、最高 %s/最低 %s" % (total, top[0], worst[0]))
ch1 = s2.Shapes.AppendChartInit(ChartType.ColumnClustered,
RectangleF.FromLTRB(60, 100, 660, 470), False)
ch1.ChartTitle.TextProperties.Text = "2026 年 9 月 店舗別売上高"
ch1.ChartTitle.TextProperties.IsCentered = True
ch1.ChartTitle.Height = 34
ch1.HasTitle = True
fill_chart_data(ch1, names, ["売上高"], [sales])
ch1.Series[0].DataLabels.LabelValueVisible = True
ch1.Series[0].DataLabels.NumberFormat = "#,##0.0"
ch1.PrimaryValueAxis.NumberFormat = "#,##0.0"
ch1.GapWidth = 120
GapWidth は棒の間隔で、値を小さくすると棒が太くなる。既定値では 6 本の棒が細く見え、数値ラベルを棒の上に置く余裕がなくなる。
フォントは LatinFont と EastAsianFont の両方を指定する。LatinFont だけを指定すると、欧文は指定どおりに描画され、日本語は描画環境の既定フォントへフォールバックする。別の端末で開いたときに見た目が変わる。日本語環境なら Meiryo や 游ゴシック、中国語環境の資料なら Microsoft YaHei のように、その資料を開く環境に存在する書体名を選ぶ。
副見出しの 3 つの数字は total、top、worst から計算しており、手書きの定数ではない。これは全頁で統一した方針で、スライドに出る数字はすべてコードで算出する。
問い 2:どこが伸び、どこが落ちたか
棒グラフでは絶対額の大小は分かるが、誰が伸びているかは分からない。2 頁目は単位の異なる 2 つの指標を同時に見る。売上高は万円単位(最大 152.6)、前年同期比は割合(±22% の範囲)。同じ軸に載せると、比較の線は x 軸に貼り付いた直線に潰れる。
そこで前年同期比を第 2 軸へ移し、2 系統の目盛りを独立させる。
s3 = pres.Slides.Append()
title_of(s3, "二、売上高と前年同期比", "前年超えは %d 店舗、前年割れは %d 店舗"
% (len(up), len(neg)))
ch2 = s3.Shapes.AppendChartInit(ChartType.ColumnClustered,
RectangleF.FromLTRB(60, 100, 660, 470), False)
ch2.ChartTitle.TextProperties.Text = "売上高(棒)と前年同期比(折れ線・右軸)"
ch2.ChartTitle.TextProperties.IsCentered = True
ch2.ChartTitle.Height = 34
ch2.HasTitle = True
fill_chart_data(ch2, names, ["売上高", "前年同期比"], [sales, growth])
ch2.Series[1].Type = ChartType.LineMarkers # 2 番目の系列をマーカー付き折れ線にする
ch2.Series[1].UseSecondAxis = True # この系列を第 2 軸に載せる
ch2.SecondaryValueAxis.NumberFormat = "0.0%"
ch2.SecondaryValueAxis.MajorGridTextLines.FillType = FillFormatType.none
ch2.Series[0].DataLabels.LabelValueVisible = True
ch2.Series[0].DataLabels.NumberFormat = "#,##0.0"
ch2.PrimaryValueAxis.NumberFormat = "#,##0.0"
ch2.GapWidth = 140
複合グラフの要点は 3 行だけである。系列を Series[1].Type で ChartType.LineMarkers に変え、UseSecondAxis で第 2 軸へ載せ、第 2 軸の NumberFormat を百分率にする。グラフ自体は ColumnClustered で作成したままで、系列の種類は個別に上書きできる。複合グラフ専用の種類が要らないのはこのためである。
第 2 軸には既定で横の目盛線が入る。主軸の目盛線と重なると図面が汚れるので切っておく——MajorGridTextLines.FillType = FillFormatType.none。サンプルでは 6 店舗のうち 4 店舗が前年超え、2 店舗が前年割れで、折れ線が 0% をまたいで上下し、落ちている 2 店舗が一目で分かる。
問い 3:売れている物の構成は変わったか
3 頁目は品目の構成で、割合を見るためにドーナツグラフを使う。ドーナツグラフ(ChartType.Doughnut)は既定で全扇区が同じ色になるため、品目の意味に合わせて配色するには点ごとに設定する。
s4 = pres.Slides.Append()
cate_names = [c[0] for c in cates]
cate_vals = [c[1] for c in cates]
cate_total = sum(cate_vals)
title_of(s4, "三、営業品目の構成", "生鮮と日用品で全体の %.1f%% を占める"
% ((cate_vals[0] + cate_vals[1]) / cate_total * 100))
ch3 = s4.Shapes.AppendChartInit(ChartType.Doughnut,
RectangleF.FromLTRB(90, 100, 630, 470), False)
ch3.ChartTitle.TextProperties.Text = "品目別売上高の構成比"
ch3.ChartTitle.TextProperties.IsCentered = True
ch3.ChartTitle.Height = 34
ch3.HasTitle = True
fill_chart_data(ch3, cate_names, ["売上高"], [cate_vals])
palette = [Color.FromArgb(255, 214, 69, 65), Color.FromArgb(255, 240, 138, 76),
Color.FromArgb(255, 247, 202, 92), Color.FromArgb(255, 122, 176, 138),
Color.FromArgb(255, 88, 148, 200), Color.FromArgb(255, 168, 168, 176)]
for i, _ in enumerate(cate_names):
dp = ChartDataPoint(ch3.Series[0])
dp.Index = i
ch3.Series[0].DataPoints.Add(dp)
ch3.Series[0].DataPoints[i].Fill.FillType = FillFormatType.Solid
ch3.Series[0].DataPoints[i].Fill.SolidColor.Color = palette[i % len(palette)]
ch3.Series[0].DataLabels.PercentValueVisible = True
ch3.Series[0].DataLabels.LabelValueVisible = False
ch3.DoughnutHoleSize = 58
順序は入れ替えられない。まず ChartDataPoint を作り、Index を設定して DataPoints コレクションへ Add し、その後で DataPoints[i] として添字でアクセスして塗りを設定する。Add の前に添字で触ると範囲外になる。配色表は i % len(palette) で剰余を取るので、品目が増えても色を循環して使い、リスト長を超えて例外になることがない。
ラベルは百分率だけを残し、絶対額は切る。ドーナツグラフの各扇区へ 2 つの数字を載せると込み入るためで、具体的な金額は台帳を見れば分かる。会議で必要なのは構成比である。
まとめ頁:会議で読む 5 行
最後の頁はグラフを出さず、そのまま読める 5 行を出す。内容も読み込み結果から導くため、前の 3 頁と同じ口径を共有する。
s5 = pres.Slides.Append()
title_of(s5, "四、今月の要点", "集計基準:売上高は税込実収、前年同期比は 2025 年 9 月対比")
best_margin = max(stores, key=lambda x: x[3])
best_growth = max(stores, key=lambda x: x[2])
lines = [
"規模:全店合計は %.1f 万円。%s が最も多く %.1f 万円。" % (total, top[0], top[1]),
"成長:前年超え %d 店舗、前年割れ %d 店舗。伸び率の首位は %s(%.1f%%)。"
% (len(up), len(neg), best_growth[0], best_growth[2] * 100),
"要対応:%s が前年割れ。次回会議までに立て直し案の提出が必要。"
% "、".join(s[0] for s in neg),
"構成:生鮮が %.1f%% で集客の柱。酒類と家庭用洗剤は合わせて %.1f%%。"
% (cate_vals[0] / cate_total * 100,
(cate_vals[3] + cate_vals[4]) / cate_total * 100),
"収益性:%s の粗利率 %.1f%% が全店で最高。売場と品揃えの手本として横展開する。"
% (best_margin[0], best_margin[3] * 100),
]
5 行の構成は 5 つの問いに対応する——規模、成長、要対応、構成、収益性。主語も数字も変数で、固定なのは文型だけである。この「文型は固定、数字は動的」という組み合わせが自動化に向く理由で、同じ文型が 10 月・11 月にも使い回せ、差し替わるのは台帳だけになる。
文字塊は薄い灰色の矩形に載せ、段落を順に追加し、段落の間に空の段落を挟んで行間を作る。
box = s5.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(60, 110, 660, 470))
box.Fill.FillType = FillFormatType.Solid
box.Fill.SolidColor.Color = Color.FromArgb(255, 246, 248, 251)
box.Line.FillType = FillFormatType.none
for text in lines:
para = TextParagraph()
para.Text = "・" + text
para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
para.TextRanges[0].FontHeight = 16
para.LeftMargin = 14
box.TextFrame.Paragraphs.Append(para)
box.TextFrame.Paragraphs.Append(TextParagraph())
引数なしの TextParagraph() が空の段落で、段落間の余白として使う。SpaceAfter を調整するより挙動が読みやすく、長い文が折り返したときの見た目も安定する。サンプルで最も長い行(前年割れの 2 店舗を並べる「要対応」)は幅 600pt で 2 行に折り返すが、行の高さは文字サイズで決まるため次の段落へはみ出さない。
生成結果の階層を照合する
5 頁を組み終えたらファイルへ保存する。
pres.SaveToFile(DECK, FileFormat.Pptx2013)
pres.Dispose()
資料を生成したら「各頁にあるべきものが揃っているか」を確かめる。生成したファイルを読み直し、頁ごとに図形の数を並べ、グラフとそれ以外を区別する。
from spire.presentation import IChart
chk = Presentation()
chk.LoadFromFile(DECK)
print("スライド枚数:", len(chk.Slides))
for i, sl in enumerate(chk.Slides):
kinds = ["グラフ" if isinstance(sh, IChart) else "テキスト" for sh in sl.Shapes]
print(" p%d:図形 %d 個 -> %s" % (i + 1, len(sl.Shapes), "、".join(kinds)))
スライド枚数: 5
p1:図形 3 個 -> テキスト、テキスト、テキスト
p2:図形 2 個 -> テキスト、グラフ
p3:図形 2 個 -> テキスト、グラフ
p4:図形 2 個 -> テキスト、グラフ
p5:図形 2 個 -> テキスト、テキスト
図形の種類を判定するには isinstance(sh, IChart) を使う。ShapeType 列挙体に「グラフ」を表すメンバーは無く、列挙体で比較しようとすると属性エラーになる。この出力は 3 つのことを同時に確認している。頁数が 5 であること、グラフの 3 頁にそれぞれグラフが 1 つ載っていること、まとめ頁に余計なグラフが増えていないこと。
チャート関連のクラスとメンバー
| クラス / メンバー | 役割 |
|---|---|
Presentation / Presentation.Slides
|
プレゼン資料と頁コレクション。Append() で頁を追加 |
Slide.Shapes.AppendChartInit |
指定した矩形へグラフを作成する。種類は ChartType
|
Chart.ChartData |
グラフのデータ表。[行, 列] に Text または NumberValue を書く |
Chart.Series.SeriesLabel |
系列名の範囲。凡例の文字列を決める |
Chart.Categories.CategoryLabels |
分類ラベルの範囲。x 軸の項目を決める |
Chart.Series[i].Values |
i 番目の系列の数値範囲 |
Chart.Series[i].Type |
系列ごとにグラフ種別を上書きする。複合グラフの基礎 |
Chart.Series[i].UseSecondAxis |
系列を第 2 軸へ載せる |
Chart.PrimaryValueAxis / SecondaryValueAxis
|
主軸と第 2 軸。NumberFormat で目盛りの書式、MajorGridTextLines で目盛線 |
Chart.Series[i].DataLabels |
データラベル。LabelValueVisible / PercentValueVisible / NumberFormat
|
Chart.GapWidth |
棒の間隔。棒の太さに影響する |
Chart.DoughnutHoleSize |
ドーナツの穴の大きさ。サンプルは 58 |
ChartDataPoint / Chart.Series[i].DataPoints
|
扇区ごとの塗りを設定する。Add してから添字で触る |
Slide.Shapes.AppendShape |
テキスト枠などの図形を挿入する。FillFormatType.none と Line.FillType を組み合わせると文字塊だけを置ける |
TextParagraph / TextFont
|
段落とフォント。和文と欧文は LatinFont と EastAsianFont を別々に指定する |
IChart |
図形がグラフかどうかを判定するインターフェース |
台帳が替わったとき
この処理が台帳に求めるのは「見出しが 3 行目、明細が連続、末尾が合計行」という条件だけである。別の月のデータへ替えるのは台帳のパスを差し替えるだけで済み、店舗数や品目数が変わってもコードの変更は要らない。読み込みのループもグラフの範囲指定も実際の行数から計算する。
ただし置き換わるのは「数字をグラフに描き、要点を文に組む」部分だけである。まとめ頁の文型は数字の正しさと口径の一致を保証するが、その表現が適切かどうかまでは保証しない。ある店舗が落ちた理由が商圏の変化なのか売場構成なのかは、会議の前に人が補う必要がある。グラフの種類が固定である点も同じで、その月に客単価の分布を主題にするなら棒グラフもドーナツも要件を満たさず、頁を足すことになる。
広げられる方向としては、まとめ頁の文章を議事メモの下書きとして同時に書き出す、前年同月の台帳も読み込んで第 2 頁を 2 年比較にする、頁ごとの組版パラメータ(位置・文字サイズ・配色)を設定へ切り出して同一データから複数ブランドの様式を出力する、といったものが考えられる。変更は様式の定数と文型に集中し、データの読み込みとグラフの結び付けには影響しない。




