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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/length"
4
+
5
+ module Pptx
6
+ # Shared by Point and Size.
7
+ module Geometry
8
+ # A coordinate must be a Length or whole EMU; nil is not a position.
9
+ def self.length(value, name)
10
+ Length.coerce(value) || raise(ArgumentError, "#{name} is required")
11
+ end
12
+ end
13
+
14
+ # A position on a slide.
15
+ #
16
+ # Pptx.point(Pptx.inches(1), Pptx.inches(2))
17
+ # slide.shapes.add_shape(:rectangle, at: origin + offset, size: box)
18
+ #
19
+ # Anywhere this library takes `at:` it has always taken a two-element array,
20
+ # and it still does. A Point defines `to_ary`, so it destructures exactly as
21
+ # that array did -- passing one costs nothing and buys named readers,
22
+ # arithmetic and pattern matching:
23
+ #
24
+ # case shape.position
25
+ # in {x:, y:} if x > Pptx.inches(5) then :right_half
26
+ # end
27
+ #
28
+ # Coordinates are normalised to {Pptx::Length}, so a bare number means EMU,
29
+ # the same as it does in the array form.
30
+ Point = Data.define(:x, :y) do
31
+ # Build from a Point or a two-element array, so callers can accept either.
32
+ def self.from(value)
33
+ value.is_a?(Point) ? value : new(*value)
34
+ end
35
+
36
+ def initialize(x:, y:)
37
+ super(x: Geometry.length(x, :x), y: Geometry.length(y, :y))
38
+ end
39
+
40
+ # Destructuring and splatting, which is what lets a Point stand in for the
41
+ # `[x, y]` array everywhere this library already unpacks one.
42
+ def to_ary = [x, y]
43
+
44
+ def +(other)
45
+ other = Point.from(other)
46
+ Point.new(x: x + other.x, y: y + other.y)
47
+ end
48
+
49
+ def -(other)
50
+ other = Point.from(other)
51
+ Point.new(x: x - other.x, y: y - other.y)
52
+ end
53
+
54
+ def to_s = "(#{x.inches.round(3)}in, #{y.inches.round(3)}in)"
55
+ def inspect = "#<Pptx::Point #{self}>"
56
+ end
57
+
58
+ # The extent of a shape: a width and a height.
59
+ #
60
+ # As with {Point}, this stands in for the `[width, height]` array that `size:`
61
+ # has always taken.
62
+ Size = Data.define(:width, :height) do
63
+ def self.from(value)
64
+ value.is_a?(Size) ? value : new(*value)
65
+ end
66
+
67
+ def initialize(width:, height:)
68
+ super(width: Geometry.length(width, :width), height: Geometry.length(height, :height))
69
+ end
70
+
71
+ def to_ary = [width, height]
72
+
73
+ # Scale both extents, for `size * 2` or `size * 0.5`.
74
+ def *(factor) = Size.new(width: width * factor, height: height * factor)
75
+
76
+ def aspect_ratio = width.emu.to_f / height.emu
77
+
78
+ def to_s = "#{width.inches.round(3)}in x #{height.inches.round(3)}in"
79
+ def inspect = "#<Pptx::Size #{self}>"
80
+ end
81
+
82
+ class << self
83
+ # @return [Pptx::Point]
84
+ def point(x, y) = Point.new(x: x, y: y)
85
+
86
+ # @return [Pptx::Size]
87
+ def size(width, height) = Size.new(width: width, height: height)
88
+ end
89
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "stringio"
5
+ require "ruby_pptx/errors"
6
+ require "ruby_pptx/length"
7
+ require "ruby_pptx/opc/constants"
8
+
9
+ module Pptx
10
+ # An image, as a value object over its bytes.
11
+ #
12
+ # Format, pixel size and resolution are read straight from the file header.
13
+ # python-pptx uses Pillow for this; a handful of header formats is a much
14
+ # smaller thing to carry than an image library, and the specs check the
15
+ # results against Pillow for every supported format.
16
+ class Image
17
+ # Canonical extension per format. The extension of the file the image came
18
+ # from is ignored: what matters is what the bytes actually are.
19
+ EXT_FOR_FORMAT = {
20
+ BMP: "bmp", GIF: "gif", JPEG: "jpg", PNG: "png", TIFF: "tiff", WMF: "wmf",
21
+ EMF: "emf", SVG: "svg"
22
+ }.freeze
23
+
24
+ # Not in the OPC spec's table; Office writes this for an SVG part.
25
+ CONTENT_TYPE_SVG = "image/svg+xml"
26
+
27
+ CONTENT_TYPE_FOR_EXT = {
28
+ "bmp" => Opc::CONTENT_TYPE::BMP,
29
+ "emf" => Opc::CONTENT_TYPE::X_EMF,
30
+ "gif" => Opc::CONTENT_TYPE::GIF,
31
+ "jpe" => Opc::CONTENT_TYPE::JPEG,
32
+ "jpeg" => Opc::CONTENT_TYPE::JPEG,
33
+ "jpg" => Opc::CONTENT_TYPE::JPEG,
34
+ "png" => Opc::CONTENT_TYPE::PNG,
35
+ "tif" => Opc::CONTENT_TYPE::TIFF,
36
+ "tiff" => Opc::CONTENT_TYPE::TIFF,
37
+ "wdp" => Opc::CONTENT_TYPE::MS_PHOTO,
38
+ "wmf" => Opc::CONTENT_TYPE::X_WMF,
39
+ "svg" => CONTENT_TYPE_SVG
40
+ }.freeze
41
+
42
+ # Resolution assumed when the file does not say.
43
+ DEFAULT_DPI = 72
44
+
45
+ # A dpi outside this range is not believable and is replaced by the default.
46
+ DPI_RANGE = (1..2048)
47
+
48
+ EMU_PER_INCH = 914_400
49
+
50
+ attr_reader :blob, :filename
51
+
52
+ class << self
53
+ def from_blob(blob, filename = nil) = new(blob, filename)
54
+
55
+ # Load from a path or an IO stream.
56
+ def from_file(image_file)
57
+ if image_file.is_a?(String)
58
+ new(File.binread(image_file), File.basename(image_file))
59
+ else
60
+ image_file.rewind if image_file.respond_to?(:rewind)
61
+ new(image_file.read, nil)
62
+ end
63
+ end
64
+ end
65
+
66
+ def initialize(blob, filename = nil)
67
+ @blob = blob
68
+ @filename = filename
69
+ end
70
+
71
+ # @return [Symbol] :PNG, :JPEG, :GIF, :BMP, :TIFF or :WMF
72
+ def format = header.fetch(:format)
73
+
74
+ # True for a vector image, which has no pixel size of its own and needs a
75
+ # raster fallback to be placed in a slide.
76
+ def vector? = format == :SVG
77
+
78
+ # @return [Array(Integer, Integer)] width and height in pixels
79
+ def size = [header.fetch(:width), header.fetch(:height)]
80
+
81
+ def width_px = header.fetch(:width)
82
+
83
+ def height_px = header.fetch(:height)
84
+
85
+ # @return [Array(Integer, Integer)] horizontal and vertical dots per inch
86
+ def dpi
87
+ [normalize_dpi(header[:horz_dpi]), normalize_dpi(header[:vert_dpi])]
88
+ end
89
+
90
+ # The canonical extension for this image's actual format.
91
+ def ext
92
+ EXT_FOR_FORMAT.fetch(format) do
93
+ raise Error, "unsupported image format #{format}"
94
+ end
95
+ end
96
+
97
+ def content_type = CONTENT_TYPE_FOR_EXT.fetch(ext)
98
+
99
+ def sha1 = @sha1 ||= Digest::SHA1.hexdigest(@blob)
100
+
101
+ # Native size in EMU, derived from the pixel size and resolution.
102
+ #
103
+ # @return [Array(Pptx::Length, Pptx::Length)]
104
+ def native_size
105
+ raise Error, "an SVG has no native pixel size; size it from its raster fallback" if vector?
106
+
107
+ horz_dpi, vert_dpi = dpi
108
+ [Length.emu((EMU_PER_INCH * width_px / horz_dpi).to_i),
109
+ Length.emu((EMU_PER_INCH * height_px / vert_dpi).to_i)]
110
+ end
111
+
112
+ def inspect = "#<Pptx::Image #{format} #{width_px}x#{height_px} #{dpi.join("x")}dpi>"
113
+
114
+ private
115
+
116
+ def normalize_dpi(value)
117
+ return DEFAULT_DPI if value.nil?
118
+
119
+ rounded = Float(value).round
120
+ DPI_RANGE.cover?(rounded) ? rounded : DEFAULT_DPI
121
+ rescue ArgumentError, TypeError
122
+ DEFAULT_DPI
123
+ end
124
+
125
+ def header
126
+ @header ||= ImageHeader.parse(@blob)
127
+ end
128
+ end
129
+
130
+ # Reads format, pixel dimensions and resolution out of an image file header.
131
+ module ImageHeader
132
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b
133
+ # A pHYs chunk gives pixels per unit; unit 1 is the metre.
134
+ METRES_PER_INCH = 0.0254
135
+
136
+ module_function
137
+
138
+ # @return [Hash] {format:, width:, height:, horz_dpi:, vert_dpi:}
139
+ def parse(blob)
140
+ blob = blob.b
141
+ return parse_png(blob) if blob.start_with?(PNG_SIGNATURE)
142
+ return parse_jpeg(blob) if blob.start_with?("\xFF\xD8".b)
143
+ return parse_gif(blob) if blob.start_with?("GIF87a".b, "GIF89a".b)
144
+ return parse_bmp(blob) if blob.start_with?("BM".b)
145
+ return parse_tiff(blob) if blob.start_with?("II*\x00".b, "MM\x00*".b)
146
+ return { format: :WMF, width: 0, height: 0 } if blob.start_with?("\xD7\xCD\xC6\x9A".b)
147
+ return parse_emf(blob) if emf?(blob)
148
+ return { format: :SVG } if svg?(blob)
149
+
150
+ raise Error, "unrecognized image format"
151
+ end
152
+
153
+ # An EMF begins with its header record, type 1, and carries the signature
154
+ # " EMF" 40 bytes in.
155
+ #
156
+ # python-pptx asks Pillow, which reports EMF and WMF alike as "WMF", and so
157
+ # stores an EMF as `imageN.wmf` with the WMF content type. This names it
158
+ # for what it is, as PowerPoint does; see PORTING.md.
159
+ def emf?(blob)
160
+ blob.bytesize >= 88 && blob.byteslice(0, 4).unpack1("V") == 1 && blob.byteslice(40, 4) == " EMF".b
161
+ end
162
+
163
+ # Size from the header's bounds in device pixels; resolution from how
164
+ # those pixels relate to the frame, which is in hundredths of a
165
+ # millimetre. Pillow reads it the same way.
166
+ def parse_emf(blob)
167
+ left, top, right, bottom = blob.byteslice(8, 16).unpack("l<4")
168
+ frame_left, frame_top, frame_right, frame_bottom = blob.byteslice(24, 16).unpack("l<4")
169
+ width = right - left
170
+ height = bottom - top
171
+ { format: :EMF, width: width, height: height,
172
+ horz_dpi: (width * 2540.0 / (frame_right - frame_left)).round,
173
+ vert_dpi: (height * 2540.0 / (frame_bottom - frame_top)).round }
174
+ end
175
+
176
+ # An SVG is XML, so it is recognized by its root element rather than by a
177
+ # magic number. Only the first kilobyte is searched, which covers any
178
+ # plausible declaration, comment or doctype preamble.
179
+ def svg?(blob)
180
+ head = blob.byteslice(0, 1024)
181
+ head.include?("<svg".b) || (head.include?("<?xml".b) && blob.include?("<svg".b))
182
+ end
183
+
184
+ # PNG is a signature followed by length-prefixed chunks. IHDR carries the
185
+ # dimensions and is always first; pHYs, if present, carries resolution.
186
+ def parse_png(blob)
187
+ result = { format: :PNG }
188
+ offset = PNG_SIGNATURE.bytesize
189
+
190
+ while offset + 8 <= blob.bytesize
191
+ length = blob.byteslice(offset, 4).unpack1("N")
192
+ type = blob.byteslice(offset + 4, 4)
193
+ data = blob.byteslice(offset + 8, length)
194
+ break if data.nil?
195
+
196
+ case type
197
+ when "IHDR"
198
+ result[:width], result[:height] = data.unpack("NN")
199
+ when "pHYs"
200
+ ppu_x, ppu_y, unit = data.unpack("NNC")
201
+ if unit == 1
202
+ result[:horz_dpi] = ppu_x * METRES_PER_INCH
203
+ result[:vert_dpi] = ppu_y * METRES_PER_INCH
204
+ end
205
+ when "IEND"
206
+ break
207
+ end
208
+ offset += 12 + length
209
+ end
210
+ result
211
+ end
212
+
213
+ # JPEG is a stream of marker segments. SOFn carries the dimensions; the
214
+ # JFIF APP0 segment, when present, carries the resolution.
215
+ def parse_jpeg(blob)
216
+ result = { format: :JPEG }
217
+ offset = 2
218
+
219
+ while offset + 4 <= blob.bytesize
220
+ # Markers may be preceded by any number of 0xFF fill bytes.
221
+ offset += 1 while offset < blob.bytesize && blob.getbyte(offset) == 0xFF
222
+ marker = blob.getbyte(offset)
223
+ offset += 1
224
+ break if marker.nil?
225
+ # Standalone markers carry no payload.
226
+ next if (0xD0..0xD9).cover?(marker) || marker == 0x01
227
+
228
+ length = blob.byteslice(offset, 2)&.unpack1("n")
229
+ break if length.nil? || length < 2
230
+
231
+ segment = blob.byteslice(offset + 2, length - 2)
232
+
233
+ if sof?(marker) && segment && segment.bytesize >= 5
234
+ _precision, height, width = segment.unpack("Cnn")
235
+ result[:height] = height
236
+ result[:width] = width
237
+ elsif marker == 0xE0 && segment&.start_with?("JFIF\x00".b)
238
+ apply_jfif_density(result, segment)
239
+ end
240
+
241
+ break if marker == 0xDA # start of scan; image data follows
242
+
243
+ offset += length
244
+ end
245
+ result
246
+ end
247
+
248
+ # SOF0..SOF15, excluding DHT (C4), JPG (C8) and DAC (CC), which share the
249
+ # numeric range but are not frame headers.
250
+ def sof?(marker)
251
+ (0xC0..0xCF).cover?(marker) && ![0xC4, 0xC8, 0xCC].include?(marker)
252
+ end
253
+
254
+ def apply_jfif_density(result, segment)
255
+ units, x_density, y_density = segment.byteslice(7, 5).unpack("Cnn")
256
+ case units
257
+ when 1 # dots per inch
258
+ result[:horz_dpi] = x_density
259
+ result[:vert_dpi] = y_density
260
+ when 2 # dots per centimetre
261
+ result[:horz_dpi] = x_density * 2.54
262
+ result[:vert_dpi] = y_density * 2.54
263
+ end
264
+ end
265
+
266
+ # GIF carries its dimensions in the logical screen descriptor and has no
267
+ # notion of resolution.
268
+ def parse_gif(blob)
269
+ width, height = blob.byteslice(6, 4).unpack("vv")
270
+ { format: :GIF, width: width, height: height }
271
+ end
272
+
273
+ # BMP keeps dimensions and pixels-per-metre in its DIB header. Note that
274
+ # writers generally fill in 3780 ppm rather than leaving it zero, so a BMP
275
+ # usually reports about 96 dpi rather than the 72 default.
276
+ def parse_bmp(blob)
277
+ width, height = blob.byteslice(18, 8).unpack("l<l<")
278
+ ppm_x, ppm_y = blob.byteslice(38, 8).unpack("l<l<")
279
+ result = { format: :BMP, width: width, height: height.abs }
280
+ if ppm_x&.positive? && ppm_y&.positive?
281
+ result[:horz_dpi] = ppm_x * METRES_PER_INCH
282
+ result[:vert_dpi] = ppm_y * METRES_PER_INCH
283
+ end
284
+ result
285
+ end
286
+
287
+ TIFF_TAGS = { width: 256, height: 257, x_res: 282, y_res: 283, res_unit: 296 }.freeze
288
+
289
+ # TIFF stores everything in tagged IFD entries, in either byte order.
290
+ def parse_tiff(blob)
291
+ little_endian = blob.start_with?("II".b)
292
+ long = little_endian ? "V" : "N"
293
+ short = little_endian ? "v" : "n"
294
+
295
+ ifd_offset = blob.byteslice(4, 4).unpack1(long)
296
+ entry_count = blob.byteslice(ifd_offset, 2).unpack1(short)
297
+ values = {}
298
+
299
+ entry_count.times do |i|
300
+ entry = blob.byteslice(ifd_offset + 2 + (i * 12), 12)
301
+ break if entry.nil? || entry.bytesize < 12
302
+
303
+ tag = entry.byteslice(0, 2).unpack1(short)
304
+ type = entry.byteslice(2, 2).unpack1(short)
305
+ values[tag] = tiff_value(blob, entry, type, long, short)
306
+ end
307
+
308
+ result = {
309
+ format: :TIFF,
310
+ width: values[TIFF_TAGS[:width]],
311
+ height: values[TIFF_TAGS[:height]]
312
+ }
313
+ apply_tiff_resolution(result, values)
314
+ result
315
+ end
316
+
317
+ # A value of four bytes or fewer is stored inline; anything larger, such as
318
+ # the two longs of a RATIONAL, is stored at an offset.
319
+ def tiff_value(blob, entry, type, long, short)
320
+ payload = entry.byteslice(8, 4)
321
+ case type
322
+ when 3 then payload.unpack1(short) # SHORT
323
+ when 4 then payload.unpack1(long) # LONG
324
+ when 5 # RATIONAL
325
+ offset = payload.unpack1(long)
326
+ numerator, denominator = blob.byteslice(offset, 8).unpack("#{long}#{long}")
327
+ denominator.to_i.zero? ? nil : numerator.to_f / denominator
328
+ end
329
+ end
330
+
331
+ def apply_tiff_resolution(result, values)
332
+ unit = values[TIFF_TAGS[:res_unit]] || 2
333
+ x_res = values[TIFF_TAGS[:x_res]]
334
+ y_res = values[TIFF_TAGS[:y_res]]
335
+ return if x_res.nil? || y_res.nil?
336
+
337
+ case unit
338
+ when 2 # inch
339
+ result[:horz_dpi] = x_res
340
+ result[:vert_dpi] = y_res
341
+ when 3 # centimetre
342
+ result[:horz_dpi] = x_res * 2.54
343
+ result[:vert_dpi] = y_res * 2.54
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/pattern_matching"
4
+
5
+ module Pptx
6
+ # A distance, stored canonically in English Metric Units (EMU).
7
+ #
8
+ # OOXML expresses nearly every distance in EMU, an integer unit chosen so that
9
+ # inches, centimetres and points all divide it evenly. `Length` keeps that
10
+ # integer as the single source of truth and converts on the way out.
11
+ #
12
+ # Pptx::Length.inches(1).emu #=> 914400
13
+ # Pptx::Length.cm(2.54).pt #=> 72.0
14
+ #
15
+ # Instances are immutable, `Comparable`, and compare against bare Integers as
16
+ # EMU, so `Pptx::Length.inches(1) == 914_400` holds.
17
+ class Length
18
+ include Comparable
19
+ include PatternMatching
20
+
21
+ # `in {inches:}` converts on demand, so a pattern can ask in whatever unit
22
+ # reads best at the point of use.
23
+ pattern_keys :emu, :inches, :cm, :mm, :pt, :centipoints
24
+
25
+ EMUS_PER_INCH = 914_400
26
+ EMUS_PER_CENTIPOINT = 127
27
+ EMUS_PER_CM = 360_000
28
+ EMUS_PER_MM = 36_000
29
+ EMUS_PER_PT = 12_700
30
+
31
+ class << self
32
+ # @param emu [Integer] distance in English Metric Units
33
+ def emu(emu) = new(emu)
34
+
35
+ def inches(inches) = new((inches * EMUS_PER_INCH).to_i)
36
+ def cm(cm) = new((cm * EMUS_PER_CM).to_i)
37
+ def mm(mm) = new((mm * EMUS_PER_MM).to_i)
38
+ def pt(points) = new((points * EMUS_PER_PT).to_i)
39
+
40
+ # Hundredths of a point (1/7200 inch); the unit PowerPoint stores font
41
+ # sizes in.
42
+ def centipoints(centipoints) = new((centipoints * EMUS_PER_CENTIPOINT).to_i)
43
+
44
+ # Coerce +value+ to a Length, treating a bare Integer as EMU.
45
+ #
46
+ # @return [Length, nil] nil when +value+ is nil
47
+ def coerce(value)
48
+ case value
49
+ when nil then nil
50
+ when Length then value
51
+ when Integer then new(value)
52
+ else
53
+ raise TypeError, "expected Pptx::Length or Integer (EMU), got #{value.class}"
54
+ end
55
+ end
56
+ end
57
+
58
+ # @return [Integer] the distance in English Metric Units
59
+ attr_reader :emu
60
+
61
+ def initialize(emu)
62
+ @emu = Integer(emu)
63
+ freeze
64
+ end
65
+
66
+ def inches = @emu / EMUS_PER_INCH.to_f
67
+ def cm = @emu / EMUS_PER_CM.to_f
68
+ def mm = @emu / EMUS_PER_MM.to_f
69
+ def pt = @emu / EMUS_PER_PT.to_f
70
+
71
+ # @return [Integer] whole hundredths of a point, rounded toward negative infinity
72
+ def centipoints = @emu / EMUS_PER_CENTIPOINT
73
+
74
+ def to_i = @emu
75
+ alias to_int to_i
76
+
77
+ def +(other) = Length.new(@emu + Length.coerce(other).emu)
78
+ def -(other) = Length.new(@emu - Length.coerce(other).emu)
79
+ def -@ = Length.new(-@emu)
80
+
81
+ # Scaling by a plain number; `length * 2` is twice as long.
82
+ def *(factor) = Length.new((@emu * factor).to_i)
83
+
84
+ # Deliberately no #coerce, so `2 * length` raises rather than working.
85
+ #
86
+ # Ruby's coerce protocol cannot see which operator is being applied, so the
87
+ # pair it returns has to be right for all of them. Returning [self, other]
88
+ # would make `2 * length` correct but silently turn `2 - length` into
89
+ # `length - 2` -- the wrong sign. A TypeError the caller can fix by writing
90
+ # `length * 2` beats an answer that is quietly negative.
91
+
92
+ def <=>(other)
93
+ other = Length.coerce(other) if other.is_a?(Integer)
94
+ return nil unless other.is_a?(Length)
95
+
96
+ @emu <=> other.emu
97
+ end
98
+
99
+ def ==(other) = (self <=> other)&.zero? || false
100
+ alias eql? ==
101
+
102
+ def hash = @emu.hash
103
+
104
+ def zero? = @emu.zero?
105
+
106
+ def to_s = "#{@emu}emu"
107
+
108
+ def inspect = "#<Pptx::Length #{@emu}emu (#{format("%g", inches)}in)>"
109
+ end
110
+
111
+ class << self
112
+ # Convenience constructors, e.g. `Pptx.inches(1.5)`.
113
+ def emu(v) = Length.emu(v)
114
+ def inches(v) = Length.inches(v)
115
+ def cm(v) = Length.cm(v)
116
+ def mm(v) = Length.mm(v)
117
+ def pt(v) = Length.pt(v)
118
+ def centipoints(v) = Length.centipoints(v)
119
+ end
120
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "ruby_pptx/opc/constants"
5
+
6
+ module Pptx
7
+ # A video, as a value object over its bytes.
8
+ #
9
+ # Unlike an image, a video is not interrogated for its type: the caller says
10
+ # what it is. Reading container formats to find out would be a project of its
11
+ # own, and PowerPoint only needs the MIME type recorded correctly.
12
+ class Video
13
+ # What PowerPoint accepts when the type is not known. It plays anyway in
14
+ # practice, which is why python-pptx defaults to it.
15
+ UNKNOWN_CONTENT_TYPE = Opc::CONTENT_TYPE::VIDEO
16
+
17
+ EXT_FOR_CONTENT_TYPE = {
18
+ Opc::CONTENT_TYPE::ASF => "asf",
19
+ Opc::CONTENT_TYPE::AVI => "avi",
20
+ Opc::CONTENT_TYPE::MOV => "mov",
21
+ Opc::CONTENT_TYPE::MP4 => "mp4",
22
+ Opc::CONTENT_TYPE::MPG => "mpg",
23
+ Opc::CONTENT_TYPE::MS_VIDEO => "avi",
24
+ Opc::CONTENT_TYPE::SWF => "swf",
25
+ Opc::CONTENT_TYPE::WMV => "wmv",
26
+ Opc::CONTENT_TYPE::X_MS_VIDEO => "avi"
27
+ }.freeze
28
+
29
+ attr_reader :blob, :content_type
30
+
31
+ def self.from_blob(blob, content_type, filename = nil) = new(blob, content_type, filename)
32
+
33
+ # Load from a path or an IO stream.
34
+ def self.from_file(movie_file, content_type)
35
+ if movie_file.is_a?(String)
36
+ new(File.binread(movie_file), content_type, File.basename(movie_file))
37
+ else
38
+ movie_file.rewind if movie_file.respond_to?(:rewind)
39
+ new(movie_file.read, content_type, nil)
40
+ end
41
+ end
42
+
43
+ def initialize(blob, content_type, filename = nil)
44
+ @blob = blob
45
+ @content_type = content_type || UNKNOWN_CONTENT_TYPE
46
+ @source_filename = filename
47
+ end
48
+
49
+ # The extension for the media part: taken from the original filename when
50
+ # there is one, otherwise derived from the MIME type.
51
+ def ext
52
+ return File.extname(@source_filename).delete_prefix(".") if @source_filename
53
+
54
+ EXT_FOR_CONTENT_TYPE.fetch(@content_type, "vid")
55
+ end
56
+
57
+ # The name PowerPoint shows for the shape.
58
+ def filename = @source_filename || "movie.#{ext}"
59
+
60
+ def sha1 = @sha1 ||= Digest::SHA1.hexdigest(@blob)
61
+
62
+ def inspect = "#<Pptx::Video #{filename} #{@content_type}>"
63
+ end
64
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/length"
4
+
5
+ module Pptx
6
+ # Numeric sugar: `1.inch`, `2.5.cm`, `18.pt`.
7
+ #
8
+ # This module only defines the methods. Two files decide how they reach
9
+ # Numeric, and they are the whole choice on offer:
10
+ #
11
+ # require "ruby_pptx/refinements" # then `using Pptx::Lengths` -- this file only
12
+ # require "ruby_pptx/core_ext" # Numeric gains them everywhere, process-wide
13
+ #
14
+ # Prefer the refinement. It is scoped to the file that asks for it, so it
15
+ # cannot surprise another library that happens to share the process.
16
+ module NumericLengths
17
+ def emu = Pptx::Length.emu(self)
18
+ def inches = Pptx::Length.inches(self)
19
+ def cm = Pptx::Length.cm(self)
20
+ def mm = Pptx::Length.mm(self)
21
+ def pt = Pptx::Length.pt(self)
22
+ def centipoints = Pptx::Length.centipoints(self)
23
+
24
+ # Spelled out rather than aliased: `import_methods`, which the refinement
25
+ # uses to share these, cannot carry an alias across.
26
+ def inch = inches
27
+ def points = pt
28
+ def point = pt
29
+ end
30
+ end