caracal 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +28 -0
- data/CHANGELOG.md +28 -1
- data/Gemfile +3 -0
- data/LICENSE.txt +1 -1
- data/README.md +66 -11
- data/caracal.gemspec +8 -6
- data/lib/caracal/core/iframes.rb +10 -5
- data/lib/caracal/core/models/border_model.rb +1 -1
- data/lib/caracal/core/models/iframe_model.rb +6 -7
- data/lib/caracal/core/models/paragraph_model.rb +2 -1
- data/lib/caracal/core/models/relationship_model.rb +28 -5
- data/lib/caracal/core/models/table_model.rb +44 -7
- data/lib/caracal/document.rb +25 -2
- data/lib/caracal/renderers/content_types_renderer.rb +4 -0
- data/lib/caracal/renderers/custom_renderer.rb +3 -3
- data/lib/caracal/renderers/document_renderer.rb +22 -11
- data/lib/caracal/renderers/footer_renderer.rb +1 -1
- data/lib/caracal/renderers/xml_renderer.rb +8 -0
- data/lib/caracal/utilities.rb +16 -1
- data/lib/caracal/version.rb +1 -1
- data/lib/caracal.rb +1 -0
- data/spec/lib/caracal/core/models/paragraph_model_spec.rb +7 -0
- data/spec/lib/caracal/core/models/table_model_spec.rb +27 -2
- data/spec/lib/caracal/core/tables_spec.rb +8 -1
- data/spec/lib/caracal/document_output_spec.rb +212 -0
- metadata +28 -19
- data/.travis.yml +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3357d1a61526623b3acb0ead4e0b990ce1c70026ea321ba014415947380daec9
|
|
4
|
+
data.tar.gz: 5be1b9e7bec559c1199c2c17befe1238c3ab522c1ef1fdb3a49a58e3b102c67f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad771cd941a199997acf1138bd1d426e5f2a4d1d5feb2065366a85de09a5f31295f9eee3a97b7264732e27b09706c722e4532f0a2164f73c00d3b3543623eddc
|
|
7
|
+
data.tar.gz: d89fe871c0f5569292adfd8ec6a5c044bf1096ecb95e711b2c55a9a4aa1324317d757f5ab8057d20582ff0ccd3c5b6f150c5b5a25a3a4aed81634a495ce32ad2
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Ruby ${{ matrix.ruby }} / rubyzip ${{ matrix.rubyzip }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
|
|
19
|
+
rubyzip: ['~> 1.3', '~> 3.4']
|
|
20
|
+
env:
|
|
21
|
+
RUBYZIP_VERSION: ${{ matrix.rubyzip }}
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: ruby/setup-ruby@v1
|
|
25
|
+
with:
|
|
26
|
+
ruby-version: ${{ matrix.ruby }}
|
|
27
|
+
bundler-cache: true
|
|
28
|
+
- run: bundle exec rspec
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
#### v1.5.0
|
|
2
|
+
|
|
3
|
+
* Enhancements
|
|
4
|
+
* Added the `keep_next` paragraph property, which keeps a paragraph on the same page as the one following it. (@benjaminketron)
|
|
5
|
+
* Added the `header_rows` table method, which repeats the leading rows of a table after each page break. (@benjaminketron)
|
|
6
|
+
* Added the `cant_split` and `cant_split?` table methods, which keep a row from splitting across a page break. (@benjaminketron)
|
|
7
|
+
* Added the `column_widths` table method, which sets the table grid explicitly instead of deriving it from the first row. (@martinsp)
|
|
8
|
+
* Widened the rubyzip dependency to `>= 1.1.6, < 4.0`, allowing rubyzip 3.x. (@acwertman)
|
|
9
|
+
* Added continuous integration across Ruby 3.1-4.0 and both supported rubyzip lines. (@dlauer)
|
|
10
|
+
* Declared `required_ruby_version >= 3.1`. (@dlauer)
|
|
11
|
+
* Documented the trust contract for image and iframe sources in a new Security section. (@dlauer)
|
|
12
|
+
|
|
13
|
+
* Bug Fixes
|
|
14
|
+
* Images and iframes are no longer loaded through `Kernel#open`, which executed a target beginning with `|` as a shell command, stopped fetching URLs entirely on Ruby 3.0 and later, and read in text mode on Windows, corrupting image data. Both are now read in binary, with `URI.open` for http(s) targets and `File.binread` for local paths. (@LItterBoy-GB, @dlauer)
|
|
15
|
+
* Removed control characters that XML does not permit from text, links, table cells, page number labels and custom properties. A single such character made Word reject the entire document. (@dlauer)
|
|
16
|
+
* Image part names and content types are now derived from the image data when it is supplied, rather than from the URL. This fixes presigned URLs, extensionless URLs and images carried in embedded documents. Registered content types for bmp, tiff and svg. (@dlauer)
|
|
17
|
+
* `iframe` now works inside table cells. (@catmando, @dlauer)
|
|
18
|
+
* Raised the rubyzip floor past versions whose `write_buffer` required an argument, which raised `ArgumentError` on save. (@acwertman)
|
|
19
|
+
* Corrected the documented units for table border and rule spacing, which are points rather than twips or eighths of a point. (@dlauer)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#### v1.4.1
|
|
23
|
+
|
|
24
|
+
* Bug Fixes
|
|
25
|
+
* Corrected rowspan logic to handle all cases. (@martinsp)
|
|
26
|
+
|
|
27
|
+
|
|
1
28
|
#### v1.4.0
|
|
2
29
|
|
|
3
30
|
* Enhancements
|
|
4
31
|
* Changed `file_name` method to accept absolute and relative paths. (@jdugan)
|
|
5
32
|
* Added instance save method so documents can be created via commands instead of blocks, if desired. (@jdugan)
|
|
6
33
|
* Removed extra paragraph tag inserted after tables; added logic to ensure final tag of table cells is always a paragraph. (@jdugan)
|
|
7
|
-
|
|
34
|
+
|
|
8
35
|
|
|
9
36
|
#### v1.3.0
|
|
10
37
|
|
data/Gemfile
CHANGED
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Caracal is not a magical HTML to Word translator. Instead, it is a markup langua
|
|
|
11
11
|
|
|
12
12
|
Or, said differently, if you use [Prawn](https://github.com/prawnpdf/prawn) for PDF generation, you'll probably like Caracal. Only you'll probably like it better. :)
|
|
13
13
|
|
|
14
|
-
Please see the [caracal-example](https://github.com/
|
|
14
|
+
Please see the [caracal-example](https://github.com/urvin-compliance/caracal-example) repository for
|
|
15
15
|
a working demonstration of the library's capabilities.
|
|
16
16
|
|
|
17
17
|
|
|
@@ -207,6 +207,19 @@ All Caracal models perform basic validations on their attributes, but this is, w
|
|
|
207
207
|
In forthcoming versions of Caracal, we'll be looking to expand the `InvalidModelError` class to provide broader error reporting abilities across the entire library.
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
## Security
|
|
211
|
+
|
|
212
|
+
Caracal reads whatever path or URL you hand it. Both `img` and `iframe` accept a local filesystem path or an http(s) URL, and Caracal reads or fetches it while the document renders.
|
|
213
|
+
|
|
214
|
+
**Treat those values as trusted input.** If a path or URL can be influenced by an end user, validate it before passing it to Caracal:
|
|
215
|
+
|
|
216
|
+
* **Local paths** are read with the permissions of your application's process. Any file that process can read can be embedded in the generated document, so `docx.img '/etc/passwd'` embeds the contents of that file.
|
|
217
|
+
* **URLs** are fetched from your application's network position, and redirects are followed. A user-supplied URL can therefore reach internal services, `localhost`, or cloud metadata endpoints that are not otherwise reachable from outside.
|
|
218
|
+
* **Documents embedded with `iframe`** are parsed, and their markup is copied into your output. Only embed documents you trust. See [IFrames](#iframes).
|
|
219
|
+
|
|
220
|
+
Caracal deliberately does not restrict paths, hosts, or directories, because it cannot know what is legitimate for your application. That policy belongs in your code: an allowlist of permitted directories or hosts, or simply never passing user-supplied values through to Caracal.
|
|
221
|
+
|
|
222
|
+
|
|
210
223
|
## Installation
|
|
211
224
|
|
|
212
225
|
Add this line to your application's Gemfile:
|
|
@@ -415,6 +428,7 @@ docx.p 'Sample text.' do
|
|
|
415
428
|
bgcolor 'cccccc' # sets the background color.
|
|
416
429
|
highlight_color 'yellow' # sets the highlight color. only accepts OOXML enumerations. see http://www.datypic.com/sc/ooxml/t-w_ST_HighlightColor.html.
|
|
417
430
|
vertical_align 'superscript' # sets the vertical alignment.
|
|
431
|
+
keep_next false # sets whether or not to keep this paragraph with the next paragraph when separated by page break.
|
|
418
432
|
end
|
|
419
433
|
```
|
|
420
434
|
|
|
@@ -505,7 +519,7 @@ docx.hr do
|
|
|
505
519
|
color '333333' # sets the color of the line. defaults to auto.
|
|
506
520
|
line :double # sets the line style (single or double). defaults to single.
|
|
507
521
|
size 8 # sets the thickness of the line. units in 1/8 points. defaults to 4.
|
|
508
|
-
spacing 4 # sets the
|
|
522
|
+
spacing 4 # sets the space between the line and the surrounding text. units in points. defaults to 1.
|
|
509
523
|
end
|
|
510
524
|
```
|
|
511
525
|
|
|
@@ -625,6 +639,8 @@ is a bit hacky, but it allows the library to key the image more effectively and
|
|
|
625
639
|
Caracal needs a file extension to apply to the renamed media file. This seemed
|
|
626
640
|
the simplest solution to both problems.*
|
|
627
641
|
|
|
642
|
+
*If the URL or path can come from user input, read [Security](#security) first. Caracal will read any local file your process can read, and will fetch any URL you give it.*
|
|
643
|
+
|
|
628
644
|
|
|
629
645
|
### Tables
|
|
630
646
|
|
|
@@ -632,7 +648,7 @@ the simplest solution to both problems.*
|
|
|
632
648
|
Release v1.4.0 deprecated the behaviour of automatically adding an empty paragraph tag after every table. If you are upgrading from an older version of the library, you will need to control such spacing in your own code.
|
|
633
649
|
```
|
|
634
650
|
|
|
635
|
-
Tables can be added using the `table` method. The method accepts several optional
|
|
651
|
+
Tables can be added using the `table` method. The method accepts several optional parameters to control the layout and style of the table cells.
|
|
636
652
|
|
|
637
653
|
The `table` command accepts data in the form of a two-dimensional arrays. This corresponds to rows and column cells within those rows. Each array item can be a string, a Hash of options, a Proc (which will be passed as a block), or a `TableCellModel`. The command will normalize all array contents into a two-dimensional array of `TableCellModel` instances.
|
|
638
654
|
|
|
@@ -640,8 +656,20 @@ The `table` command accepts data in the form of a two-dimensional arrays. This c
|
|
|
640
656
|
docx.table [['Header 1','Header 2'],['Cell 1', 'Cell 2']] do
|
|
641
657
|
border_color '666666' # sets the border color. defaults to 'auto'.
|
|
642
658
|
border_line :single # sets the border style. defaults to :single. see OOXML docs for details.
|
|
643
|
-
border_size 4 # sets the border width. defaults to 0. units in
|
|
644
|
-
border_spacing 4 # sets the
|
|
659
|
+
border_size 4 # sets the border width. defaults to 0. units in 1/8 points.
|
|
660
|
+
border_spacing 4 # sets the space between the border and the cell text. defaults to 0. units in points.
|
|
661
|
+
column_widths [2400, 1200]
|
|
662
|
+
# sets an explicit width for each column in the table grid. units in twips.
|
|
663
|
+
# defaults to the widths of the cells in the first row.
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
By default, the table grid is built from the cells in the first row. When that row contains a cell with a `colspan`, the grid can't be derived from it correctly and every column ends up the same width. `column_widths` sets the grid explicitly instead, taking one width per column.
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
docx.table [['Spans two columns'], ['Cell 1', 'Cell 2']] do
|
|
671
|
+
cell_style rows[0][0], colspan: 2
|
|
672
|
+
column_widths [2400, 1200]
|
|
645
673
|
end
|
|
646
674
|
```
|
|
647
675
|
|
|
@@ -683,6 +711,31 @@ end
|
|
|
683
711
|
|
|
684
712
|
*Note: content of cells 21 and 24 will disappear*
|
|
685
713
|
|
|
714
|
+
Table rows can be marked as header rows using the `header_rows` method which takes a number indicating how many rows from the top of the table should be marked as such. This has the effect of repeating these rows after each page break.
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
docx.table [['Header 1', 'Header 2'],['Cell 1', 'Cell 2']] do
|
|
718
|
+
header_rows 1
|
|
719
|
+
end
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Row can be marked as can't split rows that re-position themselves at the top of the next if they would have otherwise split across a page break.
|
|
723
|
+
|
|
724
|
+
```
|
|
725
|
+
docx.table [['11', '22'], ['14', '25'], ['64', '23']] do
|
|
726
|
+
cant_split rows[1]
|
|
727
|
+
end
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
or
|
|
731
|
+
|
|
732
|
+
```
|
|
733
|
+
docx.table [['11', '22'], ['14', '25'], ['64', '23']] do
|
|
734
|
+
cant_split rows[1...-1]
|
|
735
|
+
end
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
|
|
686
739
|
### Table Cells
|
|
687
740
|
|
|
688
741
|
If your table contains more complex data (multiple paragraphs, images, lists, etc.), you will probably want to instantiate your `TableCellModel` instances directly. With the exception of page breaks, table cells can contain anything the document can contain, including another table.
|
|
@@ -760,17 +813,19 @@ will not operate as expected in the parent Caracal document.
|
|
|
760
813
|
|
|
761
814
|
Again, this feature is considered experimental. Use at your own risk/discretion.
|
|
762
815
|
|
|
816
|
+
**Only embed documents you trust.** Caracal parses the file you supply and copies its markup into your output, so the embedded document controls part of the result. Caracal also places no limit on how much data it will decompress while reading the file, so a small crafted document can expand to an arbitrarily large one in memory. Documents uploaded by untrusted users should not be passed to `iframe` without your own validation. See [Security](#security).
|
|
817
|
+
|
|
763
818
|
|
|
764
819
|
## Template Rendering
|
|
765
820
|
|
|
766
821
|
Caracal includes [Tilt](https://github.com/rtomayko/tilt) integration to facilitate its inclusion in other frameworks.
|
|
767
822
|
|
|
768
|
-
Rails integration can be added via the [Caracal-Rails](https://github.com/
|
|
823
|
+
Rails integration can be added via the [Caracal-Rails](https://github.com/urvin-compliance/caracal-rails) gem.
|
|
769
824
|
|
|
770
825
|
|
|
771
826
|
## Using Variables
|
|
772
827
|
|
|
773
|
-
Lexical scope is a pretty big challenge for Caracal and it often confuses new users. This [closed issue](https://github.com/
|
|
828
|
+
Lexical scope is a pretty big challenge for Caracal and it often confuses new users. This [closed issue](https://github.com/urvin-compliance/caracal/issues/71) covers the discussion both from the user and library persepctive.
|
|
774
829
|
|
|
775
830
|
|
|
776
831
|
## Filing an Issue
|
|
@@ -789,7 +844,7 @@ happy to answer questions and provide what guidance we can. We just won't write
|
|
|
789
844
|
|
|
790
845
|
For those using reasonably current versions of Word, please consider the following:
|
|
791
846
|
|
|
792
|
-
- Before you file an issue, please run the example Caracal project [caracal-example](https://github.com/
|
|
847
|
+
- Before you file an issue, please run the example Caracal project [caracal-example](https://github.com/urvin-compliance/caracal-example) in your development environment and
|
|
793
848
|
check the output. This project implements nearly every feature Caracal supports and renders the expected output
|
|
794
849
|
correctly. It can be thought of as a canonical implementation of the library.
|
|
795
850
|
|
|
@@ -818,7 +873,7 @@ for 2010/2013, a set for 2016, etc.).
|
|
|
818
873
|
|
|
819
874
|
## Contributing
|
|
820
875
|
|
|
821
|
-
1. Fork it ( https://github.com/
|
|
876
|
+
1. Fork it ( https://github.com/urvin-compliance/caracal/fork )
|
|
822
877
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
823
878
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
824
879
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
@@ -837,6 +892,6 @@ A tip of the hat to the wonderful PDF generation library [Prawn](https://github.
|
|
|
837
892
|
|
|
838
893
|
## License
|
|
839
894
|
|
|
840
|
-
Copyright (c) 2014
|
|
895
|
+
Copyright (c) 2014 Urvin LLC
|
|
841
896
|
|
|
842
|
-
[MIT License](https://github.com/
|
|
897
|
+
[MIT License](https://github.com/urvin-compliance/caracal/blob/master/LICENSE.txt)
|
data/caracal.gemspec
CHANGED
|
@@ -6,11 +6,11 @@ require 'caracal/version'
|
|
|
6
6
|
Gem::Specification.new do |spec|
|
|
7
7
|
spec.name = 'caracal'
|
|
8
8
|
spec.version = Caracal::VERSION
|
|
9
|
-
spec.authors = ['
|
|
10
|
-
spec.email = ['jpdugan@gmail.com']
|
|
9
|
+
spec.authors = ['Urvin LLC', 'John Dugan', 'Dave Lauer']
|
|
10
|
+
spec.email = ['jpdugan@gmail.com', 'dave@plia.com']
|
|
11
11
|
spec.summary = %q{ Fast, professional Microsoft Word (docx) writer for Ruby. }
|
|
12
12
|
spec.description = %q{ Caracal is a pure Ruby Microsoft Word generation library that produces professional quality MSWord documents (docx) using a simple, HTML-style DSL. }
|
|
13
|
-
spec.homepage = 'https://github.com/
|
|
13
|
+
spec.homepage = 'https://github.com/urvin-compliance/caracal'
|
|
14
14
|
spec.license = 'MIT'
|
|
15
15
|
|
|
16
16
|
spec.files = `git ls-files -z`.split("\x0")
|
|
@@ -18,11 +18,13 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
19
19
|
spec.require_paths = ['lib']
|
|
20
20
|
|
|
21
|
+
spec.required_ruby_version = '>= 3.1'
|
|
22
|
+
|
|
21
23
|
spec.add_dependency 'nokogiri', '~> 1.6'
|
|
22
|
-
spec.add_dependency 'rubyzip', '
|
|
24
|
+
spec.add_dependency 'rubyzip', '>= 1.1.6', '< 4.0'
|
|
23
25
|
spec.add_dependency 'tilt', '>= 1.4'
|
|
24
26
|
|
|
25
|
-
spec.add_development_dependency 'bundler', '
|
|
26
|
-
spec.add_development_dependency 'rake', '
|
|
27
|
+
spec.add_development_dependency 'bundler', '>= 1.3'
|
|
28
|
+
spec.add_development_dependency 'rake', '>= 10.0'
|
|
27
29
|
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
28
30
|
end
|
data/lib/caracal/core/iframes.rb
CHANGED
|
@@ -20,11 +20,16 @@ module Caracal
|
|
|
20
20
|
model = Caracal::Core::Models::IFrameModel.new(options, &block)
|
|
21
21
|
if model.valid?
|
|
22
22
|
model.preprocess!
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
|
|
24
|
+
# table cells can't hold namespaces; the document
|
|
25
|
+
# collects those when it renders.
|
|
26
|
+
if respond_to?(:namespace)
|
|
27
|
+
model.namespaces.each do |(prefix, href)|
|
|
28
|
+
namespace({ prefix: prefix, href: href })
|
|
29
|
+
end
|
|
30
|
+
model.ignorables.each do |prefix|
|
|
31
|
+
ignorable(prefix)
|
|
32
|
+
end
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
contents << model
|
|
@@ -18,7 +18,7 @@ module Caracal
|
|
|
18
18
|
const_set(:DEFAULT_BORDER_COLOR, 'auto')
|
|
19
19
|
const_set(:DEFAULT_BORDER_LINE, :single)
|
|
20
20
|
const_set(:DEFAULT_BORDER_SIZE, 4) # 0.5pt in 1/8 points
|
|
21
|
-
const_set(:DEFAULT_BORDER_SPACING, 1) #
|
|
21
|
+
const_set(:DEFAULT_BORDER_SPACING, 1) # 1pt. w:space is measured in points, not 1/8 points.
|
|
22
22
|
const_set(:DEFAULT_BORDER_TYPE, :top)
|
|
23
23
|
|
|
24
24
|
# accessors
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'tempfile'
|
|
1
2
|
require 'caracal/core/models/base_model'
|
|
2
3
|
|
|
3
4
|
module Caracal
|
|
@@ -88,13 +89,11 @@ module Caracal
|
|
|
88
89
|
|
|
89
90
|
def file
|
|
90
91
|
@file ||= begin
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
file = open(iframe_url)
|
|
97
|
-
end
|
|
92
|
+
content = iframe_url.to_s.empty? ? iframe_data : ::Caracal::Utilities.read_resource(iframe_url)
|
|
93
|
+
file = Tempfile.new(['iframe', '.docx'])
|
|
94
|
+
file.binmode
|
|
95
|
+
file.write content
|
|
96
|
+
file.close # flushes the write; rubyzip reopens by path
|
|
98
97
|
file
|
|
99
98
|
end
|
|
100
99
|
end
|
|
@@ -27,6 +27,7 @@ module Caracal
|
|
|
27
27
|
attr_reader :paragraph_italic
|
|
28
28
|
attr_reader :paragraph_underline
|
|
29
29
|
attr_reader :paragraph_bgcolor
|
|
30
|
+
attr_reader :paragraph_keep_next
|
|
30
31
|
|
|
31
32
|
# initialization
|
|
32
33
|
def initialize(options={}, &block)
|
|
@@ -63,7 +64,7 @@ module Caracal
|
|
|
63
64
|
#========== SETTERS ===============================
|
|
64
65
|
|
|
65
66
|
# booleans
|
|
66
|
-
[:bold, :italic, :underline].each do |m|
|
|
67
|
+
[:bold, :italic, :keep_next, :underline].each do |m|
|
|
67
68
|
define_method "#{ m }" do |value|
|
|
68
69
|
instance_variable_set("@paragraph_#{ m }", !!value)
|
|
69
70
|
end
|
|
@@ -24,7 +24,17 @@ module Caracal
|
|
|
24
24
|
setting: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings',
|
|
25
25
|
style: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles'
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
IMAGE_SIGNATURES = {
|
|
29
|
+
"\x89PNG\r\n\x1A\n".b => 'png',
|
|
30
|
+
"\xFF\xD8\xFF".b => 'jpeg',
|
|
31
|
+
'GIF8'.b => 'gif',
|
|
32
|
+
'BM'.b => 'bmp',
|
|
33
|
+
"II*\x00".b => 'tiff',
|
|
34
|
+
"MM\x00*".b => 'tiff'
|
|
35
|
+
}
|
|
36
|
+
IMAGE_EXTENSIONS = %w(png jpeg jpg gif bmp tif tiff svg)
|
|
37
|
+
|
|
28
38
|
# accessors
|
|
29
39
|
attr_reader :relationship_id
|
|
30
40
|
attr_reader :relationship_key
|
|
@@ -46,14 +56,27 @@ module Caracal
|
|
|
46
56
|
|
|
47
57
|
def formatted_target
|
|
48
58
|
if relationship_type == :image
|
|
49
|
-
|
|
50
|
-
ext = ext.split('?').first
|
|
51
|
-
"media/image#{ relationship_id }.#{ ext }"
|
|
59
|
+
"media/image#{ relationship_id }.#{ image_extension }"
|
|
52
60
|
else
|
|
53
61
|
relationship_target
|
|
54
62
|
end
|
|
55
63
|
end
|
|
56
|
-
|
|
64
|
+
|
|
65
|
+
# Prefers the type detected from the image bytes, then the
|
|
66
|
+
# extension of the target's path (ignoring any query string),
|
|
67
|
+
# and falls back to png so the part name is always valid.
|
|
68
|
+
#
|
|
69
|
+
def image_extension
|
|
70
|
+
data = relationship_data.to_s.b
|
|
71
|
+
if (match = IMAGE_SIGNATURES.find { |sig, _| data.start_with?(sig) })
|
|
72
|
+
return match.last
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
path = relationship_target.to_s.split(/[?#]/).first.to_s
|
|
76
|
+
ext = File.extname(path).delete('.').downcase
|
|
77
|
+
IMAGE_EXTENSIONS.include?(ext) ? ext : 'png'
|
|
78
|
+
end
|
|
79
|
+
|
|
57
80
|
def formatted_type
|
|
58
81
|
TYPE_MAP.fetch(relationship_type)
|
|
59
82
|
end
|
|
@@ -21,7 +21,9 @@ module Caracal
|
|
|
21
21
|
const_set(:DEFAULT_TABLE_BORDER_COLOR, 'auto')
|
|
22
22
|
const_set(:DEFAULT_TABLE_BORDER_LINE, :single)
|
|
23
23
|
const_set(:DEFAULT_TABLE_BORDER_SIZE, 0) # units in 1/8 points
|
|
24
|
-
const_set(:DEFAULT_TABLE_BORDER_SPACING, 0)
|
|
24
|
+
const_set(:DEFAULT_TABLE_BORDER_SPACING, 0)
|
|
25
|
+
const_set(:DEFAULT_TABLE_HEADER_ROWS, 0)
|
|
26
|
+
const_set(:DEFAULT_TABLE_CANT_SPLIT_ROWS, {})
|
|
25
27
|
|
|
26
28
|
# accessors
|
|
27
29
|
attr_reader :table_align
|
|
@@ -36,7 +38,9 @@ module Caracal
|
|
|
36
38
|
attr_reader :table_border_right # returns border model
|
|
37
39
|
attr_reader :table_border_horizontal # returns border model
|
|
38
40
|
attr_reader :table_border_vertical # returns border model
|
|
39
|
-
|
|
41
|
+
attr_reader :table_header_rows
|
|
42
|
+
attr_reader :table_column_widths
|
|
43
|
+
|
|
40
44
|
# initialization
|
|
41
45
|
def initialize(options={}, &block)
|
|
42
46
|
@table_align = DEFAULT_TABLE_ALIGN
|
|
@@ -44,7 +48,9 @@ module Caracal
|
|
|
44
48
|
@table_border_line = DEFAULT_TABLE_BORDER_LINE
|
|
45
49
|
@table_border_size = DEFAULT_TABLE_BORDER_SIZE
|
|
46
50
|
@table_border_spacing = DEFAULT_TABLE_BORDER_SPACING
|
|
47
|
-
|
|
51
|
+
@table_header_rows = DEFAULT_TABLE_HEADER_ROWS
|
|
52
|
+
@table_rows_cant_split = {}
|
|
53
|
+
|
|
48
54
|
super options, &block
|
|
49
55
|
end
|
|
50
56
|
|
|
@@ -99,8 +105,33 @@ module Caracal
|
|
|
99
105
|
m.apply_styles(options)
|
|
100
106
|
end
|
|
101
107
|
end
|
|
108
|
+
|
|
109
|
+
# This method allows a user to specify one or more
|
|
110
|
+
# rows that should be rendered with the
|
|
111
|
+
# <w:cant_split /> property
|
|
112
|
+
#
|
|
113
|
+
# For example
|
|
114
|
+
#
|
|
115
|
+
# docx.table data do |t|
|
|
116
|
+
# t.cant_split r.rows[0...-1]
|
|
117
|
+
# end
|
|
118
|
+
#
|
|
119
|
+
# would cause all rows in the table to be rendered
|
|
120
|
+
# with the <w:cant_split /> property.
|
|
121
|
+
def cant_split(models, options={})
|
|
122
|
+
models = rows.include?(models) ? [models] : models
|
|
123
|
+
models.each do |r|
|
|
124
|
+
@table_rows_cant_split[rows.index(r)] = true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
102
127
|
|
|
103
|
-
|
|
128
|
+
# This method returns true or false depending on
|
|
129
|
+
# if a row has been marked as a can't split row
|
|
130
|
+
# via the cant_split method.
|
|
131
|
+
def cant_split?(row_index)
|
|
132
|
+
!!@table_rows_cant_split[row_index]
|
|
133
|
+
end
|
|
134
|
+
|
|
104
135
|
#=============== GETTERS ==============================
|
|
105
136
|
|
|
106
137
|
# border attrs
|
|
@@ -121,7 +152,7 @@ module Caracal
|
|
|
121
152
|
#=============== SETTERS ==============================
|
|
122
153
|
|
|
123
154
|
# integers
|
|
124
|
-
[:border_size, :border_spacing, :width].each do |m|
|
|
155
|
+
[:border_size, :border_spacing, :header_rows, :width].each do |m|
|
|
125
156
|
define_method "#{ m }" do |value|
|
|
126
157
|
instance_variable_set("@table_#{ m }", value.to_i)
|
|
127
158
|
end
|
|
@@ -148,7 +179,12 @@ module Caracal
|
|
|
148
179
|
instance_variable_set("@table_#{ m }", value.to_s.to_sym)
|
|
149
180
|
end
|
|
150
181
|
end
|
|
151
|
-
|
|
182
|
+
|
|
183
|
+
# column widths
|
|
184
|
+
def column_widths(value)
|
|
185
|
+
@table_column_widths = value.map(&:to_i) if value.is_a?(Array)
|
|
186
|
+
end
|
|
187
|
+
|
|
152
188
|
# .data
|
|
153
189
|
def data(value)
|
|
154
190
|
begin
|
|
@@ -196,6 +232,7 @@ module Caracal
|
|
|
196
232
|
k << [:data, :align, :width]
|
|
197
233
|
k << [:border_color, :border_line, :border_size, :border_spacing]
|
|
198
234
|
k << [:border_bottom, :border_left, :border_right, :border_top, :border_horizontal, :border_vertical]
|
|
235
|
+
k << [:column_widths]
|
|
199
236
|
k.flatten
|
|
200
237
|
end
|
|
201
238
|
|
|
@@ -203,4 +240,4 @@ module Caracal
|
|
|
203
240
|
|
|
204
241
|
end
|
|
205
242
|
end
|
|
206
|
-
end
|
|
243
|
+
end
|
data/lib/caracal/document.rb
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require 'open-uri'
|
|
2
1
|
require 'zip'
|
|
3
2
|
|
|
4
3
|
require 'caracal/core/bookmarks'
|
|
@@ -136,6 +135,8 @@ module Caracal
|
|
|
136
135
|
# a string buffer. Order is important!
|
|
137
136
|
#
|
|
138
137
|
def render
|
|
138
|
+
register_nested_iframes(contents)
|
|
139
|
+
|
|
139
140
|
buffer = ::Zip::OutputStream.write_buffer do |zip|
|
|
140
141
|
render_package_relationships(zip)
|
|
141
142
|
render_content_types(zip)
|
|
@@ -168,6 +169,28 @@ module Caracal
|
|
|
168
169
|
#------------------------------------------------------
|
|
169
170
|
private
|
|
170
171
|
|
|
172
|
+
#============ PREPROCESSING ===========================
|
|
173
|
+
|
|
174
|
+
# Iframes inside table cells can't register their namespaces
|
|
175
|
+
# on the document when they're created, so collect them here.
|
|
176
|
+
#
|
|
177
|
+
def register_nested_iframes(models)
|
|
178
|
+
models.each do |model|
|
|
179
|
+
case model
|
|
180
|
+
when Caracal::Core::Models::IFrameModel
|
|
181
|
+
model.namespaces.each do |(prefix, href)|
|
|
182
|
+
namespace({ prefix: prefix, href: href })
|
|
183
|
+
end
|
|
184
|
+
model.ignorables.each do |prefix|
|
|
185
|
+
ignorable(prefix)
|
|
186
|
+
end
|
|
187
|
+
when Caracal::Core::Models::TableModel
|
|
188
|
+
model.cells.each { |cell| register_nested_iframes(cell.contents) }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
|
|
171
194
|
#============ RENDERERS ===============================
|
|
172
195
|
|
|
173
196
|
def render_app(zip)
|
|
@@ -225,7 +248,7 @@ module Caracal
|
|
|
225
248
|
if rel.relationship_data.to_s.size > 0
|
|
226
249
|
content = rel.relationship_data
|
|
227
250
|
else
|
|
228
|
-
content =
|
|
251
|
+
content = ::Caracal::Utilities.read_resource(rel.relationship_target)
|
|
229
252
|
end
|
|
230
253
|
|
|
231
254
|
zip.put_next_entry("word/#{ rel.formatted_target }")
|
|
@@ -17,10 +17,14 @@ module Caracal
|
|
|
17
17
|
def to_xml
|
|
18
18
|
builder = ::Nokogiri::XML::Builder.with(declaration_xml) do |xml|
|
|
19
19
|
xml.send 'Types', root_options do
|
|
20
|
+
xml.send 'Default', { 'Extension' => 'bmp', 'ContentType' => 'image/bmp' }
|
|
20
21
|
xml.send 'Default', { 'Extension' => 'gif', 'ContentType' => 'image/gif' }
|
|
21
22
|
xml.send 'Default', { 'Extension' => 'jpeg', 'ContentType' => 'image/jpeg' }
|
|
22
23
|
xml.send 'Default', { 'Extension' => 'jpg', 'ContentType' => 'image/jpeg' }
|
|
23
24
|
xml.send 'Default', { 'Extension' => 'png', 'ContentType' => 'image/png' }
|
|
25
|
+
xml.send 'Default', { 'Extension' => 'svg', 'ContentType' => 'image/svg+xml' }
|
|
26
|
+
xml.send 'Default', { 'Extension' => 'tif', 'ContentType' => 'image/tiff' }
|
|
27
|
+
xml.send 'Default', { 'Extension' => 'tiff', 'ContentType' => 'image/tiff' }
|
|
24
28
|
xml.send 'Default', { 'Extension' => 'rels', 'ContentType' => 'application/vnd.openxmlformats-package.relationships+xml' }
|
|
25
29
|
xml.send 'Default', { 'Extension' => 'xml', 'ContentType' => 'application/xml' }
|
|
26
30
|
xml.send 'Override', { 'PartName' => '/docProps/app.xml', 'ContentType' => 'application/vnd.openxmlformats-officedocument.extended-properties+xml' }
|
|
@@ -23,7 +23,7 @@ module Caracal
|
|
|
23
23
|
{ fmtid: '{D5CDD505-2E9C-101B-9397-08002B2CF9AE}', pid: index + 2, name: property.custom_property_name } do
|
|
24
24
|
case property.custom_property_type.downcase
|
|
25
25
|
when 'text'
|
|
26
|
-
xml['vt'].lpwstr property.custom_property_value
|
|
26
|
+
xml['vt'].lpwstr xml_safe(property.custom_property_value)
|
|
27
27
|
when 'date'
|
|
28
28
|
xml['vt'].filetime property.custom_property_value.to_date
|
|
29
29
|
when 'number'
|
|
@@ -33,11 +33,11 @@ module Caracal
|
|
|
33
33
|
xml['vt'].bool property.custom_property_value
|
|
34
34
|
else
|
|
35
35
|
# Not a boolean sent, so reverting to string so docx will open
|
|
36
|
-
xml['vt'].lpwstr property.custom_property_value
|
|
36
|
+
xml['vt'].lpwstr xml_safe(property.custom_property_value)
|
|
37
37
|
end
|
|
38
38
|
else
|
|
39
39
|
# Fail to string type
|
|
40
|
-
xml['vt'].lpwstr property.custom_property_value
|
|
40
|
+
xml['vt'].lpwstr xml_safe(property.custom_property_value)
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -214,7 +214,7 @@ module Caracal
|
|
|
214
214
|
xml['w'].hyperlink(hyperlink_options) do
|
|
215
215
|
xml['w'].r run_options do
|
|
216
216
|
render_run_attributes(xml, model, false)
|
|
217
|
-
xml['w'].t({ 'xml:space' => 'preserve' }, model.link_content)
|
|
217
|
+
xml['w'].t({ 'xml:space' => 'preserve' }, xml_safe(model.link_content))
|
|
218
218
|
end
|
|
219
219
|
end
|
|
220
220
|
end
|
|
@@ -275,6 +275,7 @@ module Caracal
|
|
|
275
275
|
xml['w'].pStyle({ 'w:val' => model.paragraph_style }) unless model.paragraph_style.nil?
|
|
276
276
|
xml['w'].contextualSpacing({ 'w:val' => '0' })
|
|
277
277
|
xml['w'].jc({ 'w:val' => model.paragraph_align }) unless model.paragraph_align.nil?
|
|
278
|
+
xml['w'].keepNext if model.paragraph_keep_next == true
|
|
278
279
|
render_run_attributes(xml, model, true)
|
|
279
280
|
end
|
|
280
281
|
model.runs.each do |run|
|
|
@@ -299,7 +300,7 @@ module Caracal
|
|
|
299
300
|
def render_text(xml, model)
|
|
300
301
|
xml['w'].r run_options do
|
|
301
302
|
render_run_attributes(xml, model, false)
|
|
302
|
-
xml['w'].t({ 'xml:space' => 'preserve' }, model.text_content)
|
|
303
|
+
xml['w'].t({ 'xml:space' => 'preserve' }, xml_safe(model.text_content))
|
|
303
304
|
end
|
|
304
305
|
end
|
|
305
306
|
|
|
@@ -332,25 +333,28 @@ module Caracal
|
|
|
332
333
|
xml['w'].tblLook({ 'w:val' => '0600' })
|
|
333
334
|
end
|
|
334
335
|
xml['w'].tblGrid do
|
|
335
|
-
model.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
336
|
+
column_widths = model.table_column_widths
|
|
337
|
+
column_widths ||= model.rows.first.map do |tc|
|
|
338
|
+
[tc.cell_width] * (tc.cell_colspan || 1)
|
|
339
|
+
end.flatten
|
|
340
|
+
|
|
341
|
+
column_widths.each do |width|
|
|
342
|
+
xml['w'].gridCol({ 'w:w' => width })
|
|
339
343
|
end
|
|
344
|
+
|
|
340
345
|
xml['w'].tblGridChange({ 'w:id' => '0' }) do
|
|
341
346
|
xml['w'].tblGrid do
|
|
342
|
-
|
|
343
|
-
(
|
|
344
|
-
xml['w'].gridCol({ 'w:w' => tc.cell_width })
|
|
345
|
-
end
|
|
347
|
+
column_widths.each do |width|
|
|
348
|
+
xml['w'].gridCol({ 'w:w' => width })
|
|
346
349
|
end
|
|
347
350
|
end
|
|
348
351
|
end
|
|
349
352
|
end
|
|
350
353
|
|
|
351
354
|
rowspan_hash = {}
|
|
352
|
-
model.rows.each do |row|
|
|
355
|
+
model.rows.each.with_index do |row, row_index|
|
|
353
356
|
xml['w'].tr do
|
|
357
|
+
render_table_row_properties(xml, model, row_index)
|
|
354
358
|
tc_index = 0
|
|
355
359
|
row.each do |tc|
|
|
356
360
|
xml['w'].tc do
|
|
@@ -390,6 +394,13 @@ module Caracal
|
|
|
390
394
|
end
|
|
391
395
|
end
|
|
392
396
|
|
|
397
|
+
def render_table_row_properties(xml, model, index)
|
|
398
|
+
xml['w'].trPr do
|
|
399
|
+
xml['w'].cantSplit if model.cant_split?(index)
|
|
400
|
+
xml['w'].tblHeader if index < model.table_header_rows
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
393
404
|
|
|
394
405
|
#============= OPTIONS ===================================
|
|
395
406
|
|
|
@@ -71,6 +71,14 @@ module Caracal
|
|
|
71
71
|
{ 'w:rsidR' => '00000000', 'w:rsidRPr' => '00000000', 'w:rsidDel' => '00000000' }
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# This method removes control characters that XML 1.0 doesn't
|
|
75
|
+
# allow. A single one makes Word reject the whole document.
|
|
76
|
+
#
|
|
77
|
+
def xml_safe(value)
|
|
78
|
+
str = value.to_s
|
|
79
|
+
str.valid_encoding? ? str.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F]/, '') : str
|
|
80
|
+
end
|
|
81
|
+
|
|
74
82
|
# These save options force Nokogiri to remove indentation and
|
|
75
83
|
# line feeds from the output.
|
|
76
84
|
#
|
data/lib/caracal/utilities.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'open-uri'
|
|
2
|
+
|
|
1
3
|
# We're using this strategy borrowed from ActiveSupport to
|
|
2
4
|
# make command syntax a little more flexible. In a perfect
|
|
3
5
|
# world we'd just use the double splat feature of Ruby, but
|
|
@@ -18,6 +20,19 @@ module Caracal
|
|
|
18
20
|
{}
|
|
19
21
|
end
|
|
20
22
|
end
|
|
21
|
-
|
|
23
|
+
|
|
24
|
+
# Reads binary content from an http(s) URL or a local file path.
|
|
25
|
+
# Avoids Kernel#open, which no longer fetches URLs on Ruby 3+,
|
|
26
|
+
# reads in text mode on Windows, and runs a leading "|" as a
|
|
27
|
+
# shell command.
|
|
28
|
+
def self.read_resource(location)
|
|
29
|
+
location = location.to_s
|
|
30
|
+
if location =~ /\Ahttps?:\/\//i
|
|
31
|
+
URI.open(location, 'rb', &:read)
|
|
32
|
+
else
|
|
33
|
+
File.binread(location)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
end
|
|
23
38
|
end
|
data/lib/caracal/version.rb
CHANGED
data/lib/caracal.rb
CHANGED
|
@@ -11,6 +11,7 @@ describe Caracal::Core::Models::ParagraphModel do
|
|
|
11
11
|
italic false
|
|
12
12
|
underline true
|
|
13
13
|
bgcolor 'cccccc'
|
|
14
|
+
keep_next true
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -31,6 +32,7 @@ describe Caracal::Core::Models::ParagraphModel do
|
|
|
31
32
|
it { expect(subject.paragraph_italic).to eq false }
|
|
32
33
|
it { expect(subject.paragraph_underline).to eq true }
|
|
33
34
|
it { expect(subject.paragraph_bgcolor).to eq 'cccccc' }
|
|
35
|
+
it { expect(subject.paragraph_keep_next).to eq true }
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
end
|
|
@@ -70,6 +72,11 @@ describe Caracal::Core::Models::ParagraphModel do
|
|
|
70
72
|
|
|
71
73
|
it { expect(subject.paragraph_italic).to eq true }
|
|
72
74
|
end
|
|
75
|
+
describe '.keep_next' do
|
|
76
|
+
before { subject.keep_next(true) }
|
|
77
|
+
|
|
78
|
+
it { expect(subject.paragraph_keep_next).to eq true }
|
|
79
|
+
end
|
|
73
80
|
describe '.underline' do
|
|
74
81
|
before { subject.underline(true) }
|
|
75
82
|
|
|
@@ -72,6 +72,24 @@ describe Caracal::Core::Models::TableModel do
|
|
|
72
72
|
describe '.cells' do
|
|
73
73
|
it { expect(subject.cells[0]).to eq 'top left' }
|
|
74
74
|
end
|
|
75
|
+
|
|
76
|
+
# .cant_split and cant_split?
|
|
77
|
+
describe '.cant_split and .cant_split?' do
|
|
78
|
+
it { expect(subject.cant_split?(0)).to eq(false) }
|
|
79
|
+
|
|
80
|
+
describe "when first row can't split" do
|
|
81
|
+
before { subject.cant_split subject.rows[0] }
|
|
82
|
+
|
|
83
|
+
it { expect(subject.cant_split?(0)).to eq(true) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe 'when no rows can be split' do
|
|
87
|
+
before { subject.cant_split subject.rows }
|
|
88
|
+
|
|
89
|
+
it { expect(subject.cant_split?(0)).to eq(true) }
|
|
90
|
+
it { expect(subject.cant_split?(1)).to eq(true) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
75
93
|
end
|
|
76
94
|
|
|
77
95
|
|
|
@@ -178,6 +196,13 @@ describe Caracal::Core::Models::TableModel do
|
|
|
178
196
|
|
|
179
197
|
it { expect(subject.table_width).to eq 7500 }
|
|
180
198
|
end
|
|
199
|
+
|
|
200
|
+
# .column_widths
|
|
201
|
+
describe '.column_widths' do
|
|
202
|
+
before { subject.column_widths([2400, '1200']) }
|
|
203
|
+
|
|
204
|
+
it { expect(subject.table_column_widths).to eq [2400, 1200] }
|
|
205
|
+
end
|
|
181
206
|
|
|
182
207
|
|
|
183
208
|
|
|
@@ -209,7 +234,7 @@ describe Caracal::Core::Models::TableModel do
|
|
|
209
234
|
# .option_keys
|
|
210
235
|
describe '.option_keys' do
|
|
211
236
|
let(:actual) { subject.send(:option_keys).sort }
|
|
212
|
-
let(:expected1) { [:data, :align, :width] }
|
|
237
|
+
let(:expected1) { [:data, :align, :width, :column_widths] }
|
|
213
238
|
let(:expected2) { [:border_color, :border_line, :border_size, :border_spacing] }
|
|
214
239
|
let(:expected3) { [:border_top, :border_bottom, :border_left, :border_right, :border_horizontal, :border_vertical] }
|
|
215
240
|
let(:expected) { (expected1 + expected2 + expected3).sort }
|
|
@@ -219,4 +244,4 @@ describe Caracal::Core::Models::TableModel do
|
|
|
219
244
|
|
|
220
245
|
end
|
|
221
246
|
|
|
222
|
-
end
|
|
247
|
+
end
|
|
@@ -18,8 +18,15 @@ describe Caracal::Core::Tables do
|
|
|
18
18
|
|
|
19
19
|
it { expect(subject.contents.size).to eq size + 1 }
|
|
20
20
|
it { expect(subject.contents.last).to be_a(Caracal::Core::Models::TableModel) }
|
|
21
|
+
it { expect(subject.contents.last.table_header_rows).to eq(0) }
|
|
22
|
+
|
|
23
|
+
context 'when .header_rows' do
|
|
24
|
+
before { subject.table [['Sample Text']] do header_rows 1 end }
|
|
25
|
+
|
|
26
|
+
it { expect(subject.contents.last.table_header_rows).to eq(1) }
|
|
27
|
+
end
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
end
|
|
24
31
|
|
|
25
|
-
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'stringio'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
describe Caracal::Document do
|
|
6
|
+
W_NS = { 'w' => 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' }
|
|
7
|
+
|
|
8
|
+
let(:png) { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='.unpack1('m') }
|
|
9
|
+
let(:jpeg) { "\xFF\xD8\xFF\xE0".b + 'rest of jpeg' }
|
|
10
|
+
# the PNG plus bytes a text-mode read would mangle on Windows
|
|
11
|
+
let(:image_bytes) { png + "\r\n\x1A\r\n".b }
|
|
12
|
+
|
|
13
|
+
def parts(docx)
|
|
14
|
+
result = {}
|
|
15
|
+
Zip::File.open_buffer(StringIO.new(docx.render.string)) do |zip|
|
|
16
|
+
zip.each { |e| result[e.name] = e.get_input_stream.read }
|
|
17
|
+
end
|
|
18
|
+
result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def strict_xml(str)
|
|
22
|
+
Nokogiri::XML(str) { |config| config.strict }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#-------------------------------------------------------------
|
|
27
|
+
# Control characters
|
|
28
|
+
#-------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe 'text containing characters XML does not allow' do
|
|
31
|
+
let(:text) { "a\x01b\x0Bc\td" }
|
|
32
|
+
|
|
33
|
+
it 'strips them from paragraphs, links and table cells' do
|
|
34
|
+
text = self.text
|
|
35
|
+
docx = described_class.new('test.docx')
|
|
36
|
+
docx.p text
|
|
37
|
+
docx.p { link text, 'https://www.example.com' }
|
|
38
|
+
docx.table [[text]]
|
|
39
|
+
|
|
40
|
+
xml = parts(docx)['word/document.xml']
|
|
41
|
+
expect { strict_xml(xml) }.not_to raise_error
|
|
42
|
+
expect(xml.scan("abc\td").size).to eq 3
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'strips them from custom properties' do
|
|
46
|
+
text = self.text
|
|
47
|
+
docx = described_class.new('test.docx')
|
|
48
|
+
docx.custom_property { |p| p.name 'note'; p.value text; p.type :text }
|
|
49
|
+
|
|
50
|
+
expect { strict_xml(parts(docx)['docProps/custom.xml']) }.not_to raise_error
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#-------------------------------------------------------------
|
|
56
|
+
# Image part names
|
|
57
|
+
#-------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe 'image part names' do
|
|
60
|
+
it 'uses the type of the supplied data rather than the URL' do
|
|
61
|
+
png, jpeg = self.png, self.jpeg
|
|
62
|
+
docx = described_class.new('test.docx')
|
|
63
|
+
docx.img('https://bucket.example.com/rails/blobs/abc123?signature=x', width: 10, height: 10) { |i| i.data png }
|
|
64
|
+
docx.img('https://bucket.example.com/photo.png?signature=y', width: 10, height: 10) { |i| i.data jpeg }
|
|
65
|
+
|
|
66
|
+
media = parts(docx).keys.grep(%r{\Aword/media/})
|
|
67
|
+
expect(media.map { |name| File.extname(name) }).to eq %w(.png .jpeg)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
'https://example.com/a/photo.JPG?x=1#frag' => 'jpg',
|
|
72
|
+
'https://example.com/rails/blobs/abc' => 'png',
|
|
73
|
+
'https://example.com' => 'png',
|
|
74
|
+
'/tmp/picture.gif' => 'gif',
|
|
75
|
+
'Picture 1' => 'png'
|
|
76
|
+
}.each do |target, ext|
|
|
77
|
+
it "names a #{ target.inspect } target with .#{ ext } when no data is supplied" do
|
|
78
|
+
rel = Caracal::Core::Models::RelationshipModel.new(id: 1, type: :image, target: target)
|
|
79
|
+
expect(rel.formatted_target).to eq "media/image1.#{ ext }"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'registers content types for bmp, tiff and svg' do
|
|
84
|
+
xml = parts(described_class.new('test.docx'))['[Content_Types].xml']
|
|
85
|
+
%w(bmp tif tiff svg).each { |ext| expect(xml).to include %(Extension="#{ ext }") }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#-------------------------------------------------------------
|
|
91
|
+
# Image sources
|
|
92
|
+
#-------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe 'image sources' do
|
|
95
|
+
def media(docx)
|
|
96
|
+
parts(docx).select { |name, _| name.start_with?('word/media/') }.values
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'embeds a local image byte-for-byte' do
|
|
100
|
+
file = Tempfile.new(['image', '.png'])
|
|
101
|
+
file.binmode
|
|
102
|
+
file.write(image_bytes)
|
|
103
|
+
file.close
|
|
104
|
+
|
|
105
|
+
docx = described_class.new('test.docx')
|
|
106
|
+
docx.img file.path, width: 10, height: 10
|
|
107
|
+
|
|
108
|
+
expect(media(docx)).to eq [image_bytes]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'fetches an http(s) image via URI.open' do
|
|
112
|
+
url = 'https://www.example.com/image.png'
|
|
113
|
+
bytes = image_bytes
|
|
114
|
+
expect(URI).to receive(:open).with(url, 'rb') { |*_, &blk| blk.call(StringIO.new(bytes)) }
|
|
115
|
+
|
|
116
|
+
docx = described_class.new('test.docx')
|
|
117
|
+
docx.img url, width: 10, height: 10
|
|
118
|
+
|
|
119
|
+
expect(media(docx)).to eq [bytes]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'does not run a "|" target as a shell command' do
|
|
123
|
+
marker = File.join(Dir.tmpdir, "caracal-pipe-#{ Process.pid }")
|
|
124
|
+
docx = described_class.new('test.docx')
|
|
125
|
+
docx.img "|touch #{ marker }", width: 10, height: 10
|
|
126
|
+
|
|
127
|
+
expect { docx.render }.to raise_error(Errno::ENOENT)
|
|
128
|
+
expect(File.exist?(marker)).to eq false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
#-------------------------------------------------------------
|
|
134
|
+
# Table column widths
|
|
135
|
+
#-------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe 'table grid column widths' do
|
|
138
|
+
let(:data) do
|
|
139
|
+
[
|
|
140
|
+
[{ content: 'First', width: 2400 }, { content: 'Second', width: 1200 }],
|
|
141
|
+
[{ content: 'Third', width: 1800 }, { content: 'Fourth', width: 1800 }]
|
|
142
|
+
]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'uses explicit column widths when set' do
|
|
146
|
+
docx = described_class.new('test.docx')
|
|
147
|
+
docx.table(data) { column_widths [1000, 2600] }
|
|
148
|
+
|
|
149
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
150
|
+
expect(xml.xpath('//w:tbl/w:tblGrid/w:gridCol/@w:w', W_NS).map(&:value)).to eq %w(1000 2600)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'falls back to the first row widths otherwise' do
|
|
154
|
+
docx = described_class.new('test.docx')
|
|
155
|
+
docx.table(data)
|
|
156
|
+
|
|
157
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
158
|
+
expect(xml.xpath('//w:tbl/w:tblGrid/w:gridCol/@w:w', W_NS).map(&:value)).to eq %w(2400 1200)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
#-------------------------------------------------------------
|
|
164
|
+
# Iframes
|
|
165
|
+
#-------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
describe 'iframes' do
|
|
168
|
+
let(:snippet) do
|
|
169
|
+
inner = described_class.new('inner.docx')
|
|
170
|
+
inner.p 'Text from the iframe'
|
|
171
|
+
inner.render.string
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'renders an iframe at the top level' do
|
|
175
|
+
docx = described_class.new('test.docx')
|
|
176
|
+
docx.iframe data: snippet
|
|
177
|
+
|
|
178
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
179
|
+
expect(xml.at_xpath('//w:body/w:p/w:r/w:t[.="Text from the iframe"]', W_NS)).not_to be_nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it 'renders an iframe inside a table cell' do
|
|
183
|
+
data = snippet
|
|
184
|
+
cell = Caracal::Core::Models::TableCellModel.new { |c| c.iframe(data: data) }
|
|
185
|
+
docx = described_class.new('test.docx')
|
|
186
|
+
docx.table [[cell]]
|
|
187
|
+
|
|
188
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
189
|
+
expect(xml.at_xpath('//w:tc//w:t[.="Text from the iframe"]', W_NS)).not_to be_nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'uses the data when the url is explicitly nil' do
|
|
193
|
+
docx = described_class.new('test.docx')
|
|
194
|
+
docx.iframe url: nil, data: snippet
|
|
195
|
+
|
|
196
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
197
|
+
expect(xml.at_xpath('//w:body/w:p/w:r/w:t[.="Text from the iframe"]', W_NS)).not_to be_nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'renders an iframe fetched from a URL' do
|
|
201
|
+
url = 'https://www.example.com/snippet.docx'
|
|
202
|
+
data = snippet
|
|
203
|
+
expect(URI).to receive(:open).with(url, 'rb') { |*_, &blk| blk.call(StringIO.new(data)) }
|
|
204
|
+
|
|
205
|
+
docx = described_class.new('test.docx')
|
|
206
|
+
docx.iframe url: url
|
|
207
|
+
|
|
208
|
+
xml = strict_xml(parts(docx)['word/document.xml'])
|
|
209
|
+
expect(xml.at_xpath('//w:body/w:p/w:r/w:t[.="Text from the iframe"]', W_NS)).not_to be_nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: caracal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Urvin LLC
|
|
8
8
|
- John Dugan
|
|
9
|
-
|
|
9
|
+
- Dave Lauer
|
|
10
|
+
autorequire:
|
|
10
11
|
bindir: bin
|
|
11
12
|
cert_chain: []
|
|
12
|
-
date:
|
|
13
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
13
14
|
dependencies:
|
|
14
15
|
- !ruby/object:Gem::Dependency
|
|
15
16
|
name: nokogiri
|
|
@@ -29,16 +30,22 @@ dependencies:
|
|
|
29
30
|
name: rubyzip
|
|
30
31
|
requirement: !ruby/object:Gem::Requirement
|
|
31
32
|
requirements:
|
|
32
|
-
- - "
|
|
33
|
+
- - ">="
|
|
33
34
|
- !ruby/object:Gem::Version
|
|
34
|
-
version:
|
|
35
|
+
version: 1.1.6
|
|
36
|
+
- - "<"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '4.0'
|
|
35
39
|
type: :runtime
|
|
36
40
|
prerelease: false
|
|
37
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
38
42
|
requirements:
|
|
39
|
-
- - "
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 1.1.6
|
|
46
|
+
- - "<"
|
|
40
47
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: '
|
|
48
|
+
version: '4.0'
|
|
42
49
|
- !ruby/object:Gem::Dependency
|
|
43
50
|
name: tilt
|
|
44
51
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,28 +64,28 @@ dependencies:
|
|
|
57
64
|
name: bundler
|
|
58
65
|
requirement: !ruby/object:Gem::Requirement
|
|
59
66
|
requirements:
|
|
60
|
-
- - "
|
|
67
|
+
- - ">="
|
|
61
68
|
- !ruby/object:Gem::Version
|
|
62
69
|
version: '1.3'
|
|
63
70
|
type: :development
|
|
64
71
|
prerelease: false
|
|
65
72
|
version_requirements: !ruby/object:Gem::Requirement
|
|
66
73
|
requirements:
|
|
67
|
-
- - "
|
|
74
|
+
- - ">="
|
|
68
75
|
- !ruby/object:Gem::Version
|
|
69
76
|
version: '1.3'
|
|
70
77
|
- !ruby/object:Gem::Dependency
|
|
71
78
|
name: rake
|
|
72
79
|
requirement: !ruby/object:Gem::Requirement
|
|
73
80
|
requirements:
|
|
74
|
-
- - "
|
|
81
|
+
- - ">="
|
|
75
82
|
- !ruby/object:Gem::Version
|
|
76
83
|
version: '10.0'
|
|
77
84
|
type: :development
|
|
78
85
|
prerelease: false
|
|
79
86
|
version_requirements: !ruby/object:Gem::Requirement
|
|
80
87
|
requirements:
|
|
81
|
-
- - "
|
|
88
|
+
- - ">="
|
|
82
89
|
- !ruby/object:Gem::Version
|
|
83
90
|
version: '10.0'
|
|
84
91
|
- !ruby/object:Gem::Dependency
|
|
@@ -99,12 +106,13 @@ description: " Caracal is a pure Ruby Microsoft Word generation library that pro
|
|
|
99
106
|
professional quality MSWord documents (docx) using a simple, HTML-style DSL. "
|
|
100
107
|
email:
|
|
101
108
|
- jpdugan@gmail.com
|
|
109
|
+
- dave@plia.com
|
|
102
110
|
executables: []
|
|
103
111
|
extensions: []
|
|
104
112
|
extra_rdoc_files: []
|
|
105
113
|
files:
|
|
114
|
+
- ".github/workflows/ci.yml"
|
|
106
115
|
- ".gitignore"
|
|
107
|
-
- ".travis.yml"
|
|
108
116
|
- CHANGELOG.md
|
|
109
117
|
- Gemfile
|
|
110
118
|
- LICENSE.txt
|
|
@@ -212,14 +220,15 @@ files:
|
|
|
212
220
|
- spec/lib/caracal/core/styles_spec.rb
|
|
213
221
|
- spec/lib/caracal/core/tables_spec.rb
|
|
214
222
|
- spec/lib/caracal/core/text_spec.rb
|
|
223
|
+
- spec/lib/caracal/document_output_spec.rb
|
|
215
224
|
- spec/lib/caracal/errors_spec.rb
|
|
216
225
|
- spec/spec_helper.rb
|
|
217
226
|
- spec/support/_fixtures/snippet.docx
|
|
218
|
-
homepage: https://github.com/
|
|
227
|
+
homepage: https://github.com/urvin-compliance/caracal
|
|
219
228
|
licenses:
|
|
220
229
|
- MIT
|
|
221
230
|
metadata: {}
|
|
222
|
-
post_install_message:
|
|
231
|
+
post_install_message:
|
|
223
232
|
rdoc_options: []
|
|
224
233
|
require_paths:
|
|
225
234
|
- lib
|
|
@@ -227,16 +236,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
227
236
|
requirements:
|
|
228
237
|
- - ">="
|
|
229
238
|
- !ruby/object:Gem::Version
|
|
230
|
-
version: '
|
|
239
|
+
version: '3.1'
|
|
231
240
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
232
241
|
requirements:
|
|
233
242
|
- - ">="
|
|
234
243
|
- !ruby/object:Gem::Version
|
|
235
244
|
version: '0'
|
|
236
245
|
requirements: []
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
signing_key:
|
|
246
|
+
rubygems_version: 3.5.22
|
|
247
|
+
signing_key:
|
|
240
248
|
specification_version: 4
|
|
241
249
|
summary: Fast, professional Microsoft Word (docx) writer for Ruby.
|
|
242
250
|
test_files:
|
|
@@ -280,6 +288,7 @@ test_files:
|
|
|
280
288
|
- spec/lib/caracal/core/styles_spec.rb
|
|
281
289
|
- spec/lib/caracal/core/tables_spec.rb
|
|
282
290
|
- spec/lib/caracal/core/text_spec.rb
|
|
291
|
+
- spec/lib/caracal/document_output_spec.rb
|
|
283
292
|
- spec/lib/caracal/errors_spec.rb
|
|
284
293
|
- spec/spec_helper.rb
|
|
285
294
|
- spec/support/_fixtures/snippet.docx
|
data/.travis.yml
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
language: ruby
|
|
2
|
-
cache: bundler
|
|
3
|
-
rvm:
|
|
4
|
-
- 1.9.2
|
|
5
|
-
- 1.9.3
|
|
6
|
-
- 2.0.0
|
|
7
|
-
- 2.1
|
|
8
|
-
- rbx-2
|
|
9
|
-
- ruby-head
|
|
10
|
-
- jruby-head
|
|
11
|
-
matrix:
|
|
12
|
-
include:
|
|
13
|
-
- rvm: jruby
|
|
14
|
-
env: JRUBY_OPTS="--1.9 --debug"
|
|
15
|
-
- rvm: jruby
|
|
16
|
-
env: JRUBY_OPTS="--2.0 --debug"
|
|
17
|
-
allow_failures:
|
|
18
|
-
- rvm: ruby-head
|
|
19
|
-
- rvm: jruby-head
|
|
20
|
-
fast_finish: true
|