diff --git a/.github/workflows/excel.yml b/.github/workflows/excel.yml
new file mode 100644
index 00000000..1dacea96
--- /dev/null
+++ b/.github/workflows/excel.yml
@@ -0,0 +1,192 @@
+name: Excel Integration
+on:
+ push:
+ branches: ["*"]
+ pull_request:
+ branches: ["*"]
+
+jobs:
+ test-excel-windows:
+ runs-on: windows-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Cache Office installation
+ id: cache-office
+ uses: actions/cache@v4
+ with:
+ 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 Office via Chocolatey (if not cached)
+ if: steps.cache-office.outputs.cache-hit != 'true'
+ 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 PowerPoint Publisher Teams Word"' -y --no-progress
+ timeout-minutes: 25
+
+ - name: Wait for Office installation to complete (if not cached)
+ if: steps.cache-office.outputs.cache-hit != 'true'
+ shell: powershell
+ run: |
+ 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
+ run: |
+ Write-Host "Re-registering Excel COM objects from cache..."
+
+ # 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: |
+ 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 "Failed to create COM objects"
+ Write-Error $_.Exception.Message
+ 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
+
+ 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 without license
+ run: |
+ if [ -d "/Applications/Microsoft Excel.app" ]; then
+ echo "✓ Excel app bundle found"
+
+ # Just verify the app can launch and quit immediately
+ osascript -e 'try
+ tell application "Microsoft Excel"
+ activate
+ delay 2
+ quit
+ end tell
+ return "Excel launched successfully"
+ on error errMsg
+ return "Excel launch test: " & errMsg
+ end try' || true
+
+ echo "Basic Excel launch test completed"
+ else
+ echo "❌ Excel not found - failing build"
+ exit 1
+ fi
+
+ - name: Install ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+
+ - name: Run tests
+ run: |
+ bundle exec rake
+
+ - name: Test minimal Excel automation (no save)
+ if: success()
+ run: |
+ # Only test that we can open Excel, not create/save documents
+ ruby -e "
+ begin
+ puts 'Testing minimal Excel launch via AppleScript...'
+
+ script = <<~APPLESCRIPT
+ try
+ tell application \"Microsoft Excel\"
+ activate
+ delay 1
+ -- Don't create or save anything
+ -- Just verify app responds
+ quit
+ end tell
+ return \"Success\"
+ on error errMsg
+ return \"Error: \" & errMsg
+ end try
+ APPLESCRIPT
+
+ result = \`osascript -e '\#{script}'\`.strip
+ puts \"Result: \#{result}\"
+
+ if result.include?('Error')
+ puts '⚠️ Excel automation not available (likely no license)'
+ exit 0 # Don't fail the build
+ else
+ puts '✓ Excel basic launch test passed'
+ end if
+ rescue => e
+ puts \"⚠️ Excel test skipped: \#{e.message}\"
+ exit 0 # Don't fail the build
+ end
+ "
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03463cbe..a15034bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,8 @@
CHANGELOG
---------
-- **Unreleased**
-
+- **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..533c20d1 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,8 @@ group :test do
gem 'minitest'
gem 'timecop'
gem 'webmock'
+ gem 'rspec-mocks'
+ gem 'win32ole', platforms: [:mingw, :x64_mingw, :mswin, :mswin64]
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 04fe5f9f..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
@@ -221,6 +245,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: 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 },
@@ -357,6 +382,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
@@ -375,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
@@ -385,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/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..a5bec862
--- /dev/null
+++ b/lib/axlsx/stylesheet/theme.rb
@@ -0,0 +1,163 @@
+# 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
+ 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.delete("\n")
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ XML
+ end
+ end
+end
diff --git a/lib/axlsx/util/constants.rb b/lib/axlsx/util/constants.rb
index 63595969..ff6ee286 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"
+
# 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"
+
# 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"
+
# app part
APP_PN = "docProps/app.xml"
@@ -259,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"
+
# 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/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/content_type/tc_content_type.rb b/test/content_type/tc_content_type.rb
index 46c1b95b..38b94e6c 100644
--- a/test/content_type/tc_content_type.rb
+++ b/test/content_type/tc_content_type.rb
@@ -31,7 +31,7 @@ def test_pre_built_types
assert_equal(node["Extension"], Axlsx::RELS_EX.to_s, "relationships content type invalid")
# override
- assert_equal(4, @doc.xpath("//xmlns:Override").size, "There should be 4 Override types")
+ assert_equal(5, @doc.xpath("//xmlns:Override").size, "There should be 5 Override types")
node = @doc.xpath(format(o_path, Axlsx::APP_CT)).first
@@ -45,6 +45,10 @@ def test_pre_built_types
assert_equal(node["PartName"], "/xl/#{Axlsx::STYLES_PN}", "Styles part name invalid")
+ node = @doc.xpath(format(o_path, Axlsx::THEME_CT)).first
+
+ assert_equal(node["PartName"], "/xl/#{Axlsx::THEME_PN}", "Theme part name invalid")
+
node = @doc.xpath(format(o_path, Axlsx::WORKBOOK_CT)).first
assert_equal(node["PartName"], "/#{Axlsx::WORKBOOK_PN}", "Workbook part invalid")
@@ -56,13 +60,13 @@ def test_should_get_worksheet_for_worksheets
ws = @package.workbook.add_worksheet
doc = Nokogiri::XML(@package.send(:content_types).to_xml_string)
- assert_equal(5, doc.xpath("//xmlns:Override").size, "adding a worksheet should add another type")
+ assert_equal(6, doc.xpath("//xmlns:Override").size, "adding a worksheet should add another type")
assert_equal(doc.xpath(format(o_path, Axlsx::WORKSHEET_CT)).last["PartName"], "/xl/#{ws.pn}", "Worksheet part invalid")
ws = @package.workbook.add_worksheet
doc = Nokogiri::XML(@package.send(:content_types).to_xml_string)
- assert_equal(6, doc.xpath("//xmlns:Override").size, "adding workship should add another type")
+ assert_equal(7, doc.xpath("//xmlns:Override").size, "adding workship should add another type")
assert_equal(doc.xpath(format(o_path, Axlsx::WORKSHEET_CT)).last["PartName"], "/xl/#{ws.pn}", "Worksheet part invalid")
end
@@ -73,14 +77,14 @@ def test_drawings_and_charts_need_content_types
c = ws.add_chart Axlsx::Pie3DChart
doc = Nokogiri::XML(@package.send(:content_types).to_xml_string)
- assert_equal(7, doc.xpath("//xmlns:Override").size, "expected 7 types got #{doc.css('Types Override').size}")
+ assert_equal(8, doc.xpath("//xmlns:Override").size, "expected 8 types got #{doc.css('Types Override').size}")
assert_equal(doc.xpath(format(o_path, Axlsx::DRAWING_CT)).first["PartName"], "/xl/#{ws.drawing.pn}", "Drawing part name invalid")
assert_equal(doc.xpath(format(o_path, Axlsx::CHART_CT)).last["PartName"], "/xl/#{c.pn}", "Chart part name invalid")
c = ws.add_chart Axlsx::Pie3DChart
doc = Nokogiri::XML(@package.send(:content_types).to_xml_string)
- assert_equal(8, doc.xpath("//xmlns:Override").size, "expected 7 types got #{doc.css('Types Override').size}")
+ assert_equal(9, doc.xpath("//xmlns:Override").size, "expected 9 types got #{doc.css('Types Override').size}")
assert_equal(doc.xpath(format(o_path, Axlsx::CHART_CT)).last["PartName"], "/xl/#{c.pn}", "Chart part name invalid")
end
end
diff --git a/test/stylesheet/tc_theme.rb b/test/stylesheet/tc_theme.rb
new file mode 100644
index 00000000..732f0d4a
--- /dev/null
+++ b/test/stylesheet/tc_theme.rb
@@ -0,0 +1,106 @@
+# 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_is_valid
+ xml_string = @theme.to_xml_string
+
+ # Verify the XML is well-formed
+ refute_raises { Nokogiri::XML(xml_string, &:strict) }
+
+ # 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
+ str = +''
+ result = @theme.to_xml_string(str)
+
+ assert_same(str, result)
+ assert_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, '')
+ 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, '')
+
+ # Check for major and minor fonts
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+
+ # Check for format scheme sections
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+
+ # Check for object defaults
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+ assert_includes(xml, '')
+
+ # 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
+end
diff --git a/test/tc_excel_integration.rb b/test/tc_excel_integration.rb
new file mode 100644
index 00000000..b7cad7ba
--- /dev/null
+++ b/test/tc_excel_integration.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require 'tc_helper'
+
+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..33bbeaad 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/"
@@ -8,9 +9,15 @@
end
require 'minitest/autorun'
+require 'rspec/mocks/minitest_integration'
require 'timecop'
require 'webmock/minitest'
require 'axlsx'
+require 'ooxml_crypt' if RUBY_ENGINE == 'ruby'
+begin
+ require 'win32ole'
+rescue LoadError # rubocop:disable Lint/SuppressedException
+end
module Minitest
class Test
@@ -23,5 +30,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
diff --git a/test/tc_package.rb b/test/tc_package.rb
index 6509272a..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]) }
@@ -260,6 +301,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 +310,9 @@ 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")
+
+ # 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")
@@ -278,8 +323,17 @@ 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(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 +341,39 @@ def test_parts
assert_match(%r{\Axl/}, p[2][:entry], "third entry should begin with `xl/`")
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_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
@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 +388,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
@@ -352,8 +434,102 @@ 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
+ 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_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_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_part_schema_and_type(entry_name, expected_schema, expected_class, description)
+ part = @package.send(:parts).find { |p| p[:entry].include?(entry_name) }
+
+ 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 assert_part_array_schema_and_type(pattern, expected_schema, expected_class, description)
+ matching_parts = @package.send(:parts).select { |part| pattern.match?(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 assert_valid_xml(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(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
diff --git a/test/workbook/tc_workbook.rb b/test/workbook/tc_workbook.rb
index c0675420..6cf98545 100644
--- a/test/workbook/tc_workbook.rb
+++ b/test/workbook/tc_workbook.rb
@@ -104,13 +104,13 @@ def test_insert_worksheet
def test_relationships
# current relationship size is 1 due to style relation
- assert_equal(1, @wb.relationships.size)
+ 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