ruby_pptx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/NOTICE +48 -0
- data/README.md +272 -0
- data/lib/ruby_pptx/action.rb +120 -0
- data/lib/ruby_pptx/autoshape_spec.rb +337 -0
- data/lib/ruby_pptx/builder.rb +228 -0
- data/lib/ruby_pptx/chart/categories.rb +208 -0
- data/lib/ruby_pptx/chart/chart.rb +443 -0
- data/lib/ruby_pptx/chart/combo.rb +230 -0
- data/lib/ruby_pptx/chart/data.rb +147 -0
- data/lib/ruby_pptx/chart/format.rb +647 -0
- data/lib/ruby_pptx/chart/workbook_writer.rb +212 -0
- data/lib/ruby_pptx/chart/xml_writer.rb +953 -0
- data/lib/ruby_pptx/chart/xy_data.rb +182 -0
- data/lib/ruby_pptx/core_ext.rb +12 -0
- data/lib/ruby_pptx/dml/color.rb +155 -0
- data/lib/ruby_pptx/dml/effect.rb +33 -0
- data/lib/ruby_pptx/dml/fill.rb +234 -0
- data/lib/ruby_pptx/element_proxy.rb +53 -0
- data/lib/ruby_pptx/enum/action.rb +46 -0
- data/lib/ruby_pptx/enum/base.rb +139 -0
- data/lib/ruby_pptx/enum/chart.rb +278 -0
- data/lib/ruby_pptx/enum/dml.rb +237 -0
- data/lib/ruby_pptx/enum/lang.rb +441 -0
- data/lib/ruby_pptx/enum/prog_id.rb +38 -0
- data/lib/ruby_pptx/enum/shapes.rb +514 -0
- data/lib/ruby_pptx/enum/text.rb +108 -0
- data/lib/ruby_pptx/errors.rb +15 -0
- data/lib/ruby_pptx/geometry.rb +89 -0
- data/lib/ruby_pptx/image.rb +347 -0
- data/lib/ruby_pptx/length.rb +120 -0
- data/lib/ruby_pptx/media.rb +64 -0
- data/lib/ruby_pptx/numeric_lengths.rb +30 -0
- data/lib/ruby_pptx/opc/constants.rb +205 -0
- data/lib/ruby_pptx/opc/oxml.rb +99 -0
- data/lib/ruby_pptx/opc/pack_uri.rb +146 -0
- data/lib/ruby_pptx/opc/package.rb +482 -0
- data/lib/ruby_pptx/opc/serialized.rb +229 -0
- data/lib/ruby_pptx/opc/spec.rb +37 -0
- data/lib/ruby_pptx/oxml/action.rb +43 -0
- data/lib/ruby_pptx/oxml/chart.rb +785 -0
- data/lib/ruby_pptx/oxml/content_model.rb +186 -0
- data/lib/ruby_pptx/oxml/core_properties.rb +145 -0
- data/lib/ruby_pptx/oxml/dml/color.rb +115 -0
- data/lib/ruby_pptx/oxml/dml/fill.rb +137 -0
- data/lib/ruby_pptx/oxml/element.rb +329 -0
- data/lib/ruby_pptx/oxml/ns.rb +115 -0
- data/lib/ruby_pptx/oxml/presentation.rb +110 -0
- data/lib/ruby_pptx/oxml/section.rb +65 -0
- data/lib/ruby_pptx/oxml/shapes/autoshape.rb +310 -0
- data/lib/ruby_pptx/oxml/shapes/groupshape.rb +217 -0
- data/lib/ruby_pptx/oxml/shapes/other.rb +405 -0
- data/lib/ruby_pptx/oxml/shapes/shared.rb +292 -0
- data/lib/ruby_pptx/oxml/simple_types.rb +563 -0
- data/lib/ruby_pptx/oxml/slide.rb +290 -0
- data/lib/ruby_pptx/oxml/table.rb +253 -0
- data/lib/ruby_pptx/oxml/text.rb +324 -0
- data/lib/ruby_pptx/oxml/theme.rb +93 -0
- data/lib/ruby_pptx/package.rb +139 -0
- data/lib/ruby_pptx/parts/chart.rb +75 -0
- data/lib/ruby_pptx/parts/core_properties.rb +40 -0
- data/lib/ruby_pptx/parts/embedded_package.rb +43 -0
- data/lib/ruby_pptx/parts/image.rb +55 -0
- data/lib/ruby_pptx/parts/media.rb +17 -0
- data/lib/ruby_pptx/parts/presentation.rb +79 -0
- data/lib/ruby_pptx/parts/slide.rb +221 -0
- data/lib/ruby_pptx/parts/theme.rb +26 -0
- data/lib/ruby_pptx/pattern_matching.rb +50 -0
- data/lib/ruby_pptx/presentation.rb +128 -0
- data/lib/ruby_pptx/refinements.rb +21 -0
- data/lib/ruby_pptx/section.rb +130 -0
- data/lib/ruby_pptx/shapes/adjustments.rb +83 -0
- data/lib/ruby_pptx/shapes/authoring.rb +110 -0
- data/lib/ruby_pptx/shapes/base.rb +138 -0
- data/lib/ruby_pptx/shapes/freeform.rb +141 -0
- data/lib/ruby_pptx/shapes/placeholder.rb +184 -0
- data/lib/ruby_pptx/shapes/shape.rb +337 -0
- data/lib/ruby_pptx/shapes/shape_tree.rb +634 -0
- data/lib/ruby_pptx/sliceable.rb +34 -0
- data/lib/ruby_pptx/slide.rb +430 -0
- data/lib/ruby_pptx/table.rb +316 -0
- data/lib/ruby_pptx/table_paging.rb +91 -0
- data/lib/ruby_pptx/templates/default.pptx +0 -0
- data/lib/ruby_pptx/templates/docx-icon.emf +0 -0
- data/lib/ruby_pptx/templates/generic-icon.emf +0 -0
- data/lib/ruby_pptx/templates/media-speaker.png +0 -0
- data/lib/ruby_pptx/templates/notes.xml +23 -0
- data/lib/ruby_pptx/templates/notesMaster.xml +352 -0
- data/lib/ruby_pptx/templates/pptx-icon.emf +0 -0
- data/lib/ruby_pptx/templates/slideMaster.xml +277 -0
- data/lib/ruby_pptx/templates/theme.xml +321 -0
- data/lib/ruby_pptx/templates/xlsx-icon.emf +0 -0
- data/lib/ruby_pptx/text/fitter.rb +72 -0
- data/lib/ruby_pptx/text/font_metrics.rb +221 -0
- data/lib/ruby_pptx/text/text.rb +393 -0
- data/lib/ruby_pptx/theme.rb +98 -0
- data/lib/ruby_pptx/version.rb +5 -0
- data/lib/ruby_pptx.rb +90 -0
- metadata +174 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/pattern_matching"
|
|
4
|
+
|
|
5
|
+
require "ruby_pptx/sliceable"
|
|
6
|
+
|
|
7
|
+
require "ruby_pptx/element_proxy"
|
|
8
|
+
|
|
9
|
+
module Pptx
|
|
10
|
+
# The sections of a presentation.
|
|
11
|
+
#
|
|
12
|
+
# Sections group slides in the thumbnail pane and in presenter view. They are
|
|
13
|
+
# a PowerPoint 2010 extension rather than part of the base PresentationML
|
|
14
|
+
# schema, and python-pptx does not model them at all.
|
|
15
|
+
#
|
|
16
|
+
# prs.sections.add("Introduction", slides: prs.slides.first(2))
|
|
17
|
+
# prs.sections["Introduction"].slides.map(&:slide_id)
|
|
18
|
+
class Sections
|
|
19
|
+
include DeconstructToArray
|
|
20
|
+
|
|
21
|
+
include Sliceable
|
|
22
|
+
include Enumerable
|
|
23
|
+
|
|
24
|
+
def initialize(presentation)
|
|
25
|
+
@presentation = presentation
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def each
|
|
29
|
+
return enum_for(:each) { size } unless block_given?
|
|
30
|
+
|
|
31
|
+
section_elements.each { |element| yield Section.new(element, @presentation) }
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def size = section_elements.size
|
|
36
|
+
alias length size
|
|
37
|
+
|
|
38
|
+
def empty? = size.zero?
|
|
39
|
+
|
|
40
|
+
# Indexed by position, or looked up by name.
|
|
41
|
+
def [](key, length = nil)
|
|
42
|
+
return by_name(key) if key.is_a?(String)
|
|
43
|
+
|
|
44
|
+
slice_members(section_elements, key, length) { |element| Section.new(element, @presentation) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Section, nil]
|
|
48
|
+
def by_name(name) = find { |section| section.name == name }
|
|
49
|
+
|
|
50
|
+
# Add a section, optionally containing +slides+.
|
|
51
|
+
#
|
|
52
|
+
# @return [Section]
|
|
53
|
+
def add(name, slides: [])
|
|
54
|
+
element = @presentation.element.get_or_add_section_list.add_section(name)
|
|
55
|
+
Section.new(element, @presentation).tap do |section|
|
|
56
|
+
slides.each { |slide| section << slide }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Remove a section. Its slides stay in the presentation; only the grouping
|
|
61
|
+
# goes away, which is what PowerPoint does when a section is removed
|
|
62
|
+
# without its slides.
|
|
63
|
+
def delete(section)
|
|
64
|
+
list = @presentation.element.section_list
|
|
65
|
+
return nil if list.nil?
|
|
66
|
+
|
|
67
|
+
list.remove(section.element)
|
|
68
|
+
@presentation.element.remove_section_list if list.section_list.empty?
|
|
69
|
+
section
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect = "#<Pptx::Sections #{map(&:name).inspect}>"
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def section_elements = @presentation.element.section_list&.section_list || []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One named section of a presentation.
|
|
80
|
+
class Section < ElementProxy
|
|
81
|
+
include PatternMatching
|
|
82
|
+
|
|
83
|
+
pattern_keys :name, :id, :slides
|
|
84
|
+
|
|
85
|
+
def initialize(element, presentation)
|
|
86
|
+
super(element)
|
|
87
|
+
@presentation = presentation
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def name = @element.name
|
|
91
|
+
|
|
92
|
+
def name=(value)
|
|
93
|
+
@element.name = value.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The GUID PowerPoint uses to identify this section.
|
|
97
|
+
def id = @element.id
|
|
98
|
+
|
|
99
|
+
# The slides in this section, in section order.
|
|
100
|
+
#
|
|
101
|
+
# A section stores slide ids, so a slide deleted from the presentation
|
|
102
|
+
# simply drops out here rather than leaving a dangling entry.
|
|
103
|
+
def slides
|
|
104
|
+
slide_ids.filter_map { |slide_id| @presentation.slides.by_id(slide_id) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def slide_ids = @element.sldIdLst.sldId_list.map(&:id)
|
|
108
|
+
|
|
109
|
+
# Put +slide+ in this section. A slide belongs to at most one section, so
|
|
110
|
+
# it is removed from any other first.
|
|
111
|
+
def <<(slide)
|
|
112
|
+
@presentation.sections.each { |other| other.delete_slide(slide) unless other == self }
|
|
113
|
+
return self if slide_ids.include?(slide.slide_id)
|
|
114
|
+
|
|
115
|
+
@element.sldIdLst.add_sldId(id: slide.slide_id)
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
alias add_slide <<
|
|
119
|
+
|
|
120
|
+
def delete_slide(slide)
|
|
121
|
+
entry = @element.sldIdLst.sldId_list.find { |e| e.id == slide.slide_id }
|
|
122
|
+
@element.sldIdLst.remove(entry) if entry
|
|
123
|
+
slide
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def include?(slide) = slide_ids.include?(slide.slide_id)
|
|
127
|
+
|
|
128
|
+
def inspect = "#<Pptx::Section #{name.inspect} slides=#{slide_ids.size}>"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/autoshape_spec"
|
|
4
|
+
|
|
5
|
+
module Pptx
|
|
6
|
+
# The adjustment handles of an auto shape -- the yellow diamonds that set
|
|
7
|
+
# how round a rounded rectangle is, or how deep a chevron's notch goes.
|
|
8
|
+
#
|
|
9
|
+
# shape.adjustments[0] #=> 0.16667
|
|
10
|
+
# shape.adjustments[0] = 0.4
|
|
11
|
+
#
|
|
12
|
+
# Values are fractions: 0.5 is half way. Many shapes allow values below
|
|
13
|
+
# 0.0 or above 1.0 at extreme proportions, so neither bound is enforced.
|
|
14
|
+
#
|
|
15
|
+
# Every handle the shape type defines is always present, at its default
|
|
16
|
+
# until set. Setting any one writes all of them, as PowerPoint does.
|
|
17
|
+
class Adjustments
|
|
18
|
+
include Enumerable
|
|
19
|
+
|
|
20
|
+
# The file stores adjustments on a scale of 100,000.
|
|
21
|
+
SCALE = 100_000.0
|
|
22
|
+
|
|
23
|
+
Adjustment = Struct.new(:name, :default, :actual) do
|
|
24
|
+
def raw = actual || default
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param preset_geometry [Pptx::Oxml::Element, nil] the shape's
|
|
28
|
+
# `a:prstGeom`; nil for a freeform, which has no handles
|
|
29
|
+
def initialize(preset_geometry)
|
|
30
|
+
@geometry = preset_geometry
|
|
31
|
+
@adjustments = initial_adjustments
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Float, nil] nil past the last handle, as Array does
|
|
35
|
+
def [](index)
|
|
36
|
+
adjustment = @adjustments[index]
|
|
37
|
+
adjustment && (adjustment.raw / SCALE)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def []=(index, value)
|
|
41
|
+
unless value.is_a?(Numeric)
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"adjustment values must be numeric, got #{value.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
adjustment = @adjustments[index] or
|
|
47
|
+
raise IndexError, "this shape has #{size} adjustment#{"s" unless size == 1}, not #{index + 1}"
|
|
48
|
+
# Truncated, not rounded, to match python-pptx: 0.29 is stored as 28999.
|
|
49
|
+
adjustment.actual = (value * SCALE).to_i
|
|
50
|
+
@geometry.rewrite_guides(@adjustments.map { |a| [a.name, a.raw] })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each
|
|
54
|
+
return enum_for(:each) { size } unless block_given?
|
|
55
|
+
|
|
56
|
+
@adjustments.each { |a| yield a.raw / SCALE }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def size = @adjustments.size
|
|
61
|
+
alias length size
|
|
62
|
+
|
|
63
|
+
def inspect = "#<Pptx::Adjustments #{to_a.inspect}>"
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The type's defaults, overlaid with any values the file records. A
|
|
68
|
+
# guide naming no handle of this type is ignored rather than trusted.
|
|
69
|
+
def initial_adjustments
|
|
70
|
+
return [] if @geometry.nil?
|
|
71
|
+
|
|
72
|
+
adjustments = AutoShapeSpec.default_adjustments(@geometry.prst.name).map do |name, default|
|
|
73
|
+
Adjustment.new(name, default)
|
|
74
|
+
end
|
|
75
|
+
by_name = adjustments.to_h { |a| [a.name, a] }
|
|
76
|
+
@geometry.gd_list.each do |gd|
|
|
77
|
+
target = by_name[gd.name] or next
|
|
78
|
+
target.actual = Integer(gd.fmla.delete_prefix("val "), 10)
|
|
79
|
+
end
|
|
80
|
+
adjustments
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pptx
|
|
4
|
+
# Adding placeholders to a slide master or a slide layout.
|
|
5
|
+
#
|
|
6
|
+
# A slide's placeholders are cloned from its layout and are not created
|
|
7
|
+
# directly, so this is mixed into the master and layout collections only.
|
|
8
|
+
module PlaceholderAuthoring
|
|
9
|
+
# Add a placeholder.
|
|
10
|
+
#
|
|
11
|
+
# master.placeholders.add(:title, at: [x, y], size: [cx, cy])
|
|
12
|
+
# layout.placeholders.add(:body, idx: 1, at: [x, y], size: [cx, cy])
|
|
13
|
+
#
|
|
14
|
+
# A title placeholder carries no `idx` -- there is only ever one, and
|
|
15
|
+
# PowerPoint identifies it by type. Every other kind needs one, and gets
|
|
16
|
+
# the next free value unless told otherwise. The `idx` is what ties a
|
|
17
|
+
# layout placeholder to the master placeholder it inherits from, so it is
|
|
18
|
+
# worth setting deliberately when the two should line up.
|
|
19
|
+
#
|
|
20
|
+
# @param ph_type [Symbol, Pptx::Enum::PP_PLACEHOLDER_TYPE]
|
|
21
|
+
# @param at [Array(Length, Length), nil] position; inherited when omitted
|
|
22
|
+
# @param size [Array(Length, Length), nil] size; inherited when omitted
|
|
23
|
+
# @param idx [Integer, nil]
|
|
24
|
+
# @param orient [Symbol, nil] :vertical for a vertical placeholder
|
|
25
|
+
# @param sz [Symbol, nil] :full, :half or :quarter, the size hint
|
|
26
|
+
# PowerPoint uses when it rebuilds a layout
|
|
27
|
+
# @return [Shape] the new placeholder
|
|
28
|
+
def add(ph_type, at: nil, size: nil, idx: nil, orient: nil, sz: nil)
|
|
29
|
+
type = Enum::PP_PLACEHOLDER_TYPE.fetch(ph_type)
|
|
30
|
+
idx = resolve_idx(type, idx)
|
|
31
|
+
id = next_shape_id
|
|
32
|
+
element = @sp_tree.add_placeholder(id, next_placeholder_name(type, id, orient),
|
|
33
|
+
type, orient_value(orient), sz_value(sz), idx)
|
|
34
|
+
shape = shape_factory(element)
|
|
35
|
+
place(shape, at, size)
|
|
36
|
+
shape
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Add the five placeholders PowerPoint expects a master to have.
|
|
40
|
+
#
|
|
41
|
+
# Their geometry is the Office default scaled to +slide_size+, so a master
|
|
42
|
+
# added to a 4:3 deck reproduces the familiar layout exactly and a 16:9
|
|
43
|
+
# deck gets the same proportions rather than a 4:3 block in the corner.
|
|
44
|
+
def add_standard_set(slide_size)
|
|
45
|
+
width, height = slide_size
|
|
46
|
+
STANDARD_PLACEHOLDERS.each do |ph_type, idx, sz, (x, y, cx, cy)|
|
|
47
|
+
add(ph_type, idx: idx, sz: sz,
|
|
48
|
+
at: [scale(x, width), scale(y, height)],
|
|
49
|
+
size: [scale(cx, width), scale(cy, height)])
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fractions of the slide, taken from the Office default master, whose
|
|
55
|
+
# slide is 9144000 x 6858000 EMU.
|
|
56
|
+
STANDARD_PLACEHOLDERS = [
|
|
57
|
+
[:TITLE, nil, nil, [457_200 / 9_144_000.0, 274_638 / 6_858_000.0,
|
|
58
|
+
8_229_600 / 9_144_000.0, 1_143_000 / 6_858_000.0]],
|
|
59
|
+
[:BODY, 1, nil, [457_200 / 9_144_000.0, 1_600_200 / 6_858_000.0,
|
|
60
|
+
8_229_600 / 9_144_000.0, 4_525_963 / 6_858_000.0]],
|
|
61
|
+
[:DATE, 2, :half, [457_200 / 9_144_000.0, 6_356_350 / 6_858_000.0,
|
|
62
|
+
2_133_600 / 9_144_000.0, 365_125 / 6_858_000.0]],
|
|
63
|
+
[:FOOTER, 3, :quarter, [3_124_200 / 9_144_000.0, 6_356_350 / 6_858_000.0,
|
|
64
|
+
2_895_600 / 9_144_000.0, 365_125 / 6_858_000.0]],
|
|
65
|
+
[:SLIDE_NUMBER, 4, :quarter, [6_553_200 / 9_144_000.0, 6_356_350 / 6_858_000.0,
|
|
66
|
+
2_133_600 / 9_144_000.0, 365_125 / 6_858_000.0]]
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
SIZES = {
|
|
70
|
+
full: Oxml::SimpleTypes::ST_PlaceholderSize::FULL,
|
|
71
|
+
half: Oxml::SimpleTypes::ST_PlaceholderSize::HALF,
|
|
72
|
+
quarter: Oxml::SimpleTypes::ST_PlaceholderSize::QUARTER
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def scale(fraction, extent) = Length.emu((fraction * extent.emu).round)
|
|
78
|
+
|
|
79
|
+
def place(shape, at, size)
|
|
80
|
+
shape.left, shape.top = at if at
|
|
81
|
+
shape.width, shape.height = size if size
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A title is identified by type; everything else by idx, which must be
|
|
85
|
+
# unique within the shape tree.
|
|
86
|
+
def resolve_idx(type, idx)
|
|
87
|
+
return nil if %i[TITLE CENTER_TITLE].include?(type.name)
|
|
88
|
+
return idx unless idx.nil?
|
|
89
|
+
|
|
90
|
+
used = member_elements.filter_map(&:ph_idx)
|
|
91
|
+
(1..).find { |candidate| !used.include?(candidate) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def orient_value(orient)
|
|
95
|
+
return nil if orient.nil?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "orient must be :vertical or nil, got #{orient.inspect}" unless orient == :vertical
|
|
98
|
+
|
|
99
|
+
Oxml::SimpleTypes::ST_Direction::VERT
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sz_value(sz)
|
|
103
|
+
return nil if sz.nil?
|
|
104
|
+
|
|
105
|
+
SIZES.fetch(sz) do
|
|
106
|
+
raise ArgumentError, "sz must be :full, :half or :quarter, got #{sz.inspect}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_pptx/pattern_matching"
|
|
4
|
+
|
|
5
|
+
require "ruby_pptx/element_proxy"
|
|
6
|
+
require "ruby_pptx/enum/shapes"
|
|
7
|
+
require "ruby_pptx/action"
|
|
8
|
+
|
|
9
|
+
module Pptx
|
|
10
|
+
# A shape on a slide, layout or master.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses cover the specific kinds: {Shape} for auto shapes and text
|
|
13
|
+
# boxes, {Picture}, {GraphicFrame}, {Connector} and {GroupShape}.
|
|
14
|
+
class BaseShape
|
|
15
|
+
include PatternMatching
|
|
16
|
+
|
|
17
|
+
pattern_keys :shape_id, :name, :shape_type, :left, :top, :width, :height,
|
|
18
|
+
:rotation, :placeholder_format
|
|
19
|
+
|
|
20
|
+
attr_reader :element, :parent
|
|
21
|
+
|
|
22
|
+
def initialize(shape_element, parent)
|
|
23
|
+
@element = shape_element
|
|
24
|
+
@parent = parent
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other) = other.is_a?(BaseShape) && other.element == @element
|
|
28
|
+
alias eql? ==
|
|
29
|
+
|
|
30
|
+
def hash = @element.hash
|
|
31
|
+
|
|
32
|
+
# The package part containing this shape.
|
|
33
|
+
def part = @parent.part
|
|
34
|
+
|
|
35
|
+
# The drawing-object id, unique within the slide.
|
|
36
|
+
def shape_id = @element.shape_id
|
|
37
|
+
|
|
38
|
+
def name = @element.shape_name
|
|
39
|
+
|
|
40
|
+
def name=(value)
|
|
41
|
+
@element.nvXxPr.cNvPr.name = value.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Length, nil] nil when the shape inherits its position
|
|
45
|
+
def left = @element.x
|
|
46
|
+
|
|
47
|
+
def left=(value)
|
|
48
|
+
@element.x = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def top = @element.y
|
|
52
|
+
|
|
53
|
+
def top=(value)
|
|
54
|
+
@element.y = value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def width = @element.cx
|
|
58
|
+
|
|
59
|
+
def width=(value)
|
|
60
|
+
@element.cx = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def height = @element.cy
|
|
64
|
+
|
|
65
|
+
def height=(value)
|
|
66
|
+
@element.cy = value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clockwise rotation in degrees.
|
|
70
|
+
def rotation = @element.rot
|
|
71
|
+
|
|
72
|
+
def rotation=(value)
|
|
73
|
+
@element.rot = value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def placeholder? = @element.placeholder?
|
|
77
|
+
|
|
78
|
+
# This shape's shadow. Always returned, even when the shadow is inherited.
|
|
79
|
+
#
|
|
80
|
+
# @return [ShadowFormat]
|
|
81
|
+
def shadow = @shadow ||= ShadowFormat.new(@element.spPr)
|
|
82
|
+
|
|
83
|
+
# What happens when this shape is clicked during a slide show.
|
|
84
|
+
def click_action = @click_action ||= ActionSetting.new(@element.nvXxPr.cNvPr, self)
|
|
85
|
+
|
|
86
|
+
# What happens when the pointer rests on this shape.
|
|
87
|
+
def hover_action
|
|
88
|
+
@hover_action ||= ActionSetting.new(@element.nvXxPr.cNvPr, self, hover: true)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The URL this shape links to, or nil.
|
|
92
|
+
#
|
|
93
|
+
# A shortcut for `click_action.url`, which is the common case; reach for
|
|
94
|
+
# {#click_action} when the click does something else. Nil when the click
|
|
95
|
+
# is not a hyperlink -- a slide jump reports nil here, not a partname.
|
|
96
|
+
def hyperlink = click_action.url
|
|
97
|
+
|
|
98
|
+
def hyperlink=(url)
|
|
99
|
+
click_action.address = url
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Placeholder position and type, or nil when this is not a placeholder.
|
|
103
|
+
def placeholder_format
|
|
104
|
+
placeholder? ? PlaceholderFormat.new(@element.ph) : nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Overridden by the subclasses that can actually hold these.
|
|
108
|
+
def text_frame? = false
|
|
109
|
+
def chart? = false
|
|
110
|
+
def table? = false
|
|
111
|
+
|
|
112
|
+
# @return [Pptx::Enum::MSO_SHAPE_TYPE]
|
|
113
|
+
def shape_type = raise(NotImplementedError, "#{self.class} must implement #shape_type")
|
|
114
|
+
|
|
115
|
+
def inspect = "#<#{self.class.name} id=#{shape_id} #{name.inspect}>"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The placeholder-specific properties of a shape: which placeholder it is
|
|
119
|
+
# and what kind.
|
|
120
|
+
class PlaceholderFormat < ElementProxy
|
|
121
|
+
include PatternMatching
|
|
122
|
+
|
|
123
|
+
pattern_keys :idx, :type
|
|
124
|
+
|
|
125
|
+
# The `idx` that ties a slide placeholder to the layout one it inherits
|
|
126
|
+
# from. The title placeholder is always 0.
|
|
127
|
+
def idx = @element.idx
|
|
128
|
+
|
|
129
|
+
# @return [Pptx::Enum::PP_PLACEHOLDER]
|
|
130
|
+
def type = @element.type
|
|
131
|
+
|
|
132
|
+
def orientation = @element.orient
|
|
133
|
+
|
|
134
|
+
def size = @element.sz
|
|
135
|
+
|
|
136
|
+
def inspect = "#<Pptx::PlaceholderFormat idx=#{idx} type=#{type}>"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pptx
|
|
4
|
+
# Builds a freeform shape from a sequence of pen movements.
|
|
5
|
+
#
|
|
6
|
+
# Coordinates are given in whatever units suit the drawing -- "local"
|
|
7
|
+
# coordinates -- and +scale+ says how many EMU one local unit is worth. That
|
|
8
|
+
# lets a shape be described in convenient numbers and placed at any size:
|
|
9
|
+
#
|
|
10
|
+
# shape = slide.shapes.add_freeform(at: [Pptx.inches(1), Pptx.inches(1)],
|
|
11
|
+
# scale: Pptx.inches(1).emu / 100.0) do |f|
|
|
12
|
+
# f.line_to(100, 0)
|
|
13
|
+
# f.line_to(50, 100)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The shape's position and size follow from the path: the bounding box of
|
|
17
|
+
# the points drawn, placed at the origin given to {#convert_to_shape}.
|
|
18
|
+
class FreeformBuilder
|
|
19
|
+
include Enumerable
|
|
20
|
+
|
|
21
|
+
# One pen movement. `kind` is :move, :line or :close; a close carries no
|
|
22
|
+
# coordinates.
|
|
23
|
+
Operation = Data.define(:kind, :x, :y)
|
|
24
|
+
|
|
25
|
+
def initialize(shapes, start_x, start_y, x_scale, y_scale)
|
|
26
|
+
@shapes = shapes
|
|
27
|
+
@start_x = round(start_x)
|
|
28
|
+
@start_y = round(start_y)
|
|
29
|
+
@x_scale = x_scale
|
|
30
|
+
@y_scale = y_scale
|
|
31
|
+
@operations = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.new_builder(shapes, start_x, start_y, scale)
|
|
35
|
+
x_scale, y_scale = scale.is_a?(Array) ? scale : [scale, scale]
|
|
36
|
+
new(shapes, start_x, start_y, x_scale, y_scale)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def each(&) = @operations.each(&)
|
|
40
|
+
|
|
41
|
+
def size = @operations.size
|
|
42
|
+
|
|
43
|
+
# Draw a straight line to (x, y).
|
|
44
|
+
#
|
|
45
|
+
# @return [self] so calls can be chained
|
|
46
|
+
def line_to(x, y)
|
|
47
|
+
@operations << Operation.new(:line, round(x), round(y))
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Lift the pen and continue from (x, y), starting a new contour.
|
|
52
|
+
def move_to(x, y)
|
|
53
|
+
@operations << Operation.new(:move, round(x), round(y))
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Join the current contour back to where it started.
|
|
58
|
+
def close
|
|
59
|
+
@operations << Operation.new(:close, nil, nil)
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Draw a line to each of +vertices+, optionally closing the contour.
|
|
64
|
+
#
|
|
65
|
+
# @param vertices [Array<Array(Numeric, Numeric)>]
|
|
66
|
+
def add_line_segments(vertices, close: true)
|
|
67
|
+
vertices.each { |x, y| line_to(x, y) }
|
|
68
|
+
self.close if close
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Turn the path into a shape, with the local origin placed at +origin_at+
|
|
73
|
+
# on the slide.
|
|
74
|
+
#
|
|
75
|
+
# May be called more than once to stamp the same geometry in several
|
|
76
|
+
# places.
|
|
77
|
+
#
|
|
78
|
+
# @return [Shape]
|
|
79
|
+
def convert_to_shape(origin_at: [0, 0])
|
|
80
|
+
origin_x, origin_y = origin_at.map { |value| Length.coerce(value).emu }
|
|
81
|
+
sp = @shapes.add_freeform_element(origin_x + left, origin_y + top, width, height)
|
|
82
|
+
path = sp.add_path(local_width, local_height)
|
|
83
|
+
path.move_to(*to_shape_space(@start_x, @start_y))
|
|
84
|
+
@operations.each { |operation| apply(operation, path) }
|
|
85
|
+
@shapes.build_shape(sp)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The leftmost extent of the path, in local coordinates.
|
|
89
|
+
#
|
|
90
|
+
# The bounding box need not start at the local origin, so this is what the
|
|
91
|
+
# points are measured from.
|
|
92
|
+
def offset_x = drawn.map(&:x).push(@start_x).min
|
|
93
|
+
|
|
94
|
+
def offset_y = drawn.map(&:y).push(@start_y).min
|
|
95
|
+
|
|
96
|
+
def inspect = "#<Pptx::FreeformBuilder #{size} operations>"
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Coordinates may be given as floats for convenience; the format wants
|
|
101
|
+
# integers.
|
|
102
|
+
#
|
|
103
|
+
# Rounded half-to-even, because Python rounds that way and Ruby rounds
|
|
104
|
+
# half-up: a coordinate of 100.5 would otherwise land a unit away from
|
|
105
|
+
# where the reference implementation puts it.
|
|
106
|
+
def round(value) = value.round(half: :even)
|
|
107
|
+
|
|
108
|
+
# Operations that have a position; a close does not.
|
|
109
|
+
def drawn = @operations.reject { |operation| operation.kind == :close }
|
|
110
|
+
|
|
111
|
+
def local_width
|
|
112
|
+
xs = drawn.map(&:x).push(@start_x)
|
|
113
|
+
xs.max - xs.min
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def local_height
|
|
117
|
+
ys = drawn.map(&:y).push(@start_y)
|
|
118
|
+
ys.max - ys.min
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def width = (local_width * @x_scale).round
|
|
122
|
+
|
|
123
|
+
def height = (local_height * @y_scale).round
|
|
124
|
+
|
|
125
|
+
def left = (offset_x * @x_scale).round
|
|
126
|
+
|
|
127
|
+
def top = (offset_y * @y_scale).round
|
|
128
|
+
|
|
129
|
+
# Points inside a path are relative to the shape's top-left corner, not to
|
|
130
|
+
# the local origin.
|
|
131
|
+
def to_shape_space(x, y) = [x - offset_x, y - offset_y]
|
|
132
|
+
|
|
133
|
+
def apply(operation, path)
|
|
134
|
+
case operation.kind
|
|
135
|
+
when :move then path.move_to(*to_shape_space(operation.x, operation.y))
|
|
136
|
+
when :line then path.line_to(*to_shape_space(operation.x, operation.y))
|
|
137
|
+
when :close then path.close_contour
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|