Skip to content

Commit

Permalink
Implementing FODS format, see issue #39.
Browse files Browse the repository at this point in the history
Needs to be tested.

Merge branch 'issue-40-fods'
  • Loading branch information
gorn authored and gorn committed Jun 10, 2019
2 parents a9f012a + 80c5b0a commit 3984c00
Show file tree
Hide file tree
Showing 11 changed files with 1,249 additions and 22 deletions.
14 changes: 14 additions & 0 deletions GUIDE.md
Expand Up @@ -4,6 +4,7 @@
You can open ODS file (OpenDocument Spreadsheet) like this
````ruby
workbook = Rspreadsheet.open('./test.ods')
workbook = Rspreadsheet.open('./test.fods') # gem supports flast OpenDocument format
````
and access its first sheet like this
````ruby
Expand Down Expand Up @@ -42,6 +43,19 @@ workbook.save(any_io_object) # file can be saved to any IO like object a
workbook.to_io # coverts it to IO object which can be used to
anotherIO.write(workbook.to_io.read) # send file over internet without saving it first
````

### Creating fresh new file
You may name the spreadsheet on creation or at first save.

````ruby
workbook = Rspreadsheet.new
workbook.save('./filename.ods') # filename nust be provided at least on first save
workbook2 = Rspreadsheet.new('./filename2.fods', format: :flat)
workbook2.save
```
If you want to use the fods flat format, you must create it as such.
### Date and Time
OpenDocument and ruby have different models of date, time and datetime. Ruby containg three different objects. Time and DateTime cover all cases, Date covers dates only. OpenDocument distinguishes two groups - time of a day (time) and everything else (date). To simplify things a little we return cell values containg time of day as Time object and cell values containg datetime of date as DateTime. I am aware that this is very arbitrary choice, but it is very practical. This way and to some extend the types of values from OpenDocument are preserved when read from files, beeing acted upon and written back to spreadshhet.
Expand Down
2 changes: 1 addition & 1 deletion lib/helpers/class_extensions.rb
Expand Up @@ -119,4 +119,4 @@ def elements
# end
end

end
end
31 changes: 27 additions & 4 deletions lib/rspreadsheet.rb
Expand Up @@ -7,13 +7,36 @@

module Rspreadsheet
extend Configuration

define_setting :raise_on_negative_coordinates, true

# makes creating new workbooks as easy as `Rspreadsheet.new` or `Rspreadsheet.open('filename.ods')
def self.new(filename=nil)
Workbook.new(filename)
def self.new(*params)
raise ArgumentError.new("wrong number of arguments (given #{params.size}, expected 0-2)") if params.size >2

case params.last
when Hash then options = params.pop
else options = {}
end

if options[:format].nil? # automatické heuristické rozpoznání formátu
options[:format] = :standard
unless params.first.nil?
begin
Zip::File.open(params.first)
rescue
options[:format] = :flat
end
end
end

case options[:format]
when :flat , :fods then WorkbookFlat.new(*params)
when :standard then Workbook.new(*params)
else raise 'format of the file not recognized'
end
end
def self.open(filename)
Workbook.new(filename)
def self.open(filename, options = {})
self.new(filename, options)
end
end
9 changes: 9 additions & 0 deletions lib/rspreadsheet/empty_file_template.fods
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>

<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:dr3d="urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0" xmlns:math="http://www.w3.org/1998/Math/MathML" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" xmlns:script="urn:oasis:names:tc:opendocument:xmlns:script:1.0" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" xmlns:ooo="http://openoffice.org/2004/office" xmlns:ooow="http://openoffice.org/2004/writer" xmlns:oooc="http://openoffice.org/2004/calc" xmlns:dom="http://www.w3.org/2001/xml-events" xmlns:xforms="http://www.w3.org/2002/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:rpt="http://openoffice.org/2005/report" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:grddl="http://www.w3.org/2003/g/data-view#" xmlns:tableooo="http://openoffice.org/2009/table" xmlns:drawooo="http://openoffice.org/2010/draw" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:field="urn:openoffice:names:experimental:ooo-ms-interop:xmlns:field:1.0" xmlns:formx="urn:openoffice:names:experimental:ooxml-odf-interop:xmlns:form:1.0" xmlns:css3t="http://www.w3.org/TR/css3-text/" office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:meta><meta:generator>Rspreadshhet gem by Gorn</meta:generator></office:meta>
<office:automatic-styles />
<office:body>
<office:spreadsheet />
</office:body>
</office:document>
34 changes: 32 additions & 2 deletions lib/rspreadsheet/tools.rb
@@ -1,7 +1,11 @@
module Rspreadsheet
require 'pry'

This comment has been minimized.

Copy link
@OlivierB

OlivierB Jul 18, 2019

I think you forgot to remove this gem :)


module Rspreadsheet

# this module contains methods used bz several objects
module Tools
using ClassExtensions if RUBY_VERSION > '2.1'

def self.only_letters?(x); x.kind_of?(String) and x.match(/^[A-Za-z]*$/) != nil end
def self.kind_of_integer?(x)
(x.kind_of?(Numeric) and x.to_i==x) or
Expand Down Expand Up @@ -189,7 +193,7 @@ def self.new_time_value(h,m,s)
Time.new(StartOfEpoch.year,StartOfEpoch.month,StartOfEpoch.day,h,m,s)
end

def self.output_to_stream(io,&block)
def self.output_to_zip_stream(io,&block)
if io.kind_of? File or io.kind_of? String
Zip::File.open(io, 'br+') do |zip|
yield zip
Expand All @@ -201,6 +205,32 @@ def self.output_to_stream(io,&block)
end
end

def self.content_xml_diff(filename1,filename2)
content_xml1 = Zip::File.open(filename1) do |zip|
LibXML::XML::Document.io zip.get_input_stream('content.xml')
end
content_xml2 = Zip::File.open(filename2) do |zip|
LibXML::XML::Document.io zip.get_input_stream('content.xml')
end

return xml_diff(content_xml1.root,content_xml2.root)
end

def self.xml_file_diff(filename1,filename2)
content_xml1 = LibXML::XML::Document.file(filename1).root
content_xml2 = LibXML::XML::Document.file(filename2).root
return xml_diff(content_xml1, content_xml2)
end

def self.xml_diff(xml_node1,xml_node2)
message = []
message << xml_node2.first_diff(xml_node1)
message << xml_node1.first_diff(xml_node2)
message << 'content XML not equal' unless xml_node1.to_s.should == xml_node2.to_s
message = message.compact.join('; ')
message = nil if message == ''
message
end
end

end
2 changes: 1 addition & 1 deletion lib/rspreadsheet/version.rb
@@ -1,3 +1,3 @@
module Rspreadsheet
VERSION = "0.4.9"
VERSION = "0.5.0"
end
64 changes: 53 additions & 11 deletions lib/rspreadsheet/workbook.rb
Expand Up @@ -2,12 +2,13 @@
require 'libxml'

module Rspreadsheet

class Workbook
attr_reader :filename
attr_reader :xmlnode # debug
def xmldoc; @xmlnode.doc end

#@!group Worskheets methods
#@!group Worksheet methods
def create_worksheet_from_node(source_node)
sheet = Worksheet.new(source_node,self)
register_worksheet(sheet)
Expand All @@ -21,10 +22,11 @@ def create_worksheet(name = "Sheet#{worksheets_count+1}")
alias :add_worksheet :create_worksheet
# @return [Integer] number of sheets in the workbook
def worksheets_count; @worksheets.length end
alias :worksheet_count :worksheets_count
# @return [String] names of sheets in the workbook
def worksheet_names; @worksheets.collect{ |ws| ws.name } end
# @param [Integer,String]
# @return [Worskheet] worksheet with given index or name
# @return [Worksheet] worksheet with given index or name
def worksheets(index_or_name)
case index_or_name
when Integer then begin
Expand Down Expand Up @@ -61,8 +63,7 @@ def initialize(afilename=nil)
@xmlnode.find('./table:table').each do |node|
create_worksheet_from_node(node)
end
end

end

# @param [String] Optional new filename
# Saves the worksheet. Optionally you can provide new filename or IO stream to which the file should be saved.
Expand All @@ -71,16 +72,16 @@ def save(io=nil)
when @filename.nil? && io.nil?
raise 'New file should be named on first save.'
when @filename.kind_of?(String) && io.nil?
Tools.output_to_stream(@filename) do |input_and_output_zip| # open old file
update_manifest_and_content_xml(input_and_output_zip,input_and_output_zip) # input and output are identical
Tools.output_to_zip_stream(@filename) do |input_and_output_zip| # open old file
update_zip_manifest_and_content_xml(input_and_output_zip,input_and_output_zip) # input and output are identical
end
when @filename.kind_of?(String) && (io.kind_of?(String) || io.kind_of?(File))
when (@filename.kind_of?(String) && (io.kind_of?(String) || io.kind_of?(File)))
io = io.path if io.kind_of?(File) # convert file to its filename
FileUtils.cp(@filename , io) # copy file externally
@filename = io # remember new name
save_to_io(nil) # continue modyfying file on spot
when io.kind_of?(IO) || io.kind_of?(String) || io.kind_of?(StringIO)
Tools.output_to_stream(io) do |output_io| # open output stream of file
Tools.output_to_zip_stream(io) do |output_io| # open output stream of file
write_ods_to_io(output_io)
end
io.rewind if io.kind_of?(StringIO)
Expand All @@ -97,19 +98,22 @@ def write_ods_to_io(io)
if @filename.nil?
Zip::File.open(TEMPLATE_FILE_NAME) do |empty_template_zip| # open empty_template file
copy_internally_without_content(empty_template_zip,io) # copy empty_template internals
update_manifest_and_content_xml(empty_template_zip,io) # update xmls + pictures
update_zip_manifest_and_content_xml(empty_template_zip,io) # update xmls + pictures
end
else
Zip::File.open(@filename) do | old_zip | # open old file
copy_internally_without_content(old_zip,io) # copy the old internals
update_manifest_and_content_xml(old_zip,io) # update xmls + pictures
update_zip_manifest_and_content_xml(old_zip,io) # update xmls + pictures
end
end
end

def flat_format?; false end
def normal_format?; true end

private

def update_manifest_and_content_xml(input_zip,output_zip)
def update_zip_manifest_and_content_xml(input_zip,output_zip)
update_manifest_xml(input_zip,output_zip)
update_content_xml(output_zip)
end
Expand Down Expand Up @@ -182,6 +186,44 @@ def register_worksheet(worksheet)
@worksheets[index-1]=worksheet
@xmlnode << worksheet.xmlnode if worksheet.xmlnode.doc != @xmlnode.doc
end

end

class WorkbookFlat < Workbook
def initialize(afilename=nil)
@worksheets=[]
@filename = afilename
@xml_doc = LibXML::XML::Document.file(@filename || FLAT_TEMPLATE_FILE_NAME)
@xmlnode = @xml_doc.find_first('//office:spreadsheet')
@xmlnode.find('./table:table').each do |node|
create_worksheet_from_node(node)
end
end

def save(io=nil)
case
when @filename.nil? && io.nil?
raise 'New file should be named on first save, please provide filename (or IO).'
when @filename.kind_of?(String) && io.nil?
@xml_doc.save(@filename)
when (@filename.kind_of?(String) && (io.kind_of?(String) || io.kind_of?(File)))
@filename = (io.kind_of?(File)) ? io.path : io
@xml_doc.save(@filename)
when io.kind_of?(IO) || io.kind_of?(String) || io.kind_of?(StringIO)
IO.write(io,@xml_doc.to_s)
io.rewind if io.kind_of?(StringIO)
else raise 'Invalid combinations of parameter types in save'
end
end
alias :save_to_io :save
alias :save_as :save

def flat_format?; true end
def normal_format?; false end

private
FLAT_TEMPLATE_FILE_NAME = (File.dirname(__FILE__)+'/empty_file_template.fods').freeze

end

class WorkbookIO
Expand Down
70 changes: 70 additions & 0 deletions spec/fods_spec.rb
@@ -0,0 +1,70 @@
require 'spec_helper'
using ClassExtensions if RUBY_VERSION > '2.1'

describe 'Rspreadsheet flat ODS format' do
before do
delete_tmpfile(@tmp_filename_fods = '/tmp/testfile.fods') # delete temp file before tests
delete_tmpfile(@tmp_filename_ods = '/tmp/testfile.ods')
end
after do
delete_tmpfile(@tmp_filename_fods)
delete_tmpfile(@tmp_filename_ods)
end

it 'can open flat ods testfile and reads its content correctly' do
book = Rspreadsheet.open($test_filename_fods, format: :fods )
s = book.worksheets(1)
(1..10).each do |i|
s[i,1].should === i
end
s[1,2].should === 'text'
s[2,2].should === Date.new(2014,1,1)

cell = s.cell(6,3)
cell.format.bold.should == true
cell = s.cell(6,4)
cell.format.bold.should == false
cell.format.italic.should == true
cell = s.cell(6,5)
cell.format.italic.should == false
cell.format.color.should == '#ff3333'
cell = s.cell(6,6)
cell.format.color.should_not == '#ff3333'
cell.format.background_color.should == '#6666ff'
cell = s.cell(6,7)
cell.format.font_size.should == '7pt'
end

it 'does not change when opened and saved again' do
book = Rspreadsheet.new($test_filename_fods, format: :flat) # open test file
book.save(@tmp_filename_fods) # and save it as temp file
Rspreadsheet::Tools.xml_file_diff($test_filename_fods, @tmp_filename_fods).should be_nil
end

it 'can be converted to normal format with convert_format_to_normal', :pending do
book = Rspreadsheet.open($test_filename_fods, format: :flat)
book.convert_format_to_normal
book.save_as(@tmp_filename_ods)
Rspreadsheet::Tools.content_xml_diff($test_filename_fods, @tmp_filename_ods).should be_nil
end

it 'pick format automaticaaly' do
book = Rspreadsheet.open($test_filename_fods)
book.flat_format?.should == true
book.save_as(@tmp_filename_fods)
expect {book = Rspreadsheet.open(@tmp_filename_fods)}.not_to raise_error
book.normal_format?.should == false

book = Rspreadsheet.open($test_filename_ods)
book.normal_format?.should == true
book.save_as(@tmp_filename_ods)
expect {book = Rspreadsheet.open(@tmp_filename_ods)}.not_to raise_error
book.flat_format?.should == false
end

private
def delete_tmpfile(afile)
File.delete(afile) if File.exist?(afile)
end

end
2 changes: 2 additions & 0 deletions spec/rspreadsheet_spec.rb
Expand Up @@ -73,7 +73,9 @@
end
it 'can create new worksheet' do
book = Rspreadsheet.new
book.worksheet_count.should == 0
book.create_worksheet
book.worksheet_count.should == 1
end
it 'examples from README file are working' do
Rspreadsheet.open($test_filename).save(@tmp_filename)
Expand Down
4 changes: 1 addition & 3 deletions spec/spec_helper.rb
Expand Up @@ -30,10 +30,8 @@

# some variables used everywhere
$test_filename = './spec/testfile1.ods'
$test_filename_fods = './spec/testfile1.fods'
$test_filename_images = './spec/testfile2-images.ods'

# require my gem
require 'rspreadsheet'



0 comments on commit 3984c00

Please sign in to comment.