rdeck 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/rdeck +9 -0
- data/lib/rdeck/action_generator.rb +179 -0
- data/lib/rdeck/apply.rb +171 -0
- data/lib/rdeck/block_quote.rb +27 -0
- data/lib/rdeck/body.rb +25 -0
- data/lib/rdeck/cli.rb +361 -0
- data/lib/rdeck/client.rb +128 -0
- data/lib/rdeck/config.rb +97 -0
- data/lib/rdeck/fragment.rb +34 -0
- data/lib/rdeck/hungarian.rb +215 -0
- data/lib/rdeck/image.rb +100 -0
- data/lib/rdeck/image_uploader.rb +75 -0
- data/lib/rdeck/markdown/cel_evaluator.rb +109 -0
- data/lib/rdeck/markdown/frontmatter.rb +75 -0
- data/lib/rdeck/markdown/parser.rb +449 -0
- data/lib/rdeck/paragraph.rb +33 -0
- data/lib/rdeck/presentation.rb +216 -0
- data/lib/rdeck/request_builder.rb +416 -0
- data/lib/rdeck/retry_handler.rb +58 -0
- data/lib/rdeck/slide.rb +64 -0
- data/lib/rdeck/slide_extractor.rb +211 -0
- data/lib/rdeck/table.rb +61 -0
- data/lib/rdeck/version.rb +5 -0
- data/lib/rdeck.rb +28 -0
- metadata +185 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class RequestBuilder
|
|
5
|
+
def initialize(presentation)
|
|
6
|
+
@presentation = presentation
|
|
7
|
+
@object_id_counter = 0
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def build_slide_update_requests(_slide_index, slide, existing_slide)
|
|
11
|
+
requests = []
|
|
12
|
+
|
|
13
|
+
slide_id = existing_slide&.object_id_prop || generate_object_id
|
|
14
|
+
|
|
15
|
+
requests += build_clear_content_requests(existing_slide) if existing_slide
|
|
16
|
+
|
|
17
|
+
slide.titles.each_with_index do |title, index|
|
|
18
|
+
requests += build_text_update_requests_for_title(slide_id, [title], index)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
requests += build_text_update_requests(slide_id, slide.subtitles, 'SUBTITLE')
|
|
22
|
+
|
|
23
|
+
requests += build_body_update_requests(slide_id, slide.bodies)
|
|
24
|
+
|
|
25
|
+
requests += build_image_update_requests(slide_id, slide.images)
|
|
26
|
+
|
|
27
|
+
requests += build_table_update_requests(slide_id, slide.tables)
|
|
28
|
+
|
|
29
|
+
if slide.speaker_note && !slide.speaker_note.empty?
|
|
30
|
+
requests << build_speaker_note_request(slide_id, slide.speaker_note)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
requests
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_layout_update_request(slide_id, layout_name)
|
|
37
|
+
layout = @presentation.find_layout(layout_name)
|
|
38
|
+
return nil unless layout
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
update_slide_properties: {
|
|
42
|
+
object_id_prop: slide_id,
|
|
43
|
+
slide_properties: {
|
|
44
|
+
layout_object_id: layout.object_id_prop
|
|
45
|
+
},
|
|
46
|
+
fields: 'layoutObjectId'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_clear_content_requests(slide)
|
|
52
|
+
requests = []
|
|
53
|
+
|
|
54
|
+
slide.page_elements&.each do |element|
|
|
55
|
+
next if element.shape&.placeholder
|
|
56
|
+
|
|
57
|
+
requests << {
|
|
58
|
+
delete_object: {
|
|
59
|
+
object_id_prop: element.object_id_prop
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
requests
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_text_update_requests_for_title(slide_id, texts, index)
|
|
68
|
+
title_types = %w[TITLE CENTERED_TITLE]
|
|
69
|
+
requests = []
|
|
70
|
+
|
|
71
|
+
texts.each do |text|
|
|
72
|
+
placeholder = nil
|
|
73
|
+
title_types.each do |type|
|
|
74
|
+
placeholder = find_placeholder(slide_id, type, index)
|
|
75
|
+
break if placeholder
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
next unless placeholder
|
|
79
|
+
|
|
80
|
+
if placeholder_has_text?(placeholder)
|
|
81
|
+
requests << {
|
|
82
|
+
delete_text: {
|
|
83
|
+
object_id_prop: placeholder.object_id_prop,
|
|
84
|
+
text_range: {
|
|
85
|
+
type: 'ALL'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
requests << {
|
|
92
|
+
insert_text: {
|
|
93
|
+
object_id_prop: placeholder.object_id_prop,
|
|
94
|
+
text: text,
|
|
95
|
+
insertion_index: 0
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
requests
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_text_update_requests(slide_id, texts, placeholder_type)
|
|
104
|
+
requests = []
|
|
105
|
+
|
|
106
|
+
texts.each_with_index do |text, index|
|
|
107
|
+
placeholder = find_placeholder(slide_id, placeholder_type, index)
|
|
108
|
+
next unless placeholder
|
|
109
|
+
|
|
110
|
+
if placeholder_has_text?(placeholder)
|
|
111
|
+
requests << {
|
|
112
|
+
delete_text: {
|
|
113
|
+
object_id_prop: placeholder.object_id_prop,
|
|
114
|
+
text_range: {
|
|
115
|
+
type: 'ALL'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
requests << {
|
|
122
|
+
insert_text: {
|
|
123
|
+
object_id_prop: placeholder.object_id_prop,
|
|
124
|
+
text: text,
|
|
125
|
+
insertion_index: 0
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
requests
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_body_update_requests(slide_id, bodies)
|
|
134
|
+
requests = []
|
|
135
|
+
|
|
136
|
+
bodies.each_with_index do |body, index|
|
|
137
|
+
placeholder = find_placeholder(slide_id, 'BODY', index)
|
|
138
|
+
next unless placeholder
|
|
139
|
+
|
|
140
|
+
placeholder_id = placeholder.object_id_prop
|
|
141
|
+
|
|
142
|
+
if placeholder_has_text?(placeholder)
|
|
143
|
+
requests << {
|
|
144
|
+
delete_text: {
|
|
145
|
+
object_id_prop: placeholder_id,
|
|
146
|
+
text_range: {
|
|
147
|
+
type: 'ALL'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
insertion_index = 0
|
|
154
|
+
body.paragraphs.each do |paragraph|
|
|
155
|
+
text = paragraph_to_text(paragraph)
|
|
156
|
+
requests << {
|
|
157
|
+
insert_text: {
|
|
158
|
+
object_id_prop: placeholder_id,
|
|
159
|
+
text: "#{text}\n",
|
|
160
|
+
insertion_index: insertion_index
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if paragraph.bullet != Paragraph::BULLET_NONE
|
|
165
|
+
requests << build_bullet_request(
|
|
166
|
+
placeholder_id,
|
|
167
|
+
insertion_index,
|
|
168
|
+
insertion_index + text.length,
|
|
169
|
+
paragraph.bullet,
|
|
170
|
+
paragraph.nesting
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
requests += build_text_style_requests(
|
|
175
|
+
placeholder_id,
|
|
176
|
+
insertion_index,
|
|
177
|
+
paragraph.fragments
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
insertion_index += text.length + 1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
requests
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_image_update_requests(slide_id, images)
|
|
188
|
+
requests = []
|
|
189
|
+
|
|
190
|
+
images.each_with_index do |image, _index|
|
|
191
|
+
url = upload_image_to_drive(image)
|
|
192
|
+
next unless url
|
|
193
|
+
|
|
194
|
+
image_id = generate_object_id
|
|
195
|
+
requests << {
|
|
196
|
+
create_image: {
|
|
197
|
+
object_id_prop: image_id,
|
|
198
|
+
url: url,
|
|
199
|
+
element_properties: {
|
|
200
|
+
page_object_id: slide_id,
|
|
201
|
+
size: {
|
|
202
|
+
width: { magnitude: 400, unit: 'PT' },
|
|
203
|
+
height: { magnitude: 300, unit: 'PT' }
|
|
204
|
+
},
|
|
205
|
+
transform: {
|
|
206
|
+
scale_x: 1,
|
|
207
|
+
scale_y: 1,
|
|
208
|
+
translate_x: 100,
|
|
209
|
+
translate_y: 100,
|
|
210
|
+
unit: 'PT'
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
requests
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_table_update_requests(slide_id, tables)
|
|
221
|
+
requests = []
|
|
222
|
+
|
|
223
|
+
tables.each do |table|
|
|
224
|
+
next if table.rows.empty?
|
|
225
|
+
|
|
226
|
+
rows = table.rows.size
|
|
227
|
+
cols = table.rows.first.cells.size
|
|
228
|
+
|
|
229
|
+
table_id = generate_object_id
|
|
230
|
+
requests << {
|
|
231
|
+
create_table: {
|
|
232
|
+
object_id_prop: table_id,
|
|
233
|
+
element_properties: {
|
|
234
|
+
page_object_id: slide_id,
|
|
235
|
+
size: {
|
|
236
|
+
width: { magnitude: 500, unit: 'PT' },
|
|
237
|
+
height: { magnitude: rows * 30, unit: 'PT' }
|
|
238
|
+
},
|
|
239
|
+
transform: {
|
|
240
|
+
scale_x: 1,
|
|
241
|
+
scale_y: 1,
|
|
242
|
+
translate_x: 50,
|
|
243
|
+
translate_y: 150,
|
|
244
|
+
unit: 'PT'
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
rows: rows,
|
|
248
|
+
columns: cols
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
table.rows.each_with_index do |row, row_idx|
|
|
253
|
+
row.cells.each_with_index do |cell, col_idx|
|
|
254
|
+
text = cell.text
|
|
255
|
+
|
|
256
|
+
requests << {
|
|
257
|
+
insert_text: {
|
|
258
|
+
object_id_prop: table_id,
|
|
259
|
+
cell_location: {
|
|
260
|
+
row_index: row_idx,
|
|
261
|
+
column_index: col_idx
|
|
262
|
+
},
|
|
263
|
+
text: text,
|
|
264
|
+
insertion_index: 0
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
next unless cell.is_header
|
|
269
|
+
|
|
270
|
+
requests << build_table_cell_style_request(
|
|
271
|
+
table_id,
|
|
272
|
+
row_idx,
|
|
273
|
+
col_idx,
|
|
274
|
+
bold: true
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
requests
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def build_speaker_note_request(slide_id, _note)
|
|
284
|
+
{
|
|
285
|
+
create_paragraph_bullets: {
|
|
286
|
+
object_id_prop: "#{slide_id}_notes",
|
|
287
|
+
text_range: {
|
|
288
|
+
type: 'ALL'
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def build_bullet_request(object_id, start_idx, end_idx, bullet_type, _nesting)
|
|
295
|
+
glyph = case bullet_type
|
|
296
|
+
when Paragraph::BULLET_NUMBERED
|
|
297
|
+
'NUMBERED_DECIMAL_NESTED'
|
|
298
|
+
else
|
|
299
|
+
'BULLET_DISC_CIRCLE_SQUARE'
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
create_paragraph_bullets: {
|
|
304
|
+
object_id_prop: object_id,
|
|
305
|
+
text_range: {
|
|
306
|
+
start_index: start_idx,
|
|
307
|
+
end_index: end_idx,
|
|
308
|
+
type: 'FIXED_RANGE'
|
|
309
|
+
},
|
|
310
|
+
bullet_preset: glyph
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_text_style_requests(object_id, base_index, fragments)
|
|
316
|
+
requests = []
|
|
317
|
+
index = base_index
|
|
318
|
+
|
|
319
|
+
fragments.each do |fragment|
|
|
320
|
+
length = fragment.value.length
|
|
321
|
+
next if length.zero?
|
|
322
|
+
|
|
323
|
+
style = {}
|
|
324
|
+
style[:bold] = true if fragment.bold
|
|
325
|
+
style[:italic] = true if fragment.italic
|
|
326
|
+
style[:link] = { url: fragment.link } if fragment.link && !fragment.link.empty?
|
|
327
|
+
|
|
328
|
+
if fragment.code
|
|
329
|
+
style[:font_family] = 'Courier New'
|
|
330
|
+
style[:foreground_color] = {
|
|
331
|
+
opaque_color: {
|
|
332
|
+
rgb_color: { red: 0.2, green: 0.2, blue: 0.2 }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
unless style.empty?
|
|
338
|
+
requests << {
|
|
339
|
+
update_text_style: {
|
|
340
|
+
object_id_prop: object_id,
|
|
341
|
+
text_range: {
|
|
342
|
+
start_index: index,
|
|
343
|
+
end_index: index + length,
|
|
344
|
+
type: 'FIXED_RANGE'
|
|
345
|
+
},
|
|
346
|
+
style: style,
|
|
347
|
+
fields: style.keys.join(',')
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
index += length
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
requests
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def build_table_cell_style_request(table_id, row, col, bold: false)
|
|
359
|
+
{
|
|
360
|
+
update_text_style: {
|
|
361
|
+
object_id_prop: table_id,
|
|
362
|
+
cell_location: {
|
|
363
|
+
row_index: row,
|
|
364
|
+
column_index: col
|
|
365
|
+
},
|
|
366
|
+
style: {
|
|
367
|
+
bold: bold
|
|
368
|
+
},
|
|
369
|
+
fields: 'bold'
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
private
|
|
375
|
+
|
|
376
|
+
def generate_object_id
|
|
377
|
+
@object_id_counter += 1
|
|
378
|
+
"obj_#{Time.now.to_i}_#{@object_id_counter}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def find_placeholder(slide_id, type, index)
|
|
382
|
+
presentation_data = @presentation.instance_variable_get(:@presentation)
|
|
383
|
+
return nil unless presentation_data
|
|
384
|
+
|
|
385
|
+
slide = presentation_data.slides&.find { |s| s.object_id_prop == slide_id }
|
|
386
|
+
return nil unless slide
|
|
387
|
+
|
|
388
|
+
placeholders = []
|
|
389
|
+
slide.page_elements&.each do |element|
|
|
390
|
+
next unless element.shape&.placeholder
|
|
391
|
+
|
|
392
|
+
placeholder_type = element.shape.placeholder.type
|
|
393
|
+
placeholders << element if placeholder_type == type
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
placeholders[index]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def placeholder_has_text?(placeholder)
|
|
400
|
+
return false unless placeholder
|
|
401
|
+
return false unless placeholder.shape&.text
|
|
402
|
+
|
|
403
|
+
placeholder.shape.text.text_elements&.any? do |element|
|
|
404
|
+
element.text_run && !element.text_run.content.to_s.strip.empty?
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def paragraph_to_text(paragraph)
|
|
409
|
+
paragraph.fragments.map(&:value).join
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def upload_image_to_drive(_image)
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class RetryHandler
|
|
5
|
+
MAX_RETRIES = 10
|
|
6
|
+
INITIAL_BACKOFF = 1
|
|
7
|
+
MAX_BACKOFF = 30
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :verbose
|
|
11
|
+
|
|
12
|
+
def with_retry(max_retries: MAX_RETRIES, verbose: nil)
|
|
13
|
+
retries = 0
|
|
14
|
+
backoff = INITIAL_BACKOFF
|
|
15
|
+
should_log = verbose.nil? ? self.verbose : verbose
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
yield
|
|
19
|
+
rescue Google::Apis::RateLimitError, Google::Apis::ServerError => e
|
|
20
|
+
retries += 1
|
|
21
|
+
|
|
22
|
+
raise Error, "Max retries (#{max_retries}) exceeded: #{e.message}" unless retries <= max_retries
|
|
23
|
+
|
|
24
|
+
if should_log
|
|
25
|
+
warn "API error (attempt #{retries}/#{max_retries}): #{e.message}"
|
|
26
|
+
warn "Retrying in #{backoff} seconds..."
|
|
27
|
+
end
|
|
28
|
+
sleep backoff
|
|
29
|
+
|
|
30
|
+
backoff = [(backoff * 2) + rand, MAX_BACKOFF].min
|
|
31
|
+
retry
|
|
32
|
+
rescue Google::Apis::ClientError => e
|
|
33
|
+
error_details = e.body ? "\nDetails: #{e.body}" : ''
|
|
34
|
+
raise Error, "API client error: #{e.message}#{error_details}"
|
|
35
|
+
rescue Google::Apis::AuthorizationError => e
|
|
36
|
+
raise Error, "Authorization error: #{e.message}. Please check your credentials."
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
raise Error, "Unexpected error: #{e.class}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_batch_retry(requests, batch_size: 1000, verbose: nil)
|
|
43
|
+
results = []
|
|
44
|
+
|
|
45
|
+
requests.each_slice(batch_size) do |batch|
|
|
46
|
+
result = with_retry(verbose: verbose) do
|
|
47
|
+
yield(batch)
|
|
48
|
+
end
|
|
49
|
+
results << result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
results
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
self.verbose = true
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/rdeck/slide.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rdeck
|
|
4
|
+
class Slide
|
|
5
|
+
attr_accessor :layout, :freeze, :skip, :titles, :title_bodies, :subtitles, :subtitle_bodies, :bodies, :images,
|
|
6
|
+
:block_quotes, :tables, :speaker_note, :new_slide, :delete_slide
|
|
7
|
+
|
|
8
|
+
def initialize(attrs = {})
|
|
9
|
+
@layout = attrs[:layout] || ''
|
|
10
|
+
@freeze = attrs[:freeze] || false
|
|
11
|
+
@skip = attrs[:skip] || false
|
|
12
|
+
@titles = attrs[:titles] || []
|
|
13
|
+
@title_bodies = attrs[:title_bodies] || []
|
|
14
|
+
@subtitles = attrs[:subtitles] || []
|
|
15
|
+
@subtitle_bodies = attrs[:subtitle_bodies] || []
|
|
16
|
+
@bodies = attrs[:bodies] || []
|
|
17
|
+
@images = attrs[:images] || []
|
|
18
|
+
@block_quotes = attrs[:block_quotes] || []
|
|
19
|
+
@tables = attrs[:tables] || []
|
|
20
|
+
@speaker_note = attrs[:speaker_note] || ''
|
|
21
|
+
@new_slide = false
|
|
22
|
+
@delete_slide = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
return false unless other.is_a?(Slide)
|
|
27
|
+
|
|
28
|
+
layout == other.layout &&
|
|
29
|
+
titles == other.titles &&
|
|
30
|
+
title_bodies == other.title_bodies &&
|
|
31
|
+
subtitles == other.subtitles &&
|
|
32
|
+
subtitle_bodies == other.subtitle_bodies &&
|
|
33
|
+
bodies_equal?(bodies, other.bodies) &&
|
|
34
|
+
images_equivalent?(images, other.images) &&
|
|
35
|
+
block_quotes == other.block_quotes &&
|
|
36
|
+
tables == other.tables &&
|
|
37
|
+
speaker_note == other.speaker_note
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
titles.empty? &&
|
|
42
|
+
subtitles.empty? &&
|
|
43
|
+
bodies.empty? &&
|
|
44
|
+
images.empty? &&
|
|
45
|
+
block_quotes.empty? &&
|
|
46
|
+
tables.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def bodies_equal?(a, b)
|
|
52
|
+
return true if a == b
|
|
53
|
+
return false if a.size != b.size
|
|
54
|
+
|
|
55
|
+
a.zip(b).all? { |body_a, body_b| body_a == body_b }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def images_equivalent?(a, b)
|
|
59
|
+
return true if a.size != b.size
|
|
60
|
+
|
|
61
|
+
a.zip(b).all? { |img_a, img_b| img_a&.equivalent?(img_b) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|