Class: Uniword::Infrastructure::ZipPackager

Inherits:
Object
  • Object
show all
Defined in:
lib/uniword/infrastructure/zip_packager.rb

Overview

Packages content into ZIP archives (e.g., DOCX files).

Responsibility: Handle ZIP file creation and packaging operations. Does NOT handle: Document serialization or format-specific logic.

DOCX files are ZIP archives containing XML files and media. This class provides low-level ZIP packaging functionality.

Examples:

Package content into a DOCX file

packager = Uniword::Infrastructure::ZipPackager.new
content = {
  "word/document.xml" => xml_content,
  "[Content_Types].xml" => types_content
}
packager.package(content, "output.docx")

Instance Method Summary collapse

Instance Method Details

#add_file(zip_path, entry_path, entry_content) ⇒ void

Note:

On Windows, we must close the original ZIP handle before

This method returns an undefined value.

Add a file to an existing ZIP archive.

attempting to overwrite it. We extract content first, then close the handle, then package to a temp file and move.

Parameters:

  • zip_path (String)

    The path to the ZIP file

  • entry_path (String)

    The path for the file within the ZIP

  • entry_content (String)

    The content to add

Raises:

  • (ArgumentError)

    if arguments are invalid



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/uniword/infrastructure/zip_packager.rb', line 97

def add_file(zip_path, entry_path, entry_content)
  validate_zip_path(zip_path)
  raise ArgumentError, "Entry path cannot be nil" if entry_path.nil?
  raise ArgumentError, "Entry path cannot be empty" if entry_path.empty?

  # Extract existing content into a local variable
  content = {}
  Zip::File.open(zip_path) do |zip_file|
    zip_file.each do |entry|
      next if entry.directory?

      content[entry.name] = entry.get_input_stream.read
    end
  end
  # Handle is now fully closed before we modify the file

  # Add new entry and write to a temp file first, then move
  content[entry_path] = entry_content
  write_to_zip_file(content, zip_path)
end

#package(content, output_path) ⇒ void

Note:

On Windows, Zip::File::CREATE mode fails when target exists

This method returns an undefined value.

Package content into a ZIP file.

because Rubyzip tries to atomically rename a temp file over it. We use a temp file approach to avoid this issue.

Parameters:

  • content (Hash<String, String>)

    Hash mapping file paths to contents

  • output_path (String)

    The path for the output ZIP file

Raises:

  • (ArgumentError)

    if arguments are invalid



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/uniword/infrastructure/zip_packager.rb', line 34

def package(content, output_path)
  validate_content(content)
  validate_output_path(output_path)

  # Ensure output directory exists
  FileUtils.mkdir_p(File.dirname(output_path))

  # Use temp file to avoid Windows atomic rename issues
  # Rubyzip's CREATE mode fails when output_path exists
  temp_path = "#{output_path}.#{Process.pid}.tmp"

  Zip::File.open(temp_path, Zip::File::CREATE) do |zip_file|
    content.each do |entry_path, entry_content|
      zip_file.get_output_stream(entry_path) do |stream|
        # Binary data (ASCII-8BIT) is written as-is;
        # text content is ensured to be UTF-8
        final_content =
          if entry_content.encoding == Encoding::ASCII_8BIT
            entry_content
          else
            entry_content.encode(
              "UTF-8", invalid: :replace, undef: :replace
            )
          end
        stream.write(final_content)
      end
    end
  end

  # On Windows, the handle may still be held briefly after block ends
  # and FileUtils.mv (rename) can fail with EACCES on locked files.
  # Use File.binwrite + unlink instead, which is more Windows-friendly.
  retries = 10
  begin
    FileUtils.rm_f(output_path)
    # Wait for Windows to release the lock
    sleep(0.5)
    # Copy content instead of renaming to avoid rename locks
    File.binwrite(output_path, File.binread(temp_path))
    FileUtils.rm_f(temp_path)
  rescue Errno::EACCES
    retries -= 1
    raise unless retries.positive?

    sleep(0.5)
    retry
  end
ensure
  # Clean up temp file if it still exists
  FileUtils.rm_f(temp_path) if defined?(temp_path) && temp_path && File.exist?(temp_path)
end

#remove_file(zip_path, entry_path) ⇒ Boolean

Note:

On Windows, we must close the original ZIP handle before

Remove a file from a ZIP archive.

attempting to overwrite it. We extract content first, then close the handle, then write to a temp file and move.

Parameters:

  • zip_path (String)

    The path to the ZIP file

  • entry_path (String)

    The path of the file to remove

Returns:

  • (Boolean)

    true if file was removed, false if not found

Raises:

  • (ArgumentError)

    if arguments are invalid



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/uniword/infrastructure/zip_packager.rb', line 128

def remove_file(zip_path, entry_path)
  validate_zip_path(zip_path)

  # Extract existing content into a local variable
  content = {}
  found = false
  Zip::File.open(zip_path) do |zip_file|
    zip_file.each do |entry|
      next if entry.directory?

      if entry.name == entry_path
        found = true
      else
        content[entry.name] = entry.get_input_stream.read
      end
    end
  end
  # Handle is now fully closed before we modify the file

  return false unless found

  write_to_zip_file(content, zip_path)
  true
end