studio-engine 0.98.0 → 0.99.0.rc1

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.
@@ -0,0 +1,646 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "nokogiri"
5
+ require "set"
6
+ require_relative "zip"
7
+ require_relative "xml_guard"
8
+ require_relative "number_format"
9
+
10
+ module Studio
11
+ module KnowledgePreview
12
+ # Reads the first rows of every sheet in an .xlsx workbook as display text.
13
+ #
14
+ # A formula cell is read as its CACHED value (the <v> Excel wrote when the
15
+ # file was last calculated); nothing is evaluated here. A formula saved
16
+ # with no cached value reads as blank. Numbers are rendered through their
17
+ # number format (NumberFormat), so dates, percentages and currency read as
18
+ # they do in Excel.
19
+ #
20
+ # WHAT BOUNDS THE READ. A workbook is hostile input, and a few kilobytes of
21
+ # it can ask for gigabytes, so every cap here is on a SIZE or a COST and
22
+ # not only on a count:
23
+ #
24
+ # the read as a whole three bounds no part of the file can step
25
+ # around, because every part is read through
26
+ # each_node and nothing else:
27
+ # - ONE inflate budget for the workbook, spent
28
+ # by every part opened (Zip::Budget), so a
29
+ # part read twenty times costs twenty times;
30
+ # - a wall-clock DEADLINE, checked on every XML
31
+ # node, on a monotonic clock;
32
+ # - a budget on the TEXT THE PARSER HANDS OVER
33
+ # (max_parsed_text_bytes): every text node
34
+ # and reference turned into a Ruby string is
35
+ # counted before anything looks at it, so
36
+ # text that no other budget would count
37
+ # (past the cap, in pieces) is still bounded.
38
+ # Past any of the three the read ends as
39
+ # TooLarge, and the page offers the download.
40
+ # what the parser is fed those three count what Ruby sees, between
41
+ # nodes. What libxml allocates inside one read
42
+ # is bounded BELOW it, on the byte stream, by
43
+ # XmlGuard: no DTD, UTF-8 only, and limits on a
44
+ # text node, on the start tags open at once, and
45
+ # on comments, so the parser is never handed
46
+ # input it would amplify.
47
+ # rows, columns, sheets each sheet streams through a pull parser that
48
+ # stops at the first row past max_rows.
49
+ # filled cells max_cells across the workbook, checked per
50
+ # cell, so a row of duplicate cells is counted.
51
+ # tables relationships, sheets, custom formats, styles
52
+ # and tokenized formats each stop at a fixed
53
+ # number of entries; a format past its cap
54
+ # renders as General.
55
+ # numbers in attributes a row, style or format id is read from at most
56
+ # eight characters; a row below 1 ends the sheet.
57
+ # text per cell max_cell_chars, applied as the text is read, in
58
+ # the sheet and in the shared string table alike.
59
+ # text in total max_text_bytes across the workbook, spent once
60
+ # per CELL: a shared string is stored once but
61
+ # rendered by every cell that points at it.
62
+ # the rendered grid max_grid_cells, empty cells included, because
63
+ # one value at AX500 is a 25,000-cell rectangle.
64
+ # number formats a code is tokenized once per format, never once
65
+ # per style, and NumberFormat refuses a code
66
+ # longer than Excel allows.
67
+ # cell references at most three column letters are read.
68
+ #
69
+ # The sheet that spends the last of a workbook-wide budget is cut at that
70
+ # row and says so; the sheets after it are counted, not rendered.
71
+ #
72
+ # XML is read with entity substitution and DTD loading off and the network
73
+ # barred, so a workbook cannot make the server read a file or a URL.
74
+ class Xlsx
75
+ Workbook = Struct.new(:sheets, :omitted_sheets, keyword_init: true)
76
+
77
+ WORKBOOK_PATH = "xl/workbook.xml"
78
+ WORKBOOK_RELS = "xl/_rels/workbook.xml.rels"
79
+ WORKSHEET_TYPE = %r{/worksheet\z}
80
+ SHARED_STRINGS_TYPE = %r{/sharedStrings\z}
81
+ STYLES_TYPE = %r{/styles\z}
82
+ TEXT_NODE_TYPES = [
83
+ Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA,
84
+ Nokogiri::XML::Reader::TYPE_WHITESPACE, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
85
+ ].freeze
86
+ ELEMENT = Nokogiri::XML::Reader::TYPE_ELEMENT
87
+ END_ELEMENT = Nokogiri::XML::Reader::TYPE_END_ELEMENT
88
+ XML_OPTIONS = Nokogiri::XML::ParseOptions::NONET
89
+ XML_ENCODING = "UTF-8"
90
+ # A <sheet> element has four or five attributes. The relationship id is
91
+ # looked for by other names only on an element this small, so a tag is
92
+ # never turned into a hash of all its attributes to find one.
93
+ MAX_SCANNED_ATTRIBUTES = 16
94
+
95
+ # Excel's last column is XFD: three letters. A fourth is past any sheet.
96
+ MAX_COLUMN_LETTERS = 3
97
+ # Excel allows 31 characters in a sheet name and 65,490 cell styles;
98
+ # neither list is kept past a generous multiple of that.
99
+ MAX_SHEET_NAME_CHARS = 255
100
+ MAX_STYLES = 65_536
101
+ # A workbook has one relationship per sheet plus a handful; one with
102
+ # more parts or sheets than this is refused, not skimmed.
103
+ MAX_RELATIONSHIPS = 4_096
104
+ MAX_LISTED_SHEETS = 4_096
105
+ # An id or a target longer than this is not one a workbook writer made.
106
+ MAX_ATTRIBUTE_CHARS = 1_024
107
+ # Custom number formats held, and distinct formats tokenized. A real
108
+ # workbook uses tens; past either cap a number renders as General.
109
+ MAX_CUSTOM_FORMATS = 4_096
110
+ MAX_TOKENIZED_FORMATS = 512
111
+ # Excel's last row is 1,048,576 and its largest ids are five digits. A
112
+ # number in an attribute is read from this many characters at most, so
113
+ # no attribute is ever parsed as a two-million-digit integer.
114
+ MAX_NUMBER_CHARS = 8
115
+ CUT_MARK = "…"
116
+
117
+ # A cell pointing into the shared string table, resolved after the
118
+ # sheets are read.
119
+ SharedRef = Struct.new(:index)
120
+ # A shared string a cell wanted that the text budget had no room to hold.
121
+ OVER_BUDGET = Object.new.freeze
122
+ # One sheet as read: sparse {row => {column => Cell or SharedRef}}.
123
+ Draft = Struct.new(:name, :hidden, :rows, :truncated_rows, :truncated_columns, :row_limit, keyword_init: true)
124
+
125
+ def self.read(bytes, **caps)
126
+ new(bytes, **caps).read
127
+ end
128
+
129
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
130
+
131
+ # clock: answers seconds on a monotonic scale. Injectable so a test can
132
+ # run the deadline out without sleeping.
133
+ def initialize(bytes, max_rows:, max_columns:, max_sheets:, max_cells:, max_cell_chars:,
134
+ max_text_bytes:, max_grid_cells:, max_parsed_text_bytes:, inflate_budget:,
135
+ deadline_seconds:, clock: MONOTONIC)
136
+ @zip = Zip.new(bytes, inflate_budget: inflate_budget)
137
+ @clock = clock
138
+ @deadline_seconds = deadline_seconds
139
+ @deadline = clock.call + deadline_seconds
140
+ @parsed_left = max_parsed_text_bytes
141
+ @max_rows = max_rows
142
+ @max_columns = max_columns
143
+ @max_sheets = max_sheets
144
+ @max_cell_chars = max_cell_chars
145
+ @max_text_bytes = max_text_bytes
146
+ # The three workbook-wide budgets. The page is one response, so what
147
+ # matters is the whole workbook's cells, text and grid, not one sheet's.
148
+ @cells_left = max_cells
149
+ @text_left = max_text_bytes
150
+ @grid_left = max_grid_cells
151
+ @budget_spent = false
152
+ @shared_wanted = Set.new
153
+ end
154
+
155
+ def read
156
+ raise Unreadable, "it is not an Excel workbook" unless @zip.entry?(WORKBOOK_PATH)
157
+
158
+ listed = read_workbook
159
+ relationships = read_relationships
160
+ read_styles(relationships.values.find { |rel| rel[:type].match?(STYLES_TYPE) }&.fetch(:target) || "xl/styles.xml")
161
+
162
+ worksheets = listed.filter_map do |sheet|
163
+ rel = relationships[sheet[:rid]]
164
+ next unless rel && rel[:type].match?(WORKSHEET_TYPE)
165
+
166
+ sheet.merge(path: rel[:target])
167
+ end
168
+ raise Unreadable, "it has no worksheets" if worksheets.empty?
169
+
170
+ drafts = []
171
+ worksheets.first(@max_sheets).each do |sheet|
172
+ # A budget that cut one sheet has nothing left for the next.
173
+ break if @budget_spent || @cells_left <= 0
174
+
175
+ drafts << read_sheet(sheet)
176
+ end
177
+ strings = read_shared_strings(
178
+ relationships.values.find { |rel| rel[:type].match?(SHARED_STRINGS_TYPE) }&.fetch(:target) || "xl/sharedStrings.xml"
179
+ )
180
+ sheets = finish(drafts, strings)
181
+ Workbook.new(sheets: sheets, omitted_sheets: worksheets.size - sheets.size)
182
+ rescue Nokogiri::XML::SyntaxError, Zlib::Error
183
+ raise Unreadable, "its contents are damaged"
184
+ end
185
+
186
+ private
187
+
188
+ # --- the workbook's parts -------------------------------------------------
189
+
190
+ def read_workbook
191
+ sheets = []
192
+ @date1904 = false
193
+ each_node(WORKBOOK_PATH) do |node|
194
+ next unless node.node_type == ELEMENT
195
+
196
+ case node.local_name
197
+ when "workbookPr"
198
+ @date1904 = %w[1 true].include?(attribute(node, "date1904").to_s)
199
+ when "sheet"
200
+ raise Unreadable, "it has too many sheets" if sheets.size >= MAX_LISTED_SHEETS
201
+
202
+ sheets << { name: clip(attribute(node, "name").to_s, MAX_SHEET_NAME_CHARS),
203
+ rid: short(relationship_id(node)),
204
+ hidden: %w[hidden veryHidden].include?(attribute(node, "state").to_s) }
205
+ end
206
+ end
207
+ sheets
208
+ end
209
+
210
+ # The relationship attribute is r:id by convention, but the prefix is the
211
+ # writer's choice; the local name is not.
212
+ def relationship_id(node)
213
+ conventional = attribute(node, "r:id")
214
+ return conventional if conventional
215
+ return nil if node.attribute_count > MAX_SCANNED_ATTRIBUTES
216
+
217
+ pair = node.attributes.find { |name, _| name == "id" || name.end_with?(":id") }
218
+ pair && parsed(pair.last)
219
+ end
220
+
221
+ def read_relationships
222
+ relationships = {}
223
+ each_node(WORKBOOK_RELS) do |node|
224
+ next unless node.node_type == ELEMENT && node.local_name == "Relationship"
225
+
226
+ raise Unreadable, "it has too many parts" if relationships.size >= MAX_RELATIONSHIPS
227
+
228
+ id = short(attribute(node, "Id"))
229
+ target = short(attribute(node, "Target"))
230
+ type = short(attribute(node, "Type"))
231
+ next unless id && target && type
232
+
233
+ path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
234
+ relationships[id] = { type: type, target: path }
235
+ end
236
+ relationships
237
+ end
238
+
239
+ # cellXfs is the list a cell's `s` attribute indexes; each entry names a
240
+ # numFmtId, which is a built-in format or one of this workbook's own.
241
+ #
242
+ # Only the ids are kept here. A format is tokenized the first time a cell
243
+ # uses it and once only (sections_for): tokenizing per <xf> let five
244
+ # thousand styles on one long format cost a gigabyte.
245
+ def read_styles(path)
246
+ @custom_formats = {}
247
+ @style_formats = []
248
+ @sections = {}
249
+ in_cell_formats = false
250
+ each_node(path) do |node|
251
+ case node.node_type
252
+ when ELEMENT
253
+ case node.local_name
254
+ when "numFmt"
255
+ code = attribute(node, "formatCode").to_s
256
+ id = small_number(attribute(node, "numFmtId"))
257
+ # A code NumberFormat would refuse is not worth holding either,
258
+ # and past the table's cap a format is simply not known.
259
+ if id && code.length <= NumberFormat::MAX_CODE_LENGTH && @custom_formats.size < MAX_CUSTOM_FORMATS
260
+ @custom_formats[id] = code
261
+ end
262
+ when "cellXfs" then in_cell_formats = !node.empty_element?
263
+ when "xf"
264
+ if in_cell_formats && @style_formats.size < MAX_STYLES
265
+ @style_formats << (small_number(attribute(node, "numFmtId")) || 0)
266
+ end
267
+ end
268
+ when END_ELEMENT
269
+ in_cell_formats = false if node.local_name == "cellXfs"
270
+ end
271
+ end
272
+ end
273
+
274
+ def sections_for(style)
275
+ index = small_number(style)
276
+ id = (index && @style_formats[index]) || 0
277
+ return @sections[id] if @sections.key?(id)
278
+ # The cache is the bound: a format it has no room for is not
279
+ # tokenized at all, and its numbers render as General.
280
+ return (@sections[0] ||= NumberFormat.tokenize("General")) if @sections.size >= MAX_TOKENIZED_FORMATS
281
+
282
+ @sections[id] = NumberFormat.tokenize(NumberFormat.code_for(id, @custom_formats))
283
+ end
284
+
285
+ # A non-negative integer from an attribute, or nil when it is absent,
286
+ # not a number, or longer than any number Excel writes there.
287
+ def small_number(value)
288
+ return nil if value.nil? || value.length > MAX_NUMBER_CHARS
289
+
290
+ number = Integer(value, 10, exception: false)
291
+ number && number >= 0 ? number : nil
292
+ end
293
+
294
+ # An attribute short enough to be a real id or path, else nil.
295
+ def short(value)
296
+ value && value.length <= MAX_ATTRIBUTE_CHARS ? value : nil
297
+ end
298
+
299
+ # --- one sheet --------------------------------------------------------------
300
+
301
+ def read_sheet(sheet)
302
+ draft = Draft.new(name: sheet[:name], hidden: sheet[:hidden], rows: {}, truncated_rows: false,
303
+ truncated_columns: false, row_limit: @max_rows)
304
+ row_number = 0
305
+ column = 0
306
+ cell = nil
307
+ capture = nil
308
+ text = +""
309
+
310
+ each_node(sheet[:path]) do |node|
311
+ case node.node_type
312
+ when ELEMENT
313
+ case node.local_name
314
+ when "row"
315
+ reference = attribute(node, "r")
316
+ column = 0
317
+ if reference.nil?
318
+ row_number += 1
319
+ elsif (number = small_number(reference)) && number >= 1
320
+ row_number = number
321
+ else
322
+ # A row below 1, not a number, or with more digits than any
323
+ # row Excel has (and never parsed to find that out): the sheet
324
+ # is malformed from here, and ends at the last row that was not.
325
+ draft.truncated_rows = true
326
+ draft.row_limit = [row_number, @max_rows].min
327
+ break
328
+ end
329
+ if row_number > @max_rows
330
+ draft.truncated_rows = true
331
+ break
332
+ end
333
+ when "c"
334
+ reference = attribute(node, "r")
335
+ column = reference ? column_index(reference) : column + 1
336
+ draft.truncated_columns = true if column > @max_columns
337
+ # A cell outside the grid is not read at all: its text is never
338
+ # asked of the parser, so it is never a string in this process.
339
+ outside = column < 1 || column > @max_columns
340
+ cell = node.empty_element? || outside ? nil : { type: attribute(node, "t"), style: attribute(node, "s") }
341
+ text = +""
342
+ when "v" then capture = :value unless node.empty_element?
343
+ # <t> is the inline string's text; phonetic runs (<rPh>) are a
344
+ # reading aid, never part of the value.
345
+ when "t" then capture = :value unless node.empty_element? || cell.nil? || cell[:type] != "inlineStr"
346
+ end
347
+ when END_ELEMENT
348
+ case node.local_name
349
+ when "v", "t" then capture = nil
350
+ when "c"
351
+ if cell
352
+ if @cells_left <= 0
353
+ # Checked per cell, not per row: one row can hold any number
354
+ # of cells, duplicates of one reference included.
355
+ cut(draft, row_number)
356
+ break
357
+ elsif (value = cell_value(cell, text))
358
+ # Text read here is spent here; a shared string's size is
359
+ # not known yet and is spent in `finish`.
360
+ if value.is_a?(Cell) && !spend_text(value.text)
361
+ cut(draft, row_number)
362
+ break
363
+ end
364
+ (draft.rows[row_number] ||= {})[column] = value
365
+ @cells_left -= 1
366
+ end
367
+ end
368
+ cell = nil
369
+ when "sheetData" then break
370
+ end
371
+ else
372
+ take(text, node) if capture && cell && TEXT_NODE_TYPES.include?(node.node_type)
373
+ end
374
+ end
375
+ draft
376
+ end
377
+
378
+ def cell_value(cell, text)
379
+ case cell[:type]
380
+ when "s"
381
+ index = small_number(text.strip)
382
+ return nil unless index
383
+
384
+ @shared_wanted << index
385
+ SharedRef.new(index)
386
+ when "inlineStr", "str", "e", "d"
387
+ text.empty? ? nil : Cell.new(clipped(text), false)
388
+ when "b"
389
+ text.empty? ? nil : Cell.new(text == "1" ? "TRUE" : "FALSE", false)
390
+ else
391
+ return nil if text.strip.empty?
392
+
393
+ number = Float(text, exception: false)
394
+ return Cell.new(clipped(text), false) unless number
395
+
396
+ Cell.new(NumberFormat.render(number, sections_for(cell[:style]), date1904: @date1904), true)
397
+ end
398
+ end
399
+
400
+ # "BC12" -> 55. Letters are base 26 with no zero digit. Only the first
401
+ # letters are ever looked at: a reference with more than three is past
402
+ # the last column Excel has, and is answered as past max_columns without
403
+ # reading the rest of it.
404
+ def column_index(reference)
405
+ letters = reference.to_s[/\A[A-Za-z]{1,#{MAX_COLUMN_LETTERS + 1}}/].to_s
406
+ return @max_columns + 1 if letters.length > MAX_COLUMN_LETTERS
407
+
408
+ letters.each_char.reduce(0) { |sum, char| (sum * 26) + (char.upcase.ord - 64) }
409
+ end
410
+
411
+ # --- text sizes -----------------------------------------------------------------
412
+
413
+ # Adds to a cell's text while it is being read, and stops one character
414
+ # past the cap: enough to know it overflowed, never the megabyte after.
415
+ def append(text, more)
416
+ room = @max_cell_chars + 1 - text.length
417
+ text << more.to_s[0, room] if room.positive?
418
+ end
419
+
420
+ # Appends a text node to a cell's text. Once the cell is full the node's
421
+ # value is not asked for at all, so it never becomes a Ruby string.
422
+ def take(text, node)
423
+ return if text.length > @max_cell_chars
424
+
425
+ append(text, parsed(node.value))
426
+ end
427
+
428
+ # An attribute's value, counted like any other string the parser hands
429
+ # over. Every attribute read in this class goes through here.
430
+ def attribute(node, name)
431
+ parsed(node.attribute(name))
432
+ end
433
+
434
+ # Every string the parser hands this reader passes through here and is
435
+ # counted BEFORE anything else looks at it. This is the bound on text
436
+ # that no cell ends up keeping.
437
+ def parsed(string)
438
+ return string if string.nil?
439
+
440
+ @parsed_left -= string.bytesize
441
+ raise TooLarge, "it holds more text than a preview reads" if @parsed_left.negative?
442
+
443
+ string
444
+ end
445
+
446
+ # The text a cell keeps: whole, or cut at the cap with a mark saying so.
447
+ def clipped(text)
448
+ text.length > @max_cell_chars ? text[0, @max_cell_chars] + CUT_MARK : text
449
+ end
450
+
451
+ def clip(text, limit)
452
+ text.length > limit ? text[0, limit] + CUT_MARK : text
453
+ end
454
+
455
+ # Takes a cell's text out of the workbook's budget; false when it does
456
+ # not fit, and the caller cuts the sheet there.
457
+ def spend_text(text)
458
+ return false if text.bytesize > @text_left
459
+
460
+ @text_left -= text.bytesize
461
+ true
462
+ end
463
+
464
+ # Ends a sheet before `row`: that row and everything after it go, and
465
+ # the notice names the rows that are left.
466
+ def cut(draft, row)
467
+ draft.rows.delete_if { |number, _| number >= row }
468
+ draft.truncated_rows = true
469
+ draft.row_limit = row - 1
470
+ @budget_spent = true
471
+ end
472
+
473
+ # --- shared strings -----------------------------------------------------------
474
+
475
+ # Stores only the strings a kept cell points at, each cut at the cell
476
+ # cap, and no more of them in total than the text budget could ever
477
+ # render: past that a wanted string is recorded as OVER_BUDGET, which
478
+ # `finish` treats as the end of the sheet, never as a blank cell.
479
+ def read_shared_strings(path)
480
+ strings = {}
481
+ return strings if @shared_wanted.empty?
482
+
483
+ index = -1
484
+ wanted = false
485
+ capture = false
486
+ phonetic = 0
487
+ text = +""
488
+ held = 0
489
+ full = false
490
+ last = @shared_wanted.max
491
+
492
+ each_node(path) do |node|
493
+ case node.node_type
494
+ when ELEMENT
495
+ case node.local_name
496
+ when "si"
497
+ index += 1
498
+ break if index > last
499
+
500
+ wanted = @shared_wanted.include?(index)
501
+ text = +""
502
+ if wanted && full
503
+ # Nothing more will be held, so nothing more is read.
504
+ strings[index] = OVER_BUDGET
505
+ wanted = false
506
+ end
507
+ strings[index] = "" if wanted && node.empty_element?
508
+ when "rPh" then phonetic += 1 unless node.empty_element?
509
+ when "t" then capture = wanted && phonetic.zero? && !node.empty_element?
510
+ end
511
+ when END_ELEMENT
512
+ case node.local_name
513
+ when "t" then capture = false
514
+ when "rPh" then phonetic -= 1
515
+ when "si"
516
+ if wanted
517
+ string = clipped(text)
518
+ if held + string.bytesize > @max_text_bytes
519
+ strings[index] = OVER_BUDGET
520
+ full = true
521
+ else
522
+ held += string.bytesize
523
+ strings[index] = string
524
+ end
525
+ end
526
+ end
527
+ else
528
+ take(text, node) if capture && TEXT_NODE_TYPES.include?(node.node_type)
529
+ end
530
+ end
531
+ strings
532
+ end
533
+
534
+ # --- drafts to sheets ---------------------------------------------------------
535
+
536
+ # Resolves shared strings, spends what is left of the text budget on
537
+ # them cell by cell, then lays each sheet out as a rectangle inside the
538
+ # grid budget. Once a budget cuts a sheet, the sheets after it are left
539
+ # out (and counted by the caller).
540
+ def finish(drafts, strings)
541
+ sheets = []
542
+ spent = false
543
+ drafts.each do |draft|
544
+ break if spent
545
+
546
+ spent = true if resolve(draft, strings) == :cut
547
+ spent = true if fit_grid(draft) == :cut
548
+ # A sheet cut down to nothing is one of the sheets not shown.
549
+ next if spent && draft.rows.empty? && sheets.any?
550
+
551
+ sheets << Sheet.new(name: draft.name, rows: dense(draft.rows), truncated_rows: draft.truncated_rows,
552
+ truncated_columns: draft.truncated_columns, hidden: draft.hidden,
553
+ row_limit: draft.row_limit)
554
+ end
555
+ sheets
556
+ end
557
+
558
+ def resolve(draft, strings)
559
+ draft.rows.keys.sort.each do |number|
560
+ row = draft.rows[number]
561
+ row.keys.sort.each do |column|
562
+ value = row[column]
563
+ next unless value.is_a?(SharedRef)
564
+
565
+ string = strings[value.index]
566
+ if string.nil? || string == ""
567
+ row.delete(column)
568
+ elsif string.equal?(OVER_BUDGET) || !spend_text(string)
569
+ cut(draft, number)
570
+ return :cut
571
+ else
572
+ row[column] = Cell.new(string, false)
573
+ end
574
+ end
575
+ end
576
+ draft.rows.delete_if { |_, row| row.empty? }
577
+ :whole
578
+ end
579
+
580
+ # A sheet renders as rows x columns whether or not the cells are filled.
581
+ def fit_grid(draft)
582
+ return :whole if draft.rows.empty?
583
+
584
+ width = draft.rows.values.flat_map(&:keys).max
585
+ height = draft.rows.keys.max
586
+ if width * height <= @grid_left
587
+ @grid_left -= width * height
588
+ return :whole
589
+ end
590
+
591
+ rows = @grid_left / width
592
+ cut(draft, rows + 1)
593
+ @grid_left = 0
594
+ :cut
595
+ end
596
+
597
+ # Sparse {row => {column => cell}} to a rectangle, blank rows and cells
598
+ # kept so the grid lines up with the workbook's own row numbers.
599
+ def dense(rows)
600
+ return [] if rows.empty?
601
+
602
+ width = rows.values.flat_map(&:keys).max
603
+ (1..rows.keys.max).map do |number|
604
+ row = rows[number] || {}
605
+ (1..width).map { |column| row[column] }
606
+ end
607
+ end
608
+
609
+ # --- xml ------------------------------------------------------------------------
610
+
611
+ # Streams one zip entry through the pull parser. A missing entry yields
612
+ # nothing: styles and shared strings are both optional parts.
613
+ #
614
+ # This is the ONE way any part of the workbook is read, which is what
615
+ # makes the deadline here a bound on the whole read: whatever a file
616
+ # finds to make slow, it is slow one node at a time, and the clock is
617
+ # looked at on every one.
618
+ #
619
+ # And it is the one place the parser is fed, so XmlGuard stands there:
620
+ # the parser never receives a DTD, or a token long enough for it to
621
+ # spend a gigabyte on before a single node comes out. The encoding is
622
+ # passed, not detected: told "UTF-8", libxml ignores whatever encoding
623
+ # the part declares, so the bytes the guard scans are the characters
624
+ # the parser reads.
625
+ def each_node(path)
626
+ @zip.open(path) do |io|
627
+ guard = XmlGuard.new(io)
628
+ begin
629
+ Nokogiri::XML::Reader.from_io(guard, nil, XML_ENCODING, XML_OPTIONS).each do |node|
630
+ if @clock.call > @deadline
631
+ raise TooLarge, "it takes more than #{@deadline_seconds} seconds to read"
632
+ end
633
+
634
+ yield node
635
+ end
636
+ rescue Nokogiri::XML::SyntaxError
637
+ # The parser's complaint about a stream the guard cut short is
638
+ # noise; the guard's reason is the error.
639
+ raise guard.failure || $ERROR_INFO
640
+ end
641
+ raise guard.failure if guard.failure
642
+ end
643
+ end
644
+ end
645
+ end
646
+ end