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