From 6cf31b879c5a0af85dd062272625d29fee890f22 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:06:39 +0900 Subject: [PATCH 01/20] Add theme to package --- CHANGELOG.md | 3 +- lib/axlsx/package.rb | 2 + lib/axlsx/stylesheet/styles.rb | 1 + lib/axlsx/stylesheet/theme.rb | 161 +++++++++++++++++++++++++++++++++ lib/axlsx/util/constants.rb | 9 ++ lib/axlsx/workbook/workbook.rb | 7 ++ test/stylesheet/tc_theme.rb | 109 ++++++++++++++++++++++ 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 lib/axlsx/stylesheet/theme.rb create mode 100644 test/stylesheet/tc_theme.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 03463cbe..27b934e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ CHANGELOG --------- -- **Unreleased** +- **Unreleased**: 4.4.0 + - [PR #XXX](https://github.com/caxlsx/caxlsx/pull/XXX) Add default theme file to Excel package. - **August.16.25**: 4.3.0 diff --git a/lib/axlsx/package.rb b/lib/axlsx/package.rb index 04fe5f9f..cb5a8367 100644 --- a/lib/axlsx/package.rb +++ b/lib/axlsx/package.rb @@ -221,6 +221,7 @@ def zip_entry_for_part(part) def parts parts = [ { entry: "xl/#{STYLES_PN}", doc: workbook.styles, schema: SML_XSD }, + { entry: "xl/#{THEME_PN}", doc: workbook.theme, schema: nil }, { entry: CORE_PN, doc: @core, schema: CORE_XSD }, { entry: APP_PN, doc: @app, schema: APP_XSD }, { entry: WORKBOOK_RELS_PN, doc: workbook.relationships, schema: RELS_XSD }, @@ -357,6 +358,7 @@ def base_content_types c_types << Override.new(PartName: "/#{APP_PN}", ContentType: APP_CT) c_types << Override.new(PartName: "/#{CORE_PN}", ContentType: CORE_CT) c_types << Override.new(PartName: "/xl/#{STYLES_PN}", ContentType: STYLES_CT) + c_types << Override.new(PartName: "/xl/#{THEME_PN}", ContentType: THEME_CT) c_types << Axlsx::Override.new(PartName: "/#{WORKBOOK_PN}", ContentType: WORKBOOK_CT) c_types.lock c_types diff --git a/lib/axlsx/stylesheet/styles.rb b/lib/axlsx/stylesheet/styles.rb index 3fc36f73..27c66209 100644 --- a/lib/axlsx/stylesheet/styles.rb +++ b/lib/axlsx/stylesheet/styles.rb @@ -15,6 +15,7 @@ module Axlsx require_relative 'table_style' require_relative 'table_styles' require_relative 'table_style_element' + require_relative 'theme' require_relative 'dxf' require_relative 'xf' require_relative 'cell_protection' diff --git a/lib/axlsx/stylesheet/theme.rb b/lib/axlsx/stylesheet/theme.rb new file mode 100644 index 00000000..95c99622 --- /dev/null +++ b/lib/axlsx/stylesheet/theme.rb @@ -0,0 +1,161 @@ +module Axlsx + # Theme represents the theme part of the package and is responsible for + # generating the default Office theme that is required for encryption compatibility + class Theme + # The part name of this theme + # @return [String] + def pn + THEME_PN + end + + # Serializes the default theme to XML + # @param [String] str + # @return [String] + def to_xml_string(str = '') + str << <<~XML.gsub("\n", '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML + end + end +end diff --git a/lib/axlsx/util/constants.rb b/lib/axlsx/util/constants.rb index 63595969..d4dbe20c 100644 --- a/lib/axlsx/util/constants.rb +++ b/lib/axlsx/util/constants.rb @@ -79,6 +79,9 @@ module Axlsx # shared strings namespace SHARED_STRINGS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" + # theme rels namespace + THEME_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme".freeze + # drawing rels namespace DRAWING_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" @@ -133,6 +136,9 @@ module Axlsx # shared strings content type SHARED_STRINGS_CT = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" + # theme content type + THEME_CT = "application/vnd.openxmlformats-officedocument.theme+xml".freeze + # core content type CORE_CT = "application/vnd.openxmlformats-package.core-properties+xml" @@ -187,6 +193,9 @@ module Axlsx # shared_strings part SHARED_STRINGS_PN = "sharedStrings.xml" + # theme part + THEME_PN = "theme/theme1.xml".freeze + # app part APP_PN = "docProps/app.xml" diff --git a/lib/axlsx/workbook/workbook.rb b/lib/axlsx/workbook/workbook.rb index 2e16f5e2..4d0ecc11 100644 --- a/lib/axlsx/workbook/workbook.rb +++ b/lib/axlsx/workbook/workbook.rb @@ -184,6 +184,12 @@ def styles @styles end + # The theme associated with this workbook + # @return [Theme] + def theme + @theme ||= Theme.new + end + # An array that holds all cells with styles # @return Set def styled_cells @@ -373,6 +379,7 @@ def relationships r << Relationship.new(pivot_table.cache_definition, PIVOT_TABLE_CACHE_DEFINITION_R, format(PIVOT_TABLE_CACHE_DEFINITION_PN, index + 1)) end r << Relationship.new(self, STYLES_R, STYLES_PN) + r << Relationship.new(self, THEME_R, THEME_PN) if use_shared_strings r << Relationship.new(self, SHARED_STRINGS_R, SHARED_STRINGS_PN) end diff --git a/test/stylesheet/tc_theme.rb b/test/stylesheet/tc_theme.rb new file mode 100644 index 00000000..6597f22c --- /dev/null +++ b/test/stylesheet/tc_theme.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'tc_helper' + +class TestTheme < Minitest::Test + def setup + @theme = Axlsx::Theme.new + end + + def test_pn + assert_equal(Axlsx::THEME_PN, @theme.pn) + end + + def test_to_xml_string_returns_valid_xml + xml = @theme.to_xml_string + + # Basic structure checks + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + + # Required sections + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_to_xml_string_with_string_parameter + str = '' + result = @theme.to_xml_string(str) + + # Should return the same string object that was passed in + assert_same(str, result) + refute_empty(str) + refute_includes(str, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + + # Check accent colors + (1..6).each do |i| + assert_includes(xml, "") + end + + # Check hyperlink colors + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_font_scheme_elements + xml = @theme.to_xml_string + + # Check for major and minor fonts + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_format_scheme_elements + xml = @theme.to_xml_string + + # Check for format scheme sections + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_object_defaults + xml = @theme.to_xml_string + + # Check for object defaults + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_3d_elements_present + xml = @theme.to_xml_string + + # Check for 3D elements that are crucial for Excel compatibility + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + end + + def test_xml_is_single_line_with_no_whitespace_padding + xml = @theme.to_xml_string + + # XML should not contain extra whitespace or newlines + refute_includes(xml, "\n") + refute_includes(xml, " ") # No double spaces + end +end From 9ccd9924ef68ab1916fa68c5ec81a21b93472d7b Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:40:10 +0900 Subject: [PATCH 02/20] Theme support --- lib/axlsx/package.rb | 2 +- lib/axlsx/util/constants.rb | 3 + lib/axlsx/util/validators.rb | 8 +- test/stylesheet/tc_theme.rb | 75 ++++++----- test/tc_package.rb | 234 ++++++++++++++++++++++++++++++++++- 5 files changed, 277 insertions(+), 45 deletions(-) diff --git a/lib/axlsx/package.rb b/lib/axlsx/package.rb index cb5a8367..b9bbdd4e 100644 --- a/lib/axlsx/package.rb +++ b/lib/axlsx/package.rb @@ -221,7 +221,7 @@ def zip_entry_for_part(part) def parts parts = [ { entry: "xl/#{STYLES_PN}", doc: workbook.styles, schema: SML_XSD }, - { entry: "xl/#{THEME_PN}", doc: workbook.theme, schema: nil }, + { entry: "xl/#{THEME_PN}", doc: workbook.theme, schema: THEME_XSD }, { entry: CORE_PN, doc: @core, schema: CORE_XSD }, { entry: APP_PN, doc: @app, schema: APP_XSD }, { entry: WORKBOOK_RELS_PN, doc: workbook.relationships, schema: RELS_XSD }, diff --git a/lib/axlsx/util/constants.rb b/lib/axlsx/util/constants.rb index d4dbe20c..f3dbc482 100644 --- a/lib/axlsx/util/constants.rb +++ b/lib/axlsx/util/constants.rb @@ -268,6 +268,9 @@ module Axlsx # drawing validation schema DRAWING_XSD = "#{SCHEMA_BASE}dml-spreadsheetDrawing.xsd" + # theme validation schema + THEME_XSD = "#{SCHEMA_BASE}dml-main.xsd".freeze + # number format id for percentage formatting using the default formatting id. NUM_FMT_PERCENT = 9 diff --git a/lib/axlsx/util/validators.rb b/lib/axlsx/util/validators.rb index aa2855c1..41c52359 100644 --- a/lib/axlsx/util/validators.rb +++ b/lib/axlsx/util/validators.rb @@ -268,19 +268,19 @@ def self.validate_vertical_alignment(v) RestrictionValidator.validate :vertical_alignment, VALID_VERTICAL_ALIGNMENT_VALUES, v end - VALID_CONTENT_TYPE_VALUES = [TABLE_CT, WORKBOOK_CT, APP_CT, RELS_CT, STYLES_CT, XML_CT, WORKSHEET_CT, SHARED_STRINGS_CT, CORE_CT, CHART_CT, JPEG_CT, GIF_CT, PNG_CT, DRAWING_CT, COMMENT_CT, VML_DRAWING_CT, PIVOT_TABLE_CT, PIVOT_TABLE_CACHE_DEFINITION_CT].freeze + VALID_CONTENT_TYPE_VALUES = [TABLE_CT, WORKBOOK_CT, APP_CT, RELS_CT, STYLES_CT, THEME_CT, XML_CT, WORKSHEET_CT, SHARED_STRINGS_CT, CORE_CT, CHART_CT, JPEG_CT, GIF_CT, PNG_CT, DRAWING_CT, COMMENT_CT, VML_DRAWING_CT, PIVOT_TABLE_CT, PIVOT_TABLE_CACHE_DEFINITION_CT].freeze # Requires that the value is a valid content_type - # TABLE_CT, WORKBOOK_CT, APP_CT, RELS_CT, STYLES_CT, XML_CT, WORKSHEET_CT, SHARED_STRINGS_CT, CORE_CT, CHART_CT, DRAWING_CT, COMMENT_CT are allowed + # TABLE_CT, WORKBOOK_CT, APP_CT, RELS_CT, STYLES_CT, THEME_CT, XML_CT, WORKSHEET_CT, SHARED_STRINGS_CT, CORE_CT, CHART_CT, DRAWING_CT, COMMENT_CT are allowed # @param [Any] v The value validated def self.validate_content_type(v) RestrictionValidator.validate :content_type, VALID_CONTENT_TYPE_VALUES, v end - VALID_RELATIONSHIP_TYPE_VALUES = [XML_NS_R, TABLE_R, WORKBOOK_R, WORKSHEET_R, APP_R, RELS_R, CORE_R, STYLES_R, CHART_R, DRAWING_R, IMAGE_R, HYPERLINK_R, SHARED_STRINGS_R, COMMENT_R, VML_DRAWING_R, COMMENT_R_NULL, PIVOT_TABLE_R, PIVOT_TABLE_CACHE_DEFINITION_R].freeze + VALID_RELATIONSHIP_TYPE_VALUES = [XML_NS_R, TABLE_R, WORKBOOK_R, WORKSHEET_R, APP_R, RELS_R, CORE_R, STYLES_R, THEME_R, CHART_R, DRAWING_R, IMAGE_R, HYPERLINK_R, SHARED_STRINGS_R, COMMENT_R, VML_DRAWING_R, COMMENT_R_NULL, PIVOT_TABLE_R, PIVOT_TABLE_CACHE_DEFINITION_R].freeze # Requires that the value is a valid relationship_type - # XML_NS_R, TABLE_R, WORKBOOK_R, WORKSHEET_R, APP_R, RELS_R, CORE_R, STYLES_R, CHART_R, DRAWING_R, IMAGE_R, HYPERLINK_R, SHARED_STRINGS_R are allowed + # XML_NS_R, TABLE_R, WORKBOOK_R, WORKSHEET_R, APP_R, RELS_R, CORE_R, STYLES_R, THEME_R, CHART_R, DRAWING_R, IMAGE_R, HYPERLINK_R, SHARED_STRINGS_R are allowed # @param [Any] v The value validated def self.validate_relationship_type(v) RestrictionValidator.validate :relationship_type, VALID_RELATIONSHIP_TYPE_VALUES, v diff --git a/test/stylesheet/tc_theme.rb b/test/stylesheet/tc_theme.rb index 6597f22c..ad8cd567 100644 --- a/test/stylesheet/tc_theme.rb +++ b/test/stylesheet/tc_theme.rb @@ -11,21 +11,23 @@ def test_pn assert_equal(Axlsx::THEME_PN, @theme.pn) end - def test_to_xml_string_returns_valid_xml - xml = @theme.to_xml_string + def test_to_xml_is_valid + xml_string = theme.to_xml_string - # Basic structure checks - assert_includes(xml, '') - assert_includes(xml, '') - assert_includes(xml, '') + # Verify the XML is well-formed + refute_raises { Nokogiri::XML(xml_string, &:strict) } - # Required sections - assert_includes(xml, '') - assert_includes(xml, '') - assert_includes(xml, '') - assert_includes(xml, '') - assert_includes(xml, '') - assert_includes(xml, '') + # Verify it contains expected theme elements + doc = Nokogiri::XML(xml_string) + + assert_equal("theme", doc.root.name, "root element should be 'theme'") + assert_equal("http://schemas.openxmlformats.org/drawingml/2006/main", doc.root.namespace.href, "should use correct namespace") + + # Verify required theme elements are present + refute_nil(doc.at_xpath("//a:themeElements", "a" => "http://schemas.openxmlformats.org/drawingml/2006/main"), "themeElements should be present") + refute_nil(doc.at_xpath("//a:clrScheme", "a" => "http://schemas.openxmlformats.org/drawingml/2006/main"), "color scheme should be present") + refute_nil(doc.at_xpath("//a:fontScheme", "a" => "http://schemas.openxmlformats.org/drawingml/2006/main"), "font scheme should be present") + refute_nil(doc.at_xpath("//a:fmtScheme", "a" => "http://schemas.openxmlformats.org/drawingml/2006/main"), "format scheme should be present") end def test_to_xml_string_with_string_parameter @@ -38,9 +40,30 @@ def test_to_xml_string_with_string_parameter refute_includes(str, '') + assert_includes(xml, '') + assert_includes(xml, '') + + # Required sections + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + assert_includes(xml, '') + # Check for required color scheme elements assert_includes(xml, '') assert_includes(xml, '') @@ -55,30 +78,18 @@ def test_color_scheme_elements # Check hyperlink colors assert_includes(xml, '') assert_includes(xml, '') - end - - def test_font_scheme_elements - xml = @theme.to_xml_string # Check for major and minor fonts assert_includes(xml, '') assert_includes(xml, '') assert_includes(xml, '') assert_includes(xml, '') - end - - def test_format_scheme_elements - xml = @theme.to_xml_string # Check for format scheme sections assert_includes(xml, '') assert_includes(xml, '') assert_includes(xml, '') assert_includes(xml, '') - end - - def test_object_defaults - xml = @theme.to_xml_string # Check for object defaults assert_includes(xml, '') @@ -86,10 +97,6 @@ def test_object_defaults assert_includes(xml, '') assert_includes(xml, '') assert_includes(xml, '') - end - - def test_3d_elements_present - xml = @theme.to_xml_string # Check for 3D elements that are crucial for Excel compatibility assert_includes(xml, '') @@ -98,12 +105,4 @@ def test_3d_elements_present assert_includes(xml, '') assert_includes(xml, '') end - - def test_xml_is_single_line_with_no_whitespace_padding - xml = @theme.to_xml_string - - # XML should not contain extra whitespace or newlines - refute_includes(xml, "\n") - refute_includes(xml, " ") # No double spaces - end end diff --git a/test/tc_package.rb b/test/tc_package.rb index 6509272a..43590c4e 100644 --- a/test/tc_package.rb +++ b/test/tc_package.rb @@ -260,6 +260,7 @@ def test_validation def test_parts p = @package.send(:parts) + # all parts have an entry assert_equal(1, p.count { |part| part[:entry].include?('_rels/.rels') }, "rels missing") assert_equal(1, p.count { |part| part[:entry].include?('docProps/core.xml') }, "core missing") @@ -268,6 +269,7 @@ def test_parts assert_equal(1, p.count { |part| part[:entry].include?('xl/workbook.xml') }, "workbook missing") assert_equal(1, p.count { |part| part[:entry].include?('[Content_Types].xml') }, "content types missing") assert_equal(1, p.count { |part| part[:entry].include?('xl/styles.xml') }, "styles missing") + assert_equal(1, p.count { |part| part[:entry].include?('xl/theme/theme1.xml') }, "theme missing") assert_equal(p.count { |part| %r{xl/drawings/_rels/drawing\d\.xml\.rels}.match?(part[:entry]) }, @package.workbook.drawings.size, "one or more drawing rels missing") assert_equal(p.count { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) }, @package.workbook.drawings.size, "one or more drawings missing") assert_equal(p.count { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) }, @package.workbook.charts.size, "one or more charts missing") @@ -279,7 +281,7 @@ def test_parts assert_equal(p.count { |part| %r{xl/pivotCache/pivotCacheDefinition\d\.xml}.match?(part[:entry]) }, @package.workbook.worksheets.first.pivot_tables.size, "one or more pivot tables missing") # no mystery parts - assert_equal(25, p.size) + assert_equal(26, p.size) # sorted for correct MIME detection assert_equal("[Content_Types].xml", p[0][:entry], "first entry should be `[Content_Types].xml`") @@ -287,12 +289,148 @@ def test_parts assert_match(%r{\Axl/}, p[2][:entry], "third entry should begin with `xl/`") end + def test_part_styles + styles_part = @package.send(:parts).find { |part| part[:entry].include?('xl/styles.xml') } + + assert_equal(Axlsx::SML_XSD, styles_part[:schema], "styles should use SML_XSD schema") + assert_kind_of(Axlsx::Styles, styles_part[:doc], "styles document should be a Styles instance") + end + + def test_part_theme + theme_part = @package.send(:parts).find { |part| part[:entry].include?('xl/theme/theme1.xml') } + + assert_equal(Axlsx::THEME_XSD, theme_part[:schema], "theme should use THEME_XSD schema") + assert_kind_of(Axlsx::Theme, theme_part[:doc], "theme document should be a Theme instance") + end + + def test_part_core + core_part = @package.send(:parts).find { |part| part[:entry].include?('docProps/core.xml') } + + assert_equal(Axlsx::CORE_XSD, core_part[:schema], "core should use CORE_XSD schema") + assert_kind_of(Axlsx::Core, core_part[:doc], "core document should be a Core instance") + end + + def test_part_app + app_part = @package.send(:parts).find { |part| part[:entry].include?('docProps/app.xml') } + + assert_equal(Axlsx::APP_XSD, app_part[:schema], "app should use APP_XSD schema") + assert_kind_of(Axlsx::App, app_part[:doc], "app document should be an App instance") + end + + def test_part_workbook + workbook_part = @package.send(:parts).find { |part| part[:entry].include?('xl/workbook.xml') } + + assert_equal(Axlsx::SML_XSD, workbook_part[:schema], "workbook should use SML_XSD schema") + assert_kind_of(Axlsx::Workbook, workbook_part[:doc], "workbook document should be a Workbook instance") + end + + def test_part_content_types + content_types_part = @package.send(:parts).find { |part| part[:entry].include?('[Content_Types].xml') } + + assert_equal(Axlsx::CONTENT_TYPES_XSD, content_types_part[:schema], "content types should use CONTENT_TYPES_XSD schema") + assert_kind_of(Axlsx::ContentType, content_types_part[:doc], "content types document should be a ContentType instance") + end + + def test_part_main_rels + main_rels_part = @package.send(:parts).find { |part| part[:entry].include?('_rels/.rels') } + + assert_equal(Axlsx::RELS_XSD, main_rels_part[:schema], "main relationships should use RELS_XSD schema") + assert_kind_of(Axlsx::Relationships, main_rels_part[:doc], "main relationships document should be a Relationships instance") + end + + def test_part_workbook_rels + workbook_rels_part = @package.send(:parts).find { |part| part[:entry].include?('xl/_rels/workbook.xml.rels') } + + assert_equal(Axlsx::RELS_XSD, workbook_rels_part[:schema], "workbook relationships should use RELS_XSD schema") + assert_kind_of(Axlsx::Relationships, workbook_rels_part[:doc], "workbook relationships document should be a Relationships instance") + end + + def test_part_worksheets + worksheet_parts = @package.send(:parts).select { |part| %r{xl/worksheets/sheet\d\.xml}.match?(part[:entry]) } + + assert_equal(1, worksheet_parts.size, "should have 1 worksheet part") + assert_equal(@package.workbook.worksheets.size, worksheet_parts.size, "worksheet parts count should match worksheets count") + worksheet_parts.each do |ws_part| + assert_equal(Axlsx::SML_XSD, ws_part[:schema], "worksheet #{ws_part[:entry]} should use SML_XSD schema") + assert_kind_of(Axlsx::Worksheet, ws_part[:doc], "worksheet document should be a Worksheet instance") + end + end + + def test_part_worksheet_rels + ws_rels_parts = @package.send(:parts).select { |part| %r{xl/worksheets/_rels/sheet\d\.xml\.rels}.match?(part[:entry]) } + + assert_equal(1, ws_rels_parts.size, "should have 1 worksheet relationship part") + assert_equal(@package.workbook.worksheets.size, ws_rels_parts.size, "worksheet relationship parts count should match worksheets count") + ws_rels_parts.each do |ws_rels_part| + assert_equal(Axlsx::RELS_XSD, ws_rels_part[:schema], "worksheet relationships #{ws_rels_part[:entry]} should use RELS_XSD schema") + assert_kind_of(Axlsx::Relationships, ws_rels_part[:doc], "worksheet relationships document should be a Relationships instance") + end + end + + def test_part_drawings + drawing_parts = @package.send(:parts).select { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) } + + assert_equal(1, drawing_parts.size, "should have 1 drawing part") + assert_equal(@package.workbook.drawings.size, drawing_parts.size, "drawing parts count should match drawings count") + drawing_parts.each do |drawing_part| + assert_equal(Axlsx::DRAWING_XSD, drawing_part[:schema], "drawing #{drawing_part[:entry]} should use DRAWING_XSD schema") + assert_kind_of(Axlsx::Drawing, drawing_part[:doc], "drawing document should be a Drawing instance") + end + end + + def test_part_charts + chart_parts = @package.send(:parts).select { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) } + + assert_equal(5, chart_parts.size, "should have 5 chart parts") + assert_equal(@package.workbook.charts.size, chart_parts.size, "chart parts count should match charts count") + chart_parts.each do |chart_part| + assert_equal(Axlsx::DRAWING_XSD, chart_part[:schema], "chart #{chart_part[:entry]} should use DRAWING_XSD schema") + end + end + + def test_part_comments + comment_parts = @package.send(:parts).select { |part| %r{xl/comments\d\.xml}.match?(part[:entry]) } + + assert_equal(1, comment_parts.size, "should have 1 comment part") + assert_equal(@package.workbook.comments.size, comment_parts.size, "comment parts count should match comments count") + comment_parts.each do |comment_part| + assert_equal(Axlsx::SML_XSD, comment_part[:schema], "comment #{comment_part[:entry]} should use SML_XSD schema") + assert_kind_of(Axlsx::Comments, comment_part[:doc], "comment document should be a Comments instance") + end + end + + def test_part_tables + table_parts = @package.send(:parts).select { |part| %r{xl/tables/table\d\.xml}.match?(part[:entry]) } + + assert_equal(1, table_parts.size, "should have 1 table part") + table_parts.each do |table_part| + assert_equal(Axlsx::SML_XSD, table_part[:schema], "table #{table_part[:entry]} should use SML_XSD schema") + assert_kind_of(Axlsx::Table, table_part[:doc], "table document should be a Table instance") + end + end + + def test_part_pivot_rels + pivot_rels_parts = @package.send(:parts).select { |part| %r{xl/pivotTables/_rels/pivotTable\d\.xml\.rels}.match?(part[:entry]) } + + assert_equal(1, pivot_rels_parts.size, "should have 1 pivot table relationship part") + pivot_rels_parts.each do |pivot_rels_part| + assert_equal(Axlsx::RELS_XSD, pivot_rels_part[:schema], "pivot table relationships #{pivot_rels_part[:entry]} should use RELS_XSD schema") + assert_kind_of(Axlsx::Relationships, pivot_rels_part[:doc], "pivot table relationships document should be a Relationships instance") + end + end + def test_shared_strings_requires_part @package.use_shared_strings = true @package.to_stream # ensure all cell_serializer paths are hit p = @package.send(:parts) assert_equal(1, p.count { |part| part[:entry].include?('xl/sharedStrings.xml') }, "shared strings table missing") + + # Verify shared strings part uses correct schema + shared_strings_part = p.find { |part| part[:entry].include?('xl/sharedStrings.xml') } + + assert_equal(Axlsx::SML_XSD, shared_strings_part[:schema], "shared strings should use SML_XSD schema") + assert_kind_of(Axlsx::SharedStringsTable, shared_strings_part[:doc], "shared strings document should be a SharedStringsTable instance") end def test_workbook_is_a_workbook @@ -307,8 +445,9 @@ def test_base_content_types assert_equal(1, ct.count { |c| c.ContentType == Axlsx::APP_CT }, "app content type missing") assert_equal(1, ct.count { |c| c.ContentType == Axlsx::CORE_CT }, "core content type missing") assert_equal(1, ct.count { |c| c.ContentType == Axlsx::STYLES_CT }, "styles content type missing") + assert_equal(1, ct.count { |c| c.ContentType == Axlsx::THEME_CT }, "theme content type missing") assert_equal(1, ct.count { |c| c.ContentType == Axlsx::WORKBOOK_CT }, "workbook content type missing") - assert_equal(6, ct.size) + assert_equal(7, ct.size) end def test_content_type_added_with_shared_strings @@ -356,4 +495,95 @@ def test_encrypt # this is no where near close to ready yet assert_false(@package.encrypt('your_mom.xlsxl', 'has a password')) end + + def test_xml_valid + assert_valid_xml_for_part('xl/styles.xml') + assert_valid_xml_for_part('xl/theme/theme1.xml') + assert_valid_xml_for_part('docProps/core.xml') + assert_valid_xml_for_part('docProps/app.xml') + assert_valid_xml_for_part('xl/workbook.xml') + assert_valid_xml_for_part('[Content_Types].xml') + assert_valid_xml_for_part('_rels/.rels') + assert_valid_xml_for_part('xl/_rels/workbook.xml.rels') + end + + def test_xml_valid_worksheets + worksheet_parts = @package.send(:parts).select { |part| %r{xl/worksheets/sheet\d\.xml}.match?(part[:entry]) } + + worksheet_parts.each do |ws_part| + assert_valid_xml_for_doc(ws_part[:doc], "worksheet #{ws_part[:entry]}") + end + end + + def test_xml_valid_worksheet_rels + ws_rels_parts = @package.send(:parts).select { |part| %r{xl/worksheets/_rels/sheet\d\.xml\.rels}.match?(part[:entry]) } + + ws_rels_parts.each do |ws_rels_part| + assert_valid_xml_for_doc(ws_rels_part[:doc], "worksheet relationships #{ws_rels_part[:entry]}") + end + end + + def test_xml_valid_drawings + drawing_parts = @package.send(:parts).select { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) } + + drawing_parts.each do |drawing_part| + assert_valid_xml_for_doc(drawing_part[:doc], "drawing #{drawing_part[:entry]}") + end + end + + def test_xml_valid_charts + chart_parts = @package.send(:parts).select { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) } + + chart_parts.each do |chart_part| + assert_valid_xml_for_doc(chart_part[:doc], "chart #{chart_part[:entry]}") + end + end + + def test_xml_valid_comments + comment_parts = @package.send(:parts).select { |part| %r{xl/comments\d\.xml}.match?(part[:entry]) } + + comment_parts.each do |comment_part| + assert_valid_xml_for_doc(comment_part[:doc], "comment #{comment_part[:entry]}") + end + end + + def test_xml_valid_tables + table_parts = @package.send(:parts).select { |part| %r{xl/tables/table\d\.xml}.match?(part[:entry]) } + + table_parts.each do |table_part| + assert_valid_xml_for_doc(table_part[:doc], "table #{table_part[:entry]}") + end + end + + def test_xml_valid_shared_strings + @package.use_shared_strings = true + @package.to_stream # ensure all cell_serializer paths are hit + + assert_valid_xml_for_part('xl/sharedStrings.xml') + end + + private + + def assert_valid_xml_for_doc(doc, part_name) + refute_nil(doc, "Document for #{part_name} should not be nil") + + xml_string = doc.to_xml_string + + refute_empty(xml_string, "XML string for #{part_name} should not be empty") + + # Verify the XML is well-formed using strict parsing + refute_raises { Nokogiri::XML(xml_string, &:strict) } + + # Verify XML has a root element + parsed_doc = Nokogiri::XML(xml_string) + + refute_nil(parsed_doc.root, "XML for #{part_name} should have a root element") + end + + def assert_valid_xml_for_part(entry_name) + part = @package.send(:parts).find { |p| p[:entry].include?(entry_name) } + + refute_nil(part, "Part #{entry_name} not found") + assert_valid_xml_for_doc(part[:doc], entry_name) + end end From ea8d45b20148c794c4417adb98b7480175adac0b Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:59:12 +0900 Subject: [PATCH 03/20] Final tests --- test/tc_package.rb | 234 ++++++++++++--------------------------------- 1 file changed, 62 insertions(+), 172 deletions(-) diff --git a/test/tc_package.rb b/test/tc_package.rb index 43590c4e..e3711e62 100644 --- a/test/tc_package.rb +++ b/test/tc_package.rb @@ -270,6 +270,8 @@ def test_parts assert_equal(1, p.count { |part| part[:entry].include?('[Content_Types].xml') }, "content types missing") assert_equal(1, p.count { |part| part[:entry].include?('xl/styles.xml') }, "styles missing") assert_equal(1, p.count { |part| part[:entry].include?('xl/theme/theme1.xml') }, "theme missing") + + # verify correct numbers of parts assert_equal(p.count { |part| %r{xl/drawings/_rels/drawing\d\.xml\.rels}.match?(part[:entry]) }, @package.workbook.drawings.size, "one or more drawing rels missing") assert_equal(p.count { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) }, @package.workbook.drawings.size, "one or more drawings missing") assert_equal(p.count { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) }, @package.workbook.charts.size, "one or more charts missing") @@ -280,6 +282,15 @@ def test_parts assert_equal(p.count { |part| %r{xl/pivotTables/_rels/pivotTable\d\.xml.rels}.match?(part[:entry]) }, @package.workbook.worksheets.first.pivot_tables.size, "one or more pivot tables rels missing") assert_equal(p.count { |part| %r{xl/pivotCache/pivotCacheDefinition\d\.xml}.match?(part[:entry]) }, @package.workbook.worksheets.first.pivot_tables.size, "one or more pivot tables missing") + # actual numbers of parts + assert_equal(1, @package.workbook.worksheets.size) + assert_equal(1, @package.workbook.worksheets.size) + assert_equal(1, @package.workbook.drawings.size) + assert_equal(5, @package.workbook.charts.size) + assert_equal(1, @package.workbook.comments.size) + assert_equal(1, @@package.workbook.worksheets.first.pivot_tables.size) + assert_equal(1, @@package.workbook.worksheets.first.pivot_tables.size) + # no mystery parts assert_equal(26, p.size) @@ -289,134 +300,25 @@ def test_parts assert_match(%r{\Axl/}, p[2][:entry], "third entry should begin with `xl/`") end - def test_part_styles - styles_part = @package.send(:parts).find { |part| part[:entry].include?('xl/styles.xml') } - - assert_equal(Axlsx::SML_XSD, styles_part[:schema], "styles should use SML_XSD schema") - assert_kind_of(Axlsx::Styles, styles_part[:doc], "styles document should be a Styles instance") - end - - def test_part_theme - theme_part = @package.send(:parts).find { |part| part[:entry].include?('xl/theme/theme1.xml') } - - assert_equal(Axlsx::THEME_XSD, theme_part[:schema], "theme should use THEME_XSD schema") - assert_kind_of(Axlsx::Theme, theme_part[:doc], "theme document should be a Theme instance") - end - - def test_part_core - core_part = @package.send(:parts).find { |part| part[:entry].include?('docProps/core.xml') } - - assert_equal(Axlsx::CORE_XSD, core_part[:schema], "core should use CORE_XSD schema") - assert_kind_of(Axlsx::Core, core_part[:doc], "core document should be a Core instance") - end - - def test_part_app - app_part = @package.send(:parts).find { |part| part[:entry].include?('docProps/app.xml') } - - assert_equal(Axlsx::APP_XSD, app_part[:schema], "app should use APP_XSD schema") - assert_kind_of(Axlsx::App, app_part[:doc], "app document should be an App instance") - end - - def test_part_workbook - workbook_part = @package.send(:parts).find { |part| part[:entry].include?('xl/workbook.xml') } - - assert_equal(Axlsx::SML_XSD, workbook_part[:schema], "workbook should use SML_XSD schema") - assert_kind_of(Axlsx::Workbook, workbook_part[:doc], "workbook document should be a Workbook instance") - end - - def test_part_content_types - content_types_part = @package.send(:parts).find { |part| part[:entry].include?('[Content_Types].xml') } - - assert_equal(Axlsx::CONTENT_TYPES_XSD, content_types_part[:schema], "content types should use CONTENT_TYPES_XSD schema") - assert_kind_of(Axlsx::ContentType, content_types_part[:doc], "content types document should be a ContentType instance") - end - - def test_part_main_rels - main_rels_part = @package.send(:parts).find { |part| part[:entry].include?('_rels/.rels') } - - assert_equal(Axlsx::RELS_XSD, main_rels_part[:schema], "main relationships should use RELS_XSD schema") - assert_kind_of(Axlsx::Relationships, main_rels_part[:doc], "main relationships document should be a Relationships instance") - end - - def test_part_workbook_rels - workbook_rels_part = @package.send(:parts).find { |part| part[:entry].include?('xl/_rels/workbook.xml.rels') } - - assert_equal(Axlsx::RELS_XSD, workbook_rels_part[:schema], "workbook relationships should use RELS_XSD schema") - assert_kind_of(Axlsx::Relationships, workbook_rels_part[:doc], "workbook relationships document should be a Relationships instance") - end - - def test_part_worksheets - worksheet_parts = @package.send(:parts).select { |part| %r{xl/worksheets/sheet\d\.xml}.match?(part[:entry]) } - - assert_equal(1, worksheet_parts.size, "should have 1 worksheet part") - assert_equal(@package.workbook.worksheets.size, worksheet_parts.size, "worksheet parts count should match worksheets count") - worksheet_parts.each do |ws_part| - assert_equal(Axlsx::SML_XSD, ws_part[:schema], "worksheet #{ws_part[:entry]} should use SML_XSD schema") - assert_kind_of(Axlsx::Worksheet, ws_part[:doc], "worksheet document should be a Worksheet instance") - end - end - - def test_part_worksheet_rels - ws_rels_parts = @package.send(:parts).select { |part| %r{xl/worksheets/_rels/sheet\d\.xml\.rels}.match?(part[:entry]) } - - assert_equal(1, ws_rels_parts.size, "should have 1 worksheet relationship part") - assert_equal(@package.workbook.worksheets.size, ws_rels_parts.size, "worksheet relationship parts count should match worksheets count") - ws_rels_parts.each do |ws_rels_part| - assert_equal(Axlsx::RELS_XSD, ws_rels_part[:schema], "worksheet relationships #{ws_rels_part[:entry]} should use RELS_XSD schema") - assert_kind_of(Axlsx::Relationships, ws_rels_part[:doc], "worksheet relationships document should be a Relationships instance") - end - end - - def test_part_drawings - drawing_parts = @package.send(:parts).select { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) } - - assert_equal(1, drawing_parts.size, "should have 1 drawing part") - assert_equal(@package.workbook.drawings.size, drawing_parts.size, "drawing parts count should match drawings count") - drawing_parts.each do |drawing_part| - assert_equal(Axlsx::DRAWING_XSD, drawing_part[:schema], "drawing #{drawing_part[:entry]} should use DRAWING_XSD schema") - assert_kind_of(Axlsx::Drawing, drawing_part[:doc], "drawing document should be a Drawing instance") - end - end - - def test_part_charts - chart_parts = @package.send(:parts).select { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) } - - assert_equal(5, chart_parts.size, "should have 5 chart parts") - assert_equal(@package.workbook.charts.size, chart_parts.size, "chart parts count should match charts count") - chart_parts.each do |chart_part| - assert_equal(Axlsx::DRAWING_XSD, chart_part[:schema], "chart #{chart_part[:entry]} should use DRAWING_XSD schema") - end - end - - def test_part_comments - comment_parts = @package.send(:parts).select { |part| %r{xl/comments\d\.xml}.match?(part[:entry]) } - - assert_equal(1, comment_parts.size, "should have 1 comment part") - assert_equal(@package.workbook.comments.size, comment_parts.size, "comment parts count should match comments count") - comment_parts.each do |comment_part| - assert_equal(Axlsx::SML_XSD, comment_part[:schema], "comment #{comment_part[:entry]} should use SML_XSD schema") - assert_kind_of(Axlsx::Comments, comment_part[:doc], "comment document should be a Comments instance") - end + def test_part_schemas_and_types + assert_part_schema_and_type('xl/styles.xml', Axlsx::SML_XSD, Axlsx::Styles, 'styles') + assert_part_schema_and_type('xl/theme/theme1.xml', Axlsx::THEME_XSD, Axlsx::Theme, 'theme') + assert_part_schema_and_type('docProps/core.xml', Axlsx::CORE_XSD, Axlsx::Core, 'core') + assert_part_schema_and_type('docProps/app.xml', Axlsx::APP_XSD, Axlsx::App, 'app') + assert_part_schema_and_type('xl/workbook.xml', Axlsx::SML_XSD, Axlsx::Workbook, 'workbook') + assert_part_schema_and_type('[Content_Types].xml', Axlsx::CONTENT_TYPES_XSD, Axlsx::ContentType, 'content types') + assert_part_schema_and_type('_rels/.rels', Axlsx::RELS_XSD, Axlsx::Relationships, 'main relationships') + assert_part_schema_and_type('xl/_rels/workbook.xml.rels', Axlsx::RELS_XSD, Axlsx::Relationships, 'workbook relationships') end - def test_part_tables - table_parts = @package.send(:parts).select { |part| %r{xl/tables/table\d\.xml}.match?(part[:entry]) } - - assert_equal(1, table_parts.size, "should have 1 table part") - table_parts.each do |table_part| - assert_equal(Axlsx::SML_XSD, table_part[:schema], "table #{table_part[:entry]} should use SML_XSD schema") - assert_kind_of(Axlsx::Table, table_part[:doc], "table document should be a Table instance") - end - end - - def test_part_pivot_rels - pivot_rels_parts = @package.send(:parts).select { |part| %r{xl/pivotTables/_rels/pivotTable\d\.xml\.rels}.match?(part[:entry]) } - - assert_equal(1, pivot_rels_parts.size, "should have 1 pivot table relationship part") - pivot_rels_parts.each do |pivot_rels_part| - assert_equal(Axlsx::RELS_XSD, pivot_rels_part[:schema], "pivot table relationships #{pivot_rels_part[:entry]} should use RELS_XSD schema") - assert_kind_of(Axlsx::Relationships, pivot_rels_part[:doc], "pivot table relationships document should be a Relationships instance") - end + def test_part_array_schemas_and_types + assert_part_array_schema_and_type(%r{xl/worksheets/sheet\d\.xml}, Axlsx::SML_XSD, Axlsx::Worksheet, 'worksheet') + assert_part_array_schema_and_type(%r{xl/worksheets/_rels/sheet\d\.xml\.rels}, Axlsx::RELS_XSD, Axlsx::Relationships, 'worksheet relationships') + assert_part_array_schema_and_type(%r{xl/drawings/drawing\d\.xml}, Axlsx::DRAWING_XSD, Axlsx::Drawing, 'drawing') + assert_part_array_schema_and_type(%r{xl/charts/chart\d\.xml}, Axlsx::DRAWING_XSD, nil, 'chart') + assert_part_array_schema_and_type(%r{xl/comments\d\.xml}, Axlsx::SML_XSD, Axlsx::Comments, 'comment') + assert_part_array_schema_and_type(%r{xl/tables/table\d\.xml}, Axlsx::SML_XSD, Axlsx::Table, 'table') + assert_part_array_schema_and_type(%r{xl/pivotTables/_rels/pivotTable\d\.xml\.rels}, Axlsx::RELS_XSD, Axlsx::Relationships, 'pivot table relationships') end def test_shared_strings_requires_part @@ -507,64 +409,44 @@ def test_xml_valid assert_valid_xml_for_part('xl/_rels/workbook.xml.rels') end - def test_xml_valid_worksheets - worksheet_parts = @package.send(:parts).select { |part| %r{xl/worksheets/sheet\d\.xml}.match?(part[:entry]) } - - worksheet_parts.each do |ws_part| - assert_valid_xml_for_doc(ws_part[:doc], "worksheet #{ws_part[:entry]}") - end + def test_xml_valid_array_parts + assert_valid_xml_for_parts_matching(%r{xl/worksheets/sheet\d\.xml}, "worksheet") + assert_valid_xml_for_parts_matching(%r{xl/worksheets/_rels/sheet\d\.xml\.rels}, "worksheet relationships") + assert_valid_xml_for_parts_matching(%r{xl/drawings/drawing\d\.xml}, "drawing") + assert_valid_xml_for_parts_matching(%r{xl/charts/chart\d\.xml}, "chart") + assert_valid_xml_for_parts_matching(%r{xl/comments\d\.xml}, "comment") + assert_valid_xml_for_parts_matching(%r{xl/tables/table\d\.xml}, "table") end - def test_xml_valid_worksheet_rels - ws_rels_parts = @package.send(:parts).select { |part| %r{xl/worksheets/_rels/sheet\d\.xml\.rels}.match?(part[:entry]) } - - ws_rels_parts.each do |ws_rels_part| - assert_valid_xml_for_doc(ws_rels_part[:doc], "worksheet relationships #{ws_rels_part[:entry]}") - end - end - - def test_xml_valid_drawings - drawing_parts = @package.send(:parts).select { |part| %r{xl/drawings/drawing\d\.xml}.match?(part[:entry]) } + def test_xml_valid_shared_strings + @package.use_shared_strings = true + @package.to_stream # ensure all cell_serializer paths are hit - drawing_parts.each do |drawing_part| - assert_valid_xml_for_doc(drawing_part[:doc], "drawing #{drawing_part[:entry]}") - end + assert_valid_xml_for_part('xl/sharedStrings.xml') end - def test_xml_valid_charts - chart_parts = @package.send(:parts).select { |part| %r{xl/charts/chart\d\.xml}.match?(part[:entry]) } - - chart_parts.each do |chart_part| - assert_valid_xml_for_doc(chart_part[:doc], "chart #{chart_part[:entry]}") - end - end + private - def test_xml_valid_comments - comment_parts = @package.send(:parts).select { |part| %r{xl/comments\d\.xml}.match?(part[:entry]) } + def assert_part_schema_and_type(entry_name, expected_schema, expected_class, description) + part = @package.send(:parts).find { |p| p[:entry].include?(entry_name) } - comment_parts.each do |comment_part| - assert_valid_xml_for_doc(comment_part[:doc], "comment #{comment_part[:entry]}") - end + refute_nil(part, "Part #{entry_name} not found") + assert_equal(expected_schema, part[:schema], "#{description} should use #{expected_schema} schema") + assert_kind_of(expected_class, part[:doc], "#{description} document should be a #{expected_class} instance") end - def test_xml_valid_tables - table_parts = @package.send(:parts).select { |part| %r{xl/tables/table\d\.xml}.match?(part[:entry]) } + def assert_part_array_schema_and_type(pattern, expected_schema, expected_class, description) + matching_parts = @package.send(:parts).select { |part| pattern.match?(part[:entry]) } - table_parts.each do |table_part| - assert_valid_xml_for_doc(table_part[:doc], "table #{table_part[:entry]}") + matching_parts.each do |part| + assert_equal(expected_schema, part[:schema], "#{description} #{part[:entry]} should use #{expected_schema} schema") + if expected_class + assert_kind_of(expected_class, part[:doc], "#{description} document should be a #{expected_class} instance") + end end end - def test_xml_valid_shared_strings - @package.use_shared_strings = true - @package.to_stream # ensure all cell_serializer paths are hit - - assert_valid_xml_for_part('xl/sharedStrings.xml') - end - - private - - def assert_valid_xml_for_doc(doc, part_name) + def assert_valid_xml(doc, part_name) refute_nil(doc, "Document for #{part_name} should not be nil") xml_string = doc.to_xml_string @@ -584,6 +466,14 @@ def assert_valid_xml_for_part(entry_name) part = @package.send(:parts).find { |p| p[:entry].include?(entry_name) } refute_nil(part, "Part #{entry_name} not found") - assert_valid_xml_for_doc(part[:doc], entry_name) + assert_valid_xml(part[:doc], entry_name) + end + + def assert_valid_xml_for_parts_matching(pattern, description) + matching_parts = @package.send(:parts).select { |part| pattern.match?(part[:entry]) } + + matching_parts.each do |part| + assert_valid_xml(part[:doc], "#{description} #{part[:entry]}") + end end end From d8f0e460b12db2fc506257aa573736b5461758d0 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 01:00:58 +0900 Subject: [PATCH 04/20] Fix rubocop --- lib/axlsx/stylesheet/theme.rb | 4 +++- lib/axlsx/util/constants.rb | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/axlsx/stylesheet/theme.rb b/lib/axlsx/stylesheet/theme.rb index 95c99622..e62c61bf 100644 --- a/lib/axlsx/stylesheet/theme.rb +++ b/lib/axlsx/stylesheet/theme.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Axlsx # Theme represents the theme part of the package and is responsible for # generating the default Office theme that is required for encryption compatibility @@ -12,7 +14,7 @@ def pn # @param [String] str # @return [String] def to_xml_string(str = '') - str << <<~XML.gsub("\n", '') + str << <<~XML.delete("\n") diff --git a/lib/axlsx/util/constants.rb b/lib/axlsx/util/constants.rb index f3dbc482..ff6ee286 100644 --- a/lib/axlsx/util/constants.rb +++ b/lib/axlsx/util/constants.rb @@ -80,7 +80,7 @@ module Axlsx SHARED_STRINGS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" # theme rels namespace - THEME_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme".freeze + THEME_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" # drawing rels namespace DRAWING_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" @@ -137,7 +137,7 @@ module Axlsx SHARED_STRINGS_CT = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" # theme content type - THEME_CT = "application/vnd.openxmlformats-officedocument.theme+xml".freeze + THEME_CT = "application/vnd.openxmlformats-officedocument.theme+xml" # core content type CORE_CT = "application/vnd.openxmlformats-package.core-properties+xml" @@ -194,7 +194,7 @@ module Axlsx SHARED_STRINGS_PN = "sharedStrings.xml" # theme part - THEME_PN = "theme/theme1.xml".freeze + THEME_PN = "theme/theme1.xml" # app part APP_PN = "docProps/app.xml" @@ -269,7 +269,7 @@ module Axlsx DRAWING_XSD = "#{SCHEMA_BASE}dml-spreadsheetDrawing.xsd" # theme validation schema - THEME_XSD = "#{SCHEMA_BASE}dml-main.xsd".freeze + THEME_XSD = "#{SCHEMA_BASE}dml-main.xsd" # number format id for percentage formatting using the default formatting id. NUM_FMT_PERCENT = 9 From e0690e0adf001859d4a4cf63edee212b2555dd8a Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 01:11:38 +0900 Subject: [PATCH 05/20] Unfreeze string --- lib/axlsx/stylesheet/theme.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/axlsx/stylesheet/theme.rb b/lib/axlsx/stylesheet/theme.rb index e62c61bf..a5bec862 100644 --- a/lib/axlsx/stylesheet/theme.rb +++ b/lib/axlsx/stylesheet/theme.rb @@ -13,7 +13,7 @@ def pn # Serializes the default theme to XML # @param [String] str # @return [String] - def to_xml_string(str = '') + def to_xml_string(str = +'') str << <<~XML.delete("\n") From f66a0de72c37329b0b66bb3c54aa8c282e7c25ba Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 01:18:50 +0900 Subject: [PATCH 06/20] Fix tests --- test/stylesheet/tc_theme.rb | 8 +++----- test/tc_package.rb | 4 ++-- test/workbook/tc_workbook.rb | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/test/stylesheet/tc_theme.rb b/test/stylesheet/tc_theme.rb index ad8cd567..732f0d4a 100644 --- a/test/stylesheet/tc_theme.rb +++ b/test/stylesheet/tc_theme.rb @@ -12,7 +12,7 @@ def test_pn end def test_to_xml_is_valid - xml_string = theme.to_xml_string + xml_string = @theme.to_xml_string # Verify the XML is well-formed refute_raises { Nokogiri::XML(xml_string, &:strict) } @@ -31,13 +31,11 @@ def test_to_xml_is_valid end def test_to_xml_string_with_string_parameter - str = '' + str = +'' result = @theme.to_xml_string(str) - # Should return the same string object that was passed in assert_same(str, result) - refute_empty(str) - refute_includes(str, ' Date: Tue, 19 Aug 2025 01:23:45 +0900 Subject: [PATCH 08/20] Fix last failing test --- test/workbook/tc_workbook.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/workbook/tc_workbook.rb b/test/workbook/tc_workbook.rb index 4bfb3e58..6cf98545 100644 --- a/test/workbook/tc_workbook.rb +++ b/test/workbook/tc_workbook.rb @@ -107,10 +107,10 @@ def test_relationships assert_equal(2, @wb.relationships.size) @wb.add_worksheet - assert_equal(2, @wb.relationships.size) + assert_equal(3, @wb.relationships.size) @wb.use_shared_strings = true - assert_equal(3, @wb.relationships.size) + assert_equal(4, @wb.relationships.size) end def test_to_xml From 948db486de352881a9c96fdaa85cacda16c62750 Mon Sep 17 00:00:00 2001 From: Johnny Shields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 01:29:13 +0900 Subject: [PATCH 09/20] Update CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b934e0..66f0ca78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,7 @@ CHANGELOG --------- - **Unreleased**: 4.4.0 - - [PR #XXX](https://github.com/caxlsx/caxlsx/pull/XXX) Add default theme file to Excel package. - + - [PR #469](https://github.com/caxlsx/caxlsx/pull/469) Add default theme file to Excel package. - **August.16.25**: 4.3.0 - [PR #421](https://github.com/caxlsx/caxlsx/pull/421) Add Rubyzip >= 2.4 support From 514a5568dc87c6d54765de8c6b26cdbbf56dba14 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:51:58 +0900 Subject: [PATCH 10/20] Implement workbook-level encryption using ooxml_crypt gem. --- CHANGELOG.md | 1 + Gemfile | 5 + README.md | 19 +-- examples/encryption_example.md | 28 ++++ examples/stream_with_password_example.md | 29 ++++ lib/axlsx/package.rb | 46 ++++-- test/tc_excel_integration.rb | 169 +++++++++++++++++++++++ test/tc_helper.rb | 2 + test/tc_package.rb | 62 ++++++++- 9 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 examples/encryption_example.md create mode 100644 examples/stream_with_password_example.md create mode 100644 test/tc_excel_integration.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f0ca78..a15034bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ CHANGELOG --------- - **Unreleased**: 4.4.0 - [PR #469](https://github.com/caxlsx/caxlsx/pull/469) Add default theme file to Excel package. + - [PR #350](https://github.com/caxlsx/caxlsx/pull/350) Add package-level encryption and password protection - **August.16.25**: 4.3.0 - [PR #421](https://github.com/caxlsx/caxlsx/pull/421) Add Rubyzip >= 2.4 support diff --git a/Gemfile b/Gemfile index 5f2c458f..8170fafb 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,10 @@ group :development, :test do gem 'kramdown' gem 'yard' + if RUBY_ENGINE == 'ruby' + gem 'ooxml_crypt' + end + if RUBY_VERSION >= '2.7' gem 'rubocop', '1.79.2' gem 'rubocop-minitest', '0.38.1' @@ -21,6 +25,7 @@ group :test do gem 'minitest' gem 'timecop' gem 'webmock' + gem 'rspec-mocks' end group :profile do diff --git a/README.md b/README.md index 8a8b91b1..c278a2b1 100644 --- a/README.md +++ b/README.md @@ -51,20 +51,21 @@ cell level input data validation. 15. Support for page margins and print options -16. Support for password and non password based sheet protection. +16. Support for workbook-level encryption and password protection (requires [ooxml_crypt](https://github.com/teamsimplepay/ooxml_crypt) gem which only supports MRI Ruby.) -17. First stage interoperability support for GoogleDocs, LibreOffice, -and Numbers +17. Support for sheet-level password and non-password protection. -18. Support for defined names, which gives you repeated header rows for printing. +18. First stage interoperability support for GoogleDocs, LibreOffice, and Numbers. -19. Data labels for charts as well as series color customization. +19. Support for defined names, which gives you repeated header rows for printing. -20. Support for sheet headers and footers +20. Data labels for charts as well as series color customization. -21. Pivot Tables +21. Support for sheet headers and footers -22. Page Breaks +22. Pivot Tables + +23. Page Breaks ## Install @@ -127,6 +128,8 @@ Currently the following additional gems are available: * Provides a `.axlsx` renderer to Rails so you can move all your spreadsheet code from your controller into view files. - [activeadmin-caxlsx](https://github.com/caxlsx/activeadmin-caxlsx) * An Active Admin plugin that includes DSL to create downloadable reports. +- [ooxml_crypt](https://github.com/teamsimplepay/ooxml_crypt) + * Required to enable workbook encryption and password protection. ## Security diff --git a/examples/encryption_example.md b/examples/encryption_example.md new file mode 100644 index 00000000..214b859c --- /dev/null +++ b/examples/encryption_example.md @@ -0,0 +1,28 @@ +## Description + +You may encrypt your package and protect it with a password. +Requires `ooxml_crypt` gem to be installed. + +## Code + +```ruby +require 'ooxml_crypt' +require 'axlsx' + +p = Axlsx::Package.new +wb = p.workbook + +wb.add_worksheet(name: 'Basic Worksheet') do |sheet| + sheet.add_row ['First', 'Second', 'Third'] + sheet.add_row [1, 2, 3] +end + +p.serialize('encrypted.xlsx', password: 'abc123') + +# To decrypt the file +OoxmlCrypt.decrypt_file('encrypted.xlsx', 'abc123', 'decrypted.xlsx') +``` + +## Output + +The output file will be encrypted and password-protected. diff --git a/examples/stream_with_password_example.md b/examples/stream_with_password_example.md new file mode 100644 index 00000000..2b34c76a --- /dev/null +++ b/examples/stream_with_password_example.md @@ -0,0 +1,29 @@ +## Description + +You may return a stream for a encrypted, password-protected package. +Requires `ooxml_crypt` gem to be installed. + +## Code + +```ruby +require 'ooxml_crypt' +require 'axlsx' + +p = Axlsx::Package.new +wb = p.workbook + +wb.add_worksheet(name: 'Basic Worksheet') do |sheet| + sheet.add_row ['First', 'Second', 'Third'] + sheet.add_row [1, 2, 3] +end + +stream = p.to_stream(password: 'abc123') +File.write('encrypted.xlsx', stream.read) + +# To decrypt the file +OoxmlCrypt.decrypt_file('encrypted.xlsx', 'abc123', 'decrypted.xlsx') +``` + +## Output + +The output is equivalent to using `Axlsx::Package#serialize` with password. diff --git a/lib/axlsx/package.rb b/lib/axlsx/package.rb index b9bbdd4e..b4f738a7 100644 --- a/lib/axlsx/package.rb +++ b/lib/axlsx/package.rb @@ -82,6 +82,8 @@ def workbook=(workbook) # @option options [String] :zip_command When `nil`, `#serialize` with RubyZip to # zip the XLSX file contents. When a String, the provided zip command (e.g., # "zip") is used to zip the file contents (may be faster for large files) + # @option options [String] :password When specified, the serialized packaged will be + # encrypted with the password. Requires ooxml_crypt gem. # @return [Boolean] False if confirm_valid and validation errors exist. True if the package was serialized # @note A tremendous amount of effort has gone into ensuring that you cannot create invalid xlsx documents. # options[:confirm_valid] should be used in the rare case that you cannot open the serialized file. @@ -108,7 +110,7 @@ def serialize(output, options = {}, secondary_options = nil) workbook.apply_styles end - confirm_valid, zip_command = parse_serialize_options(options, secondary_options) + confirm_valid, zip_command, password = parse_serialize_options(options, secondary_options) return false unless !confirm_valid || validate.empty? zip_provider = if zip_command @@ -120,15 +122,31 @@ def serialize(output, options = {}, secondary_options = nil) zip_provider.open(output) do |zip| write_parts(zip) end + + if password && !password.empty? + require_ooxml_crypt! + OoxmlCrypt.encrypt_file(output, password, output) + end + true ensure Relationship.clear_ids_cache end # Serialize your workbook to a StringIO instance - # @param [Boolean] confirm_valid Validate the package prior to serialization. - # @return [StringIO|Boolean] False if confirm_valid and validation errors exist. rewound string IO if not. - def to_stream(confirm_valid = false) + # @param [Boolean] old_confirm_valid (Deprecated) Validate the package prior to serialization. + # Use :confirm_valid keyword arg instead. + # @option kwargs [Boolean] :confirm_valid Validate the package prior to serialization. + # @option kwargs [String] :password When specified, the serialized packaged will be + # encrypted with the password. Requires ooxml_crypt gem. + # @return [StringIO|Boolean] False if confirm_valid and validation errors exist. Rewound string IO if not. + def to_stream(old_confirm_valid = nil, confirm_valid: false, password: nil) + unless old_confirm_valid.nil? + warn "[DEPRECATION] Axlsx::Package#to_stream with confirm_valid as a non-keyword arg is deprecated. " \ + "Use keyword arg instead e.g., package.to_stream(confirm_valid: false)" + confirm_valid ||= old_confirm_valid + end + unless workbook.styles_applied workbook.apply_styles end @@ -140,6 +158,12 @@ def to_stream(confirm_valid = false) write_parts(zip) end stream.rewind + + if password && !password.empty? + require_ooxml_crypt! + stream = StringIO.new(OoxmlCrypt.encrypt(stream.read, password)) + end + stream ensure Relationship.clear_ids_cache @@ -377,8 +401,8 @@ def relationships end # Parse the arguments of `#serialize` - # @return [Boolean, (String or nil)] Returns an array where the first value is - # `confirm_valid` and the second is the `zip_command`. + # @return [Boolean, (String or nil), (String or nil)] Returns a 3-tuple where values are + # `confirm_valid`, `zip_command`, and `password`. # @private def parse_serialize_options(options, secondary_options) if secondary_options @@ -387,17 +411,23 @@ def parse_serialize_options(options, secondary_options) end if options.is_a?(Hash) options.merge!(secondary_options || {}) - invalid_keys = options.keys - [:confirm_valid, :zip_command] + invalid_keys = options.keys - [:confirm_valid, :zip_command, :password] if invalid_keys.any? raise ArgumentError, "Invalid keyword arguments: #{invalid_keys}" end - [options.fetch(:confirm_valid, false), options.fetch(:zip_command, nil)] + [options.fetch(:confirm_valid, false), options.fetch(:zip_command, nil), options.fetch(:password, nil)] else warn "[DEPRECATION] Axlsx::Package#serialize with confirm_valid as a boolean is deprecated. " \ "Use keyword args instead e.g., package.serialize(output, confirm_valid: false)" parse_serialize_options((secondary_options || {}).merge(confirm_valid: options), nil) end end + + def require_ooxml_crypt! + return if defined?(OoxmlCrypt) + + raise 'Axlsx encryption requires ooxml_crypt gem' + end end end diff --git a/test/tc_excel_integration.rb b/test/tc_excel_integration.rb new file mode 100644 index 00000000..c6687625 --- /dev/null +++ b/test/tc_excel_integration.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'tc_helper' + +begin + require 'ooxml_crypt' +rescue LoadError + # ooxml_crypt not available +end + +class TestEncryptionCompatibility < Test::Unit::TestCase + def setup + skip_unless_windows_with_excel + @test_password = 'test123' + @temp_files = [] + end + + def teardown + # Clean up any temporary files + @temp_files.each do |file| + FileUtils.rm_f(file) + end + end + + def test_caxlsx_encrypted_file_opens_in_excel + # Create a basic workbook with theme + package = Axlsx::Package.new + workbook = package.workbook + workbook.add_worksheet(name: 'Encryption Test') do |sheet| + sheet.add_row ['Theme', 'Encryption', 'Test'] + sheet.add_row [1, 2, 3] + sheet.add_row ['Success', 'Expected', 'Result'] + end + + # Generate unencrypted file + unencrypted_file = 'test_encryption_unencrypted.xlsx' + package.serialize(unencrypted_file) + @temp_files << unencrypted_file + + # Verify unencrypted file opens normally + assert_excel_file_opens(unencrypted_file, nil, "Unencrypted file should open in Excel") + + # Encrypt the file + encrypted_file = 'test_encryption_encrypted.xlsx' + OoxmlCrypt.encrypt_file(unencrypted_file, @test_password, encrypted_file) + @temp_files << encrypted_file + + # Verify encrypted file opens with password + assert_excel_file_opens(encrypted_file, @test_password, "Encrypted file should open in Excel with password") + end + + def test_theme_xml_contains_required_elements_for_encryption + package = Axlsx::Package.new + theme_xml = package.workbook.theme.to_xml_string + + # These elements are critical for Excel encryption compatibility + required_elements = [ + '', + '', + '', + '', + '', + '', + '', + '', + '' + ] + + required_elements.each do |element| + assert_includes theme_xml, element, "Theme XML missing required element: #{element}" + end + end + + def test_complex_workbook_encryption_compatibility + # Create a more complex workbook to test comprehensive compatibility + package = Axlsx::Package.new + workbook = package.workbook + + # Add multiple worksheets + ws1 = workbook.add_worksheet(name: 'Data Sheet') + ws1.add_row ['Name', 'Value', 'Category'] + 10.times do |i| + ws1.add_row ["Item #{i + 1}", rand(100), ['A', 'B', 'C'].sample] + end + + ws2 = workbook.add_worksheet(name: 'Charts') + ws2.add_row ['Month', 'Sales'] + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'].each_with_index do |month, _i| + ws2.add_row [month, rand(500..1499)] + end + + # Add some styling + ws1.rows[0].cells.each { |cell| cell.style = 1 } + + # Generate and test + complex_file = 'test_complex_encryption.xlsx' + package.serialize(complex_file) + @temp_files << complex_file + + # Encrypt and test + encrypted_complex = 'test_complex_encrypted.xlsx' + OoxmlCrypt.encrypt_file(complex_file, @test_password, encrypted_complex) + @temp_files << encrypted_complex + + assert_excel_file_opens(encrypted_complex, @test_password, "Complex encrypted workbook should open in Excel") + end + + private + + def skip_unless_windows_with_excel + unless windows_platform? + skip("Excel encryption compatibility tests only run on Windows") + end + + unless excel_available? + skip("Excel encryption compatibility tests require Microsoft Excel to be installed") + end + + return if defined?(OoxmlCrypt) + + skip("Excel encryption compatibility tests require ooxml_crypt gem") + end + + def assert_excel_file_opens(file_path, password = nil, message = nil) + return true unless excel_available? + + begin + require 'win32ole' + excel = WIN32OLE.new('Excel.Application') + excel.visible = false + excel.displayAlerts = false + + absolute_path = File.absolute_path(file_path) + + workbook = if password + excel.Workbooks.Open(absolute_path, nil, nil, nil, password) + else + excel.Workbooks.Open(absolute_path) + end + + # File opened successfully + workbook.Close(false) + excel.Quit + true + rescue StandardError => e + # Clean up Excel process if it's still running + begin + excel&.Quit + rescue StandardError + end + + # Re-raise the error for test failure + error_msg = "Excel failed to open file '#{file_path}': #{e.message}" + error_msg = "#{message}: #{error_msg}" if message + + flunk(error_msg) + ensure + # Ensure Excel process is terminated + begin + excel&.Quit + rescue StandardError + end + end + end +end diff --git a/test/tc_helper.rb b/test/tc_helper.rb index a2c4ddc4..f6cadb6b 100644 --- a/test/tc_helper.rb +++ b/test/tc_helper.rb @@ -8,9 +8,11 @@ end require 'minitest/autorun' +require 'rspec/mocks/minitest_integration' require 'timecop' require 'webmock/minitest' require 'axlsx' +require 'ooxml_crypt' if RUBY_ENGINE == 'ruby' module Minitest class Test diff --git a/test/tc_package.rb b/test/tc_package.rb index b179e51c..9dc35ad9 100644 --- a/test/tc_package.rb +++ b/test/tc_package.rb @@ -182,6 +182,47 @@ def test_serialize_automatically_performs_apply_styles File.delete(@fname) end + def test_serialize_with_password + return unless RUBY_ENGINE == 'ruby' + + password = 'abc123' + @package.serialize(@fname, password: password) + + decrypted_fname = 'axlsx_test_serialization_decrypted.xlsx' + OoxmlCrypt.decrypt_file(@fname, password, decrypted_fname) + + assert_zip_file_matches_package(decrypted_fname, @package) + assert_created_with_rubyzip(decrypted_fname, @package) + + File.delete(@fname) + File.delete(decrypted_fname) + end + + def test_serialization_with_password_and_zip_command + return unless RUBY_ENGINE == 'ruby' + + fname = "#{Dir.tmpdir}/#{@fname}" + password = 'abc123' + @package.serialize(fname, zip_command: 'zip', password: password) + + decrypted_fname = 'axlsx_test_serialization_decrypted.xlsx' + OoxmlCrypt.decrypt_file(fname, password, decrypted_fname) + + assert_zip_file_matches_package(decrypted_fname, @package) + assert_created_with_zip_command(decrypted_fname, @package) + + File.delete(fname) + File.delete(decrypted_fname) + end + + def test_serialize_with_password_no_ooxml_crypt + hide_const('OoxmlCrypt') + + assert_raises(RuntimeError, 'Axlsx encryption requires ooxml_crypt gem') do + @package.to_stream(password: 'abc123') + end + end + def assert_zip_file_matches_package(fname, package) zf = Zip::File.open(fname) package.send(:parts).each { |part| zf.get_entry(part[:entry]) } @@ -393,9 +434,24 @@ def test_to_stream_automatically_performs_apply_styles assert_equal 1, wb.styles.style_index.count end - def test_encrypt - # this is no where near close to ready yet - assert_false(@package.encrypt('your_mom.xlsxl', 'has a password')) + def test_to_stream_with_password + return unless RUBY_ENGINE == 'ruby' + + password = 'abc123' + stream = @package.to_stream(password: password) + + assert_kind_of(StringIO, stream) + OoxmlCrypt.decrypt(stream.read, password) + + assert true, 'no error raised' + end + + def test_to_stream_with_password_no_ooxml_crypt + hide_const('OoxmlCrypt') + + assert_raises(RuntimeError, 'Axlsx encryption requires ooxml_crypt gem') do + @package.to_stream(password: 'abc123') + end end def test_xml_valid From 2d0dc515a594196845437f22276a12bb30d5eecc Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:02:29 +0900 Subject: [PATCH 11/20] Excel GH action --- .github/workflows/excel.yml | 54 +++++++++++++++++++++++++++++++++++++ Gemfile | 1 + test/tc_helper.rb | 24 +++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 .github/workflows/excel.yml diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml new file mode 100644 index 00000000..cb9fbc03 --- /dev/null +++ b/.github/workflows/excel.yml @@ -0,0 +1,54 @@ +name: Excel Integration +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + +jobs: + test-office-com: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Install Ruby dependencies + run: | + gem install win32ole + # Add other gems as needed + + - name: Install Office via Chocolatey + run: | + Write-Host "Installing Microsoft Office (this will take 10-20 minutes)..." + choco install office365business --params '/exclude:"Access Groove Lync OneDrive OneNote Outlook Publisher Teams Word"' -y --no-progress + shell: powershell + timeout-minutes: 25 + + - name: Verify Office COM objects are available + shell: powershell + run: | + Write-Host "Waiting for Office COM objects to register..." + Start-Sleep -Seconds 30 + try { + Write-Host "Testing Excel COM object..." + $excel = New-Object -ComObject Excel.Application + $excel.Visible = $false + Write-Host "✅ Excel COM object created successfully" + Write-Host "Excel Version: $($excel.Version)" + $excel.Quit() + [System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel) | Out-Null + } catch { + Write-Error "🔥 Excel installation in Github Actions FAILED: Failed to create COM objects: $_" + exit 1 + } + + - name: Run tests + run: | + bundle exec rake diff --git a/Gemfile b/Gemfile index 8170fafb..1ef318bb 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ group :test do gem 'timecop' gem 'webmock' gem 'rspec-mocks' + gem 'win32ole', platforms: :windows end group :profile do diff --git a/test/tc_helper.rb b/test/tc_helper.rb index f6cadb6b..c9f9dfff 100644 --- a/test/tc_helper.rb +++ b/test/tc_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/../lib" + require 'simplecov' SimpleCov.start do add_filter "/test/" @@ -13,6 +14,7 @@ require 'webmock/minitest' require 'axlsx' require 'ooxml_crypt' if RUBY_ENGINE == 'ruby' +require 'win32ole' rescue LoadError module Minitest class Test @@ -25,5 +27,27 @@ def refute_raises rescue StandardError => e raise Minitest::Assertion, "Expected no exception, but raised: #{e.class.name} with message '#{e.message}'" end + + def windows? + RUBY_PLATFORM =~ /mswin|mingw|cygwin/ + end + + def excel_available? + return @excel_available if defined?(@excel_available) + + @excel_available = windows? && + defined?(WIN32OLE) && + begin + excel = WIN32OLE.new('Excel.Application') + excel.Quit + true + rescue StandardError + false + end + end + + def ooxml_crypt_available? + defined?(OoxmlCrypt) + end end end From fe9cd872587b228a7a2a231e95e5b9850f0536b8 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:07:43 +0900 Subject: [PATCH 12/20] Update actions script --- .github/workflows/excel.yml | 68 +++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index cb9fbc03..9dd1537f 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -13,29 +13,63 @@ jobs: - name: Checkout code uses: actions/checkout@v5 - - name: Install ruby - uses: ruby/setup-ruby@v1 + - name: Cache Office installation + id: cache-office + uses: actions/cache@v4 with: - ruby-version: '3.4' - bundler-cache: true + path: | + C:\Program Files\Microsoft Office + C:\Program Files (x86)\Microsoft Office + C:\ProgramData\Microsoft\Office + key: ${{ runner.os }}-office365-excel-${{ hashFiles('.github/workflows/excel-integration.yml') }} + restore-keys: | + ${{ runner.os }}-office365-excel- - - name: Install Ruby dependencies + - name: Install Office via Chocolatey (if not cached) + if: steps.cache-office.outputs.cache-hit != 'true' + shell: powershell run: | - gem install win32ole - # Add other gems as needed + Write-Host "Installing Microsoft Excel (this will take 10-20 minutes)..." + choco install office365business --params '/exclude:"Access Groove Lync OneDrive OneNote Outlook Publisher Teams Word PowerPoint"' -y --no-progress + timeout-minutes: 25 - - name: Install Office via Chocolatey + - name: Wait for Office installation to complete (if not cached) + if: steps.cache-office.outputs.cache-hit != 'true' + shell: powershell run: | - Write-Host "Installing Microsoft Office (this will take 10-20 minutes)..." - choco install office365business --params '/exclude:"Access Groove Lync OneDrive OneNote Outlook Publisher Teams Word"' -y --no-progress + Write-Host "Waiting for Excel COM objects to register..." + Start-Sleep -Seconds 30 + + - name: Re-register Excel COM objects (if cached) + if: steps.cache-office.outputs.cache-hit == 'true' shell: powershell - timeout-minutes: 25 + run: | + Write-Host "Re-registering Excel COM objects from cache..." - - name: Verify Office COM objects are available + # Find Office installation path + $officePath = if (Test-Path "C:\Program Files\Microsoft Office\Office16") { + "C:\Program Files\Microsoft Office\Office16" + } elseif (Test-Path "C:\Program Files (x86)\Microsoft Office\Office16") { + "C:\Program Files (x86)\Microsoft Office\Office16" + } else { + Write-Error "Office installation not found" + exit 1 + } + + Write-Host "Office found at: $officePath" + + # Re-register Excel COM objects + $excelPath = Join-Path $officePath "EXCEL.EXE" + if (Test-Path $excelPath) { + Write-Host "Registering Excel..." + Start-Process -FilePath $excelPath -ArgumentList "/regserver" -Wait -NoNewWindow + } + + Start-Sleep -Seconds 5 + + - name: Verify Excel COM objects are available shell: powershell run: | - Write-Host "Waiting for Office COM objects to register..." - Start-Sleep -Seconds 30 try { Write-Host "Testing Excel COM object..." $excel = New-Object -ComObject Excel.Application @@ -49,6 +83,12 @@ jobs: exit 1 } + - name: Install ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - name: Run tests run: | bundle exec rake From 5d5b6eec160327930ad99b560c20cb436b6ceda5 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:16:17 +0900 Subject: [PATCH 13/20] Fix build --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 1ef318bb..533c20d1 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,7 @@ group :test do gem 'timecop' gem 'webmock' gem 'rspec-mocks' - gem 'win32ole', platforms: :windows + gem 'win32ole', platforms: [:mingw, :x64_mingw, :mswin, :mswin64] end group :profile do From 270d20aaaaefe2347923b431aa4a8f2c9bd0f7c5 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:40:27 +0900 Subject: [PATCH 14/20] revert script --- .github/workflows/excel.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index 9dd1537f..14f46e19 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -30,7 +30,7 @@ jobs: shell: powershell run: | Write-Host "Installing Microsoft Excel (this will take 10-20 minutes)..." - choco install office365business --params '/exclude:"Access Groove Lync OneDrive OneNote Outlook Publisher Teams Word PowerPoint"' -y --no-progress + choco install office365business --params '/exclude:"Access Groove Lync OneDrive OneNote Outlook PowerPoint Publisher Teams Word"' -y --no-progress timeout-minutes: 25 - name: Wait for Office installation to complete (if not cached) @@ -74,12 +74,12 @@ jobs: Write-Host "Testing Excel COM object..." $excel = New-Object -ComObject Excel.Application $excel.Visible = $false - Write-Host "✅ Excel COM object created successfully" + Write-Host "✓ Excel COM object created successfully" Write-Host "Excel Version: $($excel.Version)" $excel.Quit() [System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel) | Out-Null } catch { - Write-Error "🔥 Excel installation in Github Actions FAILED: Failed to create COM objects: $_" + Write-Error "Failed to create COM objects: $_" exit 1 } From 24b597ef2676f7c8e80cb06fa6fa433732b86162 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:46:52 +0900 Subject: [PATCH 15/20] Add macos excel test --- .github/workflows/excel.yml | 101 +++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index 14f46e19..2654ee96 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -6,7 +6,7 @@ on: branches: ["*"] jobs: - test-office-com: + test-excel-windows: runs-on: windows-latest steps: @@ -92,3 +92,102 @@ jobs: - name: Run tests run: | bundle exec rake + + test-excel-macos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Cache Homebrew packages + id: cache-brew + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/Homebrew + /usr/local/Cellar/microsoft-excel + /Applications/Microsoft Excel.app + key: ${{ runner.os }}-brew-excel-${{ hashFiles('.github/workflows/excel-integration.yml') }} + restore-keys: | + ${{ runner.os }}-brew-excel- + + - name: Install Excel via Homebrew Cask (if not cached) + if: steps.cache-brew.outputs.cache-hit != 'true' + run: | + echo "Installing Microsoft Excel via Homebrew..." + brew update + brew install --cask microsoft-excel || true # Continue even if activation fails + timeout-minutes: 20 + continue-on-error: true # Don't fail if license activation is required + + - name: Verify Excel can launch (no license required) + run: | + if [ -d "/Applications/Microsoft Excel.app" ]; then + echo "✓ Excel app bundle found" + + # Just verify the app can launch and quit immediately + # This should work even without a license + osascript < e + puts \"⚠️ Excel test skipped: \#{e.message}\" + exit 0 # Don't fail the build + end + " From b7744e46363bd8b1e994d96229ef98f94c412bfc Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:11:22 +0900 Subject: [PATCH 16/20] whitespace --- .github/workflows/excel.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index 2654ee96..f319c922 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -8,7 +8,6 @@ on: jobs: test-excel-windows: runs-on: windows-latest - steps: - name: Checkout code uses: actions/checkout@v5 From 10805a8514aae3922faea4c22d86fc469a133ae0 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:14:02 +0900 Subject: [PATCH 17/20] Fix issue --- .github/workflows/excel.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index f319c922..63d38025 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -73,12 +73,13 @@ jobs: Write-Host "Testing Excel COM object..." $excel = New-Object -ComObject Excel.Application $excel.Visible = $false - Write-Host "✓ Excel COM object created successfully" + Write-Host "Excel COM object created successfully" Write-Host "Excel Version: $($excel.Version)" $excel.Quit() [System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel) | Out-Null } catch { - Write-Error "Failed to create COM objects: $_" + Write-Error "Failed to create COM objects" + Write-Error $_.Exception.Message exit 1 } From b7bd95b8dc508c88a315439a265a859d2ace8dad Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:16:03 +0900 Subject: [PATCH 18/20] Fix issue --- test/tc_helper.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/tc_helper.rb b/test/tc_helper.rb index c9f9dfff..33bbeaad 100644 --- a/test/tc_helper.rb +++ b/test/tc_helper.rb @@ -14,7 +14,10 @@ require 'webmock/minitest' require 'axlsx' require 'ooxml_crypt' if RUBY_ENGINE == 'ruby' -require 'win32ole' rescue LoadError +begin + require 'win32ole' +rescue LoadError # rubocop:disable Lint/SuppressedException +end module Minitest class Test From 30be064382714c5833132385e77c7d3ceb3ce310 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:24:36 +0900 Subject: [PATCH 19/20] fix script --- .github/workflows/excel.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index 63d38025..7f4939df 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -120,32 +120,27 @@ jobs: timeout-minutes: 20 continue-on-error: true # Don't fail if license activation is required - - name: Verify Excel can launch (no license required) + - name: Verify Excel can launch without license run: | if [ -d "/Applications/Microsoft Excel.app" ]; then echo "✓ Excel app bundle found" # Just verify the app can launch and quit immediately - # This should work even without a license - osascript < Date: Thu, 21 Aug 2025 10:29:27 +0900 Subject: [PATCH 20/20] Fix test run --- .github/workflows/excel.yml | 4 ++++ test/tc_excel_integration.rb | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml index 7f4939df..1dacea96 100644 --- a/.github/workflows/excel.yml +++ b/.github/workflows/excel.yml @@ -149,6 +149,10 @@ jobs: ruby-version: '3.4' bundler-cache: true + - name: Run tests + run: | + bundle exec rake + - name: Test minimal Excel automation (no save) if: success() run: | diff --git a/test/tc_excel_integration.rb b/test/tc_excel_integration.rb index c6687625..b7cad7ba 100644 --- a/test/tc_excel_integration.rb +++ b/test/tc_excel_integration.rb @@ -2,12 +2,6 @@ require 'tc_helper' -begin - require 'ooxml_crypt' -rescue LoadError - # ooxml_crypt not available -end - class TestEncryptionCompatibility < Test::Unit::TestCase def setup skip_unless_windows_with_excel