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