xlsxrb 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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +64 -0
  3. data/.devcontainer/devcontainer.json +17 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +384 -0
  8. data/Rakefile +290 -0
  9. data/benchmark.rb +390 -0
  10. data/docs/ARCHITECTURE.md +488 -0
  11. data/docs/SPEC_SOURCES.md +42 -0
  12. data/docs/visual/VisualGallery.md +4684 -0
  13. data/docs/wasm/wasm_doc_helper.css +189 -0
  14. data/docs/wasm/wasm_doc_helper.js +320 -0
  15. data/lib/xlsxrb/elements/cell.rb +77 -0
  16. data/lib/xlsxrb/elements/column.rb +28 -0
  17. data/lib/xlsxrb/elements/row.rb +47 -0
  18. data/lib/xlsxrb/elements/types.rb +36 -0
  19. data/lib/xlsxrb/elements/workbook.rb +47 -0
  20. data/lib/xlsxrb/elements/worksheet.rb +50 -0
  21. data/lib/xlsxrb/elements.rb +15 -0
  22. data/lib/xlsxrb/ooxml/reader.rb +8048 -0
  23. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
  24. data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
  25. data/lib/xlsxrb/ooxml/utils.rb +122 -0
  26. data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
  27. data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
  28. data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
  29. data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
  30. data/lib/xlsxrb/ooxml/writer.rb +5450 -0
  31. data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
  32. data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
  33. data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
  34. data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
  35. data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
  36. data/lib/xlsxrb/ooxml.rb +22 -0
  37. data/lib/xlsxrb/style_builder.rb +241 -0
  38. data/lib/xlsxrb/version.rb +5 -0
  39. data/lib/xlsxrb.rb +1427 -0
  40. data/measure_memory.rb +42 -0
  41. data/sig/xlsxrb.rbs +23 -0
  42. data/vendor/sdk_runner/Program.cs +91 -0
  43. data/vendor/sdk_runner/sdk_runner.csproj +13 -0
  44. metadata +144 -0
@@ -0,0 +1,1000 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "xml_builder"
5
+ require_relative "zip_writer"
6
+ require_relative "worksheet_writer"
7
+
8
+ module Xlsxrb
9
+ module Ooxml
10
+ # Orchestrates writing a complete XLSX workbook.
11
+ # Generates all required OpenXML parts and assembles them into a ZIP.
12
+ class WorkbookWriter
13
+ SSML_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
14
+ REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
15
+ CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
16
+ DOC_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
17
+
18
+ # Wraps a ZipWriter entry to provide a standard IO-like #write interface.
19
+ class ZipEntryIO
20
+ def initialize(zip)
21
+ @zip = zip
22
+ end
23
+
24
+ def write(data)
25
+ @zip.write_data(data)
26
+ end
27
+
28
+ alias << write
29
+ end
30
+
31
+ def self.write(target, sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
32
+ defined_names: nil, core_properties: nil, app_properties: nil,
33
+ custom_properties: nil, workbook_protection: nil)
34
+ Xlsxrb::TRACER.in_span("Ooxml::WorkbookWriter.write") do
35
+ writer = new(sheets: sheets, shared_strings: shared_strings, shared_strings_index: shared_strings_index, styles: styles,
36
+ defined_names: defined_names, core_properties: core_properties,
37
+ app_properties: app_properties, custom_properties: custom_properties,
38
+ workbook_protection: workbook_protection)
39
+ writer.write_to(target)
40
+ end
41
+ end
42
+
43
+ def initialize(sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
44
+ defined_names: nil, core_properties: nil, app_properties: nil,
45
+ custom_properties: nil, workbook_protection: nil)
46
+ @sheets = sheets
47
+ @shared_strings = shared_strings
48
+ @shared_strings_index = shared_strings_index
49
+ @styles = styles || {}
50
+ @defined_names = defined_names || []
51
+ @core_properties = core_properties || {}
52
+ @app_properties = app_properties || {}
53
+ @custom_properties = custom_properties || []
54
+ @workbook_protection = workbook_protection
55
+ @drawing_count = 0
56
+ @chart_count = 0
57
+ @comment_count = 0
58
+ @table_count = 0
59
+ @pivot_cache_count = 0
60
+ @pivot_table_count = 0
61
+ end
62
+
63
+ def write_to(target)
64
+ preprocess_conditional_formats!
65
+
66
+ ZipWriter.open(target) do |zip|
67
+ zip.add_entry("[Content_Types].xml", build_content_types)
68
+ zip.add_entry("_rels/.rels", build_root_rels)
69
+ zip.add_entry("xl/workbook.xml", build_workbook_xml)
70
+ zip.add_entry("xl/_rels/workbook.xml.rels", build_workbook_rels)
71
+ zip.add_entry("xl/styles.xml", build_styles_xml)
72
+ zip.add_entry("xl/sharedStrings.xml", build_shared_strings_xml) unless @shared_strings.empty?
73
+
74
+ # Document properties
75
+ zip.add_entry("docProps/core.xml", build_core_properties_xml) unless @core_properties.empty?
76
+ zip.add_entry("docProps/app.xml", build_app_properties_xml) unless @app_properties.empty?
77
+ zip.add_entry("docProps/custom.xml", build_custom_properties_xml) unless @custom_properties.empty?
78
+
79
+ @sheets.each_with_index do |sheet, idx|
80
+ sheet_images = sheet[:images] || []
81
+ sheet_charts = sheet[:charts] || []
82
+ sheet_shapes = sheet[:shapes] || []
83
+ sheet_comments = sheet[:comments] || []
84
+ sheet_tables = sheet[:tables] || []
85
+ sheet_hyperlinks = sheet[:hyperlinks] || []
86
+ has_drawing = sheet_images.any? || sheet_charts.any? || sheet_shapes.any?
87
+ has_comments = sheet_comments.any?
88
+
89
+ # Track relationship IDs for this sheet
90
+ sheet_rels = []
91
+ drawing_rid = nil
92
+ vml_rid = nil
93
+ table_start_rid = nil
94
+ hyperlink_rels = []
95
+
96
+ # Drawing relationships (images, charts, shapes)
97
+ if has_drawing
98
+ @drawing_count += 1
99
+ drawing_rid_num = sheet_rels.size + 1
100
+ sheet_rels << { id: "rId#{drawing_rid_num}", type: "#{DOC_REL}/drawing", target: "../drawings/drawing#{@drawing_count}.xml" }
101
+ drawing_rid = "rId#{drawing_rid_num}"
102
+
103
+ drawing_rels_data = []
104
+ drawing_parts = []
105
+
106
+ chart_writer = Xlsxrb::Ooxml::Writer.new
107
+ chart_writer.add_sheet(sheet[:name]) unless sheet[:name] == "Sheet1"
108
+
109
+ # Populate sheet data in chart_writer for chart cache resolution
110
+ (sheet[:rows] || []).each do |row|
111
+ cells = row.is_a?(Hash) ? row[:cells] : row.cells
112
+ (cells || []).each do |cell|
113
+ ref = cell.is_a?(Hash) ? cell[:ref] : cell.ref
114
+ value = cell.is_a?(Hash) ? cell[:value] : cell.value
115
+ chart_writer.set_cell(ref, value, sheet: sheet[:name])
116
+ end
117
+ end
118
+ sheet[:cells]&.each do |ref, value|
119
+ chart_writer.set_cell(ref, value, sheet: sheet[:name])
120
+ end
121
+
122
+ sheet_images.each do |img|
123
+ media_idx = @drawing_count # simplified
124
+ media_path = "xl/media/image#{media_idx}.#{img[:ext] || "png"}"
125
+ zip.add_binary_entry(media_path, img[:file_data])
126
+ drawing_rels_data << { type: :image, target: "../media/image#{media_idx}.#{img[:ext] || "png"}" }
127
+ drawing_parts << { kind: :pic, img: img, rid_index: drawing_rels_data.size }
128
+ end
129
+
130
+ sheet_charts.each do |chart_options|
131
+ chart_writer.add_chart(**chart_options)
132
+ end
133
+
134
+ processed_charts = chart_writer.charts
135
+ processed_charts.each do |chart|
136
+ @chart_count += 1
137
+ chart_path = "xl/charts/chart#{@chart_count}.xml"
138
+ zip.add_entry(chart_path, chart_writer.send(:generate_chart_xml, chart))
139
+ drawing_rels_data << { type: :chart, target: "../charts/chart#{@chart_count}.xml" }
140
+ drawing_parts << { kind: :chart, chart: chart, rid_index: drawing_rels_data.size }
141
+ end
142
+
143
+ sheet_shapes.each_with_index do |shape, si|
144
+ drawing_parts << { kind: :sp, shape: shape, id: drawing_parts.size + si + 2 }
145
+ end
146
+
147
+ drawing_xml = chart_writer.send(:generate_drawing_xml, drawing_parts)
148
+ zip.add_entry("xl/drawings/drawing#{@drawing_count}.xml", drawing_xml)
149
+ unless drawing_rels_data.empty?
150
+ drawing_rels_xml = chart_writer.send(:generate_drawing_rels, drawing_rels_data)
151
+ zip.add_entry("xl/drawings/_rels/drawing#{@drawing_count}.xml.rels", drawing_rels_xml)
152
+ end
153
+ end
154
+
155
+ # Comment relationships
156
+ if has_comments
157
+ @comment_count += 1
158
+ comment_rid_num = sheet_rels.size + 1
159
+ sheet_rels << { id: "rId#{comment_rid_num}", type: "#{DOC_REL}/comments", target: "../comments#{@comment_count}.xml" }
160
+ vml_rid_num = sheet_rels.size + 1
161
+ sheet_rels << { id: "rId#{vml_rid_num}", type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing", target: "../drawings/vmlDrawing#{@comment_count}.vml" }
162
+ vml_rid = "rId#{vml_rid_num}"
163
+
164
+ # Generate comments XML using Writer's method
165
+ comment_writer = Xlsxrb::Ooxml::Writer.new
166
+ sheet_comments.each do |c|
167
+ comment_writer.add_comment(c[:cell], c[:text], author: c[:author] || "Author")
168
+ end
169
+ zip.add_entry("xl/comments#{@comment_count}.xml", comment_writer.send(:generate_comments_xml, comment_writer.comments))
170
+ zip.add_entry("xl/drawings/vmlDrawing#{@comment_count}.vml", comment_writer.send(:generate_vml_drawing_xml, comment_writer.comments))
171
+ end
172
+
173
+ # Hyperlink relationships (external URLs need rels)
174
+ sheet_hyperlinks.each_with_index do |link, _hi|
175
+ next unless link[:url]
176
+
177
+ h_rid_num = sheet_rels.size + 1
178
+ sheet_rels << { id: "rId#{h_rid_num}", type: "#{DOC_REL}/hyperlink", target: link[:url], target_mode: "External" }
179
+ hyperlink_rels << h_rid_num
180
+ end
181
+ # Assign rIds to hyperlinks
182
+ h_rid_idx = 0
183
+ enriched_hyperlinks = sheet_hyperlinks.map do |link|
184
+ if link[:url]
185
+ rid = hyperlink_rels[h_rid_idx]
186
+ h_rid_idx += 1
187
+ link.merge(_rid: rid)
188
+ else
189
+ link
190
+ end
191
+ end
192
+
193
+ # Table relationships
194
+ unless sheet_tables.empty?
195
+ table_start_rid = sheet_rels.size + 1
196
+ sheet_tables.each_with_index do |_tbl, _ti|
197
+ @table_count += 1
198
+ t_rid_num = sheet_rels.size + 1
199
+ sheet_rels << { id: "rId#{t_rid_num}", type: "#{DOC_REL}/table", target: "../tables/table#{@table_count}.xml" }
200
+ end
201
+ end
202
+
203
+ # Pivot table relationships
204
+ sheet_pivot_tables = sheet[:pivot_tables] || []
205
+ pivot_table_start_cache = @pivot_cache_count
206
+ unless sheet_pivot_tables.empty?
207
+ pivot_writer = Xlsxrb::Ooxml::Writer.new
208
+ pivot_writer.add_sheet(sheet[:name])
209
+ sheet_pivot_tables.each do |pt|
210
+ pivot_writer.add_pivot_table(
211
+ pt[:source_ref],
212
+ row_fields: pt[:row_fields],
213
+ data_fields: pt[:data_fields],
214
+ col_fields: pt[:col_fields] || [],
215
+ dest_ref: pt[:dest_ref] || "E1",
216
+ name: pt[:name],
217
+ field_names: pt[:field_names],
218
+ items: pt[:items],
219
+ sheet: sheet[:name]
220
+ )
221
+
222
+ @pivot_cache_count += 1
223
+ @pivot_table_count += 1
224
+
225
+ pt_rid_num = sheet_rels.size + 1
226
+ sheet_rels << { id: "rId#{pt_rid_num}", type: "#{DOC_REL}/pivotTable", target: "../pivotTables/pivotTable#{@pivot_table_count}.xml" }
227
+ end
228
+
229
+ # Generate pivot table XML files
230
+ pivot_data = pivot_writer.pivot_tables(sheet: sheet[:name])
231
+ pivot_data.each_with_index do |pt_data, pi|
232
+ cache_idx = pivot_table_start_cache + pi + 1
233
+ pt_idx = pivot_table_start_cache + pi + 1
234
+ zip.add_entry("xl/pivotCache/pivotCacheDefinition#{cache_idx}.xml",
235
+ pivot_writer.send(:generate_pivot_cache_definition_xml, pt_data, cache_idx))
236
+ zip.add_entry("xl/pivotCache/pivotCacheRecords#{cache_idx}.xml",
237
+ pivot_writer.send(:generate_pivot_cache_records_xml, pt_data))
238
+ zip.add_entry("xl/pivotTables/pivotTable#{pt_idx}.xml",
239
+ pivot_writer.send(:generate_pivot_table_xml, pt_data, cache_idx))
240
+ zip.add_entry("xl/pivotCache/_rels/pivotCacheDefinition#{cache_idx}.xml.rels",
241
+ pivot_writer.send(:generate_pivot_cache_rels, cache_idx))
242
+ zip.add_entry("xl/pivotTables/_rels/pivotTable#{pt_idx}.xml.rels",
243
+ pivot_writer.send(:generate_pivot_table_rels, cache_idx))
244
+ end
245
+ end
246
+
247
+ # Build worksheet rels if any
248
+ zip.add_entry("xl/worksheets/_rels/sheet#{idx + 1}.xml.rels", build_sheet_rels_from_list(sheet_rels)) unless sheet_rels.empty?
249
+
250
+ # Generate table XML files
251
+ table_id_base = @table_count - sheet_tables.size
252
+ sheet_tables.each_with_index do |tbl, ti|
253
+ tbl_id = table_id_base + ti + 1
254
+ zip.add_entry("xl/tables/table#{tbl_id}.xml", build_table_xml(tbl, tbl_id))
255
+ end
256
+
257
+ # Build the worksheet XML with all metadata
258
+ zip.start_entry("xl/worksheets/sheet#{idx + 1}.xml")
259
+ write_worksheet_xml(
260
+ ZipEntryIO.new(zip),
261
+ sheet,
262
+ drawing_rid: drawing_rid,
263
+ sheet_protection: sheet[:sheet_protection],
264
+ auto_filter: sheet[:auto_filter],
265
+ filter_columns: sheet[:filter_columns],
266
+ sort_state: sheet[:sort_state],
267
+ merge_cells: sheet[:merge_cells],
268
+ conditional_formats: sheet[:conditional_formats],
269
+ data_validations: sheet[:data_validations],
270
+ hyperlinks: enriched_hyperlinks.empty? ? nil : enriched_hyperlinks,
271
+ print_options: sheet[:print_options],
272
+ page_margins: sheet[:page_margins],
273
+ page_setup: sheet[:page_setup],
274
+ header_footer: sheet[:header_footer],
275
+ row_breaks: sheet[:row_breaks],
276
+ col_breaks: sheet[:col_breaks],
277
+ freeze_pane: sheet[:freeze_pane],
278
+ split_pane: sheet[:split_pane],
279
+ selection: sheet[:selection],
280
+ sheet_view: sheet[:sheet_view],
281
+ sheet_properties: sheet[:sheet_properties],
282
+ tables: sheet_tables,
283
+ table_start_rid: table_start_rid,
284
+ legacy_drawing_rid: vml_rid,
285
+ sparkline_groups: sheet[:sparkline_groups]
286
+ )
287
+ zip.finish_entry
288
+ end
289
+ end
290
+ end
291
+
292
+ private
293
+
294
+ def build_sheet_rels_from_list(rels)
295
+ io = StringIO.new
296
+ b = XmlBuilder.new(io)
297
+ b.declaration
298
+ b.open_tag("Relationships", { xmlns: REL_NS })
299
+ rels.each do |rel|
300
+ attrs = { Id: rel[:id], Type: rel[:type], Target: rel[:target] }
301
+ attrs[:TargetMode] = rel[:target_mode] if rel[:target_mode]
302
+ b.empty_tag("Relationship", attrs)
303
+ end
304
+ b.close_tag("Relationships")
305
+ io.string
306
+ end
307
+
308
+ def build_content_types
309
+ io = StringIO.new
310
+ b = XmlBuilder.new(io)
311
+ b.declaration
312
+ b.open_tag("Types", { xmlns: CT_NS })
313
+ b.empty_tag("Default", { Extension: "rels", ContentType: "application/vnd.openxmlformats-package.relationships+xml" })
314
+ b.empty_tag("Default", { Extension: "xml", ContentType: "application/xml" })
315
+ b.empty_tag("Default", { Extension: "vml", ContentType: "application/vnd.openxmlformats-officedocument.vmlDrawing" })
316
+ b.empty_tag("Override", { PartName: "/xl/workbook.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" })
317
+ b.empty_tag("Override", { PartName: "/xl/styles.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" })
318
+ b.empty_tag("Override", { PartName: "/xl/sharedStrings.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" }) unless @shared_strings.empty?
319
+
320
+ # Document properties
321
+ b.empty_tag("Override", { PartName: "/docProps/core.xml", ContentType: "application/vnd.openxmlformats-package.core-properties+xml" }) unless @core_properties.empty?
322
+ b.empty_tag("Override", { PartName: "/docProps/app.xml", ContentType: "application/vnd.openxmlformats-officedocument.extended-properties+xml" }) unless @app_properties.empty?
323
+ b.empty_tag("Override", { PartName: "/docProps/custom.xml", ContentType: "application/vnd.openxmlformats-officedocument.custom-properties+xml" }) unless @custom_properties.empty?
324
+
325
+ drawing_count = 0
326
+ chart_count = 0
327
+ comment_count = 0
328
+ table_count = 0
329
+ pivot_cache_count = 0
330
+ pivot_table_count = 0
331
+ image_exts = {}
332
+ @sheets.each_with_index do |sheet, idx|
333
+ b.empty_tag("Override", { PartName: "/xl/worksheets/sheet#{idx + 1}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" })
334
+
335
+ sheet_images = sheet[:images] || []
336
+ sheet_charts = sheet[:charts] || []
337
+ sheet_shapes = sheet[:shapes] || []
338
+ sheet_comments = sheet[:comments] || []
339
+ sheet_tables = sheet[:tables] || []
340
+ has_drawing = sheet_images.any? || sheet_charts.any? || sheet_shapes.any?
341
+
342
+ if has_drawing
343
+ drawing_count += 1
344
+ b.empty_tag("Override", { PartName: "/xl/drawings/drawing#{drawing_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.drawing+xml" })
345
+ sheet_charts.each do |_chart|
346
+ chart_count += 1
347
+ b.empty_tag("Override", { PartName: "/xl/charts/chart#{chart_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" })
348
+ end
349
+ sheet_images.each do |img|
350
+ ext = img[:ext] || "png"
351
+ next if image_exts[ext]
352
+
353
+ mime = case ext
354
+ when "png" then "image/png"
355
+ when "jpg", "jpeg" then "image/jpeg"
356
+ when "gif" then "image/gif"
357
+ when "bmp" then "image/bmp"
358
+ when "emf" then "image/x-emf"
359
+ when "wmf" then "image/x-wmf"
360
+ else "application/octet-stream"
361
+ end
362
+ b.empty_tag("Default", { Extension: ext, ContentType: mime })
363
+ image_exts[ext] = true
364
+ end
365
+ end
366
+
367
+ if sheet_comments.any?
368
+ comment_count += 1
369
+ b.empty_tag("Override", { PartName: "/xl/comments#{comment_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" })
370
+ end
371
+
372
+ sheet_tables.each do |_tbl|
373
+ table_count += 1
374
+ b.empty_tag("Override", { PartName: "/xl/tables/table#{table_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml" })
375
+ end
376
+
377
+ sheet_pivots = sheet[:pivot_tables] || []
378
+ sheet_pivots.each do |_pt|
379
+ pivot_cache_count += 1
380
+ pivot_table_count += 1
381
+ b.empty_tag("Override", { PartName: "/xl/pivotCache/pivotCacheDefinition#{pivot_cache_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml" })
382
+ b.empty_tag("Override", { PartName: "/xl/pivotCache/pivotCacheRecords#{pivot_cache_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml" })
383
+ b.empty_tag("Override", { PartName: "/xl/pivotTables/pivotTable#{pivot_table_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml" })
384
+ end
385
+ end
386
+
387
+ b.close_tag("Types")
388
+ io.string
389
+ end
390
+
391
+ def build_root_rels
392
+ io = StringIO.new
393
+ b = XmlBuilder.new(io)
394
+ b.declaration
395
+ b.open_tag("Relationships", { xmlns: REL_NS })
396
+ b.empty_tag("Relationship", {
397
+ Id: "rId1",
398
+ Type: "#{DOC_REL}/officeDocument",
399
+ Target: "xl/workbook.xml"
400
+ })
401
+ rid = 1
402
+ unless @core_properties.empty?
403
+ rid += 1
404
+ b.empty_tag("Relationship", {
405
+ Id: "rId#{rid}",
406
+ Type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
407
+ Target: "docProps/core.xml"
408
+ })
409
+ end
410
+ unless @app_properties.empty?
411
+ rid += 1
412
+ b.empty_tag("Relationship", {
413
+ Id: "rId#{rid}",
414
+ Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
415
+ Target: "docProps/app.xml"
416
+ })
417
+ end
418
+ unless @custom_properties.empty?
419
+ rid += 1
420
+ b.empty_tag("Relationship", {
421
+ Id: "rId#{rid}",
422
+ Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
423
+ Target: "docProps/custom.xml"
424
+ })
425
+ end
426
+ b.close_tag("Relationships")
427
+ io.string
428
+ end
429
+
430
+ def build_workbook_xml
431
+ io = StringIO.new
432
+ b = XmlBuilder.new(io)
433
+ b.declaration
434
+ b.open_tag("workbook", {
435
+ xmlns: SSML_NS,
436
+ "xmlns:r": DOC_REL
437
+ })
438
+
439
+ # Workbook protection
440
+ if @workbook_protection
441
+ wp_attrs = {}
442
+ wp_attrs[:lockStructure] = "1" if @workbook_protection[:lock_structure]
443
+ wp_attrs[:lockWindows] = "1" if @workbook_protection[:lock_windows]
444
+ wp_attrs[:workbookPassword] = @workbook_protection[:password] if @workbook_protection[:password]
445
+ b.empty_tag("workbookProtection", wp_attrs) unless wp_attrs.empty?
446
+ end
447
+
448
+ b.open_tag("sheets")
449
+ @sheets.each_with_index do |sheet, idx|
450
+ b.empty_tag("sheet", {
451
+ name: sheet[:name],
452
+ sheetId: (idx + 1).to_s,
453
+ "r:id": "rId#{idx + 1}"
454
+ })
455
+ end
456
+ b.close_tag("sheets")
457
+
458
+ # Defined names
459
+ unless @defined_names.empty?
460
+ b.open_tag("definedNames")
461
+ @defined_names.each do |dn|
462
+ dn_attrs = { name: dn[:name] }
463
+ dn_attrs[:localSheetId] = dn[:local_sheet_id].to_s if dn[:local_sheet_id]
464
+ dn_attrs[:hidden] = "1" if dn[:hidden]
465
+ b.tag("definedName", dn_attrs) { |_| b.text(dn[:value]) }
466
+ end
467
+ b.close_tag("definedNames")
468
+ end
469
+
470
+ # Pivot caches
471
+ total_pivots = @sheets.sum { |s| (s[:pivot_tables] || []).size }
472
+ if total_pivots.positive?
473
+ # rId for pivot caches starts after sheets + styles + sharedStrings
474
+ # In build_workbook_rels: sheets(1..N), styles(N+1), sharedStrings(N+2 if present)
475
+ pivot_rid_base = @sheets.size + 1 + (@shared_strings.empty? ? 0 : 1)
476
+ b.open_tag("pivotCaches")
477
+ total_pivots.times do |i|
478
+ b.empty_tag("pivotCache", { cacheId: (i + 1).to_s, "r:id": "rId#{pivot_rid_base + i + 1}" })
479
+ end
480
+ b.close_tag("pivotCaches")
481
+ end
482
+
483
+ b.close_tag("workbook")
484
+ io.string
485
+ end
486
+
487
+ def build_workbook_rels
488
+ io = StringIO.new
489
+ b = XmlBuilder.new(io)
490
+ b.declaration
491
+ b.open_tag("Relationships", { xmlns: REL_NS })
492
+ @sheets.each_with_index do |_sheet, idx|
493
+ b.empty_tag("Relationship", {
494
+ Id: "rId#{idx + 1}",
495
+ Type: "#{DOC_REL}/worksheet",
496
+ Target: "worksheets/sheet#{idx + 1}.xml"
497
+ })
498
+ end
499
+ rid = @sheets.size + 1
500
+ b.empty_tag("Relationship", {
501
+ Id: "rId#{rid}",
502
+ Type: "#{DOC_REL}/styles",
503
+ Target: "styles.xml"
504
+ })
505
+ unless @shared_strings.empty?
506
+ rid += 1
507
+ b.empty_tag("Relationship", {
508
+ Id: "rId#{rid}",
509
+ Type: "#{DOC_REL}/sharedStrings",
510
+ Target: "sharedStrings.xml"
511
+ })
512
+ end
513
+
514
+ # Pivot cache relationships
515
+ total_pivots = @sheets.sum { |s| (s[:pivot_tables] || []).size }
516
+ total_pivots.times do |i|
517
+ rid += 1
518
+ b.empty_tag("Relationship", {
519
+ Id: "rId#{rid}",
520
+ Type: "#{DOC_REL}/pivotCacheDefinition",
521
+ Target: "pivotCache/pivotCacheDefinition#{i + 1}.xml"
522
+ })
523
+ end
524
+
525
+ b.close_tag("Relationships")
526
+ io.string
527
+ end
528
+
529
+ def build_styles_xml
530
+ io = StringIO.new
531
+ b = XmlBuilder.new(io)
532
+ b.declaration
533
+ b.open_tag("styleSheet", { xmlns: SSML_NS })
534
+
535
+ # Fonts
536
+ fonts = @styles&.dig(:fonts) || []
537
+ fonts = [{}] if fonts.empty? # At least one default font
538
+ b.tag("fonts", { count: fonts.size.to_s }) do |_|
539
+ fonts.each do |font_props|
540
+ b.tag("font") do |_|
541
+ b.empty_tag("sz", { val: (font_props[:sz] || 11).to_s }) if font_props[:sz] || fonts == [{}]
542
+ b.empty_tag("b") if font_props[:bold]
543
+ b.empty_tag("i") if font_props[:italic]
544
+ b.empty_tag("strike") if font_props[:strike]
545
+ if font_props[:underline]
546
+ b.empty_tag("u", font_props[:underline] == "single" ? {} : { val: font_props[:underline] })
547
+ end
548
+ b.empty_tag("color", { rgb: font_props[:color] }) if font_props[:color]
549
+ b.empty_tag("name", { val: font_props[:name] || "Calibri" })
550
+ end
551
+ end
552
+ end
553
+
554
+ # Fills
555
+ fills = @styles&.dig(:fills) || []
556
+ fills = [{ pattern: "none" }, { pattern: "gray125" }] if fills.empty?
557
+ b.tag("fills", { count: fills.size.to_s }) do |_|
558
+ fills.each do |fill_props|
559
+ b.tag("fill") do |_|
560
+ if fill_props[:gradient]
561
+ b.open_tag("gradientFill", { type: fill_props[:gradient][:type], degree: fill_props[:gradient][:degree]&.to_s }.compact)
562
+ fill_props[:gradient][:stops].each do |stop|
563
+ b.tag("stop", { position: stop[:position].to_s }) do |_|
564
+ b.empty_tag("color", { rgb: stop[:color] })
565
+ end
566
+ end
567
+ b.close_tag("gradientFill")
568
+ else
569
+ b.tag("patternFill", { patternType: fill_props[:pattern] || "none" }) do |_|
570
+ b.empty_tag("fgColor", { rgb: fill_props[:fg_color] }) if fill_props[:fg_color]
571
+ b.empty_tag("bgColor", { rgb: fill_props[:bg_color] }) if fill_props[:bg_color]
572
+ end
573
+ end
574
+ end
575
+ end
576
+ end
577
+
578
+ # Borders
579
+ borders = @styles&.dig(:borders) || []
580
+ borders = [{}] if borders.empty?
581
+ b.tag("borders", { count: borders.size.to_s }) do |_|
582
+ borders.each do |border_props|
583
+ border_attrs = {}
584
+ border_attrs[:diagonalUp] = "1" if border_props[:diagonal_up]
585
+ border_attrs[:diagonalDown] = "1" if border_props[:diagonal_down]
586
+ b.tag("border", border_attrs) do |_|
587
+ %i[left right top bottom diagonal].each do |side|
588
+ side_data = border_props[side]
589
+ if side_data
590
+ b.tag(side.to_s, { style: side_data[:style] }) do |_|
591
+ b.empty_tag("color", { rgb: side_data[:color] }) if side_data[:color]
592
+ end
593
+ else
594
+ b.empty_tag(side.to_s)
595
+ end
596
+ end
597
+ end
598
+ end
599
+ end
600
+
601
+ # cellStyleXfs
602
+ b.tag("cellStyleXfs", { count: "1" }) do |_|
603
+ b.empty_tag("xf", { numFmtId: "0", fontId: "0", fillId: "0", borderId: "0" })
604
+ end
605
+
606
+ # Number formats
607
+ num_fmts = @styles&.dig(:num_fmts) || []
608
+ unless num_fmts.empty?
609
+ b.tag("numFmts", { count: num_fmts.size.to_s }) do |_|
610
+ num_fmts.each do |nf|
611
+ b.empty_tag("numFmt", { numFmtId: nf[:num_fmt_id].to_s, formatCode: nf[:format_code] }) if nf.is_a?(Hash)
612
+ end
613
+ end
614
+ end
615
+
616
+ # Cell XFs (formatting)
617
+ xf_entries = @styles&.dig(:xf_entries) || []
618
+ xf_count = [xf_entries.size, 1].max
619
+ b.tag("cellXfs", { count: xf_count.to_s }) do |_|
620
+ xf_entries.each do |xf|
621
+ attrs = {
622
+ numFmtId: (xf[:num_fmt_id] || 0).to_s,
623
+ fontId: (xf[:font_id] || 0).to_s,
624
+ fillId: (xf[:fill_id] || 0).to_s,
625
+ borderId: (xf[:border_id] || 0).to_s
626
+ }
627
+ attrs[:xfId] = xf[:xf_id].to_s if xf[:xf_id]
628
+ attrs[:applyNumberFormat] = "1" if xf[:num_fmt_id]&.to_i&.positive?
629
+ attrs[:applyFont] = "1" if xf[:font_id]&.to_i&.positive?
630
+ attrs[:applyFill] = "1" if xf[:fill_id]&.to_i&.positive?
631
+ attrs[:applyBorder] = "1" if xf[:border_id]&.to_i&.positive?
632
+ attrs[:applyAlignment] = "1" if xf[:alignment]
633
+ attrs[:applyProtection] = "1" if xf[:protection]
634
+
635
+ if xf[:alignment] || xf[:protection]
636
+ b.tag("xf", attrs) do |_|
637
+ if xf[:alignment]
638
+ align_attrs = {}
639
+ align_attrs[:horizontal] = xf[:alignment][:horizontal] if xf[:alignment][:horizontal]
640
+ align_attrs[:vertical] = xf[:alignment][:vertical] if xf[:alignment][:vertical]
641
+ align_attrs[:wrapText] = "1" if xf[:alignment][:wrap_text]
642
+ align_attrs[:textRotation] = xf[:alignment][:text_rotation].to_s if xf[:alignment][:text_rotation]
643
+ align_attrs[:indent] = xf[:alignment][:indent].to_s if xf[:alignment][:indent]
644
+ align_attrs[:relativeIndent] = xf[:alignment][:relative_indent].to_s if xf[:alignment][:relative_indent]
645
+ align_attrs[:shrinkToFit] = "1" if xf[:alignment][:shrink_to_fit]
646
+ align_attrs[:readingOrder] = xf[:alignment][:reading_order].to_s if xf[:alignment][:reading_order]
647
+ align_attrs[:justifyLastLine] = "1" if xf[:alignment][:justify_last_line]
648
+ b.empty_tag("alignment", align_attrs)
649
+ end
650
+ if xf[:protection]
651
+ prot_attrs = {}
652
+ prot_attrs[:locked] = xf[:protection][:locked] ? "1" : "0" unless xf[:protection][:locked].nil?
653
+ prot_attrs[:hidden] = xf[:protection][:hidden] ? "1" : "0" unless xf[:protection][:hidden].nil?
654
+ b.empty_tag("protection", prot_attrs)
655
+ end
656
+ end
657
+ else
658
+ b.empty_tag("xf", attrs)
659
+ end
660
+ end
661
+ # Add default if empty
662
+ b.empty_tag("xf", { numFmtId: "0", fontId: "0", fillId: "0", borderId: "0" }) if xf_entries.empty?
663
+ end
664
+
665
+ # Differential formats (for conditional formatting)
666
+ dxfs = @styles&.dig(:dxfs) || []
667
+ unless dxfs.empty?
668
+ b.tag("dxfs", { count: dxfs.size.to_s }) do |_|
669
+ dxfs.each do |dxf|
670
+ b.tag("dxf") do |_|
671
+ write_dxf_font(b, dxf[:font]) if dxf[:font]
672
+ if dxf[:num_fmt]
673
+ nf = dxf[:num_fmt]
674
+ b.empty_tag("numFmt", { numFmtId: nf[:num_fmt_id].to_s, formatCode: nf[:format_code].to_s })
675
+ end
676
+ write_dxf_fill(b, dxf[:fill]) if dxf[:fill]
677
+ end
678
+ end
679
+ end
680
+ end
681
+
682
+ b.close_tag("styleSheet")
683
+ io.string
684
+ end
685
+
686
+ def preprocess_conditional_formats!
687
+ @styles[:dxfs] ||= []
688
+
689
+ @sheets.each do |sheet|
690
+ rules = sheet[:conditional_formats]
691
+ next if rules.nil? || rules.empty?
692
+
693
+ sheet[:conditional_formats] = rules.map do |rule|
694
+ next rule if rule[:format_id]
695
+
696
+ normalized = rule.dup
697
+ dxf = normalized.delete(:dxf) || extract_dxf_from_rule!(normalized)
698
+ next normalized unless dxf
699
+
700
+ dxf_id = @styles[:dxfs].index(dxf)
701
+ unless dxf_id
702
+ @styles[:dxfs] << dxf
703
+ dxf_id = @styles[:dxfs].size - 1
704
+ end
705
+ normalized[:format_id] = dxf_id
706
+ normalized
707
+ end
708
+ end
709
+ end
710
+
711
+ def extract_dxf_from_rule!(rule)
712
+ font = {}
713
+ font[:color] = rule.delete(:font_color) if rule.key?(:font_color)
714
+ font[:bold] = rule.delete(:bold) if rule.key?(:bold)
715
+ font[:italic] = rule.delete(:italic) if rule.key?(:italic)
716
+ font[:underline] = rule.delete(:underline) if rule.key?(:underline)
717
+
718
+ fill = {}
719
+ if rule.key?(:fill_color)
720
+ fill[:pattern] = "solid"
721
+ fill[:fg_color] = rule.delete(:fill_color)
722
+ end
723
+
724
+ dxf = {}
725
+ dxf[:font] = font unless font.empty?
726
+ dxf[:fill] = fill unless fill.empty?
727
+ dxf.empty? ? nil : dxf
728
+ end
729
+
730
+ def write_dxf_font(builder, font)
731
+ builder.tag("font") do |_|
732
+ builder.empty_tag("b") if font[:bold]
733
+ builder.empty_tag("i") if font[:italic]
734
+ if font[:underline]
735
+ if font[:underline] == true
736
+ builder.empty_tag("u")
737
+ else
738
+ builder.empty_tag("u", { val: font[:underline] })
739
+ end
740
+ end
741
+ builder.empty_tag("color", { rgb: font[:color] }) if font[:color]
742
+ builder.empty_tag("name", { val: font[:name] }) if font[:name]
743
+ builder.empty_tag("sz", { val: font[:sz].to_s }) if font[:sz]
744
+ end
745
+ end
746
+
747
+ def write_dxf_fill(builder, fill)
748
+ builder.tag("fill") do |_|
749
+ pattern = fill[:pattern] || "solid"
750
+ builder.tag("patternFill", { patternType: pattern }) do |_|
751
+ builder.empty_tag("fgColor", { rgb: fill[:fg_color] }) if fill[:fg_color]
752
+ builder.empty_tag("bgColor", { rgb: fill[:bg_color] }) if fill[:bg_color]
753
+ end
754
+ end
755
+ end
756
+
757
+ def build_shared_strings_xml
758
+ io = StringIO.new
759
+ b = XmlBuilder.new(io)
760
+ b.declaration
761
+ b.open_tag("sst", {
762
+ xmlns: SSML_NS,
763
+ count: @shared_strings.size.to_s,
764
+ uniqueCount: @shared_strings.size.to_s
765
+ })
766
+ @shared_strings.each do |str|
767
+ if str.is_a?(Xlsxrb::Elements::RichText)
768
+ b.open_tag("si")
769
+ str.runs.each do |run|
770
+ font = run[:font]
771
+ if font && !font.empty?
772
+ b.open_tag("r")
773
+ b.open_tag("rPr")
774
+ b.empty_tag("b") if font[:bold]
775
+ b.empty_tag("i") if font[:italic]
776
+ b.empty_tag("strike") if font[:strike]
777
+ if font[:underline]
778
+ if font[:underline] == true
779
+ b.empty_tag("u")
780
+ else
781
+ b.empty_tag("u", { val: font[:underline] })
782
+ end
783
+ end
784
+ b.empty_tag("vertAlign", { val: font[:vert_align] }) if font[:vert_align]
785
+ b.empty_tag("sz", { val: font[:sz].to_s }) if font[:sz]
786
+ if font[:color]
787
+ b.empty_tag("color", { rgb: font[:color] })
788
+ elsif font[:theme]
789
+ tc_attrs = { theme: font[:theme].to_s }
790
+ tc_attrs[:tint] = font[:tint].to_s if font[:tint]
791
+ b.empty_tag("color", tc_attrs)
792
+ end
793
+ b.empty_tag("rFont", { val: font[:name] }) if font[:name]
794
+ b.empty_tag("family", { val: font[:family].to_s }) if font[:family]
795
+ b.empty_tag("scheme", { val: font[:scheme] }) if font[:scheme]
796
+ b.close_tag("rPr")
797
+ b.tag("t") { |_| b.text(run[:text]) }
798
+ b.close_tag("r")
799
+ else
800
+ b.tag("r") { |_| b.tag("t") { |_| b.text(run[:text]) } }
801
+ end
802
+ end
803
+ b.close_tag("si")
804
+ else
805
+ b.tag("si") { |_| b.tag("t") { |_| b.text(str.to_s) } }
806
+ end
807
+ end
808
+ b.close_tag("sst")
809
+ io.string
810
+ end
811
+
812
+ def write_worksheet_xml(io, sheet, drawing_rid: nil, sheet_protection: nil,
813
+ auto_filter: nil, filter_columns: nil, sort_state: nil,
814
+ merge_cells: nil, conditional_formats: nil,
815
+ data_validations: nil, hyperlinks: nil,
816
+ print_options: nil, page_margins: nil, page_setup: nil,
817
+ header_footer: nil, row_breaks: nil, col_breaks: nil,
818
+ freeze_pane: nil, split_pane: nil, selection: nil,
819
+ sheet_view: nil, sheet_properties: nil,
820
+ tables: nil, table_start_rid: nil,
821
+ legacy_drawing_rid: nil, sparkline_groups: nil)
822
+ ws = WorksheetWriter.new(io)
823
+ ws.start(
824
+ columns: sheet[:columns] || [],
825
+ sheet_properties: sheet_properties,
826
+ freeze_pane: freeze_pane,
827
+ split_pane: split_pane,
828
+ selection: selection,
829
+ sheet_view: sheet_view
830
+ )
831
+ if sheet[:rows_tmp_path]
832
+ File.open(sheet[:rows_tmp_path], "rb") do |f|
833
+ while (chunk = f.read(16_384))
834
+ io.write(chunk)
835
+ end
836
+ end
837
+ else
838
+ (sheet[:rows] || []).each do |row|
839
+ if row.is_a?(Hash)
840
+ ws.write_row(row[:index], row[:cells], attrs: row[:attrs] || {}, unmapped: row[:unmapped] || [], sst_index: @shared_strings_index)
841
+ else
842
+ attrs = {}
843
+ attrs[:height] = row.height if row.height
844
+ attrs[:hidden] = true if row.hidden
845
+ attrs[:custom_height] = true if row.custom_height
846
+ attrs[:outline_level] = row.outline_level if row.outline_level
847
+ ws.write_row(row.index, row.cells, attrs: attrs, unmapped: row.unmapped_data || [], sst_index: @shared_strings_index)
848
+ end
849
+ end
850
+ end
851
+ ws.finish(
852
+ drawing_rid: drawing_rid,
853
+ sheet_protection: sheet_protection,
854
+ auto_filter: auto_filter,
855
+ filter_columns: filter_columns,
856
+ sort_state: sort_state,
857
+ merge_cells: merge_cells,
858
+ conditional_formats: conditional_formats,
859
+ data_validations: data_validations,
860
+ hyperlinks: hyperlinks,
861
+ print_options: print_options,
862
+ page_margins: page_margins,
863
+ page_setup: page_setup,
864
+ header_footer: header_footer,
865
+ row_breaks: row_breaks,
866
+ col_breaks: col_breaks,
867
+ tables: tables,
868
+ table_start_rid: table_start_rid,
869
+ legacy_drawing_rid: legacy_drawing_rid,
870
+ sparkline_groups: sparkline_groups
871
+ )
872
+ end
873
+
874
+ def build_table_xml(tbl, table_id)
875
+ io = StringIO.new
876
+ b = XmlBuilder.new(io)
877
+ b.declaration
878
+ tbl_name = tbl[:name] || "Table#{table_id}"
879
+ t_attrs = {
880
+ xmlns: SSML_NS,
881
+ id: table_id.to_s,
882
+ name: tbl_name,
883
+ displayName: tbl[:display_name] || tbl_name,
884
+ ref: tbl[:ref]
885
+ }
886
+ t_attrs[:totalsRowCount] = tbl[:totals_row_count].to_s if tbl[:totals_row_count]&.positive?
887
+ b.open_tag("table", t_attrs)
888
+
889
+ # Auto filter for the table range
890
+ b.empty_tag("autoFilter", { ref: tbl[:ref] })
891
+
892
+ # Table columns
893
+ columns = tbl[:columns] || []
894
+ b.open_tag("tableColumns", { count: columns.size.to_s })
895
+ columns.each_with_index do |col_name, ci|
896
+ b.empty_tag("tableColumn", { id: (ci + 1).to_s, name: col_name })
897
+ end
898
+ b.close_tag("tableColumns")
899
+
900
+ # Table style. Accepts either:
901
+ # - style: "TableStyleMedium9"
902
+ # - style: { name:, show_* }
903
+ # and also show_* values at the table root for Facade compatibility.
904
+ raw_style = tbl[:style]
905
+ style = if raw_style.is_a?(Hash)
906
+ raw_style
907
+ elsif raw_style
908
+ { name: raw_style.to_s }
909
+ else
910
+ {}
911
+ end
912
+
913
+ style_attrs = { name: style[:name] || "TableStyleMedium2" }
914
+
915
+ show_first_column = style.key?(:show_first_column) ? style[:show_first_column] : tbl[:show_first_column]
916
+ show_last_column = style.key?(:show_last_column) ? style[:show_last_column] : tbl[:show_last_column]
917
+ show_row_stripes = if style.key?(:show_row_stripes)
918
+ style[:show_row_stripes]
919
+ elsif tbl.key?(:show_row_stripes)
920
+ tbl[:show_row_stripes]
921
+ else
922
+ true
923
+ end
924
+ show_column_stripes = style.key?(:show_column_stripes) ? style[:show_column_stripes] : tbl[:show_column_stripes]
925
+
926
+ style_attrs[:showFirstColumn] = show_first_column ? "1" : "0" unless show_first_column.nil?
927
+ style_attrs[:showLastColumn] = show_last_column ? "1" : "0" unless show_last_column.nil?
928
+ style_attrs[:showRowStripes] = show_row_stripes ? "1" : "0"
929
+ style_attrs[:showColumnStripes] = show_column_stripes ? "1" : "0" unless show_column_stripes.nil?
930
+ b.empty_tag("tableStyleInfo", style_attrs)
931
+
932
+ b.close_tag("table")
933
+ io.string
934
+ end
935
+
936
+ def build_core_properties_xml
937
+ io = StringIO.new
938
+ b = XmlBuilder.new(io)
939
+ b.declaration
940
+ b.open_tag("cp:coreProperties", {
941
+ "xmlns:cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
942
+ "xmlns:dc": "http://purl.org/dc/elements/1.1/",
943
+ "xmlns:dcterms": "http://purl.org/dc/terms/",
944
+ "xmlns:dcmitype": "http://purl.org/dc/dcmitype/",
945
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance"
946
+ })
947
+ b.tag("dc:title") { |_| b.text(@core_properties[:title]) } if @core_properties[:title]
948
+ b.tag("dc:subject") { |_| b.text(@core_properties[:subject]) } if @core_properties[:subject]
949
+ b.tag("dc:creator") { |_| b.text(@core_properties[:creator]) } if @core_properties[:creator]
950
+ b.tag("cp:keywords") { |_| b.text(@core_properties[:keywords]) } if @core_properties[:keywords]
951
+ b.tag("dc:description") { |_| b.text(@core_properties[:description]) } if @core_properties[:description]
952
+ b.tag("cp:lastModifiedBy") { |_| b.text(@core_properties[:last_modified_by]) } if @core_properties[:last_modified_by]
953
+ b.tag("cp:category") { |_| b.text(@core_properties[:category]) } if @core_properties[:category]
954
+ b.close_tag("cp:coreProperties")
955
+ io.string
956
+ end
957
+
958
+ def build_app_properties_xml
959
+ io = StringIO.new
960
+ b = XmlBuilder.new(io)
961
+ b.declaration
962
+ b.open_tag("Properties", {
963
+ xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
964
+ "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"
965
+ })
966
+ b.tag("Application") { |_| b.text(@app_properties[:application]) } if @app_properties[:application]
967
+ b.tag("Company") { |_| b.text(@app_properties[:company]) } if @app_properties[:company]
968
+ b.tag("Manager") { |_| b.text(@app_properties[:manager]) } if @app_properties[:manager]
969
+ b.close_tag("Properties")
970
+ io.string
971
+ end
972
+
973
+ def build_custom_properties_xml
974
+ io = StringIO.new
975
+ b = XmlBuilder.new(io)
976
+ b.declaration
977
+ b.open_tag("Properties", {
978
+ xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties",
979
+ "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"
980
+ })
981
+ @custom_properties.each_with_index do |prop, idx|
982
+ b.open_tag("property", { fmtid: "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", pid: (idx + 2).to_s, name: prop[:name] })
983
+ case prop[:type]
984
+ when :number
985
+ b.tag("vt:i4") { |_| b.text(prop[:value].to_s) }
986
+ when :bool
987
+ b.tag("vt:bool") { |_| b.text(prop[:value] ? "true" : "false") }
988
+ when :date
989
+ b.tag("vt:filetime") { |_| b.text(prop[:value].to_s) }
990
+ else
991
+ b.tag("vt:lpwstr") { |_| b.text(prop[:value].to_s) }
992
+ end
993
+ b.close_tag("property")
994
+ end
995
+ b.close_tag("Properties")
996
+ io.string
997
+ end
998
+ end
999
+ end
1000
+ end