ruby_pptx
Create, read and update PowerPoint (.pptx) files from Ruby.
A port of python-pptx — the same battle-tested OOXML object model underneath, with a public API redesigned for Ruby. See PORTING.md for the architecture and the milestone plan.
Status: every public class and member of python-pptx 1.0.2 has a counterpart here, which
spec/ruby_pptx/api_completeness_spec.rbchecks rather than asserts. Output is verified against python-pptx part for part. Where the two deliberately differ -- mostly python-pptx bugs this does not reproduce -- PORTING.md lists each one.
require "ruby_pptx"
prs = Pptx::Presentation.new_default # or .open("deck.pptx")
slide = prs.slides.add(prs.slide_layouts["Title and Content"])
slide.shapes.title.text = "Quarterly Review"
body = slide.placeholders[1].text_frame
body.text = "Revenue up 12%\nCosts flat"
body.paragraphs.first.runs.first.font.tap do |font|
font.bold = true
font.size = Pptx.pt(24)
font.color.rgb = Pptx::RGBColor["C0504D"]
end
box = slide.shapes.add_shape(:rounded_rectangle,
at: [Pptx.inches(1), Pptx.inches(5)],
size: [Pptx.inches(3), Pptx.inches(1)])
box.fill.solid
box.fill.fore_color.rgb = Pptx::RGBColor["1F497D"]
box.text_frame.text = "Next steps"
slide.shapes.add_picture("logo.png", at: [Pptx.inches(7), Pptx.inches(0.5)],
width: Pptx.inches(2))
table_frame = slide.shapes.add_table(2, 3, at: [Pptx.inches(1), Pptx.inches(3)],
size: [Pptx.inches(8), Pptx.inches(2)])
table_frame.table[0, 0].text = "Region"
data = Pptx::ChartData.new
data.categories = ["East", "West", "Midwest"]
data.add_series("Q1", [1.2, 2.0, 3.5])
slide.shapes.add_chart(:column_clustered, data,
at: [Pptx.inches(1), Pptx.inches(3)],
size: [Pptx.inches(8), Pptx.inches(4)])
# Scatter and bubble charts take points rather than a value per category.
xy = Pptx::XyChartData.new
xy.add_series("Alpha", points: [[1, 10], [2, 20]])
box.hyperlink = "https://example.com"
# Connectors attach to shapes, and groups size themselves around their contents.
line = slide.shapes.add_connector(:straight, begin_at: [0, 0], end_at: [0, 0])
line.begin_connect(box, 3)
group = slide.shapes.add_group_shape([box, table_frame])
slide.shapes.add_movie("clip.mp4", at: [x, y], size: [w, h], content_type: "video/mp4")
prs.save("out.pptx")Or build a whole deck declaratively:
deck = Pptx.build do |d|
d.slide_size = :widescreen
d.slide("Title Slide") do |s|
s.title = "Annual Report"
s.subtitle = "Prepared in Ruby"
end
d.section("Detail") do
d.slide("Blank") do |s|
s.shape :rounded_rectangle, at: [Pptx.inches(1), Pptx.inches(1)],
size: [Pptx.inches(3), Pptx.inches(1)],
fill: "1F497D", text: "Next steps"
s.chart :column_clustered, categories: %w[East West],
series: { "Q1" => [1, 2] },
at: [Pptx.inches(1), Pptx.inches(3)],
size: [Pptx.inches(6), Pptx.inches(4)]
end
end
end
deck.save("out.pptx")Speaker notes are plain text for the common case, with the full text frame
underneath when you want formatting. Reading notes never creates a notes
page; assigning does.
slide.notes = "Mention the Q3 dip.\nThank the team."
slide.notes #=> "Mention the Q3 dip.\nThank the team."
slide.notes_slide.notes_text_frame.paragraphs.last.runs.first.font.bold = truePicture, table and chart placeholders are filled in place, so the content takes the layout's position and size. A picture is cropped to fill its placeholder, never stretched.
photo = deck.slides.add(deck.slide_layouts["Picture with Caption"])
photo.placeholders[1].insert_picture("office.jpg")
photo.placeholders[2].text_frame.text = "Our new office"
# The default template has no table or chart placeholders, so add a layout
# with both to its master.
layout = deck.slide_masters[0].slide_layouts.add("Table and Chart", type: "twoObj") do |l|
l.placeholders.add(:title)
l.placeholders.add(:table, idx: 1, at: [Pptx.inches(0.5), Pptx.inches(1.5)],
size: [Pptx.inches(4.25), Pptx.inches(4.5)])
l.placeholders.add(:chart, idx: 2, at: [Pptx.inches(5.25), Pptx.inches(1.5)],
size: [Pptx.inches(4.25), Pptx.inches(4.5)])
end
report = deck.slides.add(layout)
report.placeholders[1].insert_table(3, 2).table[0, 0].text = "Region"
report.placeholders[2].insert_chart(:column_clustered, data)Any file can be embedded as an OLE object, shown as an icon that opens it when double-clicked. Word, Excel and PowerPoint files get Office's own icon; for anything else, name its ProgID and optionally supply an icon.
sheet = slide.shapes.add_ole_object("budget.xlsx", prog_id: :xlsx, at: [x, y])
sheet.ole_format.prog_id #=> "Excel.Sheet.12"
slide.shapes.add_ole_object("report.pdf", prog_id: "AcroExch.Document", at: [x, y],
icon_file: "pdf-icon.png")Five things python-pptx does not do: SVG pictures, slide sections, paging a long table across as many slides as it needs, combo charts with a secondary axis, and defining a slide master in code.
data = Pptx::ChartData.new
data.categories = %w[Q1 Q2 Q3]
data.add_series("Revenue", [120, 135, 150])
data.add_series("Margin", [0.21, 0.24, 0.22])
slide.shapes.add_combo_chart(data, at: [x, y], size: [w, h]) do |combo|
combo.plot :column_clustered, series: "Revenue"
combo.plot :line, series: "Margin", secondary_axis: true
end# PowerPoint wants a raster stand-in beside the vector, and this gem has no
# rasterizer, so you supply it.
slide.shapes.add_picture("logo.svg", at: [x, y], fallback: "logo.png")Text can be shrunk to fit the shape holding it. Measuring needs the actual
glyph outlines, so you pass the font file; font_family is what gets written
into the deck.
box.text_frame.fit_text(font_file: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
font_family: "DejaVu Sans",
max_size: 28)
#=> 28 (the point size applied, never above max_size)That turns word wrap on, autofit off, and applies the size to every run. Sizes are measured from the font's own metrics rather than by rendering, so they can differ from python-pptx's by a point on text that only just fits; PORTING.md has the measured comparison.
A slide master can be built from scratch, rather than only edited in a template. The master gets its own theme, so its colours and fonts are independent of any other master in the deck.
master = deck.slide_masters.add(name: "Corporate")
master.theme.colors.update(accent1: "1F497D", accent2: "C0504D")
master.theme.fonts.major = "Georgia"
master.theme.fonts.minor = "Verdana"
layout = master.slide_layouts.add("Title and Content", type: "obj") do |l|
l.placeholders.add(:title, at: [Pptx.inches(0.5), Pptx.inches(0.3)],
size: [Pptx.inches(9), Pptx.inches(1.25)])
l.placeholders.add(:body, idx: 1, at: [Pptx.inches(0.5), Pptx.inches(1.75)],
size: [Pptx.inches(9), Pptx.inches(4.5)])
end
slide = deck.slides.add(layout)
slide.shapes.title.text = "Built from a hand-made master"The master starts with the five placeholders PowerPoint expects — title, body,
date, footer and slide number — scaled to the deck's slide size. Pass
placeholders: :none for a bare one.
deck.sections.add("Appendix", slides: deck.slides.to_a.last(2))
deck.slides.add_table_pages(rows,
layout: deck.slide_layouts["Blank"],
left: Pptx.inches(0.5), top: Pptx.inches(1),
width: Pptx.inches(9), height: Pptx.inches(5))Lengths are explicit rather than bare numbers:
Pptx.inches(1).emu #=> 914400
Pptx.cm(2.54).pt #=> 72.0Numeric sugar is opt-in, and comes two ways. Prefer the refinement: it is scoped to the file that asks for it, so it cannot surprise anything else sharing the process.
require "ruby_pptx/refinements"
using Pptx::Lengths # this file only
1.inch == 72.points #=> true
require "ruby_pptx/core_ext" # or patch Numeric process-wideat: and size: have always taken a two-element array, and still do. Passing
a Point or a Size instead costs nothing and buys named readers, arithmetic
and pattern matching:
origin = Pptx.point(Pptx.inches(1), Pptx.inches(1))
box = Pptx.size(Pptx.inches(3), Pptx.inches(1))
slide.shapes.add_shape(:rectangle, at: origin, size: box)
slide.shapes.add_shape(:rectangle, at: origin + [0, Pptx.inches(1.5)], size: box * 2)Reading a deck back supports case/in. Enum-valued attributes read as their
symbolic name in a pattern, and only the keys a pattern asks for are computed:
slide.shapes.each do |shape|
case shape
in {shape_type: :PICTURE, name:} then puts "picture #{name}"
in {shape_type: :PLACEHOLDER, placeholder_format: {type: :TITLE}}
then puts shape.text_frame.text
in {width:} if width > Pptx.inches(5) then puts "#{shape.name} is wide"
else next
end
endCollections index like arrays — slides[2], slides[-1], slides[1..3],
slides[1, 2] — and deconstruct into array patterns.
Nokogiri, REXML and ruby.wasm
ruby_pptx reads and writes XML with Nokogiri. Where Nokogiri cannot load -- ruby.wasm, in a browser or elsewhere -- it falls back to REXML, which is pure Ruby, with no configuration:
Pptx.xml_backend #=> :nokogiri, or :rexml without NokogiriThe output does not depend on the backend: CI runs every spec under both, and
the packages they write are identical part for part. REXML is slower -- in a
60-slide deck with text, tables and charts, about 3.3 s against 0.5 s to build
and save, and 0.7 s against 0.1 s to open and read it back. Set
RUBY_PPTX_XML_BACKEND=rexml (or nokogiri) to choose explicitly.
Nokogiri stays a declared dependency, so a normal gem install or bundle install gets the fast path. Two things to know when loading the gem in
ruby.wasm yourself:
- Load
ruby_pptx,rubyzipandrexml, but notnokogiri, which is native. A loader that installs a gem together with its runtime dependencies will stop at Nokogiri. - Ship the whole
lib/directory, not only its.rbfiles: the templates inlib/ruby_pptx/templates(the default deck, notes pages, icons) are read as files.
tools/wasm/run.mjs runs the gem this way under ruby.wasm's browser
filesystem, and CI uses it to build, save and reread a deck on every push.
Development
bundle install
bundle exec rspec
bundle exec rubocop.rubocop.yml records where this codebase deliberately departs from the
default style — chiefly that the oxml layer keeps the OOXML schema's own
names (CT_Shape, #cSld, accent1) so the XML, the specification and the
Ruby read side by side.
Specs compare the packages this gem writes against the ones python-pptx writes for the same operation, part by part, on canonicalised XML. To run those:
pip install -r spec/oracle-requirements.txtWithout it, the oracle-backed specs skip and the rest still run. CI sets
REQUIRE_ORACLE=1, which turns those skips into failures so a broken Python
environment cannot quietly reduce the suite to its unit tests. The same goes
for the fit_text specs, which measure with fonts from Debian's
fonts-dejavu-core and fonts-urw-base35 packages.
A slide master has no oracle — python-pptx can read one but not create one —
so it is validated against the published ISO/IEC 29500-4 schemas instead.
Those are not redistributed here; point OOXML_SCHEMAS at a directory holding
pml.xsd, dml-main.xsd and the shared-*.xsd files they import:
OOXML_SCHEMAS=/path/to/schemas bundle exec rspecReleasing
Bump Pptx::VERSION, add its CHANGELOG.md entry, then push a matching tag:
git tag -a v0.1.0 -m "ruby_pptx 0.1.0" && git push origin v0.1.0.github/workflows/release.yml runs the full CI suite, builds the gem, installs
it into an empty gem home to prove it loads, and creates a GitHub release with
the gem attached and the changelog entry as its notes.
Publishing to RubyGems is a separate step: in the Actions tab, open Publish to
RubyGems, choose Run workflow, and pick the release's tag under "Use
workflow from". It refuses to run from a branch. RubyGems' release-gem action
builds the tagged commit and pushes it, authenticating by RubyGems trusted
publishing rather than a stored API key.
License
MIT — see LICENSE.
This gem is a port of python-pptx by Steve Canny, which is also MIT licensed, and it vendors nine template files (the default deck, notes and theme templates, the video poster frame and the OLE object icons) from that project verbatim, plus one derived from them. See NOTICE for the full attribution and the upstream licence text.