diff --git a/.claude/skills/tdd-development/SKILL.md b/.claude/skills/tdd-development/SKILL.md index 243d80e..f76a5a9 100644 --- a/.claude/skills/tdd-development/SKILL.md +++ b/.claude/skills/tdd-development/SKILL.md @@ -24,13 +24,14 @@ before writing the implementation. git switch master && git fetch origin master && git merge --ff-only origin/master git switch -c ``` -2. **Write a failing test first.** Add a spec (usually in - `spec/docx/document_spec.rb`) that reproduces the desired behavior or the bug. - Name a regression test after the issue, e.g. a comment `# Regression test for #NNN`. +2. **Write a failing test first.** Add a spec that reproduces the desired + behavior or the bug, in the spec file of the class that owns the behavior + (see "Where tests go" below). Name a regression test after the issue, e.g. a + comment `# Regression test for #NNN`. 3. **Confirm RED.** Run just that example and verify it fails for the expected reason: ```sh - bundle exec rspec spec/docx/document_spec.rb -e "" 2>&1 | grep -vE 'warning:|previous definition' + bundle exec rspec spec/docx/containers/paragraph_spec.rb -e "" 2>&1 | grep -vE 'warning:|previous definition' ``` 4. **Implement the minimal change** to make it pass. Keep it focused. 5. **Confirm GREEN** for the new test and **run the full suite** to catch @@ -52,6 +53,25 @@ before writing the implementation. git branch -D && git push origin --delete ``` +## Where tests go + +Specs mirror `lib/` — one spec file per class: + +- **Unit specs** (`spec/docx/containers/*_spec.rb`, `spec/docx/elements/*_spec.rb`): + build the node from an XML fragment with `w_node('…')` / + `w_body(…)` (`spec/support/xml_helpers.rb`), pass `document_properties` as a + plain hash, and stand in for the Document with `instance_double(Docx::Document, …)`. + Prefer this for element behavior — no .docx fixture needed. +- **`spec/docx/document_spec.rb`**: only what Docx::Document itself owns — + opening the package, loading parts (styles, rels, headers, footers), + document-wide lookups, and save/stream/replace_entry. +- **`spec/integration/*_spec.rb`**: end-to-end scenarios on real .docx fixtures — + save-and-reopen round trips, or bugs that only reproduce with a real file's + structure (Office365, WPS, Google Docs, …). + +Use `fixture_path('x.docx')` for fixtures and `tmp_path('out.docx')` for saved +files (a per-example temp dir; never write into `spec/fixtures`). + ## Conventions specific to this repo - **Test fixtures (.docx) can be crafted programmatically.** Read the entries of diff --git a/CLAUDE.md b/CLAUDE.md index 51ac6d1..300c45f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,9 @@ thin wrapper over rubyzip + Nokogiri. - **Always work test-first.** Every behavior change (feature or bug fix) follows the `tdd-development` skill: write a failing test, confirm red, implement, confirm green + full suite, then branch → PR → CI → merge → cleanup. +- **Specs mirror `lib/`, one file per class.** Unit-test elements from XML + fragments (`w_node`), keep `document_spec.rb` to what Document owns, and put + real-file round trips in `spec/integration/` (details in `tdd-development`). - **Keep new constructor arguments optional** (`def initialize(node, props = {}, doc = nil)`) to preserve backward compatibility for existing callers. - **Be honest about scope.** When a report spans multiple causes, separate diff --git a/spec/docx/containers/container_spec.rb b/spec/docx/containers/container_spec.rb new file mode 100644 index 0000000..850e840 --- /dev/null +++ b/spec/docx/containers/container_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::Container do + let(:container_class) do + Class.new do + include Docx::Elements::Containers::Container + + def initialize(node) + @node = node + @properties_tag = 'pPr' + end + end + end + + let(:body) do + w_body(<<~XML) + Hello world + other + XML + end + let(:node) { body.element_children.first } + subject(:container) { container_class.new(node) } + + describe '#properties' do + it 'returns the properties node named by @properties_tag' do + expect(container.properties.name).to eq('pPr') + end + + it 'returns nil when there is no properties node' do + expect(container_class.new(body.element_children.last).properties).to be_nil + end + end + + describe '#blank!' do + it 'empties every w:t while keeping the runs' do + container.blank! + + expect(node.xpath('.//w:t').map(&:text)).to eq(['', '']) + end + end + + describe '#remove!' do + it 'removes the node from its parent' do + container.remove! + + expect(body.element_children.map(&:text)).to eq(['other']) + end + end +end diff --git a/spec/docx/containers/paragraph_spec.rb b/spec/docx/containers/paragraph_spec.rb new file mode 100644 index 0000000..5a0a37b --- /dev/null +++ b/spec/docx/containers/paragraph_spec.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::Paragraph do + def paragraph(xml, document_properties = {}, document = nil) + described_class.new(w_node(xml), document_properties, document) + end + + it 'is backed by the w:p tag' do + expect(described_class.tag).to eq('p') + end + + describe '#text' do + it 'joins the text of its runs and hyperlinks' do + p = paragraph(<<~XML) + + This sentence has a + hyperlink + . + + XML + + expect(p.text).to eq('This sentence has a hyperlink.') + expect(p.to_s).to eq(p.text) + end + + it 'is empty for a paragraph without runs' do + expect(paragraph('').text).to eq('') + end + end + + describe '#text=' do + it 'replaces the text of a single run, keeping its formatting' do + p = paragraph('test text') + + p.text = 'the real test' + + expect(p.text).to eq('the real test') + expect(p.text_runs.first).to be_bolded + end + + it 'adds a run when the paragraph has none' do + p = paragraph('') + + p.text = 'new' + + expect(p.text).to eq('new') + end + + it 'collapses multiple runs into one' do + p = paragraph('ab') + + p.text = 'new' + + expect(p.text_runs.map(&:text)).to eq(['new']) + end + end + + describe '#text_runs' do + it 'wraps w:r and w:hyperlink children as TextRuns' do + p = paragraph('ab') + + expect(p.text_runs).to all(be_an_instance_of(Docx::Elements::Containers::TextRun)) + expect(p.text_runs.map(&:text)).to eq(%w[a b]) + end + + it 'passes the document properties on to its runs' do + p = paragraph('b', + hyperlinks: { 'rId1' => 'http://example.com/' }) + + expect(p.text_runs.first.href).to eq('http://example.com/') + end + end + + describe '#each_text_run' do + it 'yields each text run' do + p = paragraph('ab') + + expect { |block| p.each_text_run(&block) }.to yield_successive_args( + an_object_having_attributes(text: 'a'), an_object_having_attributes(text: 'b') + ) + end + end + + # Regression tests for #147: Word often splits a placeholder across runs. + describe '#substitute' do + let(:split) do + paragraph(<<~XML) + + Hello + {{fi + rst_na + me}} + ! + + XML + end + + it 'replaces a placeholder split across runs, preserving surrounding text' do + split.substitute('{{first_name}}', 'World') + + expect(split.text).to eq('Hello World!') + end + + it 'collapses the match into the first run it touches, keeping that run formatting' do + split.substitute('{{first_name}}', 'World') + + expect(split.text_runs.map(&:text)).to eq(['Hello ', 'World', '', '', '!']) + expect(split.text_runs[1]).to be_bolded + end + + it 'supports regex patterns and capture groups' do + split.substitute(/\{\{(\w+)\}\}/, 'name=\1') + + expect(split.text).to eq('Hello name=first_name!') + end + + it 'replaces every occurrence' do + p = paragraph('{{a}} and {{a}}') + + p.substitute('{{a}}', 'x') + + expect(p.text).to eq('x and x') + end + + it 'does not re-match inside the inserted replacement' do + p = paragraph('{{a}}') + + p.substitute('{{a}}', '{{a}}{{a}}') + + expect(p.text).to eq('{{a}}{{a}}') + end + + it 'leaves the paragraph untouched when nothing matches' do + expect(split.substitute('{{missing}}', 'x').text).to eq('Hello {{first_name}}!') + end + + it 'returns self' do + expect(split.substitute('{{first_name}}', 'World')).to equal(split) + end + end + + describe 'alignment' do + it 'is centered with w:jc="center"' do + p = paragraph('') + + expect(p).to be_aligned_center + expect(p).not_to be_aligned_left + expect(p).not_to be_aligned_right + end + + it 'is right aligned with w:jc="right"' do + p = paragraph('') + + expect(p).to be_aligned_right + expect(p).not_to be_aligned_left + expect(p).not_to be_aligned_center + end + + it 'is left aligned with w:jc="left" or without w:jc' do + expect(paragraph('')).to be_aligned_left + expect(paragraph('')).to be_aligned_left + expect(paragraph('')).not_to be_aligned_center + end + end + + # ECMA-376 17.3.2.38: w:sz is in half-points, so 14pt is stored as 28. + describe '#font_size' do + it 'reads the paragraph size in points' do + expect(paragraph('', font_size: 11).font_size).to eq(14) + end + + it 'falls back to the document font size' do + expect(paragraph('', font_size: 11).font_size).to eq(11) + end + end + + describe '#font_color' do + it 'reads the color of its runs' do + expect(paragraph('x').font_color).to eq('FF0000') + end + + it 'is nil without a color' do + expect(paragraph('x').font_color).to be_nil + end + + # Regression test for #127 + it 'is nil when the color tag has no value' do + expect(paragraph('x').font_color).to be_nil + end + end + + describe '#to_html' do + it 'wraps its runs in a p tag with the font size' do + p = paragraph('NormalItalic', font_size: 11) + + expect(p.to_html).to eq('

NormalItalic

') + end + + it 'sets the color of colored text' do + p = paragraph('red', font_size: 11) + + expect(p.to_html).to eq('

red

') + end + + it 'sets text-align from the paragraph alignment' do + center = paragraph('x', font_size: 11) + plain = paragraph('x', font_size: 11) + + expect(center.to_html).to eq('

x

') + expect(plain.to_html).to eq('

x

') + end + + it 'uses the paragraph font size' do + p = paragraph('x', font_size: 11) + + expect(p.to_html).to eq('

x

') + end + end + + describe 'styles' do + let(:styles_configuration) do + Docx::Elements::Containers::StylesConfiguration.new(Nokogiri::XML(<<~XML)) + + + + + XML + end + let(:document) { instance_double(Docx::Document, styles_configuration: styles_configuration) } + let(:styled) { paragraph('', {}, document) } + + describe '#style_id' do + it 'reads the w:pStyle value' do + expect(styled.style_id).to eq('Heading1') + end + + it 'is nil when the paragraph has no style' do + expect(paragraph('').style_id).to be_nil + end + end + + describe '#style' do + it 'is nil without a document' do + expect(paragraph('').style).to be_nil + end + + it 'resolves the style name through the document' do + allow(document).to receive(:style_name_of).with('Heading1').and_return('Heading 1') + + expect(styled.style).to eq('Heading 1') + end + + it 'falls back to the default paragraph style' do + allow(document).to receive(:style_name_of).and_return(nil) + allow(document).to receive(:default_paragraph_style).and_return('Normal') + + expect(styled.style).to eq('Normal') + end + end + + describe '#style=' do + it 'sets w:pStyle from a style name' do + styled.style = 'Heading 2' + + expect(styled.style_id).to eq('Heading2') + end + + it 'sets w:pStyle from a style id (also as #style_id=)' do + styled.style_id = 'Heading2' + + expect(styled.style_id).to eq('Heading2') + end + + it 'adds w:pStyle to a paragraph without one' do + p = paragraph('', {}, document) + + p.style = 'Heading 1' + + expect(p.node.at_xpath('w:pPr/w:pStyle/@w:val').value).to eq('Heading1') + end + + it 'raises for an unknown style' do + expect { styled.style = 'invalid' }.to raise_error(Docx::Errors::StyleNotFound) + end + end + end +end diff --git a/spec/docx/containers/styles_configuration_spec.rb b/spec/docx/containers/styles_configuration_spec.rb new file mode 100644 index 0000000..7db12a2 --- /dev/null +++ b/spec/docx/containers/styles_configuration_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::StylesConfiguration do + let(:raw_styles) do + Nokogiri::XML(<<~XML) + + + + + + XML + end + subject(:configuration) { described_class.new(raw_styles) } + + describe '#styles' do + it 'wraps each w:style with a style id, skipping other children' do + expect(configuration.styles).to all(be_a(Docx::Elements::Style)) + expect(configuration.styles.map(&:id)).to eq(%w[Normal Heading1]) + end + end + + describe '#size' do + it 'counts the styles' do + expect(configuration.size).to eq(2) + end + end + + describe '#style_of' do + it 'finds a style by id or by name' do + expect(configuration.style_of('Heading1').name).to eq('Heading 1') + expect(configuration.style_of('Heading 1').id).to eq('Heading1') + end + + it 'raises StyleNotFound for an unknown style' do + expect { configuration.style_of('Red') } + .to raise_error(Docx::Errors::StyleNotFound, "Style name or id 'Red' not found") + end + end + + describe '#add_style' do + it 'adds a style named after its id by default' do + red = configuration.add_style('Red') + + expect(configuration.size).to eq(3) + expect(red).to be_a(Docx::Elements::Style) + expect(red.id).to eq('Red') + expect(red.name).to eq('Red') + expect(configuration.style_of('Red').id).to eq('Red') + end + + it 'applies the given attributes' do + red = configuration.add_style('Red', name: 'Red text', font_color: 'FF0000', font_size: 20) + + expect(red.name).to eq('Red text') + expect(red.font_color).to eq('FF0000') + expect(red.font_size).to eq(20) + end + + it 'validates attributes set afterwards' do + red = configuration.add_style('Red') + + expect { red.font_color = '#FFFFFF' }.to raise_error(Docx::Errors::StyleInvalidPropertyValue) + expect { red.font_color = 'blue' }.to raise_error(Docx::Errors::StyleInvalidPropertyValue) + expect { red.font_color = 'FF0000' }.not_to raise_error + end + end + + describe '#remove_style' do + it 'removes the style node' do + configuration.remove_style('Heading1') + + expect(configuration.size).to eq(1) + expect { configuration.style_of('Heading1') }.to raise_error(Docx::Errors::StyleNotFound) + end + end + + describe 'editing an existing style' do + it 'writes changes through to the underlying node' do + normal = configuration.style_of('Normal') + expect(normal.font_color).to be_nil + + normal.font_color = '000000' + + expect(normal.font_color).to eq('000000') + expect(normal.node.at_xpath('w:rPr/w:color/@w:val').value).to eq('000000') + end + end + + describe '#serialize' do + it 'serializes the underlying styles document, including changes' do + configuration.add_style('Red') + + expect(configuration.serialize).to include('w:styleId="Red"') + end + end +end diff --git a/spec/docx/containers/table_cell_spec.rb b/spec/docx/containers/table_cell_spec.rb new file mode 100644 index 0000000..ebf0600 --- /dev/null +++ b/spec/docx/containers/table_cell_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::TableCell do + let(:properties) { { font_size: 11, hyperlinks: { 'rId1' => 'http://google.com/' } } } + let(:node) do + w_node(<<~XML) + + + Directive + link + + XML + end + subject(:cell) { described_class.new(node, properties) } + + it 'is backed by the w:tc tag' do + expect(described_class.tag).to eq('tc') + end + + describe '#paragraphs' do + it 'wraps each w:p as a Paragraph' do + expect(cell.paragraphs).to all(be_an_instance_of(Docx::Elements::Containers::Paragraph)) + expect(cell.paragraphs.size).to eq(2) + end + + # Regression test for #160: rendering html for a cell that contains a + # hyperlink must not raise (the cell's paragraphs need document_properties). + it 'passes the document properties on so hyperlinks render as html' do + expect(cell.paragraphs.map(&:to_html).join).to include('link') + end + end + + describe '#text' do + it 'joins the text of its paragraphs' do + expect(cell.text).to eq('Directive link') + expect(cell.to_s).to eq('Directive link') + end + end + + describe '#each_paragraph' do + it 'yields each paragraph' do + expect { |block| cell.each_paragraph(&block) }.to yield_control.twice + end + end +end diff --git a/spec/docx/containers/table_column_spec.rb b/spec/docx/containers/table_column_spec.rb new file mode 100644 index 0000000..e40b8ee --- /dev/null +++ b/spec/docx/containers/table_column_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::TableColumn do + let(:cell_nodes) do + w_node(<<~XML).xpath('w:tc') + + aphids + puceron + + XML + end + subject(:column) { described_class.new(cell_nodes, font_size: 11) } + + it 'is backed by the w:gridCol tag' do + expect(described_class.tag).to eq('w:gridCol') + end + + describe '#cells' do + it 'wraps the given cell nodes as TableCells' do + expect(column.cells).to all(be_an_instance_of(Docx::Elements::Containers::TableCell)) + expect(column.cells.map(&:text)).to eq(%w[aphids puceron]) + end + + it 'passes the document properties on to the cells' do + expect(column.cells.first.paragraphs.first.font_size).to eq(11) + end + end +end diff --git a/spec/docx/containers/table_row_spec.rb b/spec/docx/containers/table_row_spec.rb new file mode 100644 index 0000000..496b8e0 --- /dev/null +++ b/spec/docx/containers/table_row_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::TableRow do + let(:node) do + w_node(<<~XML) + + Second table + Second tableau + + XML + end + subject(:row) { described_class.new(node, font_size: 11) } + + it 'is backed by the w:tr tag' do + expect(described_class.tag).to eq('tr') + end + + describe '#cells' do + it 'wraps each w:tc as a TableCell' do + expect(row.cells).to all(be_an_instance_of(Docx::Elements::Containers::TableCell)) + expect(row.cells.map(&:text)).to eq(['Second table', 'Second tableau']) + end + + it 'passes the document properties on to the cells' do + expect(row.cells.first.paragraphs.first.font_size).to eq(11) + end + end +end diff --git a/spec/docx/containers/table_spec.rb b/spec/docx/containers/table_spec.rb new file mode 100644 index 0000000..ffea2cc --- /dev/null +++ b/spec/docx/containers/table_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::Table do + let(:document) { instance_double(Docx::Document) } + let(:properties) { { font_size: 11 } } + let(:node) do + w_node(<<~XML) + + + + ENGLISH + FRANÇAIS + + + aphids + puceron + + + bee + abeille + + + XML + end + subject(:table) { described_class.new(node, properties, document) } + + it 'is backed by the w:tbl tag' do + expect(described_class.tag).to eq('tbl') + end + + describe '#rows' do + it 'wraps each w:tr as a TableRow' do + expect(table.rows).to all(be_an_instance_of(Docx::Elements::Containers::TableRow)) + expect(table.rows.map { |row| row.cells.map(&:text) }).to eq( + [%w[ENGLISH FRANÇAIS], %w[aphids puceron], %w[bee abeille]] + ) + end + + it 'passes the document properties and document on to the cells' do + paragraph = table.rows[0].cells[0].paragraphs.first + + expect(paragraph.font_size).to eq(11) + expect(paragraph.instance_variable_get(:@document)).to equal(document) + end + end + + describe '#row_count' do + it 'counts the w:tr' do + expect(table.row_count).to eq(3) + end + end + + describe '#columns' do + it 'builds one TableColumn per grid column' do + expect(table.columns).to all(be_an_instance_of(Docx::Elements::Containers::TableColumn)) + expect(table.columns.map { |column| column.cells.map(&:text) }).to eq( + [%w[ENGLISH aphids bee], %w[FRANÇAIS puceron abeille]] + ) + end + end + + describe '#column_count' do + it 'counts the w:gridCol' do + expect(table.column_count).to eq(2) + end + end + + describe '#each_rows' do + it 'yields each row' do + expect { |block| table.each_rows(&block) }.to yield_control.exactly(3).times + end + end +end diff --git a/spec/docx/containers/text_run_spec.rb b/spec/docx/containers/text_run_spec.rb new file mode 100644 index 0000000..3131b68 --- /dev/null +++ b/spec/docx/containers/text_run_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Containers::TextRun do + let(:default_formatting) { described_class::DEFAULT_FORMATTING } + + def run(xml, document_properties = {}) + described_class.new(w_node(xml), document_properties) + end + + it 'is backed by the w:r tag' do + expect(described_class.tag).to eq('r') + end + + describe '#text' do + it 'joins the w:t nodes of the run' do + expect(run('Hello world').text).to eq('Hello world') + expect(run('Hello').to_s).to eq('Hello') + end + + it 'is empty for a run without text' do + expect(run('').text).to eq('') + end + + it 'reads the text of a hyperlink' do + expect(run('link').text).to eq('link') + end + end + + describe '#text=' do + it 'replaces the text of a single w:t' do + node = w_node('old') + text_run = described_class.new(node) + + text_run.text = 'new' + + expect(text_run.text).to eq('new') + expect(node.xpath('w:t').map(&:text)).to eq(['new']) + end + + it 'adds a w:t when the run has none' do + node = w_node('') + text_run = described_class.new(node) + + text_run.text = 'new' + + expect(node.xpath('w:t').map(&:text)).to eq(['new']) + end + end + + describe '#substitute' do + it 'replaces matches in every w:t of the run' do + text_run = run('_a_ and _a_') + + text_run.substitute('_a_', 'x') + + expect(text_run.text).to eq('x and x') + end + + it 'accepts a Regexp with backreferences' do + text_run = run('_placeholder2_') + + text_run.substitute(/_placeholder(\d)_/, 'value\1') + + expect(text_run.text).to eq('value2') + end + end + + describe '#substitute_with_block' do + it 'yields the MatchData of each match' do + text_run = run('line 1_placeholder2_ line 2_placeholder3_') + + text_run.substitute_with_block(/_placeholder(\d)_/) { |match| "_replacement_#{match[1]}" } + + expect(text_run.text).to eq('line 1_replacement_2 line 2_replacement_3') + end + end + + describe '#formatting' do + it 'is the default formatting for a plain run' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting) + expect(text_run).not_to be_italicized + expect(text_run).not_to be_bolded + expect(text_run).not_to be_underlined + expect(text_run).not_to be_striked + end + + it 'detects italic' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting.merge(italic: true)) + expect(text_run).to be_italicized + end + + it 'detects bold' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting.merge(bold: true)) + expect(text_run).to be_bolded + end + + it 'detects underline' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting.merge(underline: true)) + expect(text_run).to be_underlined + end + + it 'detects strike' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting.merge(strike: true)) + expect(text_run).to be_striked + end + + it 'detects combined formatting' do + text_run = run('x') + + expect(text_run.formatting).to eq(default_formatting.merge(italic: true, bold: true, underline: true)) + end + end + + # ECMA-376 17.3.2.38: w:sz is in half-points, so 14pt is stored as 28. + describe '#font_size' do + it 'reads the run size in points' do + expect(run('x', font_size: 11).font_size).to eq(16) + end + + it 'falls back to the document font size' do + expect(run('x', font_size: 11).font_size).to eq(11) + end + + it 'is nil without a run size or document font size' do + expect(run('x').font_size).to be_nil + end + end + + describe 'hyperlinks' do + let(:properties) { { font_size: 11, hyperlinks: { 'rId1' => 'http://www.google.com/' } } } + let(:external) { run('link', properties) } + let(:internal) { run('link', properties) } + + it 'treats a hyperlink with a relationship id as an external hyperlink' do + expect(external).to be_hyperlink + expect(external).to be_external_link + expect(external.hyperlink_id).to eq('rId1') + expect(external.href).to eq('http://www.google.com/') + end + + it 'does not treat an anchor hyperlink as a hyperlink' do + expect(internal).not_to be_hyperlink + expect(internal).not_to be_external_link + end + + it 'does not treat a plain run as a hyperlink' do + expect(run('x')).not_to be_hyperlink + end + end + + describe '#to_html' do + let(:properties) { { font_size: 11, hyperlinks: { 'rId1' => 'http://www.google.com/' } } } + + it 'returns plain text for an unformatted run' do + expect(run('plain', properties).to_html).to eq('plain') + end + + it 'wraps italic text in em' do + expect(run('Italic', properties).to_html).to eq('Italic') + end + + it 'wraps bold text in strong' do + expect(run('Bold', properties).to_html).to eq('Bold') + end + + it 'wraps struck text in s' do + expect(run('Strike', properties).to_html).to eq('Strike') + end + + it 'underlines with a styled span' do + html = run('Underline', properties).to_html + + expect(html).to eq('Underline') + end + + it 'nests combined formatting as span > strong > em' do + html = run('all', properties).to_html + + expect(html).to eq('all') + end + + it 'sets the font size only when it differs from the document font size' do + larger = run('big', properties) + same = run('same', properties) + + expect(larger.to_html).to eq('big') + expect(same.to_html).to eq('same') + end + + it 'renders an external hyperlink as an anchor opening in a new tab' do + html = run('hyperlink', properties).to_html + + expect(html).to eq('hyperlink') + end + end +end diff --git a/spec/docx/document_spec.rb b/spec/docx/document_spec.rb index 7e204c6..cf9a49c 100755 --- a/spec/docx/document_spec.rb +++ b/spec/docx/document_spec.rb @@ -2,951 +2,324 @@ require 'spec_helper' require 'docx' -require 'tempfile' +require 'pathname' +# Docx::Document owns the .docx package: opening it, loading its parts +# (document, styles, relationships, headers, footers), exposing document-wide +# lookups, and writing it back out. Behavior of the individual elements is +# covered by the specs under spec/docx/containers and spec/docx/elements. describe Docx::Document do - before(:all) do - @fixtures_path = 'spec/fixtures' - @formatting_line_count = 15 # number of lines the formatting.docx file has - end - - describe '#open' do - context 'When reading a file made by Office365' do - it 'supports it' do - expect do - Docx::Document.open(@fixtures_path + '/office365.docx') - end.to_not raise_error - end - end + describe '.open' do + context 'with a String path' do + let(:doc) { described_class.open(fixture_path('basic.docx')) } - context 'When reading a un-supported file' do - it 'should throw file not found error' do - invalid_path = @fixtures_path + '/invalid_file_path.docx' - expect do - Docx::Document.open(invalid_path) - end.to raise_error(Zip::Error, "File #{invalid_path} not found") - end + it_behaves_like 'reading basic.docx' end - end - describe "#inspect" do - it "isn't too long" do - doc = Docx::Document.open(@fixtures_path + '/office365.docx') + context 'with the binary contents of a file' do + let(:doc) { described_class.open(File.binread(fixture_path('basic.docx'))) } - expect(doc.inspect.length).to be < 1000 - - doc.instance_variables.each do |var| - expect(doc.inspect).to match(/#{var}/) - end + it_behaves_like 'reading basic.docx' end - end - describe 'reading' do - context 'using normal file' do - before do - @doc = Docx::Document.open(@fixtures_path + '/basic.docx') - end + # Regression test for #101: path-like objects work the same as a String path. + context 'with a Pathname' do + let(:doc) { described_class.open(Pathname.new(fixture_path('basic.docx'))) } - it_behaves_like 'reading' + it_behaves_like 'reading basic.docx' end - context 'using stream' do - before do - stream = File.binread(@fixtures_path + '/basic.docx') - @doc = Docx::Document.open(stream) - end + it 'reads a main part named other than document.xml (e.g. Office365 document2.xml)' do + doc = described_class.open(fixture_path('office365.docx')) - it_behaves_like 'reading' + expect(doc.paragraphs).not_to be_empty end - # Regression test for #101: opening with a Pathname (or any path-like object) - # should work the same as a String path. - context 'using a Pathname' do - before do - require 'pathname' - path = Pathname.new(@fixtures_path + '/basic.docx') - @doc = Docx::Document.open(path) - end - - it_behaves_like 'reading' + it 'yields the document when given a block' do + expect { |block| described_class.open(fixture_path('basic.docx'), &block) } + .to yield_with_args(an_instance_of(described_class)) end - end - describe 'read headers' do - before do - @doc = Docx::Document.open(@fixtures_path + '/multi_doc.docx') - end + it 'raises when the file does not exist' do + path = fixture_path('invalid_file_path.docx') - it 'can extract headers' do - expect(@doc.headers).to_not be_nil - expect(@doc.headers.keys).to eq ["header1"] - expect(@doc.headers["header1"].text).to eq "Hello from the header." + expect { described_class.open(path) }.to raise_error(Zip::Error, "File #{path} not found") end end - describe 'read footers' do - before do - @doc = Docx::Document.open(@fixtures_path + '/multi_doc.docx') - end + describe '#inspect' do + it 'is short but mentions every instance variable' do + doc = described_class.open(fixture_path('office365.docx')) - it 'can extract footers' do - expect(@doc.footers).to_not be_nil - expect(@doc.footers.keys).to eq ["footer1"] - expect(@doc.footers["footer1"].text).to eq "Hello from the footer." + expect(doc.inspect.length).to be < 1000 + doc.instance_variables.each do |var| + expect(doc.inspect).to match(/#{var}/) + end end end - describe 'updating headers and footers' do - before do - @doc = Docx::Document.open(@fixtures_path + '/multi_doc.docx') - @new_doc_path = @fixtures_path + '/multi_doc_saved.docx' - end + describe '#paragraphs' do + it 'returns only the paragraphs directly in the body, not those inside tables' do + doc = described_class.open(fixture_path('tables.docx')) - after do - File.delete(@new_doc_path) if File.exist?(@new_doc_path) + expect(doc.paragraphs).to all(be_an_instance_of(Docx::Elements::Containers::Paragraph)) + expect(doc.paragraphs.map(&:text)).not_to include('Second table') end - it 'persists changes to headers and footers after save' do - @doc.headers['header1'].at_xpath('//w:t').content = 'Edited header.' - @doc.footers['footer1'].at_xpath('//w:t').content = 'Edited footer.' - @doc.save(@new_doc_path) + it 'gives paragraphs the document properties and the document itself' do + doc = described_class.open(fixture_path('styles.docx')) - reopened = Docx::Document.open(@new_doc_path) - expect(reopened.headers['header1'].text).to eq 'Edited header.' - expect(reopened.footers['footer1'].text).to eq 'Edited footer.' + expect(doc.paragraphs.first.font_size).to eq(doc.font_size) + expect(doc.paragraphs.first.style).to eq('Title') end end - describe 'bookmarks in headers and footers' do - before do - @doc = Docx::Document.open(@fixtures_path + '/multi_doc_bookmarks.docx') - @new_doc_path = @fixtures_path + '/multi_doc_bookmarks_saved.docx' - end - - after do - File.delete(@new_doc_path) if File.exist?(@new_doc_path) - end - - it 'includes bookmarks found in headers and footers' do - expect(@doc.bookmarks['header_bookmark']).to_not be_nil - expect(@doc.bookmarks['footer_bookmark']).to_not be_nil - end + describe '#each_paragraph' do + it 'yields each paragraph' do + doc = described_class.open(fixture_path('basic.docx')) - it 'allows inserting text at a header bookmark' do - @doc.bookmarks['header_bookmark'].insert_text_after('Inserted ') - expect(@doc.headers['header1'].text).to eq 'Inserted Hello from the header.' - end - - it 'allows inserting text at a footer bookmark' do - @doc.bookmarks['footer_bookmark'].insert_text_after('Inserted ') - expect(@doc.footers['footer1'].text).to eq 'Inserted Hello from the footer.' - end - - it 'persists header and footer bookmark edits after save' do - @doc.bookmarks['header_bookmark'].insert_text_after('Inserted ') - @doc.bookmarks['footer_bookmark'].insert_text_after('Inserted ') - @doc.save(@new_doc_path) - - reopened = Docx::Document.open(@new_doc_path) - expect(reopened.headers['header1'].text).to eq 'Inserted Hello from the header.' - expect(reopened.footers['footer1'].text).to eq 'Inserted Hello from the footer.' + expect { |block| doc.each_paragraph(&block) }.to yield_control.twice end end - # Regression test for #111: some generators (e.g. Google Docs) place - # w:bookmarkStart/End directly under w:body, outside any w:p. Inserting text at - # such a bookmark must land inside a paragraph (otherwise the run is orphaned - # and silently lost). - describe 'bookmarks placed outside a paragraph (e.g. Google Docs)' do - before do - @doc = Docx::Document.open(@fixtures_path + '/bookmark_outside_paragraph.docx') - @new_path = @fixtures_path + '/bookmark_outside_paragraph_saved.docx' - end - - after do - File.delete(@new_path) if File.exist?(@new_path) - end - - it 'finds the block-level bookmark' do - expect(@doc.bookmarks['bookmark_1']).to_not be_nil - expect(@doc.paragraphs.map(&:text)).to eq(['bookmark_1']) - end - - it 'inserts text after the bookmark into the following paragraph' do - @doc.bookmarks['bookmark_1'].insert_text_after('INSERTED ') - expect(@doc.paragraphs.map(&:text)).to eq(['INSERTED bookmark_1']) - end - - it 'inserts text before the bookmark as a new leading paragraph' do - @doc.bookmarks['bookmark_1'].insert_text_before('BEFORE') - expect(@doc.paragraphs.map(&:text)).to eq(['BEFORE', 'bookmark_1']) - end - - it 'inserts multiple lines at the bookmark without destroying the following paragraph' do - @doc.bookmarks['bookmark_1'].insert_multiple_lines(['line1', 'line2', 'line3']) - expect(@doc.paragraphs.map(&:text)).to eq(['line1', 'line2', 'line3', 'bookmark_1']) - end + describe '#tables' do + it 'returns the tables of the body' do + doc = described_class.open(fixture_path('tables.docx')) - it 'persists inserted text after save' do - @doc.bookmarks['bookmark_1'].insert_text_after('INSERTED ') - @doc.save(@new_path) - reopened = Docx::Document.open(@new_path) - expect(reopened.paragraphs.map(&:text)).to eq(['INSERTED bookmark_1']) + expect(doc.tables.size).to eq(2) + expect(doc.tables).to all(be_an_instance_of(Docx::Elements::Containers::Table)) end end - describe 'read tables' do - before do - @doc = Docx::Document.open(@fixtures_path + '/tables.docx') - end - - it 'should have tables with rows and cells' do - expect(@doc.tables.count).to eq 2 - @doc.tables.each do |table| - expect(table).to be_an_instance_of(Docx::Elements::Containers::Table) - table.rows.each do |row| - expect(row).to be_an_instance_of(Docx::Elements::Containers::TableRow) - row.cells.each do |cell| - expect(cell).to be_an_instance_of(Docx::Elements::Containers::TableCell) - end - end - end - end + describe '#bookmarks' do + it 'excludes the _GoBack bookmark Word adds automatically' do + doc = described_class.open(fixture_path('basic.docx')) - it 'should have tables with columns and cells' do - @doc.tables.each do |table| - table.columns.each do |column| - expect(column).to be_an_instance_of(Docx::Elements::Containers::TableColumn) - column.cells.each do |cell| - expect(cell).to be_an_instance_of(Docx::Elements::Containers::TableCell) - end - end - end - end - - it 'should have proper count' do - expect(@doc.tables[0].row_count).to eq 171 - expect(@doc.tables[1].row_count).to eq 2 - expect(@doc.tables[0].column_count).to eq 2 - expect(@doc.tables[1].column_count).to eq 2 + expect(doc.bookmarks.keys).not_to include('_GoBack') end - it 'should have tables with proper text' do - expect(@doc.tables[0].rows[0].cells[0].text).to eq 'ENGLISH' - expect(@doc.tables[0].rows[0].cells[1].text).to eq 'FRANÇAIS' - expect(@doc.tables[1].rows[0].cells[0].text).to eq 'Second table' - expect(@doc.tables[1].rows[0].cells[1].text).to eq 'Second tableau' - expect(@doc.tables[0].columns[0].cells[5].text).to eq 'aphids' - expect(@doc.tables[0].columns[1].cells[5].text).to eq 'puceron' - end + it 'includes bookmarks found in headers and footers' do + doc = described_class.open(fixture_path('multi_doc_bookmarks.docx')) - it 'should read embedded links' do - expect(@doc.tables[0].columns[1].cells[1].text).to match(/^Directive/) + expect(doc.bookmarks['header_bookmark']).to be_a(Docx::Elements::Bookmark) + expect(doc.bookmarks['footer_bookmark']).to be_a(Docx::Elements::Bookmark) end + end - # Regression test for #160: rendering html for a cell that contains a - # hyperlink must not raise (the cell's paragraphs need document_properties). - it 'renders html for cells containing hyperlinks' do - cell = @doc.tables[0].rows[1].cells[1] - html = nil - expect { html = cell.paragraphs.map(&:to_html).join }.to_not raise_error - expect(html).to include(' 'http://www.google.com/') end - it 'should allow content deletion' do - expect(@doc.paragraphs.first.text).to eq('test text') - @doc.paragraphs.first.blank! - expect(@doc.paragraphs.first.text).to eq('') - end + # Regression test for #127 + it 'skips relationships missing an Id or Target' do + doc = described_class.open(fixture_path('malformed_hyperlink_rels.docx')) - it 'should allow content deletion' do - expect { @doc.paragraphs.first.remove! }.to change { @doc.paragraphs.size }.by(-1) + expect { doc.hyperlinks }.not_to raise_error + expect(doc.hyperlinks).to be_a(Hash) end end - describe 'format-preserving substitution' do - before do - @doc = Docx::Document.open(@fixtures_path + '/substitution.docx') - end - - it 'should replace placeholder in any line of a paragraph' do - expect(@doc.paragraphs[0].text).to eq('Page title') - expect(@doc.paragraphs[1].text).to eq('Multi-line paragraph line 1_placeholder2_ line 2_placeholder3_ line3 ') - - @doc.paragraphs[1].each_text_run do |text_run| - text_run.substitute('_placeholder2_', 'same paragraph') - text_run.substitute('_placeholder3_', 'yet the same paragraph') - end + describe '#document_properties' do + it 'bundles the document-wide font size and hyperlinks' do + doc = described_class.open(fixture_path('formatting.docx')) - expect(@doc.paragraphs[1].text).to eq('Multi-line paragraph line 1same paragraph line 2yet the same paragraph line3 ') - end - - it "should replace placeholder in any line of paragraph using substitute_with_block" do - expect(@doc.paragraphs[0].text).to eq('Page title') - expect(@doc.paragraphs[1].text).to eq('Multi-line paragraph line 1_placeholder2_ line 2_placeholder3_ line3 ') - - @doc.paragraphs[1].each_text_run do |text_run| - text_run.substitute_with_block(/_placeholder(\d)_/) { |match_data| - "_replacement_#{match_data[1]}" - } - end - - expect(@doc.paragraphs[1].text).to eq('Multi-line paragraph line 1_replacement_2 line 2_replacement_3 line3 ') + expect(doc.document_properties).to eq(font_size: doc.font_size, hyperlinks: doc.hyperlinks) end end - # Regression test for #147: a placeholder split across multiple text runs - # (as Word often produces) must be replaceable. - describe 'substituting a placeholder split across runs' do - before do - @doc = Docx::Document.open(@fixtures_path + '/split_placeholder.docx') - @paragraph = @doc.paragraphs.find { |p| p.text.include?('{{') } - end - - it 'has the placeholder split across several runs' do - expect(@paragraph.text).to eq('Hello {{first_name}}!') - expect(@paragraph.text_runs.map(&:text)).to eq(['Hello ', '{{fi', 'rst_na', 'me}}', '!']) - end - - it 'replaces the placeholder via Paragraph#substitute, preserving surrounding text' do - @paragraph.substitute('{{first_name}}', 'World') - expect(@paragraph.text).to eq('Hello World!') + describe '#default_paragraph_style' do + it 'returns the name of the default paragraph style' do + expect(described_class.open(fixture_path('styles.docx')).default_paragraph_style).to eq('Normal') end - it 'supports regex patterns and capture groups' do - @paragraph.substitute(/\{\{(\w+)\}\}/, 'name=\1') - expect(@paragraph.text).to eq('Hello name=first_name!') - end - - it 'persists the substitution after save' do - @paragraph.substitute('{{first_name}}', 'World') - new_path = @fixtures_path + '/split_placeholder_saved.docx' - @doc.save(new_path) - reopened = Docx::Document.open(new_path) - expect(reopened.paragraphs.find { |p| p.text.include?('Hello') }.text).to eq('Hello World!') - File.delete(new_path) if File.exist?(new_path) + # Regression test for #127 + it 'is nil when there is no default paragraph style' do + expect(described_class.open(fixture_path('no_default_paragraph_style.docx')).default_paragraph_style).to be_nil end end - describe 'read formatting' do - before do - @doc = Docx::Document.open(@fixtures_path + '/formatting.docx') - @formatting = @doc.paragraphs.map { |p| p.text_runs.map(&:formatting) } - @default_formatting = Docx::Elements::Containers::TextRun::DEFAULT_FORMATTING - @only_italic = @default_formatting.merge italic: true - @only_bold = @default_formatting.merge bold: true - @only_underline = @default_formatting.merge underline: true - @all_formatted = @default_formatting.merge italic: true, bold: true, underline: true - end - - it 'should have the correct text' do - expect(@doc.paragraphs.size).to eq(@formatting_line_count) - expect(@doc.paragraphs[0].text).to eq('Normal') - expect(@doc.paragraphs[1].text).to eq('Italic') - expect(@doc.paragraphs[2].text).to eq('Bold') - expect(@doc.paragraphs[3].text).to eq('Underline') - expect(@doc.paragraphs[4].text).to eq('Normal') - expect(@doc.paragraphs[5].text).to eq('This is a sentence with all formatting options in the middle of the sentence.') - expect(@doc.paragraphs[6].text).to eq('This is a centered paragraph.') - expect(@doc.paragraphs[7].text).to eq('This paragraph is aligned left.') - expect(@doc.paragraphs[8].text).to eq('This paragraph is aligned right.') - expect(@doc.paragraphs[9].text).to eq('This paragraph is 14 points.') - expect(@doc.paragraphs[10].text).to eq('This paragraph has a word at 16 points.') - expect(@doc.paragraphs[11].text).to eq('This sentence has different formatting in different places.') - expect(@doc.paragraphs[12].text).to eq('This sentence has a hyperlink.') - end - - it 'should contain a paragraph with multiple text runs' do - end - - it 'should detect normal formatting' do - [0, 4].each do |i| - expect(@formatting[i][0]).to eq(@default_formatting) - expect(@doc.paragraphs[i].text_runs[0].italicized?).to eq(false) - expect(@doc.paragraphs[i].text_runs[0].bolded?).to eq(false) - expect(@doc.paragraphs[i].text_runs[0].underlined?).to eq(false) - end - end - - it 'should detect italic formatting' do - expect(@formatting[1][0]).to eq(@only_italic) - expect(@doc.paragraphs[1].text_runs[0].italicized?).to eq(true) - expect(@doc.paragraphs[1].text_runs[0].bolded?).to eq(false) - expect(@doc.paragraphs[1].text_runs[0].underlined?).to eq(false) - end + describe '#styles_configuration' do + let(:doc) { described_class.open(fixture_path('styles.docx')) } - it 'should detect bold formatting' do - expect(@formatting[2][0]).to eq(@only_bold) - expect(@doc.paragraphs[2].text_runs[0].italicized?).to eq(false) - expect(@doc.paragraphs[2].text_runs[0].bolded?).to eq(true) - expect(@doc.paragraphs[2].text_runs[0].underlined?).to eq(false) + it 'wraps styles.xml in a memoized StylesConfiguration' do + expect(doc.styles_configuration).to be_a(Docx::Elements::Containers::StylesConfiguration) + expect(doc.styles_configuration.size).to eq(37) + expect(doc.styles_configuration).to equal(doc.styles_configuration) end - it 'should detect underline formatting' do - expect(@formatting[3][0]).to eq(@only_underline) - expect(@doc.paragraphs[3].text_runs[0].italicized?).to eq(false) - expect(@doc.paragraphs[3].text_runs[0].bolded?).to eq(false) - expect(@doc.paragraphs[3].text_runs[0].underlined?).to eq(true) - end - - it 'should detect mixed formatting' do - expect(@formatting[5][0]).to eq(@default_formatting) - expect(@doc.paragraphs[5].text_runs[0].italicized?).to eq(false) - expect(@doc.paragraphs[5].text_runs[0].bolded?).to eq(false) - expect(@doc.paragraphs[5].text_runs[0].underlined?).to eq(false) - - expect(@formatting[5][1]).to eq(@all_formatted) - expect(@doc.paragraphs[5].text_runs[1].italicized?).to eq(true) - expect(@doc.paragraphs[5].text_runs[1].bolded?).to eq(true) - expect(@doc.paragraphs[5].text_runs[1].underlined?).to eq(true) - - expect(@formatting[5][2]).to eq(@default_formatting) - expect(@doc.paragraphs[5].text_runs[2].italicized?).to eq(false) - expect(@doc.paragraphs[5].text_runs[2].bolded?).to eq(false) - expect(@doc.paragraphs[5].text_runs[2].underlined?).to eq(false) - end - - it 'should detect centered paragraphs' do - expect(@doc.paragraphs[5].aligned_center?).to eq(false) - expect(@doc.paragraphs[6].aligned_center?).to eq(true) - expect(@doc.paragraphs[7].aligned_center?).to eq(false) - end - - it 'should detect left justified paragraphs' do - expect(@doc.paragraphs[6].aligned_left?).to eq(false) - expect(@doc.paragraphs[7].aligned_left?).to eq(true) - expect(@doc.paragraphs[8].aligned_left?).to eq(false) - end - - it 'should detect right justified paragraphs' do - expect(@doc.paragraphs[7].aligned_right?).to eq(false) - expect(@doc.paragraphs[8].aligned_right?).to eq(true) - expect(@doc.paragraphs[9].aligned_right?).to eq(false) - end - - # ECMA-376 Office Open XML spec (4th edition), 17.3.2.38, size is - # defined in half-points, meaning 14pt text returns a value of 28. - # http://www.ecma-international.org/publications/standards/Ecma-376.htm - it 'should return proper font size for paragraphs' do - expect(@doc.font_size).to eq 11 - expect(@doc.paragraphs[5].font_size).to eq 11 - paragraph = @doc.paragraphs[9] - expect(paragraph.font_size).to eq 14 - expect(paragraph.text_runs[0].font_size).to eq 14 + it 'resolves style names through #style_name_of' do + expect(doc.style_name_of('Heading1')).to eq('Heading 1') end + end - it 'should return proper font size for runs' do - expect(@doc.font_size).to eq 11 - paragraph = @doc.paragraphs[10] - expect(paragraph.font_size).to eq 11 - text_runs = paragraph.text_runs - expect(text_runs[0].font_size).to eq 11 - expect(text_runs[1].font_size).to eq 16 - expect(text_runs[2].font_size).to eq 11 - expect(text_runs[3].font_size).to eq 11 - expect(text_runs[4].font_size).to eq 11 + describe '#to_s' do + it 'joins the paragraph texts with newlines' do + expect(described_class.open(fixture_path('basic.docx')).to_s).to eq("hello\nworld") end - it 'should return changed value for runs' do - paragraph = @doc.paragraphs[10] - text_runs = paragraph.text_runs - - tr = text_runs[0] - expect(tr.text).to eq 'This paragraph has a ' - - tr.text = 'This paragraph hasn\'t a' - expect(tr.text).to eq 'This paragraph hasn\'t a' + it 'handles an unusually structured document' do + expect(described_class.open(fixture_path('weird_docx.docx')).to_s).to be_a(String) end end - describe 'saving' do - context 'from a normal file' do - before do - @doc = Docx::Document.open(@fixtures_path + '/saving.docx') - end - - it_behaves_like 'saving to file' - end - - context 'from a stream' do - before do - stream = File.binread(@fixtures_path + '/saving.docx') - @doc = Docx::Document.open(stream) - end + describe '#to_html' do + it 'joins the paragraph html with newlines' do + doc = described_class.open(fixture_path('basic.docx')) - it_behaves_like 'saving to file' + expect(doc.to_html).to eq(doc.paragraphs.map(&:to_html).join("\n")) end - context 'wps modified docx file' do - before { @doc = Docx::Document.open(@fixtures_path + '/saving_wps.docx') } - - it 'should save to a normal file path' do - @new_doc_path = @fixtures_path + '/new_save.docx' - @doc.save(@new_doc_path) - @new_doc = Docx::Document.open(@new_doc_path) - expect(@new_doc.paragraphs.size).to eq(@doc.paragraphs.size) - end - - after { File.delete(@new_doc_path) if File.exist?(@new_doc_path) } - end - - context 'ZIP64 global configuration' do - before { @doc = Docx::Document.open(@fixtures_path + '/basic.docx') } - - around do |example| - previous = Zip.write_zip64_support - example.run - Zip.write_zip64_support = previous - end - - it 'does not leak the disabled ZIP64 setting after saving' do - Zip.write_zip64_support = true - @new_doc_path = @fixtures_path + '/new_save.docx' - @doc.save(@new_doc_path) - expect(Zip.write_zip64_support).to eq(true) - end - - it 'does not leak the disabled ZIP64 setting after streaming' do - Zip.write_zip64_support = true - @doc.stream - expect(Zip.write_zip64_support).to eq(true) - end - - after { File.delete(@new_doc_path) if @new_doc_path && File.exist?(@new_doc_path) } + it 'handles internal (anchor) links' do + expect { described_class.open(fixture_path('internal-links.docx')).to_html }.not_to raise_error end end - describe 'streaming' do - it 'should return a StringIO to send over HTTP' do - doc = Docx::Document.open(@fixtures_path + '/basic.docx') - expect(doc.stream).to be_a(StringIO) - end - - context 'should return a valid docx stream' do - before do - doc = Docx::Document.open(@fixtures_path + '/basic.docx') - result = doc.stream - - @doc = Docx::Document.open(result) - end + describe '#to_xml' do + it 'returns the original main document XML' do + doc = described_class.open(fixture_path('basic.docx')) + doc.paragraphs.first.text = 'changed' - it_behaves_like 'reading' + expect(doc.to_xml).to be_a(Nokogiri::XML::Document) + expect(doc.to_xml.text).to include('hello') end end - describe 'outputting html' do - before do - @doc = Docx::Document.open(@fixtures_path + '/formatting.docx') - @formatted_line = @doc.paragraphs[5] - @p_regex = /(^\)\w+)(\<\/p>$)/ - @span_regex = /(\)\w+)(<\/span>)/ - @em_regex = /(\)\w+)(\<\/em\>)/ - @strong_regex = /(\)\w+)(\<\/strong\>)/ - @strike_regex = /(\)\w+)(\<\/s\>)/ - @anchor_tag_regex = /\(.+)\<\/a>/ - end - - it 'should wrap pragraphs in a p tag' do - scan = @doc.paragraphs[0].to_html.scan(@p_regex).flatten - expect(scan.first).to eq('') - expect(scan[1]).to eq('Normal') - end + describe '#save' do + context 'from a file' do + let(:doc) { described_class.open(fixture_path('saving.docx')) } - it 'should emphasize italicized text' do - scan = @doc.paragraphs[1].to_html.scan(@em_regex).flatten - expect(scan.first).to eq('') - expect(scan[1]).to eq('Italic') - end - - it 'should strong bolded text' do - scan = @doc.paragraphs[2].to_html.scan(@strong_regex).flatten - expect(scan.first).to eq '' - expect(scan[1]).to eq 'Bold' - end - - it 'should underline underlined text' do - scan = @doc.paragraphs[3].to_html.scan(/\]+)/).flatten - expect(scan.first).to eq 'style="text-decoration:underline;"' + it_behaves_like 'saving to file' end - it 'should strike striked text' do - scan = @doc.paragraphs[13].to_html.scan(@strike_regex).flatten - expect(scan.first).to eq '' - expect(scan[1]).to eq 'Strike' - end + context 'from the binary contents of a file' do + let(:doc) { described_class.open(File.binread(fixture_path('saving.docx'))) } - it 'should color the text' do - scan = @doc.paragraphs[14].to_html.scan(/\]+)/).flatten - expect(scan.first).to eq 'style="font-size:11pt;color:#FF0000;"' + it_behaves_like 'saving to file' end - it 'should justify paragraphs' do - regex = /^]+style\=\"([^\"]+).+(<\/p>)/ - scan = @doc.paragraphs[9].to_html.scan(regex).flatten - expect(scan.first).to eq '' - expect(scan[1].split(';').include?('font-size:14pt')).to eq(true) + it_behaves_like 'saving to file' end - it 'should set font size on styled text runs' do - regex = /(\]+style\=\"([^\"]+)[^\<]+(<\/span>)/ - scan = @doc.paragraphs[10].to_html.scan(regex).flatten - expect(scan.first).to eq '' - expect(scan[1].split(';').include?('font-size:16pt')).to eq(true) - end + it 'writes back edits to the body' do + doc = described_class.open(fixture_path('basic.docx')) + doc.paragraphs.first.text = 'edited' + doc.save(tmp_path('saved.docx')) - it 'should properly highlight different text in different places in a sentence' do - paragraph = @doc.paragraphs[11] - scan = paragraph.to_html.scan(@em_regex).flatten - expect(scan.first).to eq '' - expect(scan[1]).to eq 'sentence' - scan = paragraph.to_html.scan(@strong_regex).flatten - expect(scan.first).to eq '' - expect(scan[1]).to eq 'formatting' - scan = paragraph.to_html.scan(@span_regex).flatten - expect(scan.first).to eq '' - expect(scan[1]).to eq 'different' - scan = paragraph.to_html.scan(/\]+)/).flatten - expect(scan.first).to eq 'style="text-decoration:underline;"' + expect(described_class.open(tmp_path('saved.docx')).paragraphs.first.text).to eq('edited') end - it 'should output an entire document as html fragment' do - expect(@doc.to_html.scan(/(\all').size).to eq 1 + reopened = described_class.open(tmp_path('saved.docx')) + expect(reopened.headers['header1'].text).to eq('Edited header.') + expect(reopened.footers['footer1'].text).to eq('Edited footer.') end - it 'should join paragraphs with newlines' do - expect(@doc.to_html.scan(%(

Normal

\n

Italic

\n

Bold

)).size).to eq 1 - end + it 'writes back edits to styles' do + doc = described_class.open(fixture_path('styles.docx')) + doc.styles_configuration.add_style('Red', font_color: 'FF0000') + doc.save(tmp_path('saved.docx')) - it 'should convert hyperlinks to anchor tags' do - scan = @doc.to_html.scan(@anchor_tag_regex).flatten - expect(scan[0]).to eq "http://www.google.com/" - expect(scan[1]).to eq "hyperlink" + reopened = described_class.open(tmp_path('saved.docx')) + expect(reopened.styles_configuration.style_of('Red').font_color).to eq('FF0000') end end - # Regression test for #158: relationships (and therefore hyperlinks) must be - # loaded independently of styles, so a document without word/styles.xml does - # not leave @rels uninitialized. - describe 'reading relationships when styles.xml is missing' do - before do - @doc = Docx::Document.open(@fixtures_path + '/no_styles_with_hyperlink.docx') - end - - it 'still loads hyperlink relationships' do - expect { @doc.hyperlinks }.to_not raise_error - expect(@doc.hyperlinks).to eq('rId4' => 'http://www.google.com/') - end - end + describe '#stream' do + let(:doc) { described_class.open(fixture_path('basic.docx')) } - # Regression tests for #127: malformed documents should not crash with - # internal "undefined method 'value' for nil" errors. - describe 'robustness against malformed documents' do - it 'ignores hyperlink relationships missing an Id or Target' do - doc = Docx::Document.open(@fixtures_path + '/malformed_hyperlink_rels.docx') - expect { doc.hyperlinks }.to_not raise_error - # the well-formed relationships are still returned; the malformed one is skipped - expect(doc.hyperlinks).to be_a(Hash) + it 'returns a rewound StringIO to send over HTTP' do + expect(doc.stream).to be_a(StringIO) + expect(doc.stream.pos).to eq(0) end - it 'returns nil for default_paragraph_style when there is no default paragraph style' do - doc = Docx::Document.open(@fixtures_path + '/no_default_paragraph_style.docx') - expect { doc.default_paragraph_style }.to_not raise_error - expect(doc.default_paragraph_style).to be_nil - end + context 'when reopened' do + let(:doc) { described_class.open(described_class.open(fixture_path('basic.docx')).stream) } - it 'returns nil for a paragraph font_color when the color tag has no value' do - node = Nokogiri::XML(<<~XML).root - - x - - XML - paragraph = Docx::Elements::Containers::Paragraph.new(node) - expect { paragraph.font_color }.to_not raise_error - expect(paragraph.font_color).to be_nil + it_behaves_like 'reading basic.docx' end end - describe 'replacing contents' do - let(:replacement_file_path) { @fixtures_path + '/replacement.png' } - let(:temp_file_path) { Tempfile.new(['docx_gem', '.docx']).path } - let(:entry_path) { 'word/media/image1.png' } - let(:doc) { Docx::Document.open(@fixtures_path + '/replacement.docx') } - - it 'should replace existing file within the document' do - File.open replacement_file_path, 'rb' do |io| - doc.replace_entry entry_path, io.read - end - - doc.save(temp_file_path) - - File.open replacement_file_path, 'rb' do |io| - expect(Zip::File.open(temp_file_path).read(entry_path)).to eq io.read - end - end + # Regression tests for #168: ZIP64 is disabled only while writing. + describe 'ZIP64 global configuration' do + let(:doc) { described_class.open(fixture_path('basic.docx')) } - after do - File.delete(temp_file_path) if File.exist?(temp_file_path) + around do |example| + previous = Zip.write_zip64_support + Zip.write_zip64_support = true + example.run + Zip.write_zip64_support = previous end - end - describe '#to_s' do - let(:doc) { Docx::Document.open(@fixtures_path + '/weird_docx.docx') } + it 'does not leak the disabled setting after saving' do + doc.save(tmp_path('saved.docx')) - it 'does not raise error' do - expect { doc.to_s }.to_not raise_error - end - it 'returns a String' do - expect(doc.to_s).to be_a(String) + expect(Zip.write_zip64_support).to eq(true) end - end - describe 'reading and manipulating paragraph style' do - before do - @doc = Docx::Document.open(@fixtures_path + '/styles.docx') - end - - it 'read default style when not' do - nb = @doc.paragraphs.size - - expect(@doc.paragraphs.map(&:style)).to eq([ - "Title", - "Subtitle", - "Author", - "Date", - "Compact", - "Heading 1", - "Heading 2", - "Heading 3", - "Heading 4", - "Heading 5", - "Heading 6", - "Heading 7", - "Heading 8", - "Heading 9", - "First Paragraph", - "Body Text", - "Block Text", - "Table Caption", - "Image Caption", - "Definition Term", - "Definition", - "Definition Term", - "Definition", - ]) - - expect(@doc.paragraphs.map(&:style_id)).to eq([ - "Title", - "Subtitle", - "Author", - "Date", - "Compact", - "Heading1", - "Heading2", - "Heading3", - "Heading4", - "Heading5", - "Heading6", - "Heading7", - "Heading8", - "Heading9", - "FirstParagraph", - "BodyText", - "BlockText", - "TableCaption", - "ImageCaption", - "DefinitionTerm", - "Definition", - "DefinitionTerm", - "Definition", - ]) - end - - it 'set paragraph style' do - nb = @doc.paragraphs.size - expect(nb).to eq 23 - - @doc.paragraphs.each do |p| - p.style = 'Heading 1' - expect(p.style).to eq 'Heading 1' - end - - @doc.paragraphs.each do |p| - p.style_id = 'Heading2' - expect(p.style).to eq 'Heading 2' - end - end + it 'does not leak the disabled setting after streaming' do + doc.stream - it 'raises if invalid paragraph style' do - expect { @doc.paragraphs.first.style = 'invalid' }.to raise_error(Docx::Errors::StyleNotFound) + expect(Zip.write_zip64_support).to eq(true) end end - describe 'reading and manipulating document styles' do - before do - @doc = Docx::Document.open(@fixtures_path + '/styles.docx') - end - - it '#default_paragraphy_style' do - expect(@doc.default_paragraph_style).to eq 'Normal' - end - - it 'manipulates existing document styles' do - styles_config = @doc.styles_configuration - - expect(styles_config.size).to eq 37 + describe '#replace_entry' do + it 'replaces a part of the package when saving' do + doc = described_class.open(fixture_path('replacement.docx')) + image = File.binread(fixture_path('replacement.png')) - heading_style = styles_config.style_of('Normal') - expect(heading_style).to be_a(Docx::Elements::Style) - - expect(heading_style.id).to eq "Normal" - expect(heading_style.font_color).to eq(nil) - - heading_style.font_color = "000000" - expect(heading_style.font_color).to eq("000000") - - expect(heading_style.node.at_xpath("w:rPr/w:color/@w:val").value).to eq("000000") - end - - it 'creates document styles' do - styles_config = @doc.styles_configuration - - expect(styles_config.size).to eq 37 - expect { styles_config.style_of('Red') } .to raise_error(Docx::Errors::StyleNotFound) - - red_style = styles_config.add_style("Red") - expect(styles_config.size).to eq 38 - - expect(red_style).to be_a(Docx::Elements::Style) - expect(red_style.id).to eq "Red" - expect(red_style.name).to eq "Red" - - expect { red_style.font_color = "#FFFFFF" }.to raise_error(Docx::Errors::StyleInvalidPropertyValue) - expect { red_style.font_color = "blue" }.to raise_error(Docx::Errors::StyleInvalidPropertyValue) - expect { red_style.font_color = "FF0000" }.not_to raise_error - - styles_config.remove_style("Red") - expect(styles_config.size).to eq 37 - expect { styles_config.style_of('Red') }.to raise_error(Docx::Errors::StyleNotFound) - end - - it 'persists document styles' do - styles_config = @doc.styles_configuration - styles_config.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20) - @doc.paragraphs[5].style = "Red" - - first_modified_styles_path = @fixtures_path + '/styles_modified.docx' - second_modified_styles_path = @fixtures_path + '/styles_modified2.docx' - @doc.save(first_modified_styles_path) - - modified_styles_doc = Docx::Document.open(first_modified_styles_path) - modified_styles_config = modified_styles_doc.styles_configuration - - expect(modified_styles_config.style_of('Red')).to be_a(Docx::Elements::Style) - modified_styles_config.remove_style("Red") - modified_styles_doc.save(second_modified_styles_path) - - modified_styles_doc = Docx::Document.open(second_modified_styles_path) - modified_styles_config = modified_styles_doc.styles_configuration - expect { modified_styles_config.style_of('Red') }.to raise_error(Docx::Errors::StyleNotFound) - - File.delete(first_modified_styles_path) - File.delete(second_modified_styles_path) - end - - after { File.delete(@new_doc_path) if @new_doc_path && File.exist?(@new_doc_path) } - end - - describe '#to_html' do - before do - @doc = Docx::Document.open(@fixtures_path + '/internal-links.docx') - end + doc.replace_entry('word/media/image1.png', image) + doc.save(tmp_path('saved.docx')) - it 'should not raise error' do - expect { @doc.to_html }.to_not raise_error + expect(Zip::File.open(tmp_path('saved.docx')) { |zip| zip.read('word/media/image1.png') }).to eq(image) end end end diff --git a/spec/docx/elements/bookmark_spec.rb b/spec/docx/elements/bookmark_spec.rb new file mode 100644 index 0000000..568312f --- /dev/null +++ b/spec/docx/elements/bookmark_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Bookmark do + def bookmark_in(body) + described_class.new(body.at_xpath('.//w:bookmarkStart')) + end + + def paragraph_texts(body) + body.xpath('w:p').map(&:text) + end + + it 'is backed by the w:bookmarkStart tag' do + expect(described_class.tag).to eq('bookmarkStart') + end + + it 'reads its name' do + body = w_body('') + + expect(bookmark_in(body).name).to eq('my_bookmark') + end + + context 'inside a paragraph' do + describe '#insert_text_before' do + it 'appends to the run just before the bookmark' do + body = w_body(<<~XML) + atest text! + XML + + bookmark_in(body).insert_text_before('bar') + + expect(paragraph_texts(body)).to eq(['atest textbar!']) + end + + it 'creates a run before the bookmark when none precedes it' do + body = w_body('test text') + + bookmark_in(body).insert_text_before('foo') + + expect(paragraph_texts(body)).to eq(['footest text']) + end + end + + describe '#insert_text_after' do + it 'prepends to the run just after the bookmark' do + body = w_body('test text!') + + bookmark_in(body).insert_text_after('foo') + + expect(paragraph_texts(body)).to eq(['footest text!']) + end + + it 'creates a run after the bookmark when none follows it' do + body = w_body('test text') + + bookmark_in(body).insert_text_after('bar') + + expect(paragraph_texts(body)).to eq(['test textbar']) + end + end + + describe '#insert_multiple_lines' do + it 'replaces the paragraph text and adds a paragraph per extra line' do + body = w_body(<<~XML) + before + placeholder text + after + XML + + bookmark_in(body).insert_multiple_lines(['line 1', 'line 2', 'line 3']) + + expect(paragraph_texts(body)).to eq(['before', 'line 1', 'line 2', 'line 3', 'after']) + end + end + end + + # Regression tests for #111: some generators (e.g. Google Docs) place + # w:bookmarkStart/End directly under w:body, outside any w:p. Inserted text + # must land inside a paragraph, otherwise the run is orphaned and lost. + context 'outside a paragraph (block-level)' do + describe '#insert_text_after' do + it 'inserts at the start of the following paragraph, after its properties' do + body = w_body(<<~XML) + + bookmark_1 + XML + + bookmark_in(body).insert_text_after('INSERTED ') + + expect(paragraph_texts(body)).to eq(['INSERTED bookmark_1']) + expect(body.at_xpath('w:p').element_children.first.name).to eq('pPr') + end + + it 'inserts at the start of a following paragraph without properties' do + body = w_body('bookmark_1') + + bookmark_in(body).insert_text_after('INSERTED ') + + expect(paragraph_texts(body)).to eq(['INSERTED bookmark_1']) + end + + it 'creates a new paragraph after the bookmark when no paragraph follows' do + body = w_body('before') + + bookmark_in(body).insert_text_after('INSERTED') + + expect(paragraph_texts(body)).to eq(%w[before INSERTED]) + end + end + + describe '#insert_text_before' do + it 'appends to the end of the preceding paragraph' do + body = w_body('beforeafter') + + bookmark_in(body).insert_text_before(' BEFORE') + + expect(paragraph_texts(body)).to eq(['before BEFORE', 'after']) + end + + it 'creates a new leading paragraph when no paragraph precedes' do + body = w_body('bookmark_1') + + bookmark_in(body).insert_text_before('BEFORE') + + expect(paragraph_texts(body)).to eq(%w[BEFORE bookmark_1]) + end + end + + describe '#insert_multiple_lines' do + it 'inserts new paragraphs at the bookmark without touching the following paragraph' do + body = w_body('bookmark_1') + + bookmark_in(body).insert_multiple_lines(%w[line1 line2 line3]) + + expect(paragraph_texts(body)).to eq(%w[line1 line2 line3 bookmark_1]) + end + end + end +end diff --git a/spec/docx/elements/element_spec.rb b/spec/docx/elements/element_spec.rb new file mode 100644 index 0000000..a255415 --- /dev/null +++ b/spec/docx/elements/element_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Element do + let(:element_class) do + Class.new do + include Docx::Elements::Element + + def self.tag + 'p' + end + + def initialize(node) + @node = node + end + end + end + + let(:body) { w_body('firstsecond') } + let(:first) { element_class.new(body.element_children[0]) } + let(:second) { element_class.new(body.element_children[1]) } + + def texts + body.element_children.map(&:text) + end + + describe '.included' do + it 'defines a TAG constant on the including class' do + expect(element_class::TAG).to eq('') + end + end + + describe '#xpath / #at_xpath' do + it 'delegates to the node' do + expect(first.xpath('.//w:t').map(&:text)).to eq(['first']) + expect(first.at_xpath('.//w:t').text).to eq('first') + end + end + + describe '#parent' do + it 'returns the parent node' do + expect(first.parent.name).to eq('body') + end + + it 'returns nil when the parent is not of the given type' do + expect(first.parent('w:tc')).to be_nil + end + end + + describe '#parent_paragraph' do + it 'wraps the enclosing w:p in a Paragraph' do + run = element_class.new(first.node.at_xpath('w:r')) + + paragraph = run.parent_paragraph + + expect(paragraph).to be_a(Docx::Elements::Containers::Paragraph) + expect(paragraph.text).to eq('first') + end + end + + describe '#copy' do + it 'returns a detached element of the same class with a duplicated node' do + copy = first.copy + + expect(copy).to be_an_instance_of(element_class) + expect(copy.node).not_to equal(first.node) + expect(copy.node.text).to eq(first.node.text) + expect(copy.node.parent).to be_nil + end + end + + describe '#insert_after' do + it 'inserts the node as the next sibling and returns self' do + copy = first.copy + + expect(copy.insert_after(first)).to equal(copy) + expect(texts).to eq(%w[first first second]) + end + end + + describe '#insert_before' do + it 'inserts the node as the previous sibling' do + second.copy.insert_before(first) + + expect(texts).to eq(%w[second first second]) + end + end + + describe '#append_to' do + it 'moves the node to be the last child of the given element' do + run = element_class.new(second.node.at_xpath('w:r')) + + run.append_to(first) + + expect(texts).to eq(['firstsecond', '']) + end + end + + describe '.create_with' do + it 'builds a detached element from the class tag in the same XML document' do + created = element_class.create_with(first) + + expect(created.node.name).to eq('w:p') + expect(created.node.document).to equal(body.document) + expect(created.node.parent).to be_nil + end + end + + describe '.create_within' do + it 'builds an element and appends it to the given element' do + created = element_class.create_within(first) + + expect(first.node.element_children.last).to equal(created.node) + end + end + + describe '#html_tag' do + it 'wraps content in the tag' do + expect(first.html_tag(:p, content: 'hi')).to eq('

hi

') + end + + it 'renders an empty tag without content' do + expect(first.html_tag(:p)).to eq('

') + end + + it 'renders styles as an inline style attribute' do + html = first.html_tag(:span, content: 'x', styles: { 'font-size' => '12pt', 'color' => '#FF0000' }) + + expect(html).to eq('x') + end + + it 'renders attributes' do + html = first.html_tag(:a, content: 'x', attributes: { href: 'http://example.com/', target: '_blank' }) + + expect(html).to eq('
x') + end + + it 'ignores empty styles and attributes' do + expect(first.html_tag(:p, content: 'x', styles: {}, attributes: {})).to eq('

x

') + end + end +end diff --git a/spec/docx/elements/text_spec.rb b/spec/docx/elements/text_spec.rb new file mode 100644 index 0000000..c15e979 --- /dev/null +++ b/spec/docx/elements/text_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::Elements::Text do + let(:node) { w_node('hello') } + subject(:text) { described_class.new(node) } + + it 'is backed by the w:t tag' do + expect(described_class.tag).to eq('t') + end + + describe '#content' do + it 'returns the node content' do + expect(text.content).to eq('hello') + end + end + + describe '#content=' do + it 'replaces the node content' do + text.content = 'world' + + expect(node.content).to eq('world') + end + end +end diff --git a/spec/docx/helpers_spec.rb b/spec/docx/helpers_spec.rb new file mode 100644 index 0000000..55608f1 --- /dev/null +++ b/spec/docx/helpers_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +describe Docx::SimpleInspect do + let(:object) do + Class.new do + include Docx::SimpleInspect + + def initialize + @short = 'abc' + @long = 'x' * 500 + end + end.new + end + + describe '#inspect' do + it 'lists every instance variable' do + expect(object.inspect).to include('@short="abc"', '@long=') + end + + it 'truncates long values' do + expect(object.inspect).to include(%(@long="#{'x' * 100}...)) + expect(object.inspect.length).to be < 300 + end + end +end diff --git a/spec/integration/editing_spec.rb b/spec/integration/editing_spec.rb new file mode 100644 index 0000000..c5295e8 --- /dev/null +++ b/spec/integration/editing_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +# End-to-end editing scenarios against real .docx files, including saving and +# reopening. Unit-level behavior lives in spec/docx. +describe 'Editing a document' do + describe 'editing.docx' do + let(:doc) { Docx::Document.open(fixture_path('editing.docx')) } + + it 'copies and inserts a paragraph' do + first = doc.paragraphs.first + + first.copy.insert_after(first) + + expect(doc.paragraphs.map(&:text).first(2)).to eq(['test text', 'test text']) + expect(doc.paragraphs.size).to eq(4) + end + + it 'changes, blanks and removes paragraphs' do + doc.paragraphs.first.text = 'the real test' + expect(doc.paragraphs.first.text).to eq('the real test') + + doc.paragraphs.first.blank! + expect(doc.paragraphs.first.text).to eq('') + + expect { doc.paragraphs.first.remove! }.to change { doc.paragraphs.size }.by(-1) + end + + it 'inserts text around bookmarks' do + doc.bookmarks['beginning_bookmark'].insert_text_before('foo') + doc.bookmarks['end_bookmark'].insert_text_after('bar') + + expect(doc.paragraphs.first.text).to eq('footest textbar') + end + + it 'inserts multiple lines at an isolated bookmark' do + lines = ['replacement test', 'second paragraph test', 'and a third paragraph test'] + + doc.bookmarks['isolated_bookmark'].insert_multiple_lines(lines) + + expect(doc.paragraphs.map(&:text)[2..]).to eq(lines) + end + + it 'inserts multiple lines at a bookmark splitting a word' do + lines = ['replacement test', 'second paragraph test', 'and a third paragraph test'] + + doc.bookmarks['word_splitting_bookmark'].insert_multiple_lines(lines) + + expect(doc.paragraphs.map(&:text)[1, 3]).to eq(lines) + end + end + + describe 'substitution.docx' do + let(:doc) { Docx::Document.open(fixture_path('substitution.docx')) } + let(:paragraph) { doc.paragraphs[1] } + + it 'substitutes placeholders run by run, preserving formatting' do + paragraph.each_text_run do |run| + run.substitute('_placeholder2_', 'same paragraph') + run.substitute('_placeholder3_', 'yet the same paragraph') + end + + expect(paragraph.text).to eq('Multi-line paragraph line 1same paragraph line 2yet the same paragraph line3 ') + end + + it 'substitutes placeholders with a block' do + paragraph.each_text_run do |run| + run.substitute_with_block(/_placeholder(\d)_/) { |match| "_replacement_#{match[1]}" } + end + + expect(paragraph.text).to eq('Multi-line paragraph line 1_replacement_2 line 2_replacement_3 line3 ') + end + end + + # Regression test for #147 + describe 'split_placeholder.docx' do + it 'replaces a placeholder Word split across runs and persists it' do + doc = Docx::Document.open(fixture_path('split_placeholder.docx')) + paragraph = doc.paragraphs.find { |p| p.text.include?('{{') } + expect(paragraph.text_runs.map(&:text)).to eq(['Hello ', '{{fi', 'rst_na', 'me}}', '!']) + + paragraph.substitute('{{first_name}}', 'World') + doc.save(tmp_path('saved.docx')) + + reopened = Docx::Document.open(tmp_path('saved.docx')) + expect(reopened.paragraphs.map(&:text)).to include('Hello World!') + end + end + + # Regression test for #111: Google Docs places bookmarks directly under w:body. + describe 'bookmark_outside_paragraph.docx' do + let(:doc) { Docx::Document.open(fixture_path('bookmark_outside_paragraph.docx')) } + + it 'inserts text at a block-level bookmark and persists it' do + doc.bookmarks['bookmark_1'].insert_text_after('INSERTED ') + doc.save(tmp_path('saved.docx')) + + expect(Docx::Document.open(tmp_path('saved.docx')).paragraphs.map(&:text)).to eq(['INSERTED bookmark_1']) + end + + it 'inserts multiple lines without destroying the following paragraph' do + doc.bookmarks['bookmark_1'].insert_multiple_lines(%w[line1 line2 line3]) + + expect(doc.paragraphs.map(&:text)).to eq(%w[line1 line2 line3 bookmark_1]) + end + end + + describe 'multi_doc_bookmarks.docx' do + it 'edits bookmarks in headers and footers and persists them' do + doc = Docx::Document.open(fixture_path('multi_doc_bookmarks.docx')) + + doc.bookmarks['header_bookmark'].insert_text_after('Inserted ') + doc.bookmarks['footer_bookmark'].insert_text_after('Inserted ') + expect(doc.headers['header1'].text).to eq('Inserted Hello from the header.') + expect(doc.footers['footer1'].text).to eq('Inserted Hello from the footer.') + + doc.save(tmp_path('saved.docx')) + + reopened = Docx::Document.open(tmp_path('saved.docx')) + expect(reopened.headers['header1'].text).to eq('Inserted Hello from the header.') + expect(reopened.footers['footer1'].text).to eq('Inserted Hello from the footer.') + end + end +end diff --git a/spec/integration/formatting_spec.rb b/spec/integration/formatting_spec.rb new file mode 100644 index 0000000..5dc5bdd --- /dev/null +++ b/spec/integration/formatting_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +# Reading formatting and rendering html from a real Word document. +describe 'Reading formatting.docx' do + let(:doc) { Docx::Document.open(fixture_path('formatting.docx')) } + let(:paragraphs) { doc.paragraphs } + let(:default_formatting) { Docx::Elements::Containers::TextRun::DEFAULT_FORMATTING } + + it 'reads the text of every paragraph' do + expect(paragraphs.map(&:text)).to eq([ + 'Normal', + 'Italic', + 'Bold', + 'Underline', + 'Normal', + 'This is a sentence with all formatting options in the middle of the sentence.', + 'This is a centered paragraph.', + 'This paragraph is aligned left.', + 'This paragraph is aligned right.', + 'This paragraph is 14 points.', + 'This paragraph has a word at 16 points.', + 'This sentence has different formatting in different places.', + 'This sentence has a hyperlink.', + 'Strike', + 'Red color' + ]) + end + + it 'reads run formatting' do + expect(paragraphs[0].text_runs[0].formatting).to eq(default_formatting) + expect(paragraphs[1].text_runs[0].formatting).to eq(default_formatting.merge(italic: true)) + expect(paragraphs[2].text_runs[0].formatting).to eq(default_formatting.merge(bold: true)) + expect(paragraphs[3].text_runs[0].formatting).to eq(default_formatting.merge(underline: true)) + expect(paragraphs[5].text_runs.first(3).map(&:formatting)).to eq([ + default_formatting, + default_formatting.merge(italic: true, bold: true, underline: true), + default_formatting + ]) + end + + it 'reads paragraph alignment' do + expect(paragraphs[6..8].map { |p| [p.aligned_left?, p.aligned_center?, p.aligned_right?] }).to eq([ + [false, true, false], + [true, false, false], + [false, false, true] + ]) + end + + it 'reads font sizes' do + expect(doc.font_size).to eq(11) + expect(paragraphs[9].font_size).to eq(14) + expect(paragraphs[9].text_runs[0].font_size).to eq(14) + expect(paragraphs[10].font_size).to eq(11) + expect(paragraphs[10].text_runs.map(&:font_size)).to eq([11, 16, 11, 11, 11]) + end + + describe 'html output' do + it 'renders the opening paragraphs' do + expect(doc.to_html).to start_with( + %(

Normal

\n

Italic

\n

Bold

) + ) + end + + it 'renders one p tag per paragraph' do + expect(doc.to_html.scan('sentence', 'formatting', + 'different') + end + + it 'renders all formatting options on a single run' do + expect(paragraphs[5].to_html).to include('all') + end + + it 'renders alignment, size, color and strike' do + expect(paragraphs[6].to_html).to include('text-align:center') + expect(paragraphs[8].to_html).to include('text-align:right') + expect(paragraphs[9].to_html).to include('font-size:14pt') + expect(paragraphs[10].to_html).to include('') + expect(paragraphs[13].to_html).to include('Strike') + expect(paragraphs[14].to_html).to start_with('

') + end + + it 'renders hyperlinks as anchors' do + expect(doc.to_html).to include('hyperlink') + end + end +end diff --git a/spec/integration/styles_spec.rb b/spec/integration/styles_spec.rb new file mode 100644 index 0000000..395baaf --- /dev/null +++ b/spec/integration/styles_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +# Reading and changing styles in a real Word document. +describe 'Styles in styles.docx' do + let(:doc) { Docx::Document.open(fixture_path('styles.docx')) } + + it 'reads the style of every paragraph' do + expect(doc.paragraphs.map(&:style)).to eq([ + 'Title', 'Subtitle', 'Author', 'Date', 'Compact', + 'Heading 1', 'Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', + 'Heading 6', 'Heading 7', 'Heading 8', 'Heading 9', + 'First Paragraph', 'Body Text', 'Block Text', 'Table Caption', 'Image Caption', + 'Definition Term', 'Definition', 'Definition Term', 'Definition' + ]) + expect(doc.paragraphs.map(&:style_id)).to eq(%w[ + Title Subtitle Author Date Compact + Heading1 Heading2 Heading3 Heading4 Heading5 + Heading6 Heading7 Heading8 Heading9 + FirstParagraph BodyText BlockText TableCaption ImageCaption + DefinitionTerm Definition DefinitionTerm Definition + ]) + end + + it 'sets paragraph styles by name and by id' do + doc.paragraphs.each { |p| p.style = 'Heading 1' } + expect(doc.paragraphs.map(&:style).uniq).to eq(['Heading 1']) + + doc.paragraphs.each { |p| p.style_id = 'Heading2' } + expect(doc.paragraphs.map(&:style).uniq).to eq(['Heading 2']) + end + + it 'adds a style, applies it, and removes it again across saves' do + doc.styles_configuration.add_style('Red', name: 'Red', font_color: 'FF0000', font_size: 20) + doc.paragraphs[5].style = 'Red' + doc.save(tmp_path('with_red.docx')) + + with_red = Docx::Document.open(tmp_path('with_red.docx')) + expect(with_red.paragraphs[5].style).to eq('Red') + with_red.styles_configuration.remove_style('Red') + with_red.save(tmp_path('without_red.docx')) + + without_red = Docx::Document.open(tmp_path('without_red.docx')) + expect { without_red.styles_configuration.style_of('Red') }.to raise_error(Docx::Errors::StyleNotFound) + end +end diff --git a/spec/integration/tables_spec.rb b/spec/integration/tables_spec.rb new file mode 100644 index 0000000..a39d14a --- /dev/null +++ b/spec/integration/tables_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'docx' + +# Reading tables from a real Word document. +describe 'Reading tables.docx' do + let(:doc) { Docx::Document.open(fixture_path('tables.docx')) } + let(:first) { doc.tables[0] } + let(:second) { doc.tables[1] } + + it 'reads the table dimensions' do + expect([first.row_count, first.column_count]).to eq([171, 2]) + expect([second.row_count, second.column_count]).to eq([2, 2]) + end + + it 'reads cells by row and by column' do + expect(first.rows[0].cells.map(&:text)).to eq(%w[ENGLISH FRANÇAIS]) + expect(second.rows[0].cells.map(&:text)).to eq(['Second table', 'Second tableau']) + expect(first.columns[0].cells[5].text).to eq('aphids') + expect(first.columns[1].cells[5].text).to eq('puceron') + end + + it 'reads text inside embedded links' do + expect(first.columns[1].cells[1].text).to match(/^Directive/) + end + + # Regression test for #160 + it 'renders html for cells containing hyperlinks' do + html = first.rows[1].cells[1].paragraphs.map(&:to_html).join + + expect(html).to include('hello') +# Docx::Elements::Containers::Paragraph.new(paragraph_node) +module XmlHelpers + NAMESPACES = { + 'w' => 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + 'r' => 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + }.freeze + + # Wraps +xml+ in a w:document/w:body declaring the usual namespaces and + # returns the w:body node. + def w_body(xml) + namespaces = NAMESPACES.map { |prefix, uri| %(xmlns:#{prefix}="#{uri}") }.join(' ') + Nokogiri::XML(%(#{xml})).at_xpath('//w:body') + end + + # The first element of +xml+, parsed inside a w:body (see #w_body). + def w_node(xml) + w_body(xml).element_children.first + end +end + +RSpec.configure do |config| + config.include XmlHelpers +end