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.
Files changed (101) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE +48 -0
  5. data/README.md +272 -0
  6. data/lib/ruby_pptx/action.rb +120 -0
  7. data/lib/ruby_pptx/autoshape_spec.rb +337 -0
  8. data/lib/ruby_pptx/builder.rb +228 -0
  9. data/lib/ruby_pptx/chart/categories.rb +208 -0
  10. data/lib/ruby_pptx/chart/chart.rb +443 -0
  11. data/lib/ruby_pptx/chart/combo.rb +230 -0
  12. data/lib/ruby_pptx/chart/data.rb +147 -0
  13. data/lib/ruby_pptx/chart/format.rb +647 -0
  14. data/lib/ruby_pptx/chart/workbook_writer.rb +212 -0
  15. data/lib/ruby_pptx/chart/xml_writer.rb +953 -0
  16. data/lib/ruby_pptx/chart/xy_data.rb +182 -0
  17. data/lib/ruby_pptx/core_ext.rb +12 -0
  18. data/lib/ruby_pptx/dml/color.rb +155 -0
  19. data/lib/ruby_pptx/dml/effect.rb +33 -0
  20. data/lib/ruby_pptx/dml/fill.rb +234 -0
  21. data/lib/ruby_pptx/element_proxy.rb +53 -0
  22. data/lib/ruby_pptx/enum/action.rb +46 -0
  23. data/lib/ruby_pptx/enum/base.rb +139 -0
  24. data/lib/ruby_pptx/enum/chart.rb +278 -0
  25. data/lib/ruby_pptx/enum/dml.rb +237 -0
  26. data/lib/ruby_pptx/enum/lang.rb +441 -0
  27. data/lib/ruby_pptx/enum/prog_id.rb +38 -0
  28. data/lib/ruby_pptx/enum/shapes.rb +514 -0
  29. data/lib/ruby_pptx/enum/text.rb +108 -0
  30. data/lib/ruby_pptx/errors.rb +15 -0
  31. data/lib/ruby_pptx/geometry.rb +89 -0
  32. data/lib/ruby_pptx/image.rb +347 -0
  33. data/lib/ruby_pptx/length.rb +120 -0
  34. data/lib/ruby_pptx/media.rb +64 -0
  35. data/lib/ruby_pptx/numeric_lengths.rb +30 -0
  36. data/lib/ruby_pptx/opc/constants.rb +205 -0
  37. data/lib/ruby_pptx/opc/oxml.rb +99 -0
  38. data/lib/ruby_pptx/opc/pack_uri.rb +146 -0
  39. data/lib/ruby_pptx/opc/package.rb +482 -0
  40. data/lib/ruby_pptx/opc/serialized.rb +229 -0
  41. data/lib/ruby_pptx/opc/spec.rb +37 -0
  42. data/lib/ruby_pptx/oxml/action.rb +43 -0
  43. data/lib/ruby_pptx/oxml/chart.rb +785 -0
  44. data/lib/ruby_pptx/oxml/content_model.rb +186 -0
  45. data/lib/ruby_pptx/oxml/core_properties.rb +145 -0
  46. data/lib/ruby_pptx/oxml/dml/color.rb +115 -0
  47. data/lib/ruby_pptx/oxml/dml/fill.rb +137 -0
  48. data/lib/ruby_pptx/oxml/element.rb +329 -0
  49. data/lib/ruby_pptx/oxml/ns.rb +115 -0
  50. data/lib/ruby_pptx/oxml/presentation.rb +110 -0
  51. data/lib/ruby_pptx/oxml/section.rb +65 -0
  52. data/lib/ruby_pptx/oxml/shapes/autoshape.rb +310 -0
  53. data/lib/ruby_pptx/oxml/shapes/groupshape.rb +217 -0
  54. data/lib/ruby_pptx/oxml/shapes/other.rb +405 -0
  55. data/lib/ruby_pptx/oxml/shapes/shared.rb +292 -0
  56. data/lib/ruby_pptx/oxml/simple_types.rb +563 -0
  57. data/lib/ruby_pptx/oxml/slide.rb +290 -0
  58. data/lib/ruby_pptx/oxml/table.rb +253 -0
  59. data/lib/ruby_pptx/oxml/text.rb +324 -0
  60. data/lib/ruby_pptx/oxml/theme.rb +93 -0
  61. data/lib/ruby_pptx/package.rb +139 -0
  62. data/lib/ruby_pptx/parts/chart.rb +75 -0
  63. data/lib/ruby_pptx/parts/core_properties.rb +40 -0
  64. data/lib/ruby_pptx/parts/embedded_package.rb +43 -0
  65. data/lib/ruby_pptx/parts/image.rb +55 -0
  66. data/lib/ruby_pptx/parts/media.rb +17 -0
  67. data/lib/ruby_pptx/parts/presentation.rb +79 -0
  68. data/lib/ruby_pptx/parts/slide.rb +221 -0
  69. data/lib/ruby_pptx/parts/theme.rb +26 -0
  70. data/lib/ruby_pptx/pattern_matching.rb +50 -0
  71. data/lib/ruby_pptx/presentation.rb +128 -0
  72. data/lib/ruby_pptx/refinements.rb +21 -0
  73. data/lib/ruby_pptx/section.rb +130 -0
  74. data/lib/ruby_pptx/shapes/adjustments.rb +83 -0
  75. data/lib/ruby_pptx/shapes/authoring.rb +110 -0
  76. data/lib/ruby_pptx/shapes/base.rb +138 -0
  77. data/lib/ruby_pptx/shapes/freeform.rb +141 -0
  78. data/lib/ruby_pptx/shapes/placeholder.rb +184 -0
  79. data/lib/ruby_pptx/shapes/shape.rb +337 -0
  80. data/lib/ruby_pptx/shapes/shape_tree.rb +634 -0
  81. data/lib/ruby_pptx/sliceable.rb +34 -0
  82. data/lib/ruby_pptx/slide.rb +430 -0
  83. data/lib/ruby_pptx/table.rb +316 -0
  84. data/lib/ruby_pptx/table_paging.rb +91 -0
  85. data/lib/ruby_pptx/templates/default.pptx +0 -0
  86. data/lib/ruby_pptx/templates/docx-icon.emf +0 -0
  87. data/lib/ruby_pptx/templates/generic-icon.emf +0 -0
  88. data/lib/ruby_pptx/templates/media-speaker.png +0 -0
  89. data/lib/ruby_pptx/templates/notes.xml +23 -0
  90. data/lib/ruby_pptx/templates/notesMaster.xml +352 -0
  91. data/lib/ruby_pptx/templates/pptx-icon.emf +0 -0
  92. data/lib/ruby_pptx/templates/slideMaster.xml +277 -0
  93. data/lib/ruby_pptx/templates/theme.xml +321 -0
  94. data/lib/ruby_pptx/templates/xlsx-icon.emf +0 -0
  95. data/lib/ruby_pptx/text/fitter.rb +72 -0
  96. data/lib/ruby_pptx/text/font_metrics.rb +221 -0
  97. data/lib/ruby_pptx/text/text.rb +393 -0
  98. data/lib/ruby_pptx/theme.rb +98 -0
  99. data/lib/ruby_pptx/version.rb +5 -0
  100. data/lib/ruby_pptx.rb +90 -0
  101. 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