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,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
|