diff --git a/.rubocop.yml b/.rubocop.yml index 277147a..7a50f23 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,3 +20,6 @@ Metrics: Gemspec/DevelopmentDependencies: Enabled: false + +Lint/EmptyClass: + Enabled: false diff --git a/lib/ezdoc.rb b/lib/ezdoc.rb index bbb5882..129deb6 100644 --- a/lib/ezdoc.rb +++ b/lib/ezdoc.rb @@ -3,9 +3,19 @@ require "nokogiri" require_relative "ezdoc/version" +require_relative "ezdoc/nodes/base" +require_relative "ezdoc/nodes/run" +require_relative "ezdoc/nodes/paragraph" +require_relative "ezdoc/nodes/list" +require_relative "ezdoc/nodes/list_item" +require_relative "ezdoc/nodes/table" +require_relative "ezdoc/nodes/table_row" +require_relative "ezdoc/nodes/table_cell" require_relative "ezdoc/xml/content_types" require_relative "ezdoc/xml/relationships" require_relative "ezdoc/xml/document_xml" +require_relative "ezdoc/xml/numbering" +require_relative "ezdoc/builder" require_relative "ezdoc/package" require_relative "ezdoc/document" diff --git a/lib/ezdoc/builder.rb b/lib/ezdoc/builder.rb new file mode 100644 index 0000000..d3a8aea --- /dev/null +++ b/lib/ezdoc/builder.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Ezdoc + module Builder + def p(text = nil, &block) + para = Nodes::Paragraph.new + if block + with_target(para, &block) + elsif text + para.add_run(Nodes::Run.new(text, **current_formatting)) + end + @nodes << para + end + + def text(value) + @current_target.add_run(Nodes::Run.new(value, **current_formatting)) + end + + def b(&block) + with_format(:bold, &block) + end + + def i(&block) + with_format(:italic, &block) + end + + def u(&block) + with_format(:underline, &block) + end + + def ul(&block) + list(:bullet, &block) + end + + def ol(&block) + list(:number, &block) + end + + def li(text = nil, &block) + item = Nodes::ListItem.new([], list_type: @current_list.type, num_id: @current_list.num_id) + if block + with_target(item, &block) + elsif text + item.add_run(Nodes::Run.new(text, **current_formatting)) + end + @current_list.add_item(item) + end + + def table(&block) + tbl = Nodes::Table.new + previous_table = @current_table + @current_table = tbl + block.call + @current_table = previous_table + @nodes << tbl + end + + def tr(&block) + row = Nodes::TableRow.new + previous_row = @current_row + @current_row = row + block.call + @current_row = previous_row + @current_table.add_row(row) + end + + def td(text = nil, &block) + cell = Nodes::TableCell.new + if block + with_target(cell, &block) + elsif text + cell.add_run(Nodes::Run.new(text, **current_formatting)) + end + @current_row.add_cell(cell) + end + + private + + def list(type, &block) + @num_id_counter ||= 0 + @num_id_counter += 1 + + list_node = Nodes::List.new(type: type, num_id: @num_id_counter) + previous_list = @current_list + @current_list = list_node + block.call + @current_list = previous_list + @nodes << list_node + end + + def with_format(format, &block) + @format_stack ||= [] + @format_stack.push(format) + block.call + @format_stack.pop + end + + def with_target(target, &block) + previous_target = @current_target + @current_target = target + block.call + @current_target = previous_target + end + + def current_formatting + @format_stack ||= [] + { + bold: @format_stack.include?(:bold), + italic: @format_stack.include?(:italic), + underline: @format_stack.include?(:underline) + } + end + end +end diff --git a/lib/ezdoc/document.rb b/lib/ezdoc/document.rb index dff2b0d..6fffd1b 100644 --- a/lib/ezdoc/document.rb +++ b/lib/ezdoc/document.rb @@ -2,7 +2,9 @@ module Ezdoc class Document - attr_reader :content + include Builder + + attr_reader :nodes def self.create(path, &block) doc = new @@ -12,15 +14,21 @@ module Ezdoc end def initialize - @content = [] - end - - def text(value) - @content << { text: value } + @nodes = [] + @format_stack = [] + @current_target = nil + @current_list = nil + @current_table = nil + @current_row = nil + @num_id_counter = 0 end def save(path) Package.new(self).save(path) end + + def lists + @nodes.select { |n| n.is_a?(Nodes::List) } + end end end diff --git a/lib/ezdoc/nodes/base.rb b/lib/ezdoc/nodes/base.rb new file mode 100644 index 0000000..96e6a5e --- /dev/null +++ b/lib/ezdoc/nodes/base.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class Base + # Base class for all document nodes + end + end +end diff --git a/lib/ezdoc/nodes/list.rb b/lib/ezdoc/nodes/list.rb new file mode 100644 index 0000000..7b73608 --- /dev/null +++ b/lib/ezdoc/nodes/list.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class List < Base + attr_reader :items, :type, :num_id + + def initialize(type:, num_id:) + super() + @type = type + @num_id = num_id + @items = [] + end + + def add_item(item) + @items << item + end + end + end +end diff --git a/lib/ezdoc/nodes/list_item.rb b/lib/ezdoc/nodes/list_item.rb new file mode 100644 index 0000000..9f72faf --- /dev/null +++ b/lib/ezdoc/nodes/list_item.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class ListItem < Base + attr_reader :runs, :list_type, :num_id + + def initialize(runs = [], list_type:, num_id:) + super() + @runs = runs + @list_type = list_type + @num_id = num_id + end + + def add_run(run) + @runs << run + end + end + end +end diff --git a/lib/ezdoc/nodes/paragraph.rb b/lib/ezdoc/nodes/paragraph.rb new file mode 100644 index 0000000..cb43ae9 --- /dev/null +++ b/lib/ezdoc/nodes/paragraph.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class Paragraph < Base + attr_reader :runs + + def initialize(runs = []) + super() + @runs = runs + end + + def add_run(run) + @runs << run + end + end + end +end diff --git a/lib/ezdoc/nodes/run.rb b/lib/ezdoc/nodes/run.rb new file mode 100644 index 0000000..0831c35 --- /dev/null +++ b/lib/ezdoc/nodes/run.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class Run < Base + attr_reader :text, :bold, :italic, :underline + + def initialize(text, bold: false, italic: false, underline: false) + super() + @text = text + @bold = bold + @italic = italic + @underline = underline + end + end + end +end diff --git a/lib/ezdoc/nodes/table.rb b/lib/ezdoc/nodes/table.rb new file mode 100644 index 0000000..d89b7e2 --- /dev/null +++ b/lib/ezdoc/nodes/table.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class Table < Base + attr_reader :rows + + def initialize + super + @rows = [] + end + + def add_row(row) + @rows << row + end + end + end +end diff --git a/lib/ezdoc/nodes/table_cell.rb b/lib/ezdoc/nodes/table_cell.rb new file mode 100644 index 0000000..086fcdf --- /dev/null +++ b/lib/ezdoc/nodes/table_cell.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class TableCell < Base + attr_reader :runs + + def initialize + super + @runs = [] + end + + def add_run(run) + @runs << run + end + end + end +end diff --git a/lib/ezdoc/nodes/table_row.rb b/lib/ezdoc/nodes/table_row.rb new file mode 100644 index 0000000..e112197 --- /dev/null +++ b/lib/ezdoc/nodes/table_row.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ezdoc + module Nodes + class TableRow < Base + attr_reader :cells + + def initialize + super + @cells = [] + end + + def add_cell(cell) + @cells << cell + end + end + end +end diff --git a/lib/ezdoc/package.rb b/lib/ezdoc/package.rb index c2896f0..f5af348 100644 --- a/lib/ezdoc/package.rb +++ b/lib/ezdoc/package.rb @@ -14,13 +14,19 @@ module Ezdoc zipfile.get_output_stream("_rels/.rels") { |f| f.write(relationships_xml) } zipfile.get_output_stream("word/_rels/document.xml.rels") { |f| f.write(document_relationships_xml) } zipfile.get_output_stream("word/document.xml") { |f| f.write(document_xml) } + + zipfile.get_output_stream("word/numbering.xml") { |f| f.write(numbering_xml) } if lists? end end private + def lists? + @document.lists.any? + end + def content_types_xml - Xml::ContentTypes.new.to_xml + Xml::ContentTypes.new(has_numbering: lists?).to_xml end def relationships_xml @@ -28,11 +34,15 @@ module Ezdoc end def document_relationships_xml - Xml::DocumentRelationships.new.to_xml + Xml::DocumentRelationships.new(has_numbering: lists?).to_xml end def document_xml - Xml::DocumentXml.new(@document.content).to_xml + Xml::DocumentXml.new(@document.nodes).to_xml + end + + def numbering_xml + Xml::Numbering.new(@document.lists).to_xml end end end diff --git a/lib/ezdoc/xml/content_types.rb b/lib/ezdoc/xml/content_types.rb index 0eb3872..d7e429c 100644 --- a/lib/ezdoc/xml/content_types.rb +++ b/lib/ezdoc/xml/content_types.rb @@ -5,6 +5,10 @@ module Ezdoc class ContentTypes NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types" + def initialize(has_numbering: false) + @has_numbering = has_numbering + end + def to_xml builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| xml.Types(xmlns: NAMESPACE) do @@ -14,6 +18,12 @@ module Ezdoc PartName: "/word/document.xml", ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" ) + if @has_numbering + xml.Override( + PartName: "/word/numbering.xml", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml" + ) + end end end builder.to_xml diff --git a/lib/ezdoc/xml/document_xml.rb b/lib/ezdoc/xml/document_xml.rb index 99509d6..cb053dc 100644 --- a/lib/ezdoc/xml/document_xml.rb +++ b/lib/ezdoc/xml/document_xml.rb @@ -8,8 +8,8 @@ module Ezdoc "xmlns:r" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships" }.freeze - def initialize(content) - @content = content + def initialize(nodes) + @nodes = nodes end def to_xml @@ -17,18 +17,88 @@ module Ezdoc xml.document(NAMESPACES) do xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" } xml["w"].body do - @content.each do |item| - xml["w"].p do - xml["w"].r do - xml["w"].t item[:text] - end - end - end + @nodes.each { |node| render_node(xml, node) } end end end builder.to_xml end + + private + + def render_node(xml, node) + case node + when Nodes::Paragraph + render_paragraph(xml, node) + when Nodes::List + render_list(xml, node) + when Nodes::Table + render_table(xml, node) + end + end + + def render_paragraph(xml, para) + xml["w"].p do + para.runs.each { |run| render_run(xml, run) } + end + end + + def render_list(xml, list) + list.items.each { |item| render_list_item(xml, item) } + end + + def render_list_item(xml, item) + xml["w"].p do + xml["w"].pPr do + xml["w"].numPr do + xml["w"].ilvl("w:val" => "0") + xml["w"].numId("w:val" => item.num_id.to_s) + end + end + item.runs.each { |run| render_run(xml, run) } + end + end + + def render_run(xml, run) + xml["w"].r do + if run.bold || run.italic || run.underline + xml["w"].rPr do + xml["w"].b if run.bold + xml["w"].i if run.italic + xml["w"].u("w:val" => "single") if run.underline + end + end + xml["w"].t(run.text, "xml:space" => "preserve") + end + end + + def render_table(xml, table) + xml["w"].tbl do + xml["w"].tblPr do + xml["w"].tblW("w:w" => "0", "w:type" => "auto") + xml["w"].tblBorders do + %w[top left bottom right insideH insideV].each do |border| + xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:color" => "000000") + end + end + end + table.rows.each { |row| render_table_row(xml, row) } + end + end + + def render_table_row(xml, row) + xml["w"].tr do + row.cells.each { |cell| render_table_cell(xml, cell) } + end + end + + def render_table_cell(xml, cell) + xml["w"].tc do + xml["w"].p do + cell.runs.each { |run| render_run(xml, run) } + end + end + end end end end diff --git a/lib/ezdoc/xml/numbering.rb b/lib/ezdoc/xml/numbering.rb new file mode 100644 index 0000000..628a736 --- /dev/null +++ b/lib/ezdoc/xml/numbering.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Ezdoc + module Xml + class Numbering + NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + def initialize(lists) + @lists = lists + end + + def to_xml + builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + xml.numbering("xmlns:w" => NAMESPACE) do + xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" } + + @lists.each do |list| + render_abstract_num(xml, list) + + render_num(xml, list) + end + end + end + builder.to_xml + end + + private + + def render_abstract_num(xml, list) + xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do + xml["w"].lvl("w:ilvl" => "0") do + xml["w"].start("w:val" => "1") + xml["w"].numFmt("w:val" => num_format(list.type)) + xml["w"].lvlText("w:val" => lvl_text(list.type)) + xml["w"].lvlJc("w:val" => "left") + xml["w"].pPr do + xml["w"].ind("w:left" => "720", "w:hanging" => "360") + end + end + end + end + + def render_num(xml, list) + xml["w"].num("w:numId" => list.num_id.to_s) do + xml["w"].abstractNumId("w:val" => list.num_id.to_s) + end + end + + def num_format(type) + type == :bullet ? "bullet" : "decimal" + end + + def lvl_text(type) + type == :bullet ? "•" : "%1." + end + end + end +end diff --git a/lib/ezdoc/xml/relationships.rb b/lib/ezdoc/xml/relationships.rb index 5221928..521cb1f 100644 --- a/lib/ezdoc/xml/relationships.rb +++ b/lib/ezdoc/xml/relationships.rb @@ -22,9 +22,21 @@ module Ezdoc class DocumentRelationships NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships" + def initialize(has_numbering: false) + @has_numbering = has_numbering + end + def to_xml builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| - xml.Relationships(xmlns: NAMESPACE) + xml.Relationships(xmlns: NAMESPACE) do + if @has_numbering + xml.Relationship( + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", + Target: "numbering.xml" + ) + end + end end builder.to_xml end diff --git a/test/document_test.rb b/test/document_test.rb new file mode 100644 index 0000000..bdd6d60 --- /dev/null +++ b/test/document_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "test_helper" + +class DocumentTest < Minitest::Test + include EzdocTestHelpers + + def test_creates_valid_docx_structure + Tempfile.create(["test", ".docx"]) do |file| + Ezdoc::Document.create(file.path) do |doc| + doc.p "Test" + end + + assert File.exist?(file.path) + assert File.size(file.path).positive? + + Zip::File.open(file.path) do |zip| + assert zip.find_entry("[Content_Types].xml"), "Missing [Content_Types].xml" + assert zip.find_entry("_rels/.rels"), "Missing _rels/.rels" + assert zip.find_entry("word/document.xml"), "Missing word/document.xml" + assert zip.find_entry("word/_rels/document.xml.rels"), "Missing word/_rels/document.xml.rels" + end + end + end + + def test_document_xml_has_correct_namespaces + xml = create_doc_and_read_xml { |doc| doc.p "Test" } + + assert_includes xml, 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' + assert_includes xml, "" + end + + def test_empty_document + Tempfile.create(["test", ".docx"]) do |file| + Ezdoc::Document.create(file.path) { |_doc| } # rubocop:disable Lint/EmptyBlock + + assert File.exist?(file.path) + Zip::File.open(file.path) do |zip| + assert zip.find_entry("word/document.xml") + end + end + end + + def test_document_create_returns_document + result = nil + Tempfile.create(["test", ".docx"]) do |file| + result = Ezdoc::Document.create(file.path) do |doc| + doc.p "Test" + end + end + + assert_instance_of Ezdoc::Document, result + end + + def test_document_has_nodes + doc = Ezdoc::Document.new + doc.p "Test" + + assert_equal 1, doc.nodes.count + assert_instance_of Ezdoc::Nodes::Paragraph, doc.nodes.first + end + + def test_document_lists_helper + doc = Ezdoc::Document.new + doc.p "Paragraph" + doc.ul { doc.li "Bullet" } + doc.ol { doc.li "Number" } + doc.table { doc.tr { doc.td "Cell" } } + + assert_equal 2, doc.lists.count + assert(doc.lists.all? { |l| l.is_a?(Ezdoc::Nodes::List) }) + end +end diff --git a/test/ezdoc_test.rb b/test/ezdoc_test.rb deleted file mode 100644 index 99a0cc9..0000000 --- a/test/ezdoc_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "tempfile" -require "zip" - -class EzdocTest < Minitest::Test - def test_creates_valid_docx_with_hello_world - Tempfile.create(["test", ".docx"]) do |file| - Ezdoc::Document.create(file.path) do |doc| - doc.text "Hello World" - end - - assert File.exist?(file.path) - assert File.size(file.path).positive? - - Zip::File.open(file.path) do |zip| - assert zip.find_entry("[Content_Types].xml") - assert zip.find_entry("_rels/.rels") - assert zip.find_entry("word/document.xml") - assert zip.find_entry("word/_rels/document.xml.rels") - - document_xml = zip.read("word/document.xml") - assert_includes document_xml, "Hello World" - end - end - end -end diff --git a/test/formatting_test.rb b/test/formatting_test.rb new file mode 100644 index 0000000..2b2f29b --- /dev/null +++ b/test/formatting_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "test_helper" + +class FormattingTest < Minitest::Test + include EzdocTestHelpers + + def test_bold_text + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.b { doc.text "bold text" } + end + end + + assert_includes xml, "" + assert_includes xml, "bold text" + end + + def test_italic_text + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.i { doc.text "italic text" } + end + end + + assert_includes xml, "" + assert_includes xml, "italic text" + end + + def test_underline_text + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.u { doc.text "underlined text" } + end + end + + assert_includes xml, '' + assert_includes xml, "underlined text" + end + + def test_bold_and_italic_nested + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.b do + doc.i { doc.text "bold and italic" } + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "bold and italic" + # Both should be in the same rPr element + assert_match(%r{.*.*.*}m, xml) + end + + def test_italic_and_bold_nested_reverse_order + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.i do + doc.b { doc.text "italic and bold" } + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + end + + def test_all_three_formatting_options + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.b do + doc.i do + doc.u { doc.text "bold italic underline" } + end + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, '' + assert_includes xml, "bold italic underline" + end + + def test_mixed_formatting_in_paragraph + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.text "Normal " + doc.b { doc.text "bold" } + doc.text " normal " + doc.i { doc.text "italic" } + doc.text " normal " + doc.u { doc.text "underline" } + doc.text " end" + end + end + + assert_includes xml, "Normal " + assert_includes xml, "bold" + assert_includes xml, "italic" + assert_includes xml, "underline" + assert_equal 7, xml.scan("").count + end + + def test_formatting_does_not_leak_between_blocks + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.b { doc.text "bold" } + doc.text "not bold" + end + end + + # Count rPr elements - should only be one (for the bold text) + assert_equal 1, xml.scan("").count + end + + def test_deeply_nested_formatting + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.b do + doc.text "bold " + doc.i do + doc.text "bold+italic " + doc.u { doc.text "all three" } + end + end + end + end + + assert_includes xml, "bold " + assert_includes xml, "bold+italic " + assert_includes xml, "all three" + end +end diff --git a/test/integration_test.rb b/test/integration_test.rb new file mode 100644 index 0000000..f044645 --- /dev/null +++ b/test/integration_test.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "test_helper" + +class IntegrationTest < Minitest::Test + include EzdocTestHelpers + + def test_complex_document_with_all_features + xml_files = create_doc_and_read_all_xml do |doc| + doc.p "Introduction paragraph" + + doc.p do + doc.text "This has " + doc.b { doc.text "bold" } + doc.text " and " + doc.i { doc.text "italic" } + doc.text " text." + end + + doc.ul do + doc.li "Bullet point 1" + doc.li { doc.b { doc.text "Bold bullet" } } + end + + doc.ol do + doc.li "Numbered item 1" + doc.li "Numbered item 2" + end + + doc.table do + doc.tr do + doc.td "Header 1" + doc.td "Header 2" + end + doc.tr do + doc.td { doc.i { doc.text "Italic cell" } } + doc.td "Normal cell" + end + end + + doc.p "Conclusion paragraph" + end + + doc_xml = xml_files["word/document.xml"] + + # Verify all content types are present + assert_includes doc_xml, "Introduction paragraph" + assert_includes doc_xml, "Conclusion paragraph" + assert_includes doc_xml, "" + assert_includes doc_xml, "" + assert_includes doc_xml, "" + assert_includes doc_xml, "") + after_pos = xml.index("After table") + + assert before_pos < table_pos + assert table_pos < after_pos + end + + def test_formatting_in_list_items + xml = create_doc_and_read_xml do |doc| + doc.ul do + doc.li do + doc.b do + doc.i { doc.text "Bold and italic in list" } + end + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "Bold and italic in list" + end + + def test_formatting_in_table_cells + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr do + doc.td do + doc.b do + doc.i { doc.text "Bold and italic in table" } + end + end + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "Bold and italic in table" + end + + def test_multiple_features_interleaved + xml = create_doc_and_read_xml do |doc| + doc.p "Para 1" + doc.ul { doc.li "Bullet" } + doc.p "Para 2" + doc.table { doc.tr { doc.td "Cell" } } + doc.p "Para 3" + doc.ol { doc.li "Number" } + doc.p "Para 4" + end + + # Verify all elements exist + assert_includes xml, "Para 1" + assert_includes xml, "Para 4" + assert_includes xml, "Bullet" + assert_includes xml, "Cell" + assert_includes xml, "Number" + + # Verify correct ordering + positions = [ + xml.index("Para 1"), + xml.index("Bullet"), + xml.index("Para 2"), + xml.index(""), + xml.index("Para 3"), + xml.index("Number"), + xml.index("Para 4") + ] + + positions.each_cons(2) do |a, b| + assert a < b, "Elements should be in correct order" + end + end +end diff --git a/test/list_test.rb b/test/list_test.rb new file mode 100644 index 0000000..87cc8b2 --- /dev/null +++ b/test/list_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "test_helper" + +class ListTest < Minitest::Test + include EzdocTestHelpers + + # + # Bullet List Tests + # + + def test_bullet_list_basic + xml_files = create_doc_and_read_all_xml do |doc| + doc.ul do + doc.li "Item 1" + doc.li "Item 2" + end + end + + doc_xml = xml_files["word/document.xml"] + assert_includes doc_xml, "Item 1" + assert_includes doc_xml, "Item 2" + assert_includes doc_xml, "" + assert_includes xml, "" + end + + # + # Numbered List Tests + # + + def test_numbered_list_basic + xml_files = create_doc_and_read_all_xml do |doc| + doc.ol do + doc.li "First" + doc.li "Second" + doc.li "Third" + end + end + + doc_xml = xml_files["word/document.xml"] + assert_includes doc_xml, "First" + assert_includes doc_xml, "Second" + assert_includes doc_xml, "Third" + + numbering_xml = xml_files["word/numbering.xml"] + assert_includes numbering_xml, "decimal" + end + + def test_numbered_list_with_formatted_items + xml = create_doc_and_read_xml do |doc| + doc.ol do + doc.li { doc.b { doc.text "Bold numbered" } } + doc.li "Plain numbered" + end + end + + assert_includes xml, "Bold numbered" + assert_includes xml, "Plain numbered" + assert_includes xml, "" + end + + # + # Multiple Lists Tests + # + + def test_multiple_bullet_lists + xml_files = create_doc_and_read_all_xml do |doc| + doc.ul do + doc.li "List 1 Item 1" + doc.li "List 1 Item 2" + end + doc.p "Separator paragraph" + doc.ul do + doc.li "List 2 Item 1" + doc.li "List 2 Item 2" + end + end + + doc_xml = xml_files["word/document.xml"] + assert_includes doc_xml, "List 1 Item 1" + assert_includes doc_xml, "List 2 Item 1" + assert_includes doc_xml, "Separator paragraph" + end + + def test_mixed_list_types + xml_files = create_doc_and_read_all_xml do |doc| + doc.ul do + doc.li "Bullet 1" + doc.li "Bullet 2" + end + doc.ol do + doc.li "Number 1" + doc.li "Number 2" + end + end + + numbering_xml = xml_files["word/numbering.xml"] + assert_includes numbering_xml, "bullet" + assert_includes numbering_xml, "decimal" + end + + def test_document_without_lists_has_no_numbering_xml + xml_files = create_doc_and_read_all_xml do |doc| + doc.p "Just a paragraph" + doc.table do + doc.tr { doc.td "Cell" } + end + end + + refute xml_files.key?("word/numbering.xml"), "numbering.xml should not exist without lists" + end +end diff --git a/test/paragraph_test.rb b/test/paragraph_test.rb new file mode 100644 index 0000000..a1875f9 --- /dev/null +++ b/test/paragraph_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +class ParagraphTest < Minitest::Test + include EzdocTestHelpers + + def test_simple_paragraph + xml = create_doc_and_read_xml { |doc| doc.p "Hello World" } + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "").count + end + + def test_paragraph_with_block_and_multiple_text_runs + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.text "Part one " + doc.text "Part two " + doc.text "Part three" + end + end + + assert_includes xml, "Part one " + assert_includes xml, "Part two " + assert_includes xml, "Part three" + assert_equal 3, xml.scan("").count + end + + def test_paragraph_preserves_whitespace + xml = create_doc_and_read_xml do |doc| + doc.p do + doc.text " leading spaces" + doc.text "trailing spaces " + doc.text " both " + end + end + + assert_includes xml, 'xml:space="preserve"' + assert_includes xml, " leading spaces" + assert_includes xml, "trailing spaces " + assert_includes xml, " both " + end + + def test_paragraph_with_special_characters + xml = create_doc_and_read_xml do |doc| + doc.p "Special chars: <>&\"'" + doc.p "Unicode: café, naïve, 日本語" + end + + # XML should escape special characters + assert_includes xml, "<" + assert_includes xml, ">" + assert_includes xml, "&" + assert_includes xml, "café" + assert_includes xml, "日本語" + end + + def test_empty_paragraph_with_block + xml = create_doc_and_read_xml do |doc| + doc.p {} # rubocop:disable Lint/EmptyBlock + end + + # Empty paragraph may be self-closing or have opening tag + assert(xml.include?("") || xml.include?(""), "Should contain paragraph element") + end + + def test_long_text_content + long_text = "x" * 10_000 + xml = create_doc_and_read_xml do |doc| + doc.p long_text + end + + assert_includes xml, long_text + end + + def test_unicode_content + xml = create_doc_and_read_xml do |doc| + doc.p "Emoji: 🎉🚀💻" + doc.p "Chinese: 你好世界" + doc.p "Arabic: مرحبا بالعالم" + doc.p "Russian: Привет мир" + end + + assert_includes xml, "🎉🚀💻" + assert_includes xml, "你好世界" + assert_includes xml, "مرحبا بالعالم" + assert_includes xml, "Привет мир" + end + + def test_newlines_in_text + xml = create_doc_and_read_xml do |doc| + doc.p "Line 1\nLine 2\nLine 3" + end + + # Newlines should be preserved in the text + assert_includes xml, "Line 1\nLine 2\nLine 3" + end +end diff --git a/test/table_test.rb b/test/table_test.rb new file mode 100644 index 0000000..dd13981 --- /dev/null +++ b/test/table_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "test_helper" + +class TableTest < Minitest::Test + include EzdocTestHelpers + + def test_simple_table + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr do + doc.td "Cell 1" + doc.td "Cell 2" + end + end + end + + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, "Cell 1" + assert_includes xml, "Cell 2" + end + + def test_table_has_borders + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr { doc.td "Cell" } + end + end + + assert_includes xml, "" + assert_includes xml, "").count + assert_equal 6, xml.scan("").count + assert_includes xml, "R1C1" + assert_includes xml, "R3C2" + end + + def test_table_with_formatted_cells + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr do + doc.td { doc.b { doc.text "Bold" } } + doc.td { doc.i { doc.text "Italic" } } + doc.td { doc.u { doc.text "Underline" } } + end + end + end + + assert_includes xml, "Bold" + assert_includes xml, "Italic" + assert_includes xml, "Underline" + assert_includes xml, "" + assert_includes xml, "" + assert_includes xml, '' + end + + def test_table_with_mixed_content_cells + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr do + doc.td do + doc.text "Normal " + doc.b { doc.text "bold" } + doc.text " normal" + end + end + end + end + + assert_includes xml, "Normal " + assert_includes xml, "bold" + assert_includes xml, " normal" + end + + def test_multiple_tables + xml = create_doc_and_read_xml do |doc| + doc.table do + doc.tr { doc.td "Table 1" } + end + doc.p "Between tables" + doc.table do + doc.tr { doc.td "Table 2" } + end + end + + assert_equal 2, xml.scan("").count + assert_includes xml, "Table 1" + assert_includes xml, "Table 2" + assert_includes xml, "Between tables" + end + + def test_large_table + xml = create_doc_and_read_xml do |doc| + doc.table do + 5.times do |row| + doc.tr do + 5.times do |col| + doc.td "R#{row}C#{col}" + end + end + end + end + end + + assert_equal 5, xml.scan("").count + assert_equal 25, xml.scan("").count + assert_includes xml, "R0C0" + assert_includes xml, "R4C4" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 806ca9d..80dd0c0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,3 +3,33 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "ezdoc" require "minitest/autorun" +require "tempfile" +require "zip" + +module EzdocTestHelpers + # Helper to create a document and return the document.xml content + def create_doc_and_read_xml(&block) + content = nil + Tempfile.create(["test", ".docx"]) do |file| + Ezdoc::Document.create(file.path, &block) + Zip::File.open(file.path) do |zip| + content = zip.read("word/document.xml").force_encoding("UTF-8") + end + end + content + end + + # Helper to create a document and return all XML files + def create_doc_and_read_all_xml(&block) + result = {} + Tempfile.create(["test", ".docx"]) do |file| + Ezdoc::Document.create(file.path, &block) + Zip::File.open(file.path) do |zip| + zip.each do |entry| + result[entry.name] = zip.read(entry.name).force_encoding("UTF-8") if entry.name.end_with?(".xml") + end + end + end + result + end +end