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,321 @@
1
+ <?xml version='1.0' encoding='UTF-8' standalone='yes'?>
2
+ <a:theme
3
+ xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
4
+ name="Office Theme"
5
+ >
6
+ <a:themeElements>
7
+ <a:clrScheme name="Office">
8
+ <a:dk1>
9
+ <a:sysClr val="windowText" lastClr="000000"/>
10
+ </a:dk1>
11
+ <a:lt1>
12
+ <a:sysClr val="window" lastClr="FFFFFF"/>
13
+ </a:lt1>
14
+ <a:dk2>
15
+ <a:srgbClr val="1F497D"/>
16
+ </a:dk2>
17
+ <a:lt2>
18
+ <a:srgbClr val="EEECE1"/>
19
+ </a:lt2>
20
+ <a:accent1>
21
+ <a:srgbClr val="4F81BD"/>
22
+ </a:accent1>
23
+ <a:accent2>
24
+ <a:srgbClr val="C0504D"/>
25
+ </a:accent2>
26
+ <a:accent3>
27
+ <a:srgbClr val="9BBB59"/>
28
+ </a:accent3>
29
+ <a:accent4>
30
+ <a:srgbClr val="8064A2"/>
31
+ </a:accent4>
32
+ <a:accent5>
33
+ <a:srgbClr val="4BACC6"/>
34
+ </a:accent5>
35
+ <a:accent6>
36
+ <a:srgbClr val="F79646"/>
37
+ </a:accent6>
38
+ <a:hlink>
39
+ <a:srgbClr val="0000FF"/>
40
+ </a:hlink>
41
+ <a:folHlink>
42
+ <a:srgbClr val="800080"/>
43
+ </a:folHlink>
44
+ </a:clrScheme>
45
+ <a:fontScheme name="Office">
46
+ <a:majorFont>
47
+ <a:latin typeface="Calibri"/>
48
+ <a:ea typeface=""/>
49
+ <a:cs typeface=""/>
50
+ <a:font script="Jpan" typeface="MS Pゴシック"/>
51
+ <a:font script="Hang" typeface="맑은 고딕"/>
52
+ <a:font script="Hans" typeface="宋体"/>
53
+ <a:font script="Hant" typeface="新細明體"/>
54
+ <a:font script="Arab" typeface="Times New Roman"/>
55
+ <a:font script="Hebr" typeface="Times New Roman"/>
56
+ <a:font script="Thai" typeface="Angsana New"/>
57
+ <a:font script="Ethi" typeface="Nyala"/>
58
+ <a:font script="Beng" typeface="Vrinda"/>
59
+ <a:font script="Gujr" typeface="Shruti"/>
60
+ <a:font script="Khmr" typeface="MoolBoran"/>
61
+ <a:font script="Knda" typeface="Tunga"/>
62
+ <a:font script="Guru" typeface="Raavi"/>
63
+ <a:font script="Cans" typeface="Euphemia"/>
64
+ <a:font script="Cher" typeface="Plantagenet Cherokee"/>
65
+ <a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
66
+ <a:font script="Tibt" typeface="Microsoft Himalaya"/>
67
+ <a:font script="Thaa" typeface="MV Boli"/>
68
+ <a:font script="Deva" typeface="Mangal"/>
69
+ <a:font script="Telu" typeface="Gautami"/>
70
+ <a:font script="Taml" typeface="Latha"/>
71
+ <a:font script="Syrc" typeface="Estrangelo Edessa"/>
72
+ <a:font script="Orya" typeface="Kalinga"/>
73
+ <a:font script="Mlym" typeface="Kartika"/>
74
+ <a:font script="Laoo" typeface="DokChampa"/>
75
+ <a:font script="Sinh" typeface="Iskoola Pota"/>
76
+ <a:font script="Mong" typeface="Mongolian Baiti"/>
77
+ <a:font script="Viet" typeface="Times New Roman"/>
78
+ <a:font script="Uigh" typeface="Microsoft Uighur"/>
79
+ <a:font script="Geor" typeface="Sylfaen"/>
80
+ </a:majorFont>
81
+ <a:minorFont>
82
+ <a:latin typeface="Calibri"/>
83
+ <a:ea typeface=""/>
84
+ <a:cs typeface=""/>
85
+ <a:font script="Jpan" typeface="MS Pゴシック"/>
86
+ <a:font script="Hang" typeface="맑은 고딕"/>
87
+ <a:font script="Hans" typeface="宋体"/>
88
+ <a:font script="Hant" typeface="新細明體"/>
89
+ <a:font script="Arab" typeface="Arial"/>
90
+ <a:font script="Hebr" typeface="Arial"/>
91
+ <a:font script="Thai" typeface="Cordia New"/>
92
+ <a:font script="Ethi" typeface="Nyala"/>
93
+ <a:font script="Beng" typeface="Vrinda"/>
94
+ <a:font script="Gujr" typeface="Shruti"/>
95
+ <a:font script="Khmr" typeface="DaunPenh"/>
96
+ <a:font script="Knda" typeface="Tunga"/>
97
+ <a:font script="Guru" typeface="Raavi"/>
98
+ <a:font script="Cans" typeface="Euphemia"/>
99
+ <a:font script="Cher" typeface="Plantagenet Cherokee"/>
100
+ <a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
101
+ <a:font script="Tibt" typeface="Microsoft Himalaya"/>
102
+ <a:font script="Thaa" typeface="MV Boli"/>
103
+ <a:font script="Deva" typeface="Mangal"/>
104
+ <a:font script="Telu" typeface="Gautami"/>
105
+ <a:font script="Taml" typeface="Latha"/>
106
+ <a:font script="Syrc" typeface="Estrangelo Edessa"/>
107
+ <a:font script="Orya" typeface="Kalinga"/>
108
+ <a:font script="Mlym" typeface="Kartika"/>
109
+ <a:font script="Laoo" typeface="DokChampa"/>
110
+ <a:font script="Sinh" typeface="Iskoola Pota"/>
111
+ <a:font script="Mong" typeface="Mongolian Baiti"/>
112
+ <a:font script="Viet" typeface="Arial"/>
113
+ <a:font script="Uigh" typeface="Microsoft Uighur"/>
114
+ <a:font script="Geor" typeface="Sylfaen"/>
115
+ </a:minorFont>
116
+ </a:fontScheme>
117
+ <a:fmtScheme name="Office">
118
+ <a:fillStyleLst>
119
+ <a:solidFill>
120
+ <a:schemeClr val="phClr"/>
121
+ </a:solidFill>
122
+ <a:gradFill rotWithShape="1">
123
+ <a:gsLst>
124
+ <a:gs pos="0">
125
+ <a:schemeClr val="phClr">
126
+ <a:tint val="50000"/>
127
+ <a:satMod val="300000"/>
128
+ </a:schemeClr>
129
+ </a:gs>
130
+ <a:gs pos="35000">
131
+ <a:schemeClr val="phClr">
132
+ <a:tint val="37000"/>
133
+ <a:satMod val="300000"/>
134
+ </a:schemeClr>
135
+ </a:gs>
136
+ <a:gs pos="100000">
137
+ <a:schemeClr val="phClr">
138
+ <a:tint val="15000"/>
139
+ <a:satMod val="350000"/>
140
+ </a:schemeClr>
141
+ </a:gs>
142
+ </a:gsLst>
143
+ <a:lin ang="16200000" scaled="1"/>
144
+ </a:gradFill>
145
+ <a:gradFill rotWithShape="1">
146
+ <a:gsLst>
147
+ <a:gs pos="0">
148
+ <a:schemeClr val="phClr">
149
+ <a:tint val="100000"/>
150
+ <a:shade val="100000"/>
151
+ <a:satMod val="130000"/>
152
+ </a:schemeClr>
153
+ </a:gs>
154
+ <a:gs pos="100000">
155
+ <a:schemeClr val="phClr">
156
+ <a:tint val="50000"/>
157
+ <a:shade val="100000"/>
158
+ <a:satMod val="350000"/>
159
+ </a:schemeClr>
160
+ </a:gs>
161
+ </a:gsLst>
162
+ <a:lin ang="16200000" scaled="0"/>
163
+ </a:gradFill>
164
+ </a:fillStyleLst>
165
+ <a:lnStyleLst>
166
+ <a:ln w="9525" cap="flat" cmpd="sng" algn="ctr">
167
+ <a:solidFill>
168
+ <a:schemeClr val="phClr">
169
+ <a:shade val="95000"/>
170
+ <a:satMod val="105000"/>
171
+ </a:schemeClr>
172
+ </a:solidFill>
173
+ <a:prstDash val="solid"/>
174
+ </a:ln>
175
+ <a:ln w="25400" cap="flat" cmpd="sng" algn="ctr">
176
+ <a:solidFill>
177
+ <a:schemeClr val="phClr"/>
178
+ </a:solidFill>
179
+ <a:prstDash val="solid"/>
180
+ </a:ln>
181
+ <a:ln w="38100" cap="flat" cmpd="sng" algn="ctr">
182
+ <a:solidFill>
183
+ <a:schemeClr val="phClr"/>
184
+ </a:solidFill>
185
+ <a:prstDash val="solid"/>
186
+ </a:ln>
187
+ </a:lnStyleLst>
188
+ <a:effectStyleLst>
189
+ <a:effectStyle>
190
+ <a:effectLst>
191
+ <a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0">
192
+ <a:srgbClr val="000000">
193
+ <a:alpha val="38000"/>
194
+ </a:srgbClr>
195
+ </a:outerShdw>
196
+ </a:effectLst>
197
+ </a:effectStyle>
198
+ <a:effectStyle>
199
+ <a:effectLst>
200
+ <a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
201
+ <a:srgbClr val="000000">
202
+ <a:alpha val="35000"/>
203
+ </a:srgbClr>
204
+ </a:outerShdw>
205
+ </a:effectLst>
206
+ </a:effectStyle>
207
+ <a:effectStyle>
208
+ <a:effectLst>
209
+ <a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
210
+ <a:srgbClr val="000000">
211
+ <a:alpha val="35000"/>
212
+ </a:srgbClr>
213
+ </a:outerShdw>
214
+ </a:effectLst>
215
+ <a:scene3d>
216
+ <a:camera prst="orthographicFront">
217
+ <a:rot lat="0" lon="0" rev="0"/>
218
+ </a:camera>
219
+ <a:lightRig rig="threePt" dir="t">
220
+ <a:rot lat="0" lon="0" rev="1200000"/>
221
+ </a:lightRig>
222
+ </a:scene3d>
223
+ <a:sp3d>
224
+ <a:bevelT w="63500" h="25400"/>
225
+ </a:sp3d>
226
+ </a:effectStyle>
227
+ </a:effectStyleLst>
228
+ <a:bgFillStyleLst>
229
+ <a:solidFill>
230
+ <a:schemeClr val="phClr"/>
231
+ </a:solidFill>
232
+ <a:gradFill rotWithShape="1">
233
+ <a:gsLst>
234
+ <a:gs pos="0">
235
+ <a:schemeClr val="phClr">
236
+ <a:tint val="40000"/>
237
+ <a:satMod val="350000"/>
238
+ </a:schemeClr>
239
+ </a:gs>
240
+ <a:gs pos="40000">
241
+ <a:schemeClr val="phClr">
242
+ <a:tint val="45000"/>
243
+ <a:shade val="99000"/>
244
+ <a:satMod val="350000"/>
245
+ </a:schemeClr>
246
+ </a:gs>
247
+ <a:gs pos="100000">
248
+ <a:schemeClr val="phClr">
249
+ <a:shade val="20000"/>
250
+ <a:satMod val="255000"/>
251
+ </a:schemeClr>
252
+ </a:gs>
253
+ </a:gsLst>
254
+ <a:path path="circle">
255
+ <a:fillToRect l="50000" t="-80000" r="50000" b="180000"/>
256
+ </a:path>
257
+ </a:gradFill>
258
+ <a:gradFill rotWithShape="1">
259
+ <a:gsLst>
260
+ <a:gs pos="0">
261
+ <a:schemeClr val="phClr">
262
+ <a:tint val="80000"/>
263
+ <a:satMod val="300000"/>
264
+ </a:schemeClr>
265
+ </a:gs>
266
+ <a:gs pos="100000">
267
+ <a:schemeClr val="phClr">
268
+ <a:shade val="30000"/>
269
+ <a:satMod val="200000"/>
270
+ </a:schemeClr>
271
+ </a:gs>
272
+ </a:gsLst>
273
+ <a:path path="circle">
274
+ <a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
275
+ </a:path>
276
+ </a:gradFill>
277
+ </a:bgFillStyleLst>
278
+ </a:fmtScheme>
279
+ </a:themeElements>
280
+ <a:objectDefaults>
281
+ <a:spDef>
282
+ <a:spPr/>
283
+ <a:bodyPr/>
284
+ <a:lstStyle/>
285
+ <a:style>
286
+ <a:lnRef idx="1">
287
+ <a:schemeClr val="accent1"/>
288
+ </a:lnRef>
289
+ <a:fillRef idx="3">
290
+ <a:schemeClr val="accent1"/>
291
+ </a:fillRef>
292
+ <a:effectRef idx="2">
293
+ <a:schemeClr val="accent1"/>
294
+ </a:effectRef>
295
+ <a:fontRef idx="minor">
296
+ <a:schemeClr val="lt1"/>
297
+ </a:fontRef>
298
+ </a:style>
299
+ </a:spDef>
300
+ <a:lnDef>
301
+ <a:spPr/>
302
+ <a:bodyPr/>
303
+ <a:lstStyle/>
304
+ <a:style>
305
+ <a:lnRef idx="2">
306
+ <a:schemeClr val="accent1"/>
307
+ </a:lnRef>
308
+ <a:fillRef idx="0">
309
+ <a:schemeClr val="accent1"/>
310
+ </a:fillRef>
311
+ <a:effectRef idx="1">
312
+ <a:schemeClr val="accent1"/>
313
+ </a:effectRef>
314
+ <a:fontRef idx="minor">
315
+ <a:schemeClr val="tx1"/>
316
+ </a:fontRef>
317
+ </a:style>
318
+ </a:lnDef>
319
+ </a:objectDefaults>
320
+ <a:extraClrSchemeLst/>
321
+ </a:theme>
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_pptx/text/font_metrics"
4
+
5
+ module Pptx
6
+ # Finds the largest whole point size at which a string still fits a box.
7
+ #
8
+ # The search is a port of python-pptx's: candidate sizes are tried largest
9
+ # first, and at each size the text is wrapped greedily onto as many lines as
10
+ # it needs and the total height compared against the box. Only the
11
+ # measurement underneath differs -- see {FontMetrics}.
12
+ class TextFitter
13
+ # @param text [String]
14
+ # @param extents [Array(Integer, Integer)] width and height in EMU
15
+ # @param max_size [Integer] largest point size to consider
16
+ # @param font_file [String] path to the font to measure with
17
+ # @return [Integer, nil] nil when the text does not fit at any size
18
+ def self.best_fit_font_size(text, extents:, max_size:, font_file:)
19
+ new(text, extents: extents, font_file: font_file).best_fit(max_size)
20
+ end
21
+
22
+ def initialize(text, extents:, font_file:)
23
+ @text = text
24
+ # Extents may arrive as Length; measurements are plain EMU integers.
25
+ @width, @height = extents.map(&:to_i)
26
+ @metrics = font_file.is_a?(FontMetrics) ? font_file : FontMetrics.open(font_file)
27
+ end
28
+
29
+ # The largest whole size in 1..max_size that fits, or nil if none does.
30
+ def best_fit(max_size)
31
+ max_size.to_i.downto(1) { |size| return size if fits?(size) }
32
+ nil
33
+ end
34
+
35
+ # Wrap +text+ at +point_size+ and report the lines it needs.
36
+ #
37
+ # Exposed because it is the interesting half of the algorithm and is worth
38
+ # being able to check on its own.
39
+ def wrap(point_size)
40
+ lines = []
41
+ remainder = @text.split
42
+ until remainder.empty?
43
+ line, remainder = break_line(remainder, point_size)
44
+ lines << line
45
+ end
46
+ lines
47
+ end
48
+
49
+ private
50
+
51
+ # The height of a line is measured from "Ty" rather than from the text, so
52
+ # that every line is the same height whether or not it happens to contain
53
+ # an ascender or a descender.
54
+ def fits?(point_size)
55
+ line_height = @metrics.text_extents("Ty", point_size)[1]
56
+ wrap(point_size).size * line_height <= @height
57
+ end
58
+
59
+ # Take as many whole words as fit, but never fewer than one -- a single
60
+ # word wider than the box would otherwise loop forever. Such a word makes
61
+ # the line overflow, and the size search then rejects that size on height.
62
+ def break_line(words, point_size)
63
+ taken = 1
64
+ taken += 1 while taken < words.size && fits_width?(words[0, taken + 1], point_size)
65
+ [words[0, taken].join(" "), words[taken..]]
66
+ end
67
+
68
+ def fits_width?(words, point_size)
69
+ @metrics.text_extents(words.join(" "), point_size)[0] <= @width
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pptx
4
+ # Text measurements read straight out of a TrueType or OpenType font file.
5
+ #
6
+ # This is what {TextFitter} measures with. python-pptx asks Pillow to
7
+ # rasterize the text and reports the ink bounding box of the result; there is
8
+ # no Ruby equivalent of that, so this reads the glyph outlines' own bounding
9
+ # boxes from the font instead. See PORTING.md for how far the two diverge --
10
+ # in short, well under a point size.
11
+ #
12
+ # Only the tables needed for measurement are parsed. Composite glyphs are not
13
+ # recursed into: their bounding box is stored on the glyph itself, which is
14
+ # all this needs.
15
+ class FontMetrics
16
+ EMU_PER_INCH = 914_400
17
+ POINTS_PER_INCH = 72.0
18
+
19
+ class << self
20
+ # Fonts are immutable and parsing one is not free, so they are cached.
21
+ def open(path)
22
+ real = File.realpath(path)
23
+ cache[real] ||= new(real)
24
+ end
25
+
26
+ def clear_cache = cache.clear
27
+
28
+ private
29
+
30
+ def cache = @cache ||= {}
31
+ end
32
+
33
+ # @param path [String] a .ttf or .otf file
34
+ def initialize(path)
35
+ @path = path
36
+ @data = File.binread(path).b
37
+ @tables = read_table_directory
38
+ require_tables!
39
+ @units_per_em = u16(@tables["head"] + 18)
40
+ @index_to_loc_format = u16(@tables["head"] + 50)
41
+ @num_glyphs = u16(@tables["maxp"] + 4)
42
+ @num_h_metrics = u16(@tables["hhea"] + 34)
43
+ @cmap = read_cmap
44
+ @glyph_boxes = {}
45
+ end
46
+
47
+ attr_reader :path, :units_per_em
48
+
49
+ # The size of +text+ rendered at +point_size+, as [width, height] in EMU.
50
+ #
51
+ # The height is the ink height, so it depends on which glyphs are present:
52
+ # "Ty" is taller than "ace". That is deliberate -- it is how python-pptx
53
+ # measures, and {TextFitter} relies on it by always measuring "Ty".
54
+ def text_extents(text, point_size)
55
+ box = ink_box(text)
56
+ return [0, 0] if box.nil?
57
+
58
+ # Width is measured from the pen's starting position rather than from the
59
+ # first mark, which is what Pillow reports and so what python-pptx fits
60
+ # against: it counts a leading space, and the first glyph's left side
61
+ # bearing. Only ink that starts left of the origin pushes the edge out.
62
+ # Height is pure ink, top mark to bottom mark.
63
+ left = [box[0], 0].min
64
+ [to_emu(box[2] - left, point_size), to_emu(box[3] - box[1], point_size)]
65
+ end
66
+
67
+ def inspect = "#<Pptx::FontMetrics #{File.basename(@path)} upem=#{@units_per_em}>"
68
+
69
+ private
70
+
71
+ # Laying the glyphs out along the baseline and unioning their boxes.
72
+ # Kerning is not applied; see PORTING.md.
73
+ def ink_box(text)
74
+ pen = 0
75
+ left = bottom = right = top = nil
76
+ text.each_char do |char|
77
+ gid = glyph_id(char.ord)
78
+ box = glyph_box(gid)
79
+ if box
80
+ left = min(left, pen + box[0])
81
+ bottom = min(bottom, box[1])
82
+ right = max(right, pen + box[2])
83
+ top = max(top, box[3])
84
+ end
85
+ pen += advance(gid)
86
+ end
87
+ left && [left, bottom, right, top]
88
+ end
89
+
90
+ def min(a, b) = a.nil? || b < a ? b : a
91
+ def max(a, b) = a.nil? || b > a ? b : a
92
+
93
+ # Font units are relative to the em square, so scaling by the point size
94
+ # gives points, which are 1/72 inch by definition.
95
+ def to_emu(units, point_size)
96
+ (units * point_size / @units_per_em.to_f / POINTS_PER_INCH * EMU_PER_INCH).to_i
97
+ end
98
+
99
+ def glyph_id(codepoint) = @cmap[codepoint] || 0
100
+
101
+ # A font may store one advance for a run of trailing glyphs, in which case
102
+ # hmtx is truncated and the last entry stands for all of them.
103
+ def advance(gid)
104
+ u16(@tables["hmtx"] + ([gid, @num_h_metrics - 1].min * 4))
105
+ end
106
+
107
+ # @return [Array(Integer, Integer, Integer, Integer), nil] xMin, yMin,
108
+ # xMax, yMax in font units, or nil for a glyph with no outline
109
+ def glyph_box(gid)
110
+ return @glyph_boxes[gid] if @glyph_boxes.key?(gid)
111
+
112
+ @glyph_boxes[gid] = compute_glyph_box(gid)
113
+ end
114
+
115
+ def compute_glyph_box(gid)
116
+ return cff_glyph_box(gid) if @tables["glyf"].nil?
117
+ return nil if gid >= @num_glyphs
118
+
119
+ from = loca(gid)
120
+ # A glyph with no contours, such as the space, has a zero-length entry.
121
+ return nil if from == loca(gid + 1)
122
+
123
+ at = @tables["glyf"] + from
124
+ [s16(at + 2), s16(at + 4), s16(at + 6), s16(at + 8)]
125
+ end
126
+
127
+ # OpenType/CFF fonts have no glyf table, and reading their charstrings to
128
+ # find outlines is a disproportionate amount of machinery for a measurement
129
+ # that only has to be close. Fall back to the advance width and the font's
130
+ # overall vertical extents.
131
+ def cff_glyph_box(gid)
132
+ width = advance(gid)
133
+ return nil if width.zero?
134
+
135
+ ascender = s16(@tables["hhea"] + 4)
136
+ descender = s16(@tables["hhea"] + 6)
137
+ [0, descender, width, ascender]
138
+ end
139
+
140
+ def loca(index)
141
+ base = @tables["loca"]
142
+ @index_to_loc_format.zero? ? u16(base + (index * 2)) * 2 : u32(base + (index * 4))
143
+ end
144
+
145
+ def u16(at) = @data.byteslice(at, 2).unpack1("n")
146
+ def s16(at) = @data.byteslice(at, 2).unpack1("s>")
147
+ def u32(at) = @data.byteslice(at, 4).unpack1("N")
148
+
149
+ def read_table_directory
150
+ raise Error, "#{@path} is not a TrueType or OpenType font" unless sfnt_version?
151
+
152
+ u16(4).times.to_h do |i|
153
+ entry = @data.byteslice(12 + (i * 16), 16)
154
+ [entry.byteslice(0, 4), entry.byteslice(8, 4).unpack1("N")]
155
+ end
156
+ end
157
+
158
+ # A TrueType outline font, an OpenType/CFF font, or an Apple-flavoured one.
159
+ def sfnt_version?
160
+ %W[\x00\x01\x00\x00 OTTO true].include?(@data.byteslice(0, 4))
161
+ end
162
+
163
+ def require_tables!
164
+ missing = %w[head maxp hhea hmtx cmap] - @tables.keys
165
+ return if missing.empty?
166
+
167
+ raise Error, "#{@path} is missing required font tables: #{missing.join(", ")}"
168
+ end
169
+
170
+ # A Unicode BMP subtable is enough: presentation text outside the BMP falls
171
+ # back to glyph 0, which measures as .notdef rather than crashing.
172
+ def read_cmap
173
+ base = @tables["cmap"]
174
+ offset = nil
175
+ u16(base + 2).times do |i|
176
+ platform, encoding, sub = @data.byteslice(base + 4 + (i * 8), 8).unpack("nnN")
177
+ next unless unicode_bmp?(platform, encoding)
178
+ next unless u16(base + sub) == 4
179
+
180
+ offset = base + sub
181
+ break
182
+ end
183
+ offset.nil? ? {} : read_cmap_format4(offset)
184
+ end
185
+
186
+ def unicode_bmp?(platform, encoding)
187
+ (platform == 3 && encoding == 1) || (platform.zero? && encoding <= 4)
188
+ end
189
+
190
+ def read_cmap_format4(offset)
191
+ seg_x2 = u16(offset + 6)
192
+ ends = @data.byteslice(offset + 14, seg_x2).unpack("n*")
193
+ # A reserved padding word sits between the end and start arrays.
194
+ starts = @data.byteslice(offset + 16 + seg_x2, seg_x2).unpack("n*")
195
+ deltas = @data.byteslice(offset + 16 + (seg_x2 * 2), seg_x2).unpack("n*")
196
+ ranges_at = offset + 16 + (seg_x2 * 3)
197
+ ranges = @data.byteslice(ranges_at, seg_x2).unpack("n*")
198
+
199
+ map = {}
200
+ (seg_x2 / 2).times do |i|
201
+ (starts[i]..ends[i]).each do |code|
202
+ # The final segment is a sentinel ending at 0xFFFF.
203
+ next if code == 0xFFFF
204
+
205
+ gid = segment_glyph(i, code, starts, deltas, ranges, ranges_at)
206
+ map[code] = gid if gid
207
+ end
208
+ end
209
+ map
210
+ end
211
+
212
+ # idRangeOffset is a byte offset from its own slot in the array, which is
213
+ # why the segment index has to be folded back in.
214
+ def segment_glyph(index, code, starts, deltas, ranges, ranges_at)
215
+ return (code + deltas[index]) % 65_536 if ranges[index].zero?
216
+
217
+ gid = u16(ranges_at + (index * 2) + ranges[index] + ((code - starts[index]) * 2))
218
+ gid.zero? ? nil : (gid + deltas[index]) % 65_536
219
+ end
220
+ end
221
+ end