Skip to content

Commit 5c6c121

Browse files
melchapythonicrubyist
authored andcommitted
Parsing images (#32)
* Add image parsing support * Refactor image parsing logic and fix some issues * Add comments to new methods for image parsing in sheet * Add tests for parsing images * Refactor image parsing, add comments and update tests * Update Readme
1 parent e361d6f commit 5c6c121

8 files changed

Lines changed: 335 additions & 2 deletions

File tree

README.rdoc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ Creek can parse this directly without the need for file upload gems such as Carr
5151
Creek::Book.new file.path, check_file_extension: false
5252
end
5353

54+
== Parsing images
55+
Creek does not parse images by default. If you want to parse the images,
56+
use *with_images* method before iterating over rows to preload images information. If you don't call this method, Creek will not return images anywhere.
57+
58+
59+
Cells with images will be an array of Pathname objects.
60+
If an image is spread across multiple cells, same Pathname object will be returned for each cell.
61+
62+
sheet.with_images.rows.each do |row|
63+
puts row # => {"A1"=>[#<Pathname:/var/folders/ck/l64nmm3d4k75pvxr03ndk1tm0000gn/T/creek__drawing20161101-53599-274q0vimage1.jpeg>], "B2"=>"Fluffy"}
64+
end
65+
66+
67+
Images for a specific cell can be obtained with images_at method:
68+
puts sheet.images_at('A1') # => [#<Pathname:/var/folders/ck/l64nmm3d4k75pvxr03ndk1tm0000gn/T/creek__drawing20161101-53599-274q0vimage1.jpeg>]
69+
70+
# no images in a cell
71+
puts sheet.images_at('C1') # => nil
72+
73+
Creek will most likely return nil for a cell with images if there is no other text cell in that row - you can use *images_at* method for retrieving images in that cell.
74+
5475
== Contributing
5576

5677
Contributions are welcomed. You can fork a repository, add your code changes to the forked branch, ensure all existing unit tests pass, create new unit tests cover your new changes and finally create a pull request.

lib/creek.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
require "creek/version"
1+
require 'creek/version'
22
require 'creek/book'
33
require 'creek/styles/constants'
44
require 'creek/styles/style_types'
55
require 'creek/styles/converter'
6+
require 'creek/utils'
67
require 'creek/styles'
8+
require 'creek/drawing'
79
require 'creek/sheet'
810
require 'creek/shared_strings'
911

lib/creek/drawing.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
require 'pathname'
2+
3+
module Creek
4+
class Creek::Drawing
5+
include Creek::Utils
6+
7+
COLUMNS = ('A'..'AZ').to_a
8+
9+
def initialize(book, drawing_filepath)
10+
@book = book
11+
@drawing_filepath = drawing_filepath
12+
@drawings = []
13+
@drawings_rels = []
14+
@images_pathnames = Hash.new { |hash, key| hash[key] = [] }
15+
16+
if file_exist?(@drawing_filepath)
17+
load_drawings_and_rels
18+
load_images_pathnames_by_cells if has_images?
19+
end
20+
end
21+
22+
##
23+
# Returns false if there are no images in the drawing file or the drawing file does not exist, true otherwise.
24+
def has_images?
25+
@has_images ||= !@drawings.empty?
26+
end
27+
28+
##
29+
# Extracts images from excel to tmpdir for a cell, if the images are not already extracted (multiple calls or same image file in multiple cells).
30+
# Returns array of images as Pathname objects or nil.
31+
def images_at(cell_name)
32+
coordinate = calc_coordinate(cell_name)
33+
pathnames_at_coordinate = @images_pathnames[coordinate]
34+
return if pathnames_at_coordinate.empty?
35+
36+
pathnames_at_coordinate.map do |image_pathname|
37+
if image_pathname.exist?
38+
image_pathname
39+
else
40+
excel_image_path = "xl/media/#{image_pathname.to_path.split(tmpdir).last}"
41+
IO.copy_stream(@book.files.file.open(excel_image_path), image_pathname.to_path)
42+
image_pathname
43+
end
44+
end
45+
end
46+
47+
private
48+
49+
##
50+
# Transforms cell name to [row, col], e.g. A1 => [0, 0], B3 => [1, 2]
51+
# Rows and cols start with 0.
52+
def calc_coordinate(cell_name)
53+
col = COLUMNS.index(cell_name.slice /[A-Z]+/)
54+
row = (cell_name.slice /\d+/).to_i - 1 # rows in drawings start with 0
55+
[row, col]
56+
end
57+
58+
##
59+
# Creates/loads temporary directory for extracting images from excel
60+
def tmpdir
61+
@tmpdir ||= ::Dir.mktmpdir('creek__drawing')
62+
end
63+
64+
##
65+
# Parses drawing and drawing's relationships xmls.
66+
# Drawing xml contains relationships ID's and coordinates (row, col).
67+
# Drawing relationships xml contains images' locations.
68+
def load_drawings_and_rels
69+
@drawings = parse_xml(@drawing_filepath).css('xdr|twoCellAnchor')
70+
drawing_rels_filepath = expand_to_rels_path(@drawing_filepath)
71+
@drawings_rels = parse_xml(drawing_rels_filepath).css('Relationships')
72+
end
73+
74+
##
75+
# Iterates through the drawings and saves images' paths as Pathname objects to a hash with [row, col] keys.
76+
# As multiple images can be located in a single cell, hash values are array of Pathname objects.
77+
# One image can be spread across multiple cells (defined with from-row/to-row/from-col/to-col attributes) - same Pathname object is associated to each row-col combination for the range.
78+
def load_images_pathnames_by_cells
79+
image_selector = 'xdr:pic/xdr:blipFill/a:blip'.freeze
80+
row_from_selector = 'xdr:from/xdr:row'.freeze
81+
row_to_selector = 'xdr:to/xdr:row'.freeze
82+
col_from_selector = 'xdr:from/xdr:col'.freeze
83+
col_to_selector = 'xdr:to/xdr:col'.freeze
84+
85+
@drawings.xpath('//xdr:twoCellAnchor').each do |drawing|
86+
embed = drawing.xpath(image_selector).first.attributes['embed']
87+
next if embed.nil?
88+
89+
rid = embed.value
90+
path = Pathname.new("#{tmpdir}#{extract_drawing_path(rid).slice(/[^\/]*$/)}")
91+
92+
row_from = drawing.xpath(row_from_selector).text.to_i
93+
col_from = drawing.xpath(col_from_selector).text.to_i
94+
row_to = drawing.xpath(row_to_selector).text.to_i
95+
col_to = drawing.xpath(col_to_selector).text.to_i
96+
97+
(col_from..col_to).each do |col|
98+
(row_from..row_to).each do |row|
99+
@images_pathnames[[row, col]].push(path)
100+
end
101+
end
102+
end
103+
end
104+
105+
def extract_drawing_path(rid)
106+
@drawings_rels.css("Relationship[@Id=#{rid}]").first.attributes['Target'].value
107+
end
108+
end
109+
end

lib/creek/sheet.rb

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
module Creek
55
class Creek::Sheet
6+
include Creek::Utils
67

78
attr_reader :book,
89
:name,
@@ -21,6 +22,28 @@ def initialize book, name, sheetid, state, visible, rid, sheetfile
2122
@rid = rid
2223
@state = state
2324
@sheetfile = sheetfile
25+
@images_present = false
26+
end
27+
28+
##
29+
# Preloads images info (coordinates and paths) from related drawing.xml and drawing rels.
30+
# Must be called before #rows method if you want to have images included.
31+
# Returns self so you can chain the calls (sheet.with_images.rows).
32+
def with_images
33+
@drawingfile = extract_drawing_filepath
34+
if @drawingfile
35+
@drawing = Creek::Drawing.new(@book, @drawingfile.sub('..', 'xl'))
36+
@images_present = @drawing.has_images?
37+
end
38+
self
39+
end
40+
41+
##
42+
# Extracts images for a cell to a temporary folder.
43+
# Returns array of Pathnames for the cell.
44+
# Returns nil if images asre not found for the cell or images were not preloaded with #with_images.
45+
def images_at(cell)
46+
@drawing.images_at(cell) if @images_present
2447
end
2548

2649
##
@@ -62,6 +85,14 @@ def rows_generator include_meta_data=false
6285
y << (include_meta_data ? row : cells) if node.self_closing?
6386
elsif (node.name.eql? 'row') and (node.node_type.eql? closer)
6487
processed_cells = fill_in_empty_cells(cells, row['r'], cell)
88+
89+
if @images_present
90+
processed_cells.each do |cell_name, cell_value|
91+
next unless cell_value.nil?
92+
processed_cells[cell_name] = images_at(cell_name)
93+
end
94+
end
95+
6596
row['cells'] = processed_cells
6697
y << (include_meta_data ? row : processed_cells)
6798
elsif (node.name.eql? 'c') and (node.node_type.eql? opener)
@@ -108,5 +139,22 @@ def fill_in_empty_cells(cells, row_number, last_col)
108139

109140
new_cells
110141
end
142+
143+
##
144+
# Find drawing filepath for the current sheet.
145+
# Sheet xml contains drawing relationship ID.
146+
# Sheet relationships xml contains drawing file's location.
147+
def extract_drawing_filepath
148+
# Read drawing relationship ID from the sheet.
149+
sheet_filepath = "xl/#{@sheetfile}"
150+
drawing = parse_xml(sheet_filepath).css('drawing').first
151+
return if drawing.nil?
152+
153+
drawing_rid = drawing.attributes['id'].value
154+
155+
# Read sheet rels to find drawing file's location.
156+
sheet_rels_filepath = expand_to_rels_path(sheet_filepath)
157+
parse_xml(sheet_rels_filepath).css("Relationship[@Id='#{drawing_rid}']").first.attributes['Target'].value
158+
end
111159
end
112-
end
160+
end

lib/creek/utils.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module Creek
2+
module Utils
3+
def expand_to_rels_path(filepath)
4+
filepath.sub(/(\/[^\/]+$)/, '/_rels\1.rels')
5+
end
6+
7+
def file_exist?(path)
8+
@book.files.file.exist?(path)
9+
end
10+
11+
def parse_xml(xml_path)
12+
doc = @book.files.file.open(xml_path)
13+
Nokogiri::XML::Document.parse(doc)
14+
end
15+
end
16+
end

spec/drawing_spec.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
require './spec/spec_helper'
2+
3+
describe 'drawing' do
4+
let(:book) { Creek::Book.new('spec/fixtures/sample-with-images.xlsx') }
5+
let(:book_no_images) { Creek::Book.new('spec/fixtures/sample.xlsx') }
6+
let(:drawingfile) { 'xl/drawings/drawing1.xml' }
7+
let(:drawing) { Creek::Drawing.new(book, drawingfile) }
8+
let(:drawing_without_images) { Creek::Drawing.new(book_no_images, drawingfile) }
9+
10+
describe '#has_images?' do
11+
it 'has' do
12+
expect(drawing.has_images?).to eq(true)
13+
end
14+
15+
it 'does not have' do
16+
expect(drawing_without_images.has_images?).to eq(false)
17+
end
18+
end
19+
20+
describe '#images_at' do
21+
it 'returns images pathnames at cell' do
22+
image = drawing.images_at('A2')[0]
23+
expect(image.class).to eq(Pathname)
24+
expect(image.exist?).to eq(true)
25+
expect(image.to_path).to match(/.+creek__drawing.+\.jpeg$/)
26+
end
27+
28+
context 'when no images in cell' do
29+
it 'returns nil' do
30+
images = drawing.images_at('B2')
31+
expect(images).to eq(nil)
32+
end
33+
end
34+
35+
context 'when more images in one cell' do
36+
it 'returns all images at cell' do
37+
images = drawing.images_at('A10')
38+
expect(images.size).to eq(2)
39+
expect(images.all?(&:exist?)).to eq(true)
40+
end
41+
end
42+
43+
context 'when same image across multiple cells' do
44+
it 'returns same image for each cell' do
45+
image1 = drawing.images_at('A4')[0]
46+
image2 = drawing.images_at('A5')[0]
47+
expect(image1.class).to eq(Pathname)
48+
expect(image1).to eq(image2)
49+
end
50+
end
51+
end
52+
end
21.4 KB
Binary file not shown.

spec/sheet_spec.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
require './spec/spec_helper'
2+
3+
describe 'sheet' do
4+
let(:book_with_images) { Creek::Book.new('spec/fixtures/sample-with-images.xlsx') }
5+
let(:book_no_images) { Creek::Book.new('spec/fixtures/sample.xlsx') }
6+
let(:sheetfile) { 'worksheets/sheet1.xml' }
7+
let(:sheet_with_images) { Creek::Sheet.new(book_with_images, 'Sheet 1', 1, '', '', '1', sheetfile) }
8+
let(:sheet_no_images) { Creek::Sheet.new(book_no_images, 'Sheet 1', 1, '', '', '1', sheetfile) }
9+
10+
def load_cell(rows, cell_name)
11+
cell = rows.find { |row| !row[cell_name].nil? }
12+
cell[cell_name] if cell
13+
end
14+
15+
describe '#rows' do
16+
context 'with excel with images' do
17+
context 'with images preloading' do
18+
let(:rows) { sheet_with_images.with_images.rows.map { |r| r } }
19+
20+
it 'parses single image in a cell' do
21+
expect(load_cell(rows, 'A2').size).to eq(1)
22+
end
23+
24+
it 'returns nil for cells without images' do
25+
expect(load_cell(rows, 'A3')).to eq(nil)
26+
expect(load_cell(rows, 'A7')).to eq(nil)
27+
expect(load_cell(rows, 'A9')).to eq(nil)
28+
end
29+
30+
it 'returns nil for merged cell within empty row' do
31+
expect(load_cell(rows, 'A5')).to eq(nil)
32+
end
33+
34+
it 'returns nil for image in a cell with empty row' do
35+
expect(load_cell(rows, 'A8')).to eq(nil)
36+
end
37+
38+
it 'returns images for merged cells' do
39+
expect(load_cell(rows, 'A4').size).to eq(1)
40+
expect(load_cell(rows, 'A6').size).to eq(1)
41+
end
42+
43+
it 'returns multiple images' do
44+
expect(load_cell(rows, 'A10').size).to eq(2)
45+
end
46+
end
47+
48+
it 'ignores images' do
49+
rows = sheet_with_images.rows.map { |r| r }
50+
expect(load_cell(rows, 'A2')).to eq(nil)
51+
expect(load_cell(rows, 'A3')).to eq(nil)
52+
expect(load_cell(rows, 'A4')).to eq(nil)
53+
end
54+
end
55+
56+
context 'with excel without images' do
57+
it 'does not break on with_images' do
58+
rows = sheet_no_images.with_images.rows.map { |r| r }
59+
expect(load_cell(rows, 'A10')).to eq(nil)
60+
end
61+
end
62+
end
63+
64+
describe '#images_at' do
65+
it 'returns images for merged cell' do
66+
image = sheet_with_images.with_images.images_at('A5')[0]
67+
expect(image.class).to eq(Pathname)
68+
end
69+
70+
it 'returns images for empty row' do
71+
image = sheet_with_images.with_images.images_at('A8')[0]
72+
expect(image.class).to eq(Pathname)
73+
end
74+
75+
it 'returns nil for empty cell' do
76+
image = sheet_with_images.with_images.images_at('B3')
77+
expect(image).to eq(nil)
78+
end
79+
80+
it 'returns nil for empty cell without preloading images' do
81+
image = sheet_with_images.images_at('B3')
82+
expect(image).to eq(nil)
83+
end
84+
end
85+
end

0 commit comments

Comments
 (0)