studio-engine 0.98.0.rc1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +10 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +24 -1
- data/app/javascript/studio/controllers/modal_host_controller.js +6 -16
- data/app/javascript/studio/modal_host.js +25 -5
- data/app/models/studio/knowledge_doc.rb +37 -2
- data/app/views/studio/knowledge_docs/_preview.html.erb +66 -0
- data/app/views/studio/knowledge_docs/_preview_fallback.html.erb +9 -0
- data/app/views/studio/knowledge_docs/_preview_sheet.html.erb +43 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +24 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +12 -0
- data/app/views/studio/knowledge_docs/show.html.erb +26 -2
- data/lib/studio/knowledge_preview/number_format.rb +342 -0
- data/lib/studio/knowledge_preview/xlsx.rb +646 -0
- data/lib/studio/knowledge_preview/xml_guard.rb +344 -0
- data/lib/studio/knowledge_preview/zip.rb +242 -0
- data/lib/studio/knowledge_preview.rb +322 -0
- data/lib/studio/s3.rb +39 -4
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +5 -2
- data/studio-engine.gemspec +4 -0
- metadata +26 -2
|
@@ -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
|