├── .fasterer.yml ├── .github ├── FUNDING.yml └── workflows │ ├── linters.yml │ └── specs.yml ├── .gitignore ├── .reviewdog.yml ├── .rspec ├── .rubocop.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── bin ├── fasterer ├── rspec └── rubocop ├── examples ├── elements.html ├── elements.pdf ├── examples │ └── image.jpg ├── headings.html ├── headings.pdf ├── image.jpg ├── instance.pdf ├── random_content.html ├── random_content.pdf ├── samples.rb ├── styles.html └── styles.pdf ├── lib ├── prawn-html.rb └── prawn_html │ ├── attributes.rb │ ├── callbacks │ ├── background.rb │ └── strike_through.rb │ ├── context.rb │ ├── document_renderer.rb │ ├── html_parser.rb │ ├── instance.rb │ ├── pdf_wrapper.rb │ ├── tag.rb │ ├── tags │ ├── a.rb │ ├── b.rb │ ├── blockquote.rb │ ├── body.rb │ ├── br.rb │ ├── code.rb │ ├── del.rb │ ├── div.rb │ ├── h.rb │ ├── hr.rb │ ├── i.rb │ ├── img.rb │ ├── li.rb │ ├── mark.rb │ ├── ol.rb │ ├── p.rb │ ├── pre.rb │ ├── small.rb │ ├── span.rb │ ├── sub.rb │ ├── sup.rb │ ├── u.rb │ └── ul.rb │ ├── utils.rb │ └── version.rb ├── prawn-html.gemspec └── spec ├── features ├── instance_spec.rb └── samples_spec.rb ├── integrations ├── blocks_spec.rb ├── headings_spec.rb ├── lists_spec.rb ├── misc_spec.rb ├── styles_spec.rb └── tags │ └── br_spec.rb ├── spec_helper.rb ├── support ├── common_checks.rb ├── shared_contexts.rb └── test_utils.rb └── units ├── prawn_html ├── attributes_spec.rb ├── context_spec.rb ├── document_renderer_spec.rb ├── html_parser_spec.rb ├── instance_spec.rb ├── pdf_wrapper_spec.rb ├── tag_spec.rb ├── tags │ ├── a_spec.rb │ ├── b_spec.rb │ ├── blockquote_spec.rb │ ├── body_spec.rb │ ├── br_spec.rb │ ├── code_spec.rb │ ├── del_spec.rb │ ├── div_spec.rb │ ├── h_spec.rb │ ├── hr_spec.rb │ ├── i_spec.rb │ ├── img_spec.rb │ ├── li_spec.rb │ ├── mark_spec.rb │ ├── ol_spec.rb │ ├── p_spec.rb │ ├── pre_spec.rb │ ├── small_spec.rb │ ├── span_spec.rb │ ├── sub_spec.rb │ ├── sup_spec.rb │ ├── u_spec.rb │ └── ul_spec.rb └── utils_spec.rb ├── prawn_html_spec.rb └── styles_spec.rb /.fasterer.yml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - bin/* 4 | - vendor/**/* 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [blocknotes] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linters 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | reviewdog: 12 | name: reviewdog 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: '2.7' 23 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 24 | 25 | - uses: reviewdog/action-setup@v1 26 | with: 27 | reviewdog_version: latest 28 | 29 | - name: Run reviewdog 30 | env: 31 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: | 33 | reviewdog -fail-on-error -reporter=github-pr-review -runners=fasterer,rubocop 34 | 35 | # NOTE: check with: reviewdog -fail-on-error -reporter=github-pr-review -runners=fasterer -diff="git diff" -tee 36 | -------------------------------------------------------------------------------- /.github/workflows/specs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Specs 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | tests: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | ruby: ['2.5', '2.6', '2.7', '3.0'] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | 28 | - name: Run tests 29 | run: bundle exec rspec --profile 30 | 31 | - name: Code Climate test coverage 32 | uses: paambaati/codeclimate-action@v3.0.0 33 | env: 34 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 35 | with: 36 | coverageLocations: | 37 | ${{github.workspace}}/coverage/lcov/prawn-html.lcov:lcov 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _misc/ 2 | coverage/ 3 | 4 | Gemfile.lock 5 | 6 | .rubocop-* 7 | .vscode 8 | -------------------------------------------------------------------------------- /.reviewdog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | runner: 3 | fasterer: 4 | cmd: bin/fasterer 5 | level: info 6 | rubocop: 7 | cmd: bin/rubocop 8 | level: info 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_from: 3 | - https://relaxed.ruby.style/rubocop.yml 4 | 5 | require: 6 | - rubocop-packaging 7 | - rubocop-performance 8 | - rubocop-rspec 9 | 10 | AllCops: 11 | TargetRubyVersion: 2.5 12 | Exclude: 13 | - _misc/* 14 | - bin/* 15 | - vendor/**/* 16 | NewCops: enable 17 | 18 | Lint/UnusedMethodArgument: 19 | Exclude: 20 | - lib/prawn_html/utils.rb 21 | 22 | Naming/FileName: 23 | Exclude: 24 | - lib/prawn-html.rb 25 | 26 | Naming/MethodParameterName: 27 | Exclude: 28 | - lib/prawn_html/pdf_wrapper.rb 29 | 30 | RSpec/ExampleLength: 31 | # default: 5 32 | Max: 15 33 | 34 | RSpec/MultipleMemoizedHelpers: 35 | # default: 5 36 | Max: 6 37 | 38 | RSpec/SubjectStub: 39 | Exclude: 40 | - spec/units/prawn_html/attributes_spec.rb 41 | 42 | Style/OpenStructUse: 43 | Exclude: 44 | - lib/prawn_html/attributes.rb 45 | - spec/units/prawn_html/attributes_spec.rb 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :development, :test do 8 | # Testing 9 | gem 'pdf-inspector', require: 'pdf/inspector' 10 | gem 'rspec' 11 | gem 'simplecov', require: false 12 | gem 'simplecov-lcov', require: false 13 | 14 | # Linters 15 | gem 'fasterer' 16 | gem 'rubocop' 17 | gem 'rubocop-packaging' 18 | gem 'rubocop-performance' 19 | gem 'rubocop-rspec' 20 | 21 | # Tools 22 | gem 'pry' 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Mattia Roccoberton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prawn HTML 2 | [![gem version](https://badge.fury.io/rb/prawn-html.svg)](https://rubygems.org/gems/prawn-html) 3 | [![gem downloads](https://badgen.net/rubygems/dt/prawn-html)](https://rubygems.org/gems/prawn-html) 4 | [![maintainability](https://api.codeclimate.com/v1/badges/db674db00817d56ca1e9/maintainability)](https://codeclimate.com/github/blocknotes/prawn-html/maintainability) 5 | [![linters](https://github.com/blocknotes/prawn-html/actions/workflows/linters.yml/badge.svg)](https://github.com/blocknotes/prawn-html/actions/workflows/linters.yml) 6 | [![specs](https://github.com/blocknotes/prawn-html/actions/workflows/specs.yml/badge.svg)](https://github.com/blocknotes/prawn-html/actions/workflows/specs.yml) 7 | 8 | HTML to PDF renderer using [Prawn PDF](https://github.com/prawnpdf/prawn). 9 | 10 | Features: 11 | - support a [good set](#supported-tags--attributes) of HTML tags and CSS properties; 12 | - handle [document styles](#document-styles); 13 | - custom [data attributes](#data-attributes) for Prawn PDF features; 14 | - no extra settings: it just parses an input HTML and outputs to a Prawn PDF document. 15 | 16 | **Notice**: render HTML documents properly is not an easy task, this gem support only some HTML tags and a small set of CSS attributes. If you need more rendering accuracy take a look at other projects like *WickedPDF* or *PDFKit*. 17 | 18 | > [prawn-styled-text](https://github.com/blocknotes/prawn-styled-text) rewritten from scratch, finally! 19 | 20 | Please :star: if you like it. 21 | 22 | ## Install 23 | 24 | - Add to your Gemfile: `gem 'prawn-html'` (and execute `bundle`) 25 | - Just call `PrawnHtml.append_html` on a `Prawn::Document` instance (see the examples) 26 | 27 | ## Examples 28 | 29 | ```rb 30 | require 'prawn-html' 31 | pdf = Prawn::Document.new(page_size: 'A4') 32 | PrawnHtml.append_html(pdf, '

Just a test

') 33 | pdf.render_file('test.pdf') 34 | ``` 35 | 36 | To check some examples with the PDF output see [examples](examples/) folder. 37 | 38 | Alternative form using _PrawnHtml::Instance_ to preserve the context: 39 | 40 | ```rb 41 | require 'prawn-html' 42 | pdf = Prawn::Document.new(page_size: 'A4') 43 | phtml = PrawnHtml::Instance.new(pdf) 44 | css = <<~CSS 45 | h1 { color: green } 46 | i { color: red } 47 | CSS 48 | phtml.append(css: css) 49 | phtml.append(html: '

Some HTML before

') 50 | pdf.text 'Some Prawn text' 51 | phtml.append(html: '

Some HTML after

') 52 | pdf.render_file('test.pdf') 53 | ``` 54 | 55 | ## Supported tags & attributes 56 | 57 | HTML tags (using MDN definitions): 58 | 59 | - **a**: the Anchor element 60 | - **b**: the Bring Attention To element 61 | - **blockquote**: the Block Quotation element 62 | - **br**: the Line Break element 63 | - **code**: the Inline Code element 64 | - **del**: the Deleted Text element 65 | - **div**: the Content Division element 66 | - **em**: the Emphasis element 67 | - **h1** - **h6**: the HTML Section Heading elements 68 | - **hr**: the Thematic Break (Horizontal Rule) element 69 | - **i**: the Idiomatic Text element 70 | - **ins**: the added text element 71 | - **img**: the Image Embed element 72 | - **li**: the list item element 73 | - **mark**: the Mark Text element 74 | - **ol**: the Ordered List element 75 | - **p**: the Paragraph element 76 | - **pre**: the Preformatted Text element 77 | - **s**: the strike-through text element 78 | - **small**: the side comment element 79 | - **span**: the generic inline element 80 | - **strong**: the Strong Importance element 81 | - **sub**: the Subscript element 82 | - **sup**: the Superscript element 83 | - **u**: the Unarticulated Annotation (Underline) element 84 | - **ul**: the Unordered List element 85 | 86 | CSS attributes (dimensional units are ignored and considered in pixel): 87 | 88 | - **background**: for *mark* tag (3/6 hex digits or RGB or color name), ex. `style="background: #FECD08"` 89 | - **break-after**: go to a new page after some elements, ex. `style="break-after: auto"` 90 | - **break-before**: go to a new page before some elements, ex. `style="break-before: auto"` 91 | - **color**: (3/6 hex digits or RGB or color name) ex. `style="color: #FB1"` 92 | - **font-family**: font must be registered, quotes are optional, ex. `style="font-family: Courier"` 93 | - **font-size**: ex. `style="font-size: 20px"` 94 | - **font-style**: values: *:italic*, ex. `style="font-style: italic"` 95 | - **font-weight**: values: *:bold*, ex. `style="font-weight: bold"` 96 | - **height**: for *img* tag, ex. `` 97 | - **href**: for *a* tag, ex. `Google` 98 | - **left**: see *position (absolute)* 99 | - **letter-spacing**: ex. `style="letter-spacing: 1.5"` 100 | - **line-height**: ex. `style="line-height: 10px"` 101 | - **list-style-type**: for *ul*, a string, ex. `style="list-style-type: '- '"` 102 | - **margin-bottom**: ex. `style="margin-bottom: 10px"` 103 | - **margin-left**: ex. `style="margin-left: 15px"` 104 | - **margin-top**: ex. `style="margin-top: 20px"` 105 | - **position**: `absolute`, ex. `style="position: absolute; left: 20px; top: 100px"` 106 | - **src**: for *img* tag, ex. `` 107 | - **text-align**: `left` | `center` | `right` | `justify`, ex. `style="text-align: center"` 108 | - **text-decoration**: `underline`, ex. `style="text-decoration: underline"` 109 | - **top**: see *position (absolute)* 110 | - **width**: for *img* tag, support also percentage, ex. `` 111 | 112 | The above attributes supports the `initial` value to reset them to their original value. 113 | 114 | For colors, the supported formats are: 115 | - 3 hex digits, ex. `color: #FB1`; 116 | - 6 hex digits, ex. `color: #abcdef`; 117 | - RGB, ex. `color: RGB(64, 0, 128)`; 118 | - color name, ex. `color: yellow`. 119 | 120 | ## Data attributes 121 | 122 | Some custom data attributes are used to pass options: 123 | 124 | - **dash**: for *hr* tag, accepts an integer or a list of integers), ex. `data-data="2, 4, 3"` 125 | - **mode**: allow to specify the text mode (stroke|fill||fill_stroke), ex. `data-mode="stroke"` 126 | 127 | ## Document styles 128 | 129 | You can define document CSS rules inside an _head_ tag. Example: 130 | 131 | ```html 132 | 133 | 134 | 135 | A test 136 | 144 | 145 | 146 |
147 | Div content 148 | Span content 149 |
150 | 151 | 152 | ``` 153 | 154 | ## Additional notes 155 | 156 | ### Rails: generate PDF on the fly 157 | 158 | Sample controller's action to create a PDF from Rails: 159 | 160 | ```rb 161 | class SomeController < ApplicationController 162 | def sample_action 163 | respond_to do |format| 164 | format.pdf do 165 | pdf = Prawn::Document.new 166 | PrawnHtml.append_html(pdf, '

Just a test

') 167 | send_data(pdf.render, filename: 'sample.pdf', type: 'application/pdf') 168 | end 169 | end 170 | end 171 | end 172 | ``` 173 | 174 | More details in this blogpost: [generate PDF from HTML](https://www.blocknot.es/2021-08-20-rails-generate-pdf-from-html/) 175 | 176 | ## Do you like it? Star it! 177 | 178 | If you use this component just star it. A developer is more motivated to improve a project when there is some interest. 179 | 180 | Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me). 181 | 182 | ## Contributors 183 | 184 | - [Mattia Roccoberton](https://www.blocknot.es): author 185 | 186 | ## License 187 | 188 | The gem is available as open-source under the terms of the [MIT](LICENSE.txt). 189 | -------------------------------------------------------------------------------- /bin/fasterer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'fasterer' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("fasterer", "fasterer") 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rubocop", "rubocop") 30 | -------------------------------------------------------------------------------- /examples/elements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Supported elements 5 | 6 | 7 | tag A: link
8 | tag B: bold
9 | tag Blockquote:
block quotation element
10 | tag Br: new line
11 | tag Code: inline code element
12 | tag Del: strike-through
13 | tag Div:
block element
14 | tag Em: italic
15 | tag H1:

heading h1

16 | tag H2:

heading h2

17 | tag H3:

heading h3

18 | tag H4:

heading h4

19 | tag H5:
heading h5
20 | tag H6:
heading h6
21 | tag Hr: horizontal line
22 | tag I: italic
23 | tag Ins: underline
24 | tag Img: image
25 | tag Mark: highlight
26 | tag Ol:
    27 |
  1. ordered list
  2. 28 |
  3. tag Li: list item (ordered)
  4. 29 |
30 | tag P:

block element

31 | tag Pre:
preformatted text element
32 | tag S: strike-through
33 | tag Small: smaller text
34 | tag Span: inline element
35 | tag Strong: bold
36 | tag Sub: subscript element
37 | tag Sup: superscript element
38 | tag U: underline
39 | tag Ul: 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/elements.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/elements.pdf -------------------------------------------------------------------------------- /examples/examples/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/examples/image.jpg -------------------------------------------------------------------------------- /examples/headings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Headings 5 | 6 | 7 |
8 |

h1 element

9 |

h2 element

10 |

h3 element

11 |

h4 element

12 |
h5 element
13 |
h6 element
14 | 15 |
16 | 17 |
Lorem ipsum dolor sit amet consectetur adipisicing elit...
18 |

h1 element

19 | 20 |
... laboriosam rerum sed veritatis, quisquam aliquid impedit...
21 |

h2 element

22 | 23 |
... architecto sunt fugiat magni molestias iste, nam quidem...
24 |

h3 element

25 | 26 |
... praesentium quaerat, eius eveniet minima facilis quos.
27 |

h4 element

28 | 29 |
Lorem ipsum dolor sit amet consectetur adipisicing elit...
30 |
h5 element
31 | 32 |
... suscipit saepe magni quasi voluptatum cupiditate, mollitia ipsum, qui rerum sint natus...
33 |
h6 element
34 | 35 |
... itaque perspiciatis. Delectus vero eveniet consectetur impedit necessitatibus animi dolore!
36 | 37 |
38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/headings.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/headings.pdf -------------------------------------------------------------------------------- /examples/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/image.jpg -------------------------------------------------------------------------------- /examples/instance.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/instance.pdf -------------------------------------------------------------------------------- /examples/random_content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Random content 5 | 6 | 7 |

Random content (with various elements and styles)

8 | 9 | Test inline elements: 10 | this is italic, underline and bold, bold italic 11 | 12 |
13 |
14 |
15 | 16 |
div element
17 |
div (2) element
18 | span element 19 | span (2) element 20 | 21 |
The Block Quotation element
22 | 23 |
24 | 25 |
text-align: left
26 |
text-align: center
27 |
text-align: right
28 |
text-align: justify
29 | 30 |
31 | 32 |
margin-top
33 |
margin-left
34 |
margin-bottom
35 | 36 |
37 | 38 | Begin of list 39 | 56 | End of list 57 | 58 |
59 | 60 |
    61 |
  1. Li 1
  2. 62 |
  3. 63 | Li 2 64 |
      65 |
    1. Li 2 - 1
    2. 66 |
    3. Text that is bold and italic also.
    4. 67 |
    5. Li 2 - 3
    6. 68 |
    69 |
  4. 70 |
  5. Li 3
  6. 71 |
72 | 73 |
74 | 75 | Some HTML entities: € × ÷ ½ « » © 76 |

A paragraph

77 |

Another paragraph with highlight words and deleted words.

78 |
79 |
80 | 81 | A span with a color: this is courier red 20, this is green bold italic, this is character spacing 2.8, this is a Google link 82 | 83 |
84 | Line1
Line2
Line3 85 |
86 | Word1 87 | Word2 88 | 89 | Word3 90 |
91 |
92 | Line4 93 | 94 |

More text

95 |
line-height test... Lorem ipsum dolor sit amet, 96 | consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna 97 | aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 98 | Duis aute irure dolor in reprehenderit in voluptate velit.
99 |
100 |
101 | 102 | This is an inline code element. 103 |
  this is  pre element  
104 | last line. 105 | 106 |
107 | An image:
108 | 109 |
110 |

Some other paragraph

111 | 112 |
113 | Position: absolute 114 |
115 | 116 |

New page

117 | 118 | End of document 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /examples/random_content.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/random_content.pdf -------------------------------------------------------------------------------- /examples/samples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << '../lib' 4 | 5 | require 'prawn' 6 | require 'prawn-html' 7 | require 'oga' 8 | require 'pry' 9 | 10 | Dir[File.expand_path('*.html', __dir__)].sort.each do |file| 11 | html = File.read(file) 12 | pdf = Prawn::Document.new(page_size: 'A4', page_layout: :portrait) 13 | PrawnHtml.append_html(pdf, html) 14 | out = file.gsub(/\.html\Z/, '.pdf') 15 | pdf.render_file(out) 16 | end 17 | -------------------------------------------------------------------------------- /examples/styles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Styles 5 | 18 | 19 | 20 |
CSS rules
21 |
a div
22 |
23 |
div with id
24 |
div with class
25 |
26 |
another div
27 |

a p

28 | 29 |
Padding and margin
30 |
31 | padding top, bottom and left (outer) 32 |
33 | padding top, bottom and left (inner) 34 |
35 |
36 | 37 |
Some content
38 |
39 | margin top, bottom and left (outer) 40 |
41 | margin top, bottom and left (inner) 42 |
43 |
44 |
Some content
45 | 46 | 47 | -------------------------------------------------------------------------------- /examples/styles.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/prawn-html/4b339e6cc7cd79934af7e26adfe41a34280b2a26/examples/styles.pdf -------------------------------------------------------------------------------- /lib/prawn-html.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | ADJUST_LEADING = { nil => 0.18, 'Courier' => -0.07, 'Helvetica' => -0.17, 'Times-Roman' => 0.03 }.freeze 5 | PX = 0.6 # conversion constant for pixel sixes 6 | 7 | COLORS = { 8 | 'aliceblue' => 'f0f8ff', 9 | 'antiquewhite' => 'faebd7', 10 | 'aqua' => '00ffff', 11 | 'aquamarine' => '7fffd4', 12 | 'azure' => 'f0ffff', 13 | 'beige' => 'f5f5dc', 14 | 'bisque' => 'ffe4c4', 15 | 'black' => '000000', 16 | 'blanchedalmond' => 'ffebcd', 17 | 'blue' => '0000ff', 18 | 'blueviolet' => '8a2be2', 19 | 'brown' => 'a52a2a', 20 | 'burlywood' => 'deb887', 21 | 'cadetblue' => '5f9ea0', 22 | 'chartreuse' => '7fff00', 23 | 'chocolate' => 'd2691e', 24 | 'coral' => 'ff7f50', 25 | 'cornflowerblue' => '6495ed', 26 | 'cornsilk' => 'fff8dc', 27 | 'crimson' => 'dc143c', 28 | 'cyan' => '00ffff', 29 | 'darkblue' => '00008b', 30 | 'darkcyan' => '008b8b', 31 | 'darkgoldenrod' => 'b8860b', 32 | 'darkgray' => 'a9a9a9', 33 | 'darkgreen' => '006400', 34 | 'darkgrey' => 'a9a9a9', 35 | 'darkkhaki' => 'bdb76b', 36 | 'darkmagenta' => '8b008b', 37 | 'darkolivegreen' => '556b2f', 38 | 'darkorange' => 'ff8c00', 39 | 'darkorchid' => '9932cc', 40 | 'darkred' => '8b0000', 41 | 'darksalmon' => 'e9967a', 42 | 'darkseagreen' => '8fbc8f', 43 | 'darkslateblue' => '483d8b', 44 | 'darkslategray' => '2f4f4f', 45 | 'darkslategrey' => '2f4f4f', 46 | 'darkturquoise' => '00ced1', 47 | 'darkviolet' => '9400d3', 48 | 'deeppink' => 'ff1493', 49 | 'deepskyblue' => '00bfff', 50 | 'dimgray' => '696969', 51 | 'dimgrey' => '696969', 52 | 'dodgerblue' => '1e90ff', 53 | 'firebrick' => 'b22222', 54 | 'floralwhite' => 'fffaf0', 55 | 'forestgreen' => '228b22', 56 | 'fuchsia' => 'ff00ff', 57 | 'gainsboro' => 'dcdcdc', 58 | 'ghostwhite' => 'f8f8ff', 59 | 'gold' => 'ffd700', 60 | 'goldenrod' => 'daa520', 61 | 'gray' => '808080', 62 | 'green' => '008000', 63 | 'greenyellow' => 'adff2f', 64 | 'grey' => '808080', 65 | 'honeydew' => 'f0fff0', 66 | 'hotpink' => 'ff69b4', 67 | 'indianred' => 'cd5c5c', 68 | 'indigo' => '4b0082', 69 | 'ivory' => 'fffff0', 70 | 'khaki' => 'f0e68c', 71 | 'lavender' => 'e6e6fa', 72 | 'lavenderblush' => 'fff0f5', 73 | 'lawngreen' => '7cfc00', 74 | 'lemonchiffon' => 'fffacd', 75 | 'lightblue' => 'add8e6', 76 | 'lightcoral' => 'f08080', 77 | 'lightcyan' => 'e0ffff', 78 | 'lightgoldenrodyellow' => 'fafad2', 79 | 'lightgray' => 'd3d3d3', 80 | 'lightgreen' => '90ee90', 81 | 'lightgrey' => 'd3d3d3', 82 | 'lightpink' => 'ffb6c1', 83 | 'lightsalmon' => 'ffa07a', 84 | 'lightseagreen' => '20b2aa', 85 | 'lightskyblue' => '87cefa', 86 | 'lightslategray' => '778899', 87 | 'lightslategrey' => '778899', 88 | 'lightsteelblue' => 'b0c4de', 89 | 'lightyellow' => 'ffffe0', 90 | 'lime' => '00ff00', 91 | 'limegreen' => '32cd32', 92 | 'linen' => 'faf0e6', 93 | 'magenta' => 'ff00ff', 94 | 'maroon' => '800000', 95 | 'mediumaquamarine' => '66cdaa', 96 | 'mediumblue' => '0000cd', 97 | 'mediumorchid' => 'ba55d3', 98 | 'mediumpurple' => '9370db', 99 | 'mediumseagreen' => '3cb371', 100 | 'mediumslateblue' => '7b68ee', 101 | 'mediumspringgreen' => '00fa9a', 102 | 'mediumturquoise' => '48d1cc', 103 | 'mediumvioletred' => 'c71585', 104 | 'midnightblue' => '191970', 105 | 'mintcream' => 'f5fffa', 106 | 'mistyrose' => 'ffe4e1', 107 | 'moccasin' => 'ffe4b5', 108 | 'navajowhite' => 'ffdead', 109 | 'navy' => '000080', 110 | 'oldlace' => 'fdf5e6', 111 | 'olive' => '808000', 112 | 'olivedrab' => '6b8e23', 113 | 'orange' => 'ffa500', 114 | 'orangered' => 'ff4500', 115 | 'orchid' => 'da70d6', 116 | 'palegoldenrod' => 'eee8aa', 117 | 'palegreen' => '98fb98', 118 | 'paleturquoise' => 'afeeee', 119 | 'palevioletred' => 'db7093', 120 | 'papayawhip' => 'ffefd5', 121 | 'peachpuff' => 'ffdab9', 122 | 'peru' => 'cd853f', 123 | 'pink' => 'ffc0cb', 124 | 'plum' => 'dda0dd', 125 | 'powderblue' => 'b0e0e6', 126 | 'purple' => '800080', 127 | 'rebeccapurple' => '663399', 128 | 'red' => 'ff0000', 129 | 'rosybrown' => 'bc8f8f', 130 | 'royalblue' => '4169e1', 131 | 'saddlebrown' => '8b4513', 132 | 'salmon' => 'fa8072', 133 | 'sandybrown' => 'f4a460', 134 | 'seagreen' => '2e8b57', 135 | 'seashell' => 'fff5ee', 136 | 'sienna' => 'a0522d', 137 | 'silver' => 'c0c0c0', 138 | 'skyblue' => '87ceeb', 139 | 'slateblue' => '6a5acd', 140 | 'slategray' => '708090', 141 | 'slategrey' => '708090', 142 | 'snow' => 'fffafa', 143 | 'springgreen' => '00ff7f', 144 | 'steelblue' => '4682b4', 145 | 'tan' => 'd2b48c', 146 | 'teal' => '008080', 147 | 'thistle' => 'd8bfd8', 148 | 'tomato' => 'ff6347', 149 | 'turquoise' => '40e0d0', 150 | 'violet' => 'ee82ee', 151 | 'wheat' => 'f5deb3', 152 | 'white' => 'ffffff', 153 | 'whitesmoke' => 'f5f5f5', 154 | 'yellow' => 'ffff00', 155 | 'yellowgreen' => '9acd32' 156 | }.freeze 157 | 158 | def append_html(pdf, html) 159 | PrawnHtml::Instance.new(pdf).append(html: html) 160 | end 161 | 162 | module_function :append_html 163 | end 164 | 165 | require 'prawn' 166 | 167 | require 'prawn_html/utils' 168 | 169 | Dir["#{__dir__}/prawn_html/callbacks/*.rb"].sort.each { |f| require f } 170 | 171 | require 'prawn_html/tag' 172 | Dir["#{__dir__}/prawn_html/tags/*.rb"].sort.each { |f| require f } 173 | 174 | require 'prawn_html/attributes' 175 | require 'prawn_html/context' 176 | require 'prawn_html/pdf_wrapper' 177 | require 'prawn_html/document_renderer' 178 | require 'prawn_html/html_parser' 179 | require 'prawn_html/instance' 180 | -------------------------------------------------------------------------------- /lib/prawn_html/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | require 'set' 5 | 6 | module PrawnHtml 7 | class Attributes < OpenStruct 8 | attr_reader :initial, :styles 9 | 10 | STYLES_APPLY = { 11 | block: %i[align bottom leading left margin_left padding_left position right top], 12 | tag_close: %i[margin_bottom padding_bottom break_after], 13 | tag_open: %i[margin_top padding_top break_before], 14 | text_node: %i[callback character_spacing color font link list_style_type size styles white_space] 15 | }.freeze 16 | 17 | STYLES_LIST = { 18 | # text node styles 19 | 'background' => { key: :callback, set: :callback_background }, 20 | 'color' => { key: :color, set: :convert_color }, 21 | 'font-family' => { key: :font, set: :filter_font_family }, 22 | 'font-size' => { key: :size, set: :convert_size }, 23 | 'font-style' => { key: :styles, set: :append_styles, values: %i[italic] }, 24 | 'font-weight' => { key: :styles, set: :append_styles, values: %i[bold] }, 25 | 'href' => { key: :link, set: :copy_value }, 26 | 'letter-spacing' => { key: :character_spacing, set: :convert_float }, 27 | 'list-style-type' => { key: :list_style_type, set: :unquote }, 28 | 'text-decoration' => { key: :styles, set: :append_styles, values: %i[underline] }, 29 | 'vertical-align' => { key: :styles, set: :append_styles, values: %i[subscript superscript] }, 30 | 'white-space' => { key: :white_space, set: :convert_symbol }, 31 | # tag opening styles 32 | 'break-before' => { key: :break_before, set: :convert_symbol }, 33 | 'margin-top' => { key: :margin_top, set: :convert_size }, 34 | 'padding-top' => { key: :padding_top, set: :convert_size }, 35 | # tag closing styles 36 | 'break-after' => { key: :break_after, set: :convert_symbol }, 37 | 'margin-bottom' => { key: :margin_bottom, set: :convert_size }, 38 | 'padding-bottom' => { key: :padding_bottom, set: :convert_size }, 39 | # block styles 40 | 'bottom' => { key: :bottom, set: :convert_size, options: :height }, 41 | 'left' => { key: :left, set: :convert_size, options: :width }, 42 | 'line-height' => { key: :leading, set: :convert_size }, 43 | 'margin-left' => { key: :margin_left, set: :convert_size }, 44 | 'padding-left' => { key: :padding_left, set: :convert_size }, 45 | 'position' => { key: :position, set: :convert_symbol }, 46 | 'right' => { key: :right, set: :convert_size, options: :width }, 47 | 'text-align' => { key: :align, set: :convert_symbol }, 48 | 'top' => { key: :top, set: :convert_size, options: :height }, 49 | # special styles 50 | 'text-decoration-line-through' => { key: :callback, set: :callback_strike_through } 51 | }.freeze 52 | 53 | STYLES_MERGE = %i[margin_left padding_left].freeze 54 | 55 | # Init the Attributes 56 | def initialize(attributes = {}) 57 | super 58 | @styles = {} # result styles 59 | @initial = Set.new 60 | end 61 | 62 | # Processes the data attributes 63 | # 64 | # @return [Hash] hash of data attributes with 'data-' prefix removed and stripped values 65 | def data 66 | to_h.each_with_object({}) do |(key, value), res| 67 | data_key = key.match /\Adata-(.+)/ 68 | res[data_key[1]] = value.strip if data_key 69 | end 70 | end 71 | 72 | # Merge text styles 73 | # 74 | # @param text_styles [String] styles to parse and process 75 | # @param options [Hash] options (container width/height/etc.) 76 | def merge_text_styles!(text_styles, options: {}) 77 | hash_styles = Attributes.parse_styles(text_styles) 78 | process_styles(hash_styles, options: options) unless hash_styles.empty? 79 | end 80 | 81 | # Remove an attribute value from the context styles 82 | # 83 | # @param context_styles [Hash] hash of the context styles that will be updated 84 | # @param rule [Hash] rule from the STYLES_LIST to lookup in the context style for value removal 85 | def remove_value(context_styles, rule) 86 | if rule[:set] == :append_styles 87 | context_styles[rule[:key]] -= rule[:values] if context_styles[:styles] 88 | else 89 | default = Context::DEFAULT_STYLES[rule[:key]] 90 | default ? (context_styles[rule[:key]] = default) : context_styles.delete(rule[:key]) 91 | end 92 | end 93 | 94 | # Update context styles applying the initial rules (if set) 95 | # 96 | # @param context_styles [Hash] hash of the context styles that will be updated 97 | # 98 | # @return [Hash] the update context styles 99 | def update_styles(context_styles) 100 | initial.each do |rule| 101 | next unless rule 102 | 103 | remove_value(context_styles, rule) 104 | end 105 | context_styles 106 | end 107 | 108 | class << self 109 | # Merges attributes 110 | # 111 | # @param attributes [Hash] target attributes hash 112 | # @param key [Symbol] key 113 | # @param value 114 | # 115 | # @return [Hash] the updated hash of attributes 116 | def merge_attr!(attributes, key, value) 117 | return unless key 118 | return (attributes[key] = value) unless Attributes::STYLES_MERGE.include?(key) 119 | 120 | attributes[key] ||= 0 121 | attributes[key] += value 122 | end 123 | 124 | # Parses a string of styles 125 | # 126 | # @param styles [String] styles to parse 127 | # 128 | # @return [Hash] hash of styles 129 | def parse_styles(styles) 130 | (styles || '').scan(/\s*([^:;]+)\s*:\s*([^;]+)\s*/).to_h 131 | end 132 | end 133 | 134 | private 135 | 136 | def process_styles(hash_styles, options:) 137 | hash_styles.each do |key, value| 138 | rule = evaluate_rule(key, value) 139 | next unless rule 140 | 141 | apply_rule!(merged_styles: @styles, rule: rule, value: value, options: options) 142 | end 143 | @styles 144 | end 145 | 146 | def evaluate_rule(rule_key, attr_value) 147 | key = nil 148 | key = 'text-decoration-line-through' if rule_key == 'text-decoration' && attr_value == 'line-through' 149 | key ||= rule_key 150 | STYLES_LIST[key] 151 | end 152 | 153 | def apply_rule!(merged_styles:, rule:, value:, options:) 154 | return (@initial << rule) if value == 'initial' 155 | 156 | if rule[:set] == :append_styles 157 | val = Utils.normalize_style(value, rule[:values]) 158 | (merged_styles[rule[:key]] ||= []) << val if val 159 | else 160 | opts = rule[:options] ? options[rule[:options]] : nil 161 | val = Utils.send(rule[:set], value, options: opts) 162 | merged_styles[rule[:key]] = val if val 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/prawn_html/callbacks/background.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Callbacks 5 | class Background 6 | DEF_HIGHLIGHT = 'ffff00' 7 | 8 | def initialize(pdf, color = nil) 9 | @pdf = pdf 10 | @color = color || DEF_HIGHLIGHT 11 | end 12 | 13 | def render_behind(fragment) 14 | top, left = fragment.top_left 15 | @pdf.draw_rectangle(x: left, y: top, width: fragment.width, height: fragment.height, color: @color) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/prawn_html/callbacks/strike_through.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Callbacks 5 | class StrikeThrough 6 | def initialize(pdf, _item) 7 | @pdf = pdf 8 | end 9 | 10 | def render_in_front(fragment) 11 | x1 = fragment.left 12 | x2 = fragment.right 13 | y = (fragment.top + fragment.bottom) / 2 14 | @pdf.underline(x1: x1, x2: x2, y: y) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/prawn_html/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | class Context < Array 5 | DEFAULT_STYLES = { 6 | size: 16 * PX 7 | }.freeze 8 | 9 | attr_reader :previous_tag 10 | attr_accessor :last_text_node 11 | 12 | # Init the Context 13 | def initialize(*_args) 14 | super 15 | @last_text_node = false 16 | @merged_styles = nil 17 | @previous_tag = nil 18 | end 19 | 20 | # Add an element to the context 21 | # 22 | # Set the parent for the previous element in the chain. 23 | # Run `on_context_add` callback method on the added element. 24 | # 25 | # @param element [Tag] the element to add 26 | # 27 | # @return [Context] the context updated 28 | def add(element) 29 | element.parent = last 30 | push(element) 31 | element.on_context_add(self) if element.respond_to?(:on_context_add) 32 | @merged_styles = nil 33 | self 34 | end 35 | 36 | # Evaluate before content 37 | # 38 | # @return [String] before content string 39 | def before_content 40 | (last.respond_to?(:before_content) && last.before_content) || '' 41 | end 42 | 43 | # Merges the context block styles 44 | # 45 | # @return [Hash] the hash of merged styles 46 | def block_styles 47 | each_with_object({}) do |element, res| 48 | element.block_styles.each do |key, value| 49 | Attributes.merge_attr!(res, key, value) 50 | end 51 | end 52 | end 53 | 54 | # Merge the context styles for text nodes 55 | # 56 | # @return [Hash] the hash of merged styles 57 | def merged_styles 58 | @merged_styles ||= 59 | each_with_object(DEFAULT_STYLES.dup) do |element, res| 60 | evaluate_element_styles(element, res) 61 | element.update_styles(res) 62 | end 63 | end 64 | 65 | # :nocov: 66 | def inspect 67 | map(&:class).map(&:to_s).join(', ') 68 | end 69 | # :nocov: 70 | 71 | # Remove the last element from the context 72 | def remove_last 73 | last.on_context_remove(self) if last.respond_to?(:on_context_remove) 74 | @merged_styles = nil 75 | @last_text_node = false 76 | @previous_tag = last 77 | pop 78 | end 79 | 80 | # White space is equal to 'pre'? 81 | # 82 | # @return [boolean] white space property of the last element is equal to 'pre' 83 | def white_space_pre? 84 | last && last.styles[:white_space] == :pre 85 | end 86 | 87 | private 88 | 89 | def evaluate_element_styles(element, res) 90 | styles = element.styles.slice(*Attributes::STYLES_APPLY[:text_node]) 91 | styles.each do |key, val| 92 | if res.include?(key) && res[key].is_a?(Array) 93 | res[key] += val 94 | else 95 | res[key] = val 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/prawn_html/document_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | class DocumentRenderer 5 | NEW_LINE = { text: "\n" }.freeze 6 | SPACE = { text: ' ' }.freeze 7 | 8 | # Init the DocumentRenderer 9 | # 10 | # @param pdf [PdfWrapper] target PDF wrapper 11 | def initialize(pdf) 12 | @before_content = [] 13 | @buffer = [] 14 | @context = Context.new 15 | @last_margin = 0 16 | @last_text = '' 17 | @last_tag_open = false 18 | @pdf = pdf 19 | end 20 | 21 | # On tag close callback 22 | # 23 | # @param element [Tag] closing element wrapper 24 | def on_tag_close(element) 25 | render_if_needed(element) 26 | apply_tag_close_styles(element) 27 | context.remove_last 28 | @last_tag_open = false 29 | @last_text = '' 30 | end 31 | 32 | # On tag open callback 33 | # 34 | # @param tag_name [String] the tag name of the opening element 35 | # @param attributes [Hash] an hash of the element attributes 36 | # @param element_styles [String] document styles to apply to the element 37 | # 38 | # @return [Tag] the opening element wrapper 39 | def on_tag_open(tag_name, attributes:, element_styles: '') 40 | tag_class = Tag.class_for(tag_name) 41 | return unless tag_class 42 | 43 | options = { width: pdf.page_width, height: pdf.page_height } 44 | tag_class.new(tag_name, attributes: attributes, options: options).tap do |element| 45 | setup_element(element, element_styles: element_styles) 46 | @before_content.push(element.before_content) if element.respond_to?(:before_content) 47 | @last_tag_open = true 48 | end 49 | end 50 | 51 | # On text node callback 52 | # 53 | # @param content [String] the text node content 54 | # 55 | # @return [NilClass] nil value (=> no element) 56 | def on_text_node(content) 57 | return if context.previous_tag&.block? && content.match?(/\A\s*\Z/) 58 | 59 | text = prepare_text(content) 60 | buffer << context.merged_styles.merge(text: text) unless text.empty? 61 | context.last_text_node = true 62 | nil 63 | end 64 | 65 | # Render the buffer content to the PDF document 66 | def render 67 | return if buffer.empty? 68 | 69 | output_content(buffer.dup, context.block_styles) 70 | buffer.clear 71 | @last_margin = 0 72 | end 73 | 74 | alias_method :flush, :render 75 | 76 | private 77 | 78 | attr_reader :buffer, :context, :last_margin, :pdf 79 | 80 | def setup_element(element, element_styles:) 81 | render_if_needed(element) 82 | context.add(element) 83 | element.process_styles(element_styles: element_styles) 84 | apply_tag_open_styles(element) 85 | element.custom_render(pdf, context) if element.respond_to?(:custom_render) 86 | end 87 | 88 | def render_if_needed(element) 89 | render_needed = element&.block? && buffer.any? && buffer.last != NEW_LINE 90 | return false unless render_needed 91 | 92 | render 93 | true 94 | end 95 | 96 | def apply_tag_close_styles(element) 97 | tag_styles = element.tag_close_styles 98 | @last_margin = tag_styles[:margin_bottom].to_f 99 | pdf.advance_cursor(last_margin + tag_styles[:padding_bottom].to_f) 100 | pdf.start_new_page if tag_styles[:break_after] 101 | end 102 | 103 | def apply_tag_open_styles(element) 104 | tag_styles = element.tag_open_styles 105 | move_down = (tag_styles[:margin_top].to_f - last_margin) + tag_styles[:padding_top].to_f 106 | pdf.advance_cursor(move_down) if move_down > 0 107 | pdf.start_new_page if tag_styles[:break_before] 108 | end 109 | 110 | def prepare_text(content) 111 | text = @before_content.any? ? ::Oga::HTML::Entities.decode(@before_content.join) : '' 112 | @before_content.clear 113 | 114 | return (@last_text = text + content) if context.white_space_pre? 115 | 116 | content = content.lstrip if @last_text[-1] == ' ' || @last_tag_open 117 | text += content.tr("\n", ' ').squeeze(' ') 118 | @last_text = text 119 | end 120 | 121 | def output_content(buffer, block_styles) 122 | apply_callbacks(buffer) 123 | left_indent = block_styles[:margin_left].to_f + block_styles[:padding_left].to_f 124 | options = block_styles.slice(:align, :indent_paragraphs, :leading, :mode, :padding_left) 125 | options[:leading] = adjust_leading(buffer, options[:leading]) 126 | pdf.puts(buffer, options, bounding_box: bounds(buffer, options, block_styles), left_indent: left_indent) 127 | end 128 | 129 | def apply_callbacks(buffer) 130 | buffer.select { |item| item[:callback] }.each do |item| 131 | callback, arg = item[:callback] 132 | callback_class = Tag::CALLBACKS[callback] 133 | item[:callback] = callback_class.new(pdf, arg) 134 | end 135 | end 136 | 137 | def adjust_leading(buffer, leading) 138 | return leading if leading 139 | 140 | leadings = buffer.map do |item| 141 | (item[:size] || Context::DEFAULT_STYLES[:size]) * (ADJUST_LEADING[item[:font]] || ADJUST_LEADING[nil]) 142 | end 143 | leadings.max.round(4) 144 | end 145 | 146 | def bounds(buffer, options, block_styles) 147 | return unless block_styles[:position] == :absolute 148 | 149 | x = if block_styles.include?(:right) 150 | x1 = pdf.calc_buffer_width(buffer) + block_styles[:right] 151 | x1 < pdf.page_width ? (pdf.page_width - x1) : 0 152 | else 153 | block_styles[:left] || 0 154 | end 155 | y = if block_styles.include?(:bottom) 156 | pdf.calc_buffer_height(buffer, options) + block_styles[:bottom] 157 | else 158 | pdf.page_height - (block_styles[:top] || 0) 159 | end 160 | 161 | [[x, y], { width: pdf.page_width - x }] 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/prawn_html/html_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'oga' 4 | 5 | module PrawnHtml 6 | class HtmlParser 7 | REGEXP_STYLES = /\s*([^{\s]+)\s*{\s*([^}]*?)\s*}/m.freeze 8 | 9 | # Init the HtmlParser 10 | # 11 | # @param renderer [DocumentRenderer] document renderer 12 | # @param ignore_content_tags [Array] array of tags (symbols) to skip their contents while preparing the PDF document 13 | def initialize(renderer, ignore_content_tags: %i[script style]) 14 | @processing = false 15 | @ignore = false 16 | @ignore_content_tags = ignore_content_tags 17 | @renderer = renderer 18 | @raw_styles = {} 19 | end 20 | 21 | # Processes HTML and renders it 22 | # 23 | # @param html [String] The HTML content to process 24 | def process(html) 25 | @styles = {} 26 | @processing = !html.include?(' Callbacks::Background, 9 | 'StrikeThrough' => Callbacks::StrikeThrough 10 | }.freeze 11 | 12 | TAG_CLASSES = %w[A B Blockquote Body Br Code Del Div H Hr I Img Li Mark Ol P Pre Small Span Sub Sup U Ul].freeze 13 | 14 | def_delegators :@attrs, :styles, :update_styles 15 | 16 | attr_accessor :parent 17 | attr_reader :attrs, :tag 18 | 19 | # Init the Tag 20 | # 21 | # @param tag [Symbol] tag name 22 | # @param attributes [Hash] hash of element attributes 23 | # @param options [Hash] options (container width/height/etc.) 24 | def initialize(tag, attributes: {}, options: {}) 25 | @tag = tag 26 | @options = options 27 | @attrs = Attributes.new(attributes) 28 | end 29 | 30 | # Is a block tag? 31 | # 32 | # @return [Boolean] true if the type of the tag is block, false otherwise 33 | def block? 34 | false 35 | end 36 | 37 | # Styles to apply to the block 38 | # 39 | # @return [Hash] hash of styles to apply 40 | def block_styles 41 | block_styles = styles.slice(*Attributes::STYLES_APPLY[:block]) 42 | block_styles[:mode] = attrs.data['mode'].to_sym if attrs.data.include?('mode') 43 | block_styles 44 | end 45 | 46 | # Process tag styles 47 | # 48 | # @param element_styles [String] extra styles to apply to the element 49 | def process_styles(element_styles: nil) 50 | attrs.merge_text_styles!(tag_styles, options: options) if respond_to?(:tag_styles) 51 | attrs.merge_text_styles!(element_styles, options: options) if element_styles 52 | attrs.merge_text_styles!(attrs.style, options: options) 53 | attrs.merge_text_styles!(extra_styles, options: options) if respond_to?(:extra_styles) 54 | end 55 | 56 | # Styles to apply on tag closing 57 | # 58 | # @return [Hash] hash of styles to apply 59 | def tag_close_styles 60 | styles.slice(*Attributes::STYLES_APPLY[:tag_close]) 61 | end 62 | 63 | # Styles to apply on tag opening 64 | # 65 | # @return [Hash] hash of styles to apply 66 | def tag_open_styles 67 | styles.slice(*Attributes::STYLES_APPLY[:tag_open]) 68 | end 69 | 70 | class << self 71 | # Evaluate the Tag class from a tag name 72 | # 73 | # @params tag_name [Symbol] the tag name 74 | # 75 | # @return [Tag] the class for the tag if available or nil 76 | def class_for(tag_name) 77 | @tag_classes ||= TAG_CLASSES.each_with_object({}) do |tag_class, res| 78 | klass = const_get("PrawnHtml::Tags::#{tag_class}") 79 | k = [klass] * klass::ELEMENTS.size 80 | res.merge!(klass::ELEMENTS.zip(k).to_h) 81 | end 82 | @tag_classes[tag_name] 83 | end 84 | end 85 | 86 | private 87 | 88 | attr_reader :options 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/a.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class A < Tag 6 | ELEMENTS = [:a].freeze 7 | 8 | def extra_styles 9 | attrs.href ? "href: #{attrs.href}" : nil 10 | end 11 | 12 | def tag_styles 13 | <<~STYLES 14 | color: #00E; 15 | text-decoration: underline; 16 | STYLES 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/b.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class B < Tag 6 | ELEMENTS = [:b, :strong].freeze 7 | 8 | def tag_styles 9 | 'font-weight: bold' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/blockquote.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Blockquote < Tag 6 | ELEMENTS = [:blockquote].freeze 7 | 8 | MARGIN_BOTTOM = 12.7 9 | MARGIN_LEFT = 40.4 10 | MARGIN_TOP = 12.7 11 | 12 | def block? 13 | true 14 | end 15 | 16 | def tag_styles 17 | <<~STYLES 18 | margin-bottom: #{MARGIN_BOTTOM}px; 19 | margin-left: #{MARGIN_LEFT}px; 20 | margin-top: #{MARGIN_TOP}px; 21 | STYLES 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Body < Tag 6 | ELEMENTS = [:body].freeze 7 | 8 | def block? 9 | true 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/br.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Br < Tag 6 | ELEMENTS = [:br].freeze 7 | 8 | BR_SPACING = Utils.convert_size('17') 9 | 10 | def block? 11 | true 12 | end 13 | 14 | def custom_render(pdf, context) 15 | return if context.last_text_node || !context.previous_tag.is_a?(Br) 16 | 17 | pdf.advance_cursor(BR_SPACING) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Code < Tag 6 | ELEMENTS = [:code].freeze 7 | 8 | def tag_styles 9 | 'font-family: Courier' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/del.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Del < Tag 6 | ELEMENTS = [:del, :s].freeze 7 | 8 | def tag_styles 9 | 'text-decoration: line-through' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/div.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Div < Tag 6 | ELEMENTS = [:div].freeze 7 | 8 | def block? 9 | true 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/h.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class H < Tag 6 | ELEMENTS = [:h1, :h2, :h3, :h4, :h5, :h6].freeze 7 | 8 | MARGINS_TOP = { 9 | h1: 25, 10 | h2: 20.5, 11 | h3: 18, 12 | h4: 21.2, 13 | h5: 21.2, 14 | h6: 22.8 15 | }.freeze 16 | 17 | MARGINS_BOTTOM = { 18 | h1: 15.8, 19 | h2: 15.8, 20 | h3: 15.8, 21 | h4: 20, 22 | h5: 21.4, 23 | h6: 24.8 24 | }.freeze 25 | 26 | SIZES = { 27 | h1: 31.5, 28 | h2: 24, 29 | h3: 18.7, 30 | h4: 15.7, 31 | h5: 13, 32 | h6: 10.8 33 | }.freeze 34 | 35 | def block? 36 | true 37 | end 38 | 39 | def tag_styles 40 | <<~STYLES 41 | font-size: #{SIZES[tag]}px; 42 | font-weight: bold; 43 | margin-bottom: #{MARGINS_BOTTOM[tag]}px; 44 | margin-top: #{MARGINS_TOP[tag]}px; 45 | STYLES 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/hr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Hr < Tag 6 | ELEMENTS = [:hr].freeze 7 | 8 | MARGIN_BOTTOM = 12 9 | MARGIN_TOP = 6 10 | 11 | def block? 12 | true 13 | end 14 | 15 | def custom_render(pdf, _context) 16 | dash = attrs.data.include?('dash') ? parse_dash_value(attrs.data['dash']) : nil 17 | pdf.horizontal_rule(color: attrs.styles[:color], dash: dash) 18 | end 19 | 20 | def tag_styles 21 | <<~STYLES 22 | margin-bottom: #{MARGIN_BOTTOM}px; 23 | margin-top: #{MARGIN_TOP}px; 24 | STYLES 25 | end 26 | 27 | private 28 | 29 | def parse_dash_value(dash_string) 30 | if dash_string.match? /\A\d+\Z/ 31 | dash_string.to_i 32 | else 33 | dash_array = dash_string.split(',') 34 | dash_array.map(&:to_i) if dash_array.any? 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/i.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class I < Tag 6 | ELEMENTS = [:i, :em].freeze 7 | 8 | def tag_styles 9 | 'font-style: italic' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/img.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Img < Tag 6 | ELEMENTS = [:img].freeze 7 | 8 | def block? 9 | true 10 | end 11 | 12 | def custom_render(pdf, context) 13 | parsed_styles = Attributes.parse_styles(attrs.style) 14 | block_styles = context.block_styles 15 | evaluated_styles = adjust_styles(pdf, block_styles.merge(parsed_styles)) 16 | pdf.image(@attrs.src, evaluated_styles) 17 | end 18 | 19 | private 20 | 21 | def adjust_styles(pdf, img_styles) 22 | {}.tap do |result| 23 | w, h = img_styles['width'], img_styles['height'] 24 | result[:width] = Utils.convert_size(w, options: pdf.page_width) if w 25 | result[:height] = Utils.convert_size(h, options: pdf.page_height) if h 26 | result[:position] = img_styles[:align] if %i[left center right].include?(img_styles[:align]) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/li.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Li < Tag 6 | ELEMENTS = [:li].freeze 7 | 8 | INDENT_OL = -12 9 | INDENT_UL = -6 10 | 11 | def block? 12 | true 13 | end 14 | 15 | def before_content 16 | return if @before_content_once 17 | 18 | @before_content_once = @counter ? "#{@counter}. " : "#{@symbol} " 19 | end 20 | 21 | def block_styles 22 | super.tap do |bs| 23 | bs[:indent_paragraphs] = @indent 24 | end 25 | end 26 | 27 | def on_context_add(_context) 28 | case parent.class.to_s 29 | when 'PrawnHtml::Tags::Ol' 30 | @indent = INDENT_OL 31 | @counter = (parent.counter += 1) 32 | when 'PrawnHtml::Tags::Ul' 33 | @indent = INDENT_UL 34 | @symbol = parent.styles[:list_style_type] || '•' 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/mark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Mark < Tag 6 | ELEMENTS = [:mark].freeze 7 | 8 | def tag_styles 9 | 'background: #ff0' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/ol.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Ol < Tag 6 | ELEMENTS = [:ol].freeze 7 | 8 | MARGIN_TOP = 15 9 | MARGIN_LEFT = 40 10 | MARGIN_BOTTOM = 15 11 | 12 | attr_accessor :counter 13 | 14 | def initialize(tag, attributes: {}, options: {}) 15 | super 16 | @counter = 0 17 | @first_level = false 18 | end 19 | 20 | def block? 21 | true 22 | end 23 | 24 | def on_context_add(context) 25 | return if context.map(&:tag).count { |el| el == :ol } > 1 26 | 27 | @first_level = true 28 | end 29 | 30 | def tag_styles 31 | if @first_level 32 | <<~STYLES 33 | margin-top: #{MARGIN_TOP}px; 34 | margin-left: #{MARGIN_LEFT}px; 35 | margin-bottom: #{MARGIN_BOTTOM}px; 36 | STYLES 37 | else 38 | "margin-left: #{MARGIN_LEFT}px" 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/p.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class P < Tag 6 | ELEMENTS = [:p].freeze 7 | 8 | MARGIN_BOTTOM = 12.5 9 | MARGIN_TOP = 12.5 10 | 11 | def block? 12 | true 13 | end 14 | 15 | def tag_styles 16 | <<~STYLES 17 | margin-bottom: #{MARGIN_BOTTOM}px; 18 | margin-top: #{MARGIN_TOP}px; 19 | STYLES 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/pre.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Pre < Tag 6 | ELEMENTS = [:pre].freeze 7 | 8 | MARGIN_BOTTOM = 14 9 | MARGIN_TOP = 14 10 | 11 | def block? 12 | true 13 | end 14 | 15 | def tag_styles 16 | <<~STYLES 17 | font-family: Courier; 18 | margin-bottom: #{MARGIN_BOTTOM}px; 19 | margin-top: #{MARGIN_TOP}px; 20 | white-space: pre; 21 | STYLES 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/small.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Small < Tag 6 | ELEMENTS = [:small].freeze 7 | 8 | def update_styles(context_styles) 9 | size = (context_styles[:size] || Context::DEFAULT_STYLES[:size]) * 0.85 10 | context_styles[:size] = size 11 | super(context_styles) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/span.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Span < Tag 6 | ELEMENTS = [:span].freeze 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/sub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Sub < Tag 6 | ELEMENTS = [:sub].freeze 7 | 8 | def tag_styles 9 | 'vertical-align: sub' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/sup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Sup < Tag 6 | ELEMENTS = [:sup].freeze 7 | 8 | def tag_styles 9 | 'vertical-align: super' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/u.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class U < Tag 6 | ELEMENTS = [:ins, :u].freeze 7 | 8 | def tag_styles 9 | 'text-decoration: underline' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prawn_html/tags/ul.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Tags 5 | class Ul < Tag 6 | ELEMENTS = [:ul].freeze 7 | 8 | MARGIN_TOP = 15 9 | MARGIN_LEFT = 40 10 | MARGIN_BOTTOM = 15 11 | 12 | def initialize(tag, attributes: {}, options: {}) 13 | super 14 | @first_level = false 15 | end 16 | 17 | def block? 18 | true 19 | end 20 | 21 | def on_context_add(context) 22 | return if context.map(&:tag).count { |el| el == :ul } > 1 23 | 24 | @first_level = true 25 | end 26 | 27 | def tag_styles 28 | if @first_level 29 | <<~STYLES 30 | margin-top: #{MARGIN_TOP}px; 31 | margin-left: #{MARGIN_LEFT}px; 32 | margin-bottom: #{MARGIN_BOTTOM}px; 33 | STYLES 34 | else 35 | "margin-left: #{MARGIN_LEFT}px" 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/prawn_html/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml 4 | module Utils 5 | NORMALIZE_STYLES = { 6 | 'bold' => :bold, 7 | 'italic' => :italic, 8 | 'sub' => :subscript, 9 | 'super' => :superscript, 10 | 'underline' => :underline 11 | }.freeze 12 | 13 | # Setup a background callback 14 | # 15 | # @param value [String] HTML string color 16 | # 17 | # @return [Array] callback name and argument value 18 | def callback_background(value, options: nil) 19 | ['Background', convert_color(value, options: options)] 20 | end 21 | 22 | # Setup a strike through callback 23 | # 24 | # @param value [String] unused 25 | # 26 | # @return [Array] callback name and argument value 27 | def callback_strike_through(value, options: nil) 28 | ['StrikeThrough', nil] 29 | end 30 | 31 | # Converts a color string 32 | # 33 | # Supported formats: 34 | # - 3 hex digits, ex. `color: #FB1`; 35 | # - 6 hex digits, ex. `color: #abcdef`; 36 | # - RGB, ex. `color: RGB(64, 0, 128)`; 37 | # - color name, ex. `color: red`. 38 | # 39 | # @param value [String] HTML string color 40 | # 41 | # @return [String] adjusted string color or nil if value is invalid 42 | def convert_color(value, options: nil) 43 | val = value.to_s.strip.downcase 44 | return Regexp.last_match[1] if val.match /\A#([a-f0-9]{6})\Z/ # rubocop:disable Performance/RedundantMatch 45 | 46 | if val.match /\A#([a-f0-9]{3})\Z/ # rubocop:disable Performance/RedundantMatch 47 | r, g, b = Regexp.last_match[1].chars 48 | return (r * 2) + (g * 2) + (b * 2) 49 | end 50 | if val.match /\Argb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\Z/ # rubocop:disable Performance/RedundantMatch 51 | r, g, b = Regexp.last_match[1..3].map { |v| v.to_i.to_s(16) } 52 | return "#{r.rjust(2, '0')}#{g.rjust(2, '0')}#{b.rjust(2, '0')}" 53 | end 54 | 55 | COLORS[val] 56 | end 57 | 58 | # Converts a decimal number string 59 | # 60 | # @param value [String] string decimal 61 | # 62 | # @return [Float] converted and rounded float number 63 | def convert_float(value, options: nil) 64 | val = value&.gsub(/[^0-9.]/, '') || '' 65 | val.to_f.round(4) 66 | end 67 | 68 | # Converts a size string 69 | # 70 | # @param value [String] size string 71 | # @param options [Numeric] container size 72 | # 73 | # @return [Float] converted and rounded size 74 | def convert_size(value, options: nil) 75 | val = value&.gsub(/[^0-9.]/, '') || '' 76 | val = 77 | if options && value&.include?('%') 78 | val.to_f * options * 0.01 79 | else 80 | val.to_f * PrawnHtml::PX 81 | end 82 | val.round(4) 83 | end 84 | 85 | # Converts a string to symbol 86 | # 87 | # @param value [String] string 88 | # 89 | # @return [Symbol] symbol 90 | def convert_symbol(value, options: nil) 91 | value.to_sym if value && !value.match?(/\A\s*\Z/) 92 | end 93 | 94 | # Copy a value without conversion 95 | # 96 | # @param value 97 | # 98 | # @return value 99 | def copy_value(value, options: nil) 100 | value 101 | end 102 | 103 | # Filter font family 104 | # 105 | # @param value [String] string value 106 | # 107 | # @return [Symbol] unquoted font family or nil if the input value is 'inherit' 108 | def filter_font_family(value, options: nil) 109 | result = unquote(value, options: options) 110 | result == 'inherit' ? nil : result 111 | end 112 | 113 | # Normalize a style value 114 | # 115 | # @param value [String] string value 116 | # @param accepted_values [Array] allowlist of valid values (symbols) 117 | # 118 | # @return [Symbol] style value or nil 119 | def normalize_style(value, accepted_values) 120 | val = value&.strip&.downcase 121 | ret = NORMALIZE_STYLES[val] 122 | accepted_values.include?(ret) ? ret : nil 123 | end 124 | 125 | # Unquotes a string 126 | # 127 | # @param value [String] string 128 | # 129 | # @return [String] string without quotes at the beginning/ending 130 | def unquote(value, options: nil) 131 | (value&.strip || +'').tap do |val| 132 | val.gsub!(/\A['"]|["']\Z/, '') 133 | end 134 | end 135 | 136 | module_function :callback_background, :callback_strike_through, :convert_color, :convert_float, :convert_size, 137 | :convert_symbol, :copy_value, :filter_font_family, :normalize_style, :unquote 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/prawn_html/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PrawnHtml # :nodoc: 4 | VERSION = '0.7.1' 5 | end 6 | -------------------------------------------------------------------------------- /prawn-html.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'prawn_html/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'prawn-html' 9 | spec.version = PrawnHtml::VERSION 10 | spec.summary = 'Prawn PDF - HTML renderer' 11 | spec.description = 'HTML to PDF with Prawn PDF' 12 | 13 | spec.required_ruby_version = '>= 2.5.0' 14 | 15 | spec.license = 'MIT' 16 | spec.authors = ['Mattia Roccoberton'] 17 | spec.email = 'mat@blocknot.es' 18 | spec.homepage = 'https://github.com/blocknotes/prawn-html' 19 | 20 | spec.metadata['homepage_uri'] = spec.homepage 21 | spec.metadata['source_code_uri'] = spec.homepage 22 | spec.metadata['rubygems_mfa_required'] = 'true' 23 | 24 | spec.files = Dir['lib/**/*', 'LICENSE.txt', 'README.md'] 25 | spec.require_paths = ['lib'] 26 | 27 | spec.add_runtime_dependency 'oga', '~> 3.3' 28 | spec.add_runtime_dependency 'prawn', '~> 2.4' 29 | end 30 | -------------------------------------------------------------------------------- /spec/features/instance_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Instance' do 4 | it 'preserves the styles in the instance' do 5 | pdf = Prawn::Document.new(page_size: 'A4') 6 | phtml = PrawnHtml::Instance.new(pdf) 7 | css = <<~CSS 8 | h1 { color: green } 9 | i { color: red } 10 | CSS 11 | phtml.append(css: css) 12 | phtml.append(html: '

Some HTML before

') 13 | pdf.text 'Some Prawn text' 14 | phtml.append(html: '

Some HTML after

') 15 | 16 | output_file = File.expand_path('../../examples/instance.pdf', __dir__) 17 | pdf.render_file(output_file) unless File.exist?(output_file) 18 | 19 | expected_pdf = File.read(output_file) 20 | expect(Zlib.crc32(pdf.render)).to eq Zlib.crc32(expected_pdf) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/features/samples_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Samples' do 4 | Dir[File.expand_path('../../examples/*.html', __dir__)].sort.each do |file| 5 | it "renders the expected output for #{File.basename(file)}", :aggregate_failures do 6 | html = File.read(file) 7 | pdf = Prawn::Document.new(page_size: 'A4', page_layout: :portrait) 8 | PrawnHtml.append_html(pdf, html) 9 | expected_pdf = File.read(file.gsub(/\.html\Z/, '.pdf')) 10 | expect(Zlib.crc32(pdf.render)).to eq Zlib.crc32(expected_pdf) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/integrations/blocks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Blocks' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | let(:size) { PrawnHtml::Context::DEFAULT_STYLES[:size] } 6 | 7 | before do 8 | PrawnHtml.append_html(pdf, html) 9 | end 10 | 11 | context 'with some content in an element div' do 12 | let(:html) { '
Some content in a element div
' } 13 | 14 | let(:expected_content) { ['Some content in a element div'] } 15 | let(:expected_positions) do 16 | [[ 17 | pdf.page.margins[:left], 18 | (pdf.y - TestUtils.default_font.ascender).round(4) 19 | ]] 20 | end 21 | let(:expected_font_settings) { [{ name: TestUtils.default_font_family, size: size }] } 22 | 23 | include_examples 'checks contents, positions and font settings' 24 | end 25 | 26 | context 'with some content in an element p' do 27 | let(:html) { '

Some content in a element p

' } 28 | 29 | let(:expected_content) { ['Some content in a element p'] } 30 | let(:expected_positions) do 31 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::P::MARGIN_TOP.to_s) 32 | y = pdf.y - TestUtils.default_font.ascender - margin 33 | [[pdf.page.margins[:left], y.round(4)]] 34 | end 35 | let(:expected_font_settings) { [{ name: TestUtils.default_font_family, size: size }] } 36 | 37 | include_examples 'checks contents, positions and font settings' 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/integrations/headings_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Headings' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | 6 | before do 7 | PrawnHtml.append_html(pdf, html) 8 | end 9 | 10 | def base_position(pdf, font_size, margin_top: 16) 11 | font = Prawn::Document.new(page_size: 'A4', page_layout: :portrait).font('Helvetica-Bold', size: font_size) 12 | [pdf.page.margins[:left], pdf.y - font.ascender - margin_top] 13 | end 14 | 15 | context 'with some content in an element h1' do 16 | let(:html) { '

Some content in a element h1

' } 17 | 18 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h1].to_s) } 19 | let(:expected_content) { ['Some content in a element h1'] } 20 | let(:expected_positions) do 21 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h1].to_s) 22 | y = pdf.y - TestUtils.font_ascender(font_size: size) - margin 23 | [[pdf.page.margins[:left], y.round(4)]] 24 | end 25 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 26 | 27 | include_examples 'checks contents, positions and font settings' 28 | end 29 | 30 | context 'with some content in an element h2' do 31 | let(:html) { '

Some content in a element h2

' } 32 | 33 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h2].to_s) } 34 | let(:expected_content) { ['Some content in a element h2'] } 35 | let(:expected_positions) do 36 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h2].to_s) 37 | y = pdf.y - TestUtils.font_ascender(font_size: size) - margin 38 | [[pdf.page.margins[:left], y.round(4)]] 39 | end 40 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 41 | 42 | include_examples 'checks contents, positions and font settings' 43 | end 44 | 45 | context 'with some content in an element h3' do 46 | let(:html) { '

Some content in a element h3

' } 47 | 48 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h3].to_s) } 49 | let(:expected_content) { ['Some content in a element h3'] } 50 | let(:expected_positions) do 51 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h3].to_s) 52 | y = pdf.y - TestUtils.font_ascender(font_size: size) - margin 53 | [[pdf.page.margins[:left], y.round(4)]] 54 | end 55 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 56 | 57 | include_examples 'checks contents, positions and font settings' 58 | end 59 | 60 | context 'with some content in an element h4' do 61 | let(:html) { '

Some content in a element h4

' } 62 | 63 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h4].to_s) } 64 | let(:expected_content) { ['Some content in a element h4'] } 65 | let(:expected_positions) do 66 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h4].to_s) 67 | y = pdf.y - TestUtils.font_ascender(font_size: size) - margin 68 | [[pdf.page.margins[:left], y.round(4)]] 69 | end 70 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 71 | 72 | include_examples 'checks contents, positions and font settings' 73 | end 74 | 75 | context 'with some content in an element h5' do 76 | let(:html) { '
Some content in a element h5
' } 77 | 78 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h5].to_s) } 79 | let(:expected_content) { ['Some content in a element h5'] } 80 | let(:expected_positions) do 81 | ascender = TestUtils.font_ascender(font_family: 'Helvetica-Bold', font_size: size) 82 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h5].to_s) 83 | y = pdf.y - ascender - margin 84 | [[pdf.page.margins[:left], y.round(4)]] 85 | end 86 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 87 | 88 | include_examples 'checks contents, positions and font settings' 89 | end 90 | 91 | context 'with some content in an element h6' do 92 | let(:html) { '
Some content in a element h6
' } 93 | 94 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h6].to_s) } 95 | let(:expected_content) { ['Some content in a element h6'] } 96 | let(:expected_positions) do 97 | ascender = TestUtils.font_ascender(font_family: 'Helvetica-Bold', font_size: size) 98 | margin = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::MARGINS_TOP[:h6].to_s) 99 | y = pdf.y - ascender - margin 100 | [[pdf.page.margins[:left], y.round(4)]] 101 | end 102 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 103 | 104 | include_examples 'checks contents, positions and font settings' 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/integrations/lists_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Lists' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | 6 | before do 7 | PrawnHtml.append_html(pdf, html) 8 | end 9 | 10 | context 'with an empty ul list' do 11 | let(:html) do 12 | <<~HTML 13 | 15 | HTML 16 | end 17 | 18 | it 'renders no strings', :aggregate_failures do 19 | text_analysis = PDF::Inspector::Text.analyze(pdf.render) 20 | 21 | expect(text_analysis.strings).to be_empty 22 | expect(text_analysis.font_settings).to be_empty 23 | expect(text_analysis.positions).to be_empty 24 | end 25 | end 26 | 27 | context 'with an ul list' do 28 | let(:html) do 29 | <<~HTML 30 | 35 | HTML 36 | end 37 | 38 | it 'renders the list of elements', :aggregate_failures do 39 | text_analysis = PDF::Inspector::Text.analyze(pdf.render) 40 | 41 | expected_array = [{ name: TestUtils.default_font_family, size: TestUtils.default_font_size }] * 3 42 | 43 | expect(text_analysis.strings).to match_array(['• First item', '• Second item', '• Third item']) 44 | expect(text_analysis.font_settings).to match_array(expected_array) 45 | 46 | font = TestUtils.default_font 47 | margin_left = PrawnHtml::Utils.convert_size(PrawnHtml::Tags::Ul::MARGIN_LEFT.to_s) 48 | x = pdf.page.margins[:left] + margin_left + PrawnHtml::Tags::Li::INDENT_UL 49 | y = pdf.y - font.ascender - PrawnHtml::Utils.convert_size(PrawnHtml::Tags::Ul::MARGIN_TOP.to_s) 50 | 51 | expected_array = [ 52 | [x, y.round(5)], 53 | [x, (y - font.height - TestUtils.adjust_leading).round(5)], 54 | [x, (y - (font.height * 2) - (TestUtils.adjust_leading * 2)).round(5)] 55 | ] 56 | 57 | expect(text_analysis.positions).to match_array(expected_array) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/integrations/misc_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Misc' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | 6 | before do 7 | PrawnHtml.append_html(pdf, html) 8 | end 9 | 10 | shared_examples 'a specific element with some test content' do 11 | let(:html) { [expected_content[0], test_content, expected_content[2]].join } 12 | 13 | let(:expected_content) { ['Some', 'test', 'content'] } 14 | let(:expected_positions) do 15 | x1 = pdf.page.margins[:left] 16 | x2 = x1 + TestUtils.font_string_width(pdf, expected_content.first) 17 | x3 = x2 + TestUtils.font_string_width(pdf, test_content, inline_format: true) 18 | y = pdf.y - TestUtils.default_font.ascender 19 | [[x1.round(4), y.round(4)], [x2.round(4), y.round(4)], [x3.round(4), y.round(4)]] 20 | end 21 | let(:expected_font_settings) do 22 | [ 23 | { name: :Helvetica, size: TestUtils.default_font_size }, 24 | { name: expected_font_family.to_sym, size: TestUtils.default_font_size }, 25 | { name: :Helvetica, size: TestUtils.default_font_size } 26 | ] 27 | end 28 | 29 | include_examples 'checks contents, positions and font settings' 30 | end 31 | 32 | context 'with some content in an element b' do 33 | it_behaves_like 'a specific element with some test content' do 34 | let(:test_content) { 'test' } 35 | let(:expected_font_family) { 'Helvetica-Bold' } 36 | end 37 | end 38 | 39 | context 'with some content in an element em' do 40 | it_behaves_like 'a specific element with some test content' do 41 | let(:test_content) { 'test' } 42 | let(:expected_font_family) { 'Helvetica-Oblique' } 43 | end 44 | end 45 | 46 | context 'with some content in an element i' do 47 | it_behaves_like 'a specific element with some test content' do 48 | let(:test_content) { 'test' } 49 | let(:expected_font_family) { 'Helvetica-Oblique' } 50 | end 51 | end 52 | 53 | context 'with some content in an element strong' do 54 | it_behaves_like 'a specific element with some test content' do 55 | let(:test_content) { 'test' } 56 | let(:expected_font_family) { 'Helvetica-Bold' } 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/integrations/styles_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Styles' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | 6 | before do 7 | PrawnHtml.append_html(pdf, html) 8 | end 9 | 10 | describe 'attribute text-align' do 11 | context 'with some content left aligned' do 12 | let(:html) { '
Some content
' } 13 | 14 | let(:expected_content) { ['Some content'] } 15 | let(:expected_positions) do 16 | [[ 17 | pdf.page.margins[:left], 18 | (pdf.y - TestUtils.default_font.ascender).round(4) 19 | ]] 20 | end 21 | 22 | include_examples 'checks contents and positions' 23 | end 24 | 25 | context 'with some content center aligned' do 26 | let(:html) { '
Some content
' } 27 | 28 | let(:content_width) { TestUtils.font_string_width(pdf, expected_content.first) } 29 | let(:expected_content) { ['Some content'] } 30 | let(:expected_positions) do 31 | [[ 32 | (pdf.page.margins[:left] + ((pdf.bounds.width - content_width) / 2)).round(4), 33 | (pdf.y - TestUtils.default_font.ascender).round(4) 34 | ]] 35 | end 36 | 37 | include_examples 'checks contents and positions' 38 | end 39 | 40 | context 'with some content right aligned' do 41 | let(:html) { '
Some content
' } 42 | 43 | let(:content_width) { TestUtils.font_string_width(pdf, expected_content.first) } 44 | let(:expected_content) { ['Some content'] } 45 | let(:expected_positions) do 46 | x = pdf.page.margins[:left] + pdf.bounds.width - content_width 47 | [[ 48 | x.round(4), 49 | (pdf.y - TestUtils.default_font.ascender).round(4) 50 | ]] 51 | end 52 | 53 | include_examples 'checks contents and positions' 54 | end 55 | end 56 | 57 | describe 'attribute break-after' do 58 | let(:html) { '
Some content
' } 59 | 60 | it 'creates a new page' do 61 | pdf.render 62 | expect(pdf.page_count).to eq 2 63 | end 64 | end 65 | 66 | describe 'attribute break-before' do 67 | let(:html) { '
Some content
' } 68 | 69 | it 'creates a new page' do 70 | pdf.render 71 | expect(pdf.page_count).to eq 2 72 | end 73 | end 74 | 75 | describe 'attribute font-family' do 76 | context 'with some content with Courier font' do 77 | let(:html) { '
Some content
' } 78 | let(:size) { TestUtils.default_font_size } 79 | 80 | let(:expected_content) { ['Some content'] } 81 | let(:expected_positions) do 82 | [[ 83 | pdf.page.margins[:left], 84 | (pdf.y - TestUtils.font_ascender(font_family: 'Courier', font_size: size)).round(4) 85 | ]] 86 | end 87 | let(:expected_font_settings) { [{ name: :Courier, size: size }] } 88 | 89 | include_examples 'checks contents, positions and font settings' 90 | end 91 | end 92 | 93 | describe 'attribute font-size' do 94 | context 'with some content with a font size of 20px' do 95 | let(:html) { '
Some content
' } 96 | let(:size) { PrawnHtml::Utils.convert_size('20px') } 97 | 98 | let(:expected_content) { ['Some content'] } 99 | let(:expected_positions) do 100 | [[ 101 | pdf.page.margins[:left], 102 | (pdf.y - TestUtils.prawn_document.font('Helvetica', size: size).ascender).round(4) 103 | ]] 104 | end 105 | let(:expected_font_settings) { [{ name: TestUtils.default_font_family, size: size }] } 106 | 107 | include_examples 'checks contents, positions and font settings' 108 | end 109 | end 110 | 111 | describe 'attribute font-style' do 112 | context 'with some content with italic style' do 113 | let(:html) { '
Some content
' } 114 | let(:size) { TestUtils.default_font_size } 115 | 116 | let(:expected_content) { ['Some content'] } 117 | let(:expected_positions) do 118 | [[ 119 | pdf.page.margins[:left], 120 | (pdf.y - TestUtils.prawn_document.font('Helvetica-Oblique', size: size).ascender).round(4) 121 | ]] 122 | end 123 | let(:expected_font_settings) { [{ name: :'Helvetica-Oblique', size: size }] } 124 | 125 | include_examples 'checks contents, positions and font settings' 126 | end 127 | end 128 | 129 | describe 'attribute font-weight' do 130 | context 'with some content with bold weight' do 131 | let(:html) { '
Some content
' } 132 | let(:size) { TestUtils.default_font_size } 133 | 134 | let(:expected_content) { ['Some content'] } 135 | let(:expected_positions) do 136 | [[ 137 | pdf.page.margins[:left], 138 | (pdf.y - TestUtils.prawn_document.font('Helvetica-Bold', size: size).ascender).round(4) 139 | ]] 140 | end 141 | let(:expected_font_settings) { [{ name: :'Helvetica-Bold', size: size }] } 142 | 143 | include_examples 'checks contents, positions and font settings' 144 | end 145 | end 146 | 147 | describe 'attribute letter-spacing' do 148 | let(:html) { '
aaa
bbb
ccc
' } 149 | 150 | it 'renders some content with letter spacing', :aggregate_failures do 151 | text_analysis = PDF::Inspector::Text.analyze(pdf.render) 152 | 153 | expect(text_analysis.strings).to eq(['aaa', 'bbb', 'ccc']) 154 | expect(text_analysis.character_spacing).to eq(([1.5, 0.0] * 3) + ([2.0, 0.0] * 3)) 155 | end 156 | end 157 | 158 | describe 'attribute margin-left' do 159 | let(:html) { '
Some content
' } 160 | let(:size) { PrawnHtml::Utils.convert_size('40px') } 161 | 162 | let(:expected_content) { ['Some content'] } 163 | let(:expected_positions) do 164 | [[ 165 | pdf.page.margins[:left] + size, 166 | (pdf.y - TestUtils.default_font.ascender).round(4) 167 | ]] 168 | end 169 | 170 | include_examples 'checks contents and positions' 171 | end 172 | 173 | describe 'attribute margin-top' do 174 | let(:html) { '
Some content
' } 175 | let(:size) { PrawnHtml::Utils.convert_size('40px') } 176 | 177 | let(:expected_content) { ['Some content'] } 178 | let(:expected_positions) do 179 | [[ 180 | pdf.page.margins[:left], 181 | (pdf.y - TestUtils.default_font.ascender - size).round(4) 182 | ]] 183 | end 184 | 185 | include_examples 'checks contents and positions' 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /spec/integrations/tags/br_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Misc' do 4 | let(:pdf) { Prawn::Document.new(page_size: 'A4', page_layout: :portrait) } 5 | let(:text_analysis) { PDF::Inspector::Text.analyze(pdf.render) } 6 | 7 | before do 8 | PrawnHtml.append_html(pdf, html) 9 | end 10 | 11 | context 'with some br elements' do 12 | let(:html) { 'First line
Second line
Third line

Last line' } 13 | 14 | it 'renders some breaking line elements' do 15 | expected_strings = ['First line', 'Second line', 'Third line', 'Last line'] 16 | expect(text_analysis.strings).to match_array(expected_strings) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'simplecov-lcov' 5 | 6 | SimpleCov::Formatter::LcovFormatter.config do |c| 7 | c.report_with_single_file = true 8 | # c.single_report_path = ENV['LCOV_PATH'] if ENV['LCOV_PATH'].present? 9 | end 10 | simplecov_formatters = [SimpleCov::Formatter::LcovFormatter, SimpleCov.formatter] 11 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(simplecov_formatters) 12 | SimpleCov.start do 13 | add_filter '/spec/' 14 | end 15 | 16 | require 'ostruct' 17 | require 'prawn-html' 18 | require 'pdf/inspector' 19 | require 'pry' 20 | 21 | Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } 22 | 23 | Prawn::Fonts::AFM.hide_m17n_warning = true 24 | 25 | RSpec.configure do |config| 26 | config.color = true 27 | config.tty = true 28 | 29 | config.expect_with :rspec do |expectations| 30 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 31 | end 32 | 33 | config.mock_with :rspec do |mocks| 34 | mocks.verify_partial_doubles = true 35 | end 36 | 37 | config.shared_context_metadata_behavior = :apply_to_host_groups 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/common_checks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'checks contents and positions' do 4 | it 'matches the expected contents and positions', :aggregate_failures do 5 | text_analysis = PDF::Inspector::Text.analyze(pdf.render) 6 | positions = text_analysis.positions.map { |item| [item[0].round(4), item[1].round(4)] } 7 | expect(text_analysis.strings).to eq expected_content 8 | expect(positions).to eq expected_positions 9 | end 10 | end 11 | 12 | RSpec.shared_examples 'checks contents, positions and font settings' do 13 | it 'matches the expected contents, positions and font settings', :aggregate_failures do 14 | text_analysis = PDF::Inspector::Text.analyze(pdf.render) 15 | positions = text_analysis.positions.map { |item| [item[0].round(4), item[1].round(4)] } 16 | expect(text_analysis.strings).to eq expected_content 17 | expect(text_analysis.font_settings).to eq expected_font_settings 18 | expect(positions).to eq expected_positions 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/shared_contexts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'with pdf wrapper' do 4 | let(:pdf) do 5 | methods = { advance_cursor: true, puts: true, horizontal_rule: true } 6 | instance_double(PrawnHtml::PdfWrapper, methods) 7 | end 8 | 9 | def append_html_to_pdf(html) 10 | allow(pdf).to receive_messages(page_width: 540, page_height: 720) 11 | allow(PrawnHtml::PdfWrapper).to receive(:new).and_return(pdf) 12 | pdf_document = Prawn::Document.new(page_size: 'A4', page_layout: :portrait) 13 | PrawnHtml.append_html(pdf_document, html) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/test_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TestUtils 4 | extend self 5 | 6 | def adjust_leading(size = PrawnHtml::Context::DEFAULT_STYLES[:size], font = nil) 7 | (size * PrawnHtml::ADJUST_LEADING[font]).round(4) 8 | end 9 | 10 | def default_font 11 | prawn_document.font('Helvetica', size: default_font_size) 12 | end 13 | 14 | def default_font_family 15 | default_font.family.to_sym 16 | end 17 | 18 | def default_font_size 19 | PrawnHtml::Context::DEFAULT_STYLES[:size] 20 | end 21 | 22 | def font_ascender(font_family: 'Helvetica', font_size: default_font_size) 23 | prawn_document.font(font_family, size: font_size).ascender 24 | end 25 | 26 | def font_string_width(pdf_doc, string, font_family: 'Helvetica', font_size: default_font_size, inline_format: false) 27 | width = 0 28 | pdf_doc.font(font_family, size: font_size) { width = pdf_doc.width_of(string, inline_format: inline_format) } 29 | width 30 | end 31 | 32 | def prawn_document 33 | ::Prawn::Document.new(page_size: 'A4', page_layout: :portrait) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/units/prawn_html/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Attributes do 4 | subject(:attributes) { described_class.new(attributes_hash) } 5 | 6 | let(:attributes_hash) { { attr1: 'value 1', attr2: 'value 2' } } 7 | 8 | it { expect(described_class).to be < OpenStruct } 9 | 10 | describe '#initialize' do 11 | it 'returns an empty styles hash' do 12 | expect(attributes.styles).to eq({}) 13 | end 14 | end 15 | 16 | describe '#data' do 17 | subject(:data) { attributes.data } 18 | 19 | context 'with some data attributes (data-dash: 5 and data-something-else: "some value")' do 20 | let(:attributes_hash) { { 'data-dash': '5', 'data-something-else': '"some value"' } } 21 | 22 | it { is_expected.to match('dash' => '5', 'something-else' => '"some value"') } 23 | end 24 | end 25 | 26 | describe '#merge_text_styles!' do 27 | subject(:merge_text_styles!) { attributes.merge_text_styles!(text_styles, options: options) } 28 | 29 | let(:options) { {} } 30 | 31 | before do 32 | allow(PrawnHtml::Utils).to receive(:send).and_call_original 33 | end 34 | 35 | context 'with an empty hash' do 36 | let(:text_styles) { '' } 37 | 38 | it "doesn't merge new styles" do 39 | expect(attributes.styles).to eq({}) 40 | end 41 | end 42 | 43 | context 'with some styles' do 44 | let(:text_styles) do 45 | <<~STYLES 46 | font-family: 'Times-Roman'; 47 | font-size: 16px; 48 | font-weight: bold; 49 | margin-bottom: 22px; 50 | STYLES 51 | end 52 | 53 | it 'receives the expected messages', :aggregate_failures do 54 | merge_text_styles! 55 | 56 | expect(PrawnHtml::Utils).to have_received(:send).with(:filter_font_family, "'Times-Roman'", options: nil) 57 | expect(PrawnHtml::Utils).to have_received(:send).with(:convert_size, '16px', options: nil) 58 | expect(PrawnHtml::Utils).to have_received(:send).with(:convert_size, '22px', options: nil) 59 | end 60 | end 61 | 62 | context 'with some options' do 63 | let(:options) { { width: 540, height: 720 } } 64 | let(:text_styles) { 'top: 50%' } 65 | 66 | it 'receives the expected convert messages', :aggregate_failures do 67 | merge_text_styles! 68 | 69 | expect(PrawnHtml::Utils).to have_received(:send).with(:convert_size, '50%', options: 720) 70 | end 71 | end 72 | end 73 | 74 | describe '#remove_value' do 75 | subject(:remove_value) { attributes.remove_value(context_styles, rule) } 76 | 77 | let(:context_styles) { { size: 9.6, styles: %i[bold italic] } } 78 | 79 | context 'with a missing rule' do 80 | let(:rule) { { key: :color, set: :convert_color } } 81 | 82 | it "doesn't change the context styles" do 83 | expect { remove_value }.not_to change(context_styles, :values) 84 | end 85 | end 86 | 87 | context 'with an applied rule' do 88 | let(:rule) { { key: :styles, set: :append_styles, values: %i[bold] } } 89 | 90 | it "changes the context styles" do 91 | expect { remove_value }.to change(context_styles, :values).from([9.6, %i[bold italic]]).to([9.6, %i[italic]]) 92 | end 93 | end 94 | end 95 | 96 | describe '#update_styles' do 97 | subject(:update_styles) { attributes.update_styles(context_styles) } 98 | 99 | let(:context_styles) { { size: 9.6 } } 100 | let(:rule) { { key: :styles, set: :append_styles, values: %i[bold] } } 101 | 102 | before do 103 | allow(attributes).to receive_messages(initial: Set.new([rule]), remove_value: nil) 104 | end 105 | 106 | it 'asks to the attributes to remove the value from the context styles that match the specified rule' do 107 | update_styles 108 | expect(attributes).to have_received(:remove_value).with(context_styles, rule) 109 | end 110 | end 111 | 112 | describe '.merge_attr!' do 113 | context 'with an empty key' do 114 | let(:key) { nil } 115 | let(:value) { '123' } 116 | 117 | it "doesn't update the hash" do 118 | hash = { some_key: 'some value' } 119 | described_class.merge_attr!(hash, key, value) 120 | 121 | expect(hash).to eq(some_key: 'some value') 122 | end 123 | end 124 | 125 | context 'with a mergeable key (ex. :margin_left)' do 126 | let(:key) { :margin_left } 127 | let(:value) { 10 } 128 | 129 | it 'updates the hash increasing the target attribute', :aggregate_failures do 130 | hash = { some_key: 'some value' } 131 | described_class.merge_attr!(hash, key, value) 132 | expect(hash).to eq(margin_left: value, some_key: 'some value') 133 | 134 | described_class.merge_attr!(hash, key, 5) 135 | expect(hash).to eq(margin_left: 15, some_key: 'some value') 136 | end 137 | end 138 | 139 | context 'with a non-mergeable key (ex. :another_key)' do 140 | let(:key) { :another_key } 141 | let(:value) { 10 } 142 | 143 | it 'replaces the target attribute value in the hash' do 144 | hash = { another_key: 5, some_key: 'some value' } 145 | described_class.merge_attr!(hash, key, value) 146 | 147 | expect(hash).to eq(another_key: 10, some_key: 'some value') 148 | end 149 | end 150 | end 151 | 152 | describe '.parse_styles' do 153 | subject(:parse_styles) { described_class.parse_styles(styles) } 154 | 155 | context 'with nil styles' do 156 | let(:styles) { nil } 157 | 158 | it { is_expected.to eq({}) } 159 | end 160 | 161 | context 'with an invalid styles string' do 162 | let(:styles) { 'a a a' } 163 | 164 | it { is_expected.to eq({}) } 165 | end 166 | 167 | context 'with a valid styles string' do 168 | let(:styles) { 'padding-left: 10px; padding-top: 20px; padding-bottom: 30px' } 169 | 170 | it 'parses the styles string and return an hash' do 171 | expected_hash = { 172 | 'padding-bottom' => '30px', 173 | 'padding-left' => '10px', 174 | 'padding-top' => '20px' 175 | } 176 | 177 | expect(parse_styles).to match(expected_hash) 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/units/prawn_html/context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Context do 4 | subject(:context) { described_class.new } 5 | 6 | it { expect(described_class).to be < Array } 7 | 8 | describe '#initialize' do 9 | it 'last_text_node is set to false' do 10 | expect(context.last_text_node).to be_falsey 11 | end 12 | end 13 | 14 | describe '#add' do 15 | let(:some_tag_class) do 16 | Class.new(PrawnHtml::Tag) do 17 | def on_context_add(context) 18 | # extra init after context insertion 19 | end 20 | end 21 | end 22 | let(:tag1) { instance_double(PrawnHtml::Tag, 'parent=': true) } 23 | let(:tag2) { instance_double(some_tag_class, 'parent=': true, on_context_add: true) } 24 | 25 | it 'adds the elements', :aggregate_failures do 26 | expect { context.add(tag1) }.to change(context, :size).from(0).to(1) 27 | expect(tag1).to have_received('parent=').with(nil) 28 | 29 | expect { context.add(tag2) }.to change(context, :size).from(1).to(2) 30 | expect(tag2).to have_received(:'parent=').with(tag1) 31 | expect(tag2).to have_received(:on_context_add).with(context) 32 | end 33 | 34 | it 'returns the context itself' do 35 | expect(context.add(tag1)).to eq(context) 36 | end 37 | end 38 | 39 | describe '#before_content' do 40 | subject(:before_content) { context.before_content } 41 | 42 | context 'with no elements' do 43 | it { is_expected.to eq '' } 44 | end 45 | 46 | context 'with the last element that has no tag_styles' do 47 | before do 48 | context << PrawnHtml::Tag.new(:some_tag) 49 | end 50 | 51 | it { is_expected.to eq '' } 52 | end 53 | 54 | context 'with the last element that has some tag_styles' do 55 | let(:some_tag_class) do 56 | Class.new(PrawnHtml::Tag) do 57 | def before_content 58 | 'Some before content' 59 | end 60 | end 61 | end 62 | 63 | before do 64 | context << some_tag_class.new(:some_tag) 65 | end 66 | 67 | it { is_expected.to eq 'Some before content' } 68 | end 69 | end 70 | 71 | describe '#block_styles' do 72 | subject(:block_styles) { context.block_styles } 73 | 74 | context 'with no elements' do 75 | it { is_expected.to eq({}) } 76 | end 77 | 78 | context 'with some elements' do 79 | let(:tag1) { instance_double(PrawnHtml::Tag, block_styles: { margin_left: 11.11 }) } 80 | let(:tag2) { instance_double(PrawnHtml::Tag, block_styles: { margin_left: 22.22 }) } 81 | 82 | before do 83 | allow(PrawnHtml::Attributes).to receive(:merge_attr!).and_call_original 84 | context << tag1 << tag2 85 | end 86 | 87 | it 'merges the block styles of the elements', :aggregate_failures do 88 | expect(block_styles).to eq(margin_left: 33.33) 89 | expect(PrawnHtml::Attributes).to have_received(:merge_attr!).twice 90 | end 91 | end 92 | end 93 | 94 | describe '#remove_last' do 95 | subject(:remove_last) { context.remove_last } 96 | 97 | let(:some_tag_class) do 98 | Class.new(PrawnHtml::Tag) do 99 | def on_context_remove(context) 100 | # callback after removal 101 | end 102 | end 103 | end 104 | let(:tag) { instance_double(some_tag_class, on_context_remove: true, :parent= => true, tag: :some_tag) } 105 | 106 | before do 107 | context.add(tag) 108 | end 109 | 110 | it 'removes the last element from the context', :aggregate_failures do 111 | expect { remove_last }.to( 112 | change(context, :size).from(1).to(0).and( 113 | change(context, :previous_tag).from(nil).to(tag) 114 | ) 115 | ) 116 | expect(tag).to have_received(:on_context_remove) 117 | end 118 | end 119 | 120 | describe '#merged_styles' do 121 | subject(:merged_styles) { context.merged_styles } 122 | 123 | context 'with no elements' do 124 | it { is_expected.to eq(size: PrawnHtml::Context::DEFAULT_STYLES[:size]) } 125 | end 126 | 127 | context 'with some elements' do 128 | let(:tag1) { instance_double(PrawnHtml::Tag, styles: { color: 'fb1', size: 12.34 }, update_styles: nil) } 129 | let(:tag2) { instance_double(PrawnHtml::Tag, styles: { color: 'abc' }, update_styles: nil) } 130 | 131 | before do 132 | context << tag1 << tag2 133 | end 134 | 135 | it 'merges the styles of the elements' do 136 | expect(merged_styles).to match(color: 'abc', size: 12.34) 137 | end 138 | end 139 | 140 | context 'with an element with update_styles' do 141 | let(:some_tag_class) do 142 | Class.new(PrawnHtml::Tag) do 143 | def update_styles(res) 144 | res[:some_style] = :some_value 145 | end 146 | end 147 | end 148 | let(:tag1) { instance_double(PrawnHtml::Tag, styles: { color: 'fb1', size: 12.34 }, update_styles: nil) } 149 | let(:tag2) { some_tag_class.new(:some_tag) } 150 | 151 | before do 152 | allow(tag2).to receive(:update_styles).and_call_original 153 | context << tag1 << tag2 154 | end 155 | 156 | it 'sends the context styles to the update_styles method', :aggregate_failures do 157 | expect(merged_styles).to match(color: 'fb1', size: 12.34, some_style: :some_value) 158 | expect(tag2).to have_received(:update_styles) 159 | end 160 | end 161 | end 162 | 163 | describe '#white_space_pre?' do 164 | subject(:white_space_pre?) { context.white_space_pre? } 165 | 166 | before do 167 | context << instance_double(PrawnHtml::Tag, styles: {}) 168 | end 169 | 170 | it { is_expected.to be_falsey } 171 | 172 | context 'when the last element has white-space property set to pre' do 173 | before do 174 | context << instance_double(PrawnHtml::Tag, styles: { white_space: :pre }) 175 | end 176 | 177 | it { is_expected.to be_truthy } 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/units/prawn_html/document_renderer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::DocumentRenderer do 4 | subject(:document_renderer) { described_class.new(pdf) } 5 | 6 | let(:context) { PrawnHtml::Context.new } 7 | let(:pdf) { PrawnHtml::PdfWrapper.new(pdf_doc) } 8 | let(:pdf_doc) { Prawn::Document.new } 9 | 10 | before do 11 | allow(PrawnHtml::Context).to receive(:new).and_return(context) 12 | end 13 | 14 | describe '#on_tag_close' do 15 | subject(:on_tag_close) { document_renderer.on_tag_close(element) } 16 | 17 | let(:element) { PrawnHtml::Tags::Div.new(:div) } 18 | 19 | before do 20 | allow(context).to receive(:remove_last) 21 | allow(element).to receive(:tag_close_styles).and_call_original 22 | end 23 | 24 | it 'handles tag closing', :aggregate_failures do 25 | on_tag_close 26 | expect(element).to have_received(:tag_close_styles) 27 | expect(context).to have_received(:remove_last) 28 | end 29 | end 30 | 31 | describe '#on_tag_open' do 32 | subject(:on_tag_open) do 33 | document_renderer.on_tag_open(tag, attributes: attributes, element_styles: element_styles) 34 | end 35 | 36 | let(:attributes) { { 'class' => 'green' } } 37 | let(:element_styles) { 'color: red' } 38 | 39 | context 'with a div tag' do 40 | let(:tag) { :div } 41 | 42 | before do 43 | allow(context).to receive(:add) 44 | end 45 | 46 | it { is_expected.to be_kind_of PrawnHtml::Tags::Div } 47 | 48 | it 'adds the element to the context' do 49 | on_tag_open 50 | expect(context).to have_received(:add) 51 | end 52 | end 53 | 54 | context 'with an unknown tag' do 55 | let(:tag) { :unknown_tag } 56 | 57 | it { is_expected.to be_nil } 58 | end 59 | end 60 | 61 | describe '#on_text_node' do 62 | subject(:on_text_node) { document_renderer.on_text_node(content) } 63 | 64 | let(:content) { 'some content' } 65 | 66 | it { is_expected.to be_nil } 67 | end 68 | 69 | describe '#render' do 70 | subject(:render) { document_renderer.render } 71 | 72 | before do 73 | allow(pdf).to receive_messages(puts: true) 74 | end 75 | 76 | it "renders nothing when the buffer's content is empty" do 77 | render 78 | expect(pdf).not_to have_received(:puts) 79 | end 80 | 81 | context 'with some content in the buffer' do 82 | before do 83 | document_renderer.on_text_node('Some content') 84 | end 85 | 86 | it "renders the current buffer's content" do 87 | render 88 | expect(pdf).to have_received(:puts) 89 | end 90 | end 91 | 92 | context 'with position absolute, top and left properties' do 93 | before do 94 | document_renderer.on_tag_open(:div, attributes: { 'style' => 'position: absolute; top: 10px; left: 50px' }) 95 | document_renderer.on_text_node('Some content') 96 | end 97 | 98 | it "renders the current buffer's content in a bounded box" do 99 | render 100 | expect(pdf).to have_received(:puts) 101 | end 102 | end 103 | 104 | context 'with position absolute, bottom and right properties' do 105 | before do 106 | document_renderer.on_tag_open(:div, attributes: { 'style' => 'position: absolute; bottom: 10px; right: 80px' }) 107 | document_renderer.on_text_node('Some content') 108 | end 109 | 110 | it "renders the current buffer's content in a bounded box" do 111 | render 112 | expect(pdf).to have_received(:puts) 113 | end 114 | end 115 | end 116 | 117 | describe 'alias method: flush' do 118 | it { is_expected.to respond_to(:flush) } 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/units/prawn_html/html_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::HtmlParser do 4 | subject(:html_parser) { described_class.new(renderer) } 5 | 6 | let(:renderer) { instance_double(PrawnHtml::DocumentRenderer, flush: true, on_text_node: nil) } 7 | 8 | describe 'REGEXP_STYLES' do 9 | subject(:regexp) { text.scan(described_class::REGEXP_STYLES) } 10 | 11 | context 'with a single rule and a single property: "i{color:red}"' do 12 | let(:text) { 'i{color:red}' } 13 | 14 | it { is_expected.to match_array [['i', 'color:red']] } 15 | end 16 | 17 | context 'with a single rule and more properties: "i { color: red; font-style: italic; font-weight: bold }"' do 18 | let(:text) { 'i { color: red; font-style: italic; font-weight: bold }' } 19 | 20 | it { is_expected.to match_array [['i', 'color: red; font-style: italic; font-weight: bold']] } 21 | end 22 | 23 | context 'with some rules' do 24 | let(:text) do 25 | <<~CSS 26 | i { 27 | color: red; 28 | font-style: italic; font-weight: bold 29 | } 30 | b { font-size: 20px; } 31 | u { 32 | color: RGB(128, 128, 128); 33 | } 34 | CSS 35 | end 36 | 37 | it { is_expected.to match_array [["i", "color: red;\n font-style: italic; font-weight: bold"], ["b", "font-size: 20px;"], ["u", "color: RGB(128, 128, 128);"]] } 38 | end 39 | end 40 | 41 | describe '#parse_styles' do 42 | subject(:parse_styles) { html_parser.parse_styles(css) } 43 | 44 | let(:css) { 'h1 { color: red }' } 45 | 46 | it { is_expected.to eq('h1' => 'color: red') } 47 | end 48 | 49 | describe '#process' do 50 | subject(:process) { html_parser.process(html) } 51 | 52 | let(:html) { 'some text' } 53 | 54 | before do 55 | allow(Oga).to receive(:parse_html).and_call_original 56 | end 57 | 58 | it 'calls Oga parse html', :aggregate_failures do 59 | process 60 | expect(Oga).to have_received(:parse_html).with(html) 61 | expect(renderer).to have_received(:on_text_node).with(html) 62 | end 63 | end 64 | 65 | describe 'ignore some elements' do 66 | let(:process) { html_parser.process(html) } 67 | 68 | before do 69 | allow(Oga).to receive(:parse_html).and_call_original 70 | allow(PrawnHtml::IgnoredTag).to receive(:new).and_call_original 71 | allow(renderer).to receive(:on_tag_open) 72 | end 73 | 74 | context 'with an HTML comment' do 75 | let(:html) { 'before comment bold --> italicafter' } 76 | 77 | it 'skips the HTML comments', :aggregate_failures do 78 | process 79 | expect(renderer).to have_received(:on_text_node).with('before').ordered 80 | expect(renderer).not_to have_received(:on_text_node).with('bold') 81 | expect(renderer).to have_received(:on_text_node).with('italic').ordered 82 | expect(renderer).to have_received(:on_text_node).with('after').ordered 83 | end 84 | end 85 | 86 | context 'with a script tag' do 87 | let(:html) { 'beforeafter' } 88 | 89 | it 'skips the content of the script tag', :aggregate_failures do 90 | process 91 | expect(renderer).to have_received(:on_text_node).with('before').ordered 92 | expect(PrawnHtml::IgnoredTag).to have_received(:new).with(:script).ordered 93 | expect(renderer).not_to have_received(:on_text_node).with('a script') 94 | expect(renderer).to have_received(:on_text_node).with('after').ordered 95 | end 96 | end 97 | 98 | context 'with a style tag' do 99 | let(:html) { 'beforeafter' } 100 | 101 | before do 102 | allow(renderer).to receive(:on_tag_open) 103 | end 104 | 105 | it 'skips the content of style tag but parses the styles', :aggregate_failures do 106 | process 107 | expect(renderer).to have_received(:on_text_node).with('before').ordered 108 | expect(PrawnHtml::IgnoredTag).to have_received(:new).with(:style).ordered 109 | expect(renderer).not_to have_received(:on_text_node).with('some styles') 110 | expect(renderer).to have_received(:on_text_node).with('after').ordered 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/units/prawn_html/instance_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Instance do 4 | describe 'initialize' do 5 | subject(:instance) { described_class.new(pdf) } 6 | 7 | let(:pdf) { instance_double(Prawn::Document) } 8 | 9 | before do 10 | allow(PrawnHtml::PdfWrapper).to receive(:new) 11 | allow(PrawnHtml::DocumentRenderer).to receive(:new) 12 | allow(PrawnHtml::HtmlParser).to receive(:new) 13 | end 14 | 15 | it 'initializes the required entities', :aggregate_failures do 16 | instance 17 | expect(PrawnHtml::PdfWrapper).to have_received(:new) 18 | expect(PrawnHtml::DocumentRenderer).to have_received(:new) 19 | expect(PrawnHtml::HtmlParser).to have_received(:new) 20 | end 21 | end 22 | 23 | describe '#append' do 24 | subject(:append) { described_class.new(pdf).append(html: html) } 25 | 26 | let(:html) { '

Some HTML

' } 27 | let(:html_parser) { instance_double(PrawnHtml::HtmlParser, process: nil) } 28 | let(:pdf) { instance_double(Prawn::Document) } 29 | 30 | before do 31 | allow(PrawnHtml::HtmlParser).to receive(:new).and_return(html_parser) 32 | end 33 | 34 | it 'sends a process message to the html parser' do 35 | append 36 | expect(html_parser).to have_received(:process).with(html) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/units/prawn_html/pdf_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::PdfWrapper do 4 | subject(:pdf_wrapper) { described_class.new(pdf) } 5 | 6 | let(:pdf) { instance_double(Prawn::Document) } 7 | 8 | describe 'delegated methods' do 9 | %i[start_new_page].each do |method_name| 10 | context "with #{method_name} method" do 11 | before do 12 | allow(pdf).to receive(method_name) 13 | end 14 | 15 | it 'delegates the method call to the pdf instance' do 16 | pdf_wrapper.send(method_name) 17 | expect(pdf).to have_received(method_name) 18 | end 19 | end 20 | end 21 | end 22 | 23 | describe '#advance_cursor' do 24 | subject(:advance_cursor) { pdf_wrapper.advance_cursor(quantity) } 25 | 26 | before do 27 | allow(pdf).to receive(:move_down) 28 | end 29 | 30 | context 'with some quantity' do 31 | let(:quantity) { 20 } 32 | 33 | it 'delegates to PDF move_down' do 34 | advance_cursor 35 | expect(pdf).to have_received(:move_down).with(20) 36 | end 37 | end 38 | 39 | context 'with zero quantity' do 40 | let(:quantity) { 0 } 41 | 42 | it "doesn't move down" do 43 | advance_cursor 44 | expect(pdf).not_to have_received(:move_down).with(20) 45 | end 46 | end 47 | end 48 | 49 | describe '#calc_buffer_height' do 50 | subject(:calc_buffer_height) { pdf_wrapper.calc_buffer_height(buffer, options) } 51 | 52 | let(:buffer) { [{ text: 'some content' }] } 53 | let(:options) { {} } 54 | 55 | before do 56 | allow(pdf).to receive(:height_of_formatted) 57 | end 58 | 59 | it 'calls the PDF height_of_formatted method' do 60 | calc_buffer_height 61 | expect(pdf).to have_received(:height_of_formatted).with(buffer, options) 62 | end 63 | end 64 | 65 | describe '#calc_buffer_width' do 66 | subject(:calc_buffer_width) { pdf_wrapper.calc_buffer_width(buffer) } 67 | 68 | let(:buffer) { [{ text: 'some content', font: 'Courier', size: 12 }] } 69 | 70 | before do 71 | allow(pdf).to receive(:font).and_yield 72 | allow(pdf).to receive(:width_of).and_return(0) 73 | end 74 | 75 | it 'calls the PDF width_of method' do 76 | calc_buffer_width 77 | expect(pdf).to have_received(:width_of) # .with(buffer, inline_format: true) 78 | end 79 | end 80 | 81 | describe '#draw_rectangle' do 82 | subject(:draw_rectangle) { pdf_wrapper.draw_rectangle(x: 50, y: 80, width: 200, height: 150, color: 'ffbb111') } 83 | 84 | before do 85 | allow(pdf).to receive_messages(fill_color: 'aabbbcc', :fill_color= => true, fill_rectangle: true) 86 | end 87 | 88 | it 'calls the PDF fill_rectangle method', :aggregate_failures do 89 | draw_rectangle 90 | expect(pdf).to have_received(:fill_color=).with('ffbb111').ordered 91 | expect(pdf).to have_received(:fill_rectangle).with([80, 50], 200, 150).ordered 92 | expect(pdf).to have_received(:fill_color=).with('aabbbcc').ordered 93 | end 94 | end 95 | 96 | describe '#horizontal_rule' do 97 | subject(:horizontal_rule) { pdf_wrapper.horizontal_rule(color: color, dash: dash) } 98 | 99 | let(:color) { 'ffbb11' } 100 | let(:dash) { 5 } 101 | 102 | before do 103 | methods = { dash: nil, stroke_color: 'abcdef', :stroke_color= => nil, stroke_horizontal_rule: nil, undash: nil } 104 | allow(pdf).to receive_messages(methods) 105 | end 106 | 107 | it 'calls the PDF stroke_horizontal_rule method', :aggregate_failures do 108 | horizontal_rule 109 | expect(pdf).to have_received(:stroke_color).ordered 110 | expect(pdf).to have_received(:dash).with(5).ordered 111 | expect(pdf).to have_received(:stroke_color=).with('ffbb11').ordered 112 | expect(pdf).to have_received(:stroke_horizontal_rule).ordered 113 | expect(pdf).to have_received(:stroke_color=).with('abcdef').ordered 114 | expect(pdf).to have_received(:undash).ordered 115 | end 116 | end 117 | 118 | describe '#image' do 119 | subject(:image) { pdf_wrapper.image(src, options) } 120 | 121 | let(:src) { 'some_image_path' } 122 | let(:options) { {} } 123 | 124 | before do 125 | allow(pdf).to receive(:image) 126 | end 127 | 128 | it 'calls the PDF image method' do 129 | image 130 | expect(pdf).to have_received(:image).with(src, options) 131 | end 132 | end 133 | 134 | describe '#puts' do 135 | subject(:puts) { pdf_wrapper.puts(buffer, options, bounding_box: bounding_box) } 136 | 137 | let(:bounding_box) { nil } 138 | let(:buffer) { [] } 139 | let(:options) { {} } 140 | 141 | before do 142 | allow(pdf).to receive(:formatted_text) 143 | end 144 | 145 | it 'calls the PDF formatted_text method' do 146 | puts 147 | expect(pdf).to have_received(:formatted_text).with(buffer, options) 148 | end 149 | end 150 | 151 | describe '#underline' do 152 | subject(:underline) { pdf_wrapper.underline(x1: x1, x2: x2, y: y) } 153 | 154 | let(:x1) { 20 } 155 | let(:x2) { 50 } 156 | let(:y) { 40 } 157 | 158 | before do 159 | allow(pdf).to receive(:stroke).and_yield 160 | allow(pdf).to receive(:line) 161 | end 162 | 163 | it 'calls the PDF formatted_text method', :aggregate_failures do 164 | underline 165 | expect(pdf).to have_received(:stroke).ordered 166 | expect(pdf).to have_received(:line).with([20, 40], [50, 40]).ordered 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tag_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tag do 4 | subject(:tag) { described_class.new(:some_tag, attributes: attributes) } 5 | 6 | let(:attributes) { { 'style' => 'color: #0088ff', 'some_attr' => 'some value' } } 7 | 8 | describe '#initialize' do 9 | before do 10 | allow(PrawnHtml::Attributes).to receive(:new).and_call_original 11 | end 12 | 13 | it 'instantiates a new Attributes object', :aggregate_failures do 14 | tag 15 | expect(PrawnHtml::Attributes).to have_received(:new).with(attributes) 16 | expect(tag.attrs).to be_kind_of(PrawnHtml::Attributes) 17 | end 18 | 19 | it 'sets the tag' do 20 | expect(tag.tag).to eq(:some_tag) 21 | end 22 | end 23 | 24 | describe '#block?' do 25 | subject(:block?) { tag.block? } 26 | 27 | it { is_expected.to be_falsey } 28 | end 29 | 30 | describe '#block_styles' do 31 | subject(:block_styles) { tag.block_styles } 32 | 33 | it { is_expected.to eq({}) } 34 | 35 | context 'with some data attributes' do 36 | let(:attributes) { { 'data-mode' => 'mode1' } } 37 | 38 | it 'delegates to the tag attributes' do 39 | expect(block_styles).to eq(mode: :mode1) 40 | end 41 | end 42 | end 43 | 44 | describe '#process_styles' do 45 | subject(:process_styles) { tag.process_styles(element_styles: element_styles) } 46 | 47 | let(:element_styles) { nil } 48 | 49 | before do 50 | allow(tag.attrs).to receive(:merge_text_styles!) 51 | end 52 | 53 | it 'merges the inline styles' do 54 | process_styles 55 | expect(tag.attrs).to have_received(:merge_text_styles!).with('color: #0088ff', options: {}) 56 | end 57 | 58 | context 'with some additional styles' do 59 | let(:some_tag_class) do 60 | Class.new(described_class) do 61 | def extra_styles 62 | 'color: green; text-decoration: underline' 63 | end 64 | 65 | def tag_styles 66 | 'color: yellow; font-style: italic' 67 | end 68 | end 69 | end 70 | 71 | let(:element_styles) { 'color: red; font-weight: bold' } 72 | let(:tag) { some_tag_class.new(:some_tag, attributes: attributes) } 73 | 74 | it 'merges the tag styles', :aggregate_failures do 75 | process_styles 76 | 77 | expected_styles = 'color: yellow; font-style: italic' 78 | expect(tag.attrs).to have_received(:merge_text_styles!).with(expected_styles, options: {}).ordered 79 | expect(tag.attrs).to have_received(:merge_text_styles!).with(element_styles, options: {}).ordered 80 | expect(tag.attrs).to have_received(:merge_text_styles!).with('color: #0088ff', options: {}).ordered 81 | expected_styles = 'color: green; text-decoration: underline' 82 | expect(tag.attrs).to have_received(:merge_text_styles!).with(expected_styles, options: {}).ordered 83 | end 84 | end 85 | end 86 | 87 | describe '#styles' do 88 | subject(:styles) { tag.styles } 89 | 90 | before do 91 | tag.process_styles 92 | end 93 | 94 | it { is_expected.to eq(color: '0088ff') } 95 | end 96 | 97 | describe '#tag_close_styles' do 98 | subject(:tag_close_styles) { tag.tag_close_styles } 99 | 100 | it { is_expected.to eq({}) } 101 | end 102 | 103 | describe '#tag_open_styles' do 104 | subject(:tag_open_styles) { tag.tag_open_styles } 105 | 106 | it { is_expected.to eq({}) } 107 | end 108 | 109 | describe '#update_styles' do 110 | subject(:update_styles) { tag.update_styles(context_styles) } 111 | 112 | let(:context_styles) { { size: 9.6 } } 113 | 114 | before do 115 | allow(tag.attrs).to receive(:update_styles) 116 | end 117 | 118 | it 'asks to the attributes to update the context styles' do 119 | update_styles 120 | expect(tag.attrs).to have_received(:update_styles).with(context_styles) 121 | end 122 | end 123 | 124 | describe '.class_for' do 125 | subject(:class_for) { described_class.class_for(tag_name) } 126 | 127 | context 'with an unknown tag name' do 128 | let(:tag_name) { :some_tag } 129 | 130 | it { is_expected.to be_nil } 131 | end 132 | 133 | context 'with an h6 tag' do 134 | let(:tag_name) { :h6 } 135 | 136 | it { is_expected.to eq(PrawnHtml::Tags::H) } 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/a_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::A do 4 | subject(:a) { described_class.new(:a, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | a.process_styles 11 | end 12 | 13 | it "styles doesn't include the link property" do 14 | expect(a.styles).to eq(color: 'ffbb11', styles: [:underline]) 15 | end 16 | end 17 | 18 | context 'with an href attribute' do 19 | subject(:a) do 20 | described_class.new(:a, attributes: { 'href' => 'https://www.google.it', 'style' => 'font-weight: bold' }) 21 | end 22 | 23 | before do 24 | a.process_styles 25 | end 26 | 27 | it 'includes the link property in the styles' do 28 | expect(a.styles).to match(color: '0000ee', link: 'https://www.google.it', styles: [:underline, :bold]) 29 | end 30 | end 31 | 32 | describe 'tag rendering' do 33 | include_context 'with pdf wrapper' 34 | 35 | let(:html) { 'A link' } 36 | 37 | before { append_html_to_pdf(html) } 38 | 39 | it 'sends the expected buffer elements to the pdf', :aggregate_failures do 40 | expected_buffer = [{ size: TestUtils.default_font_size, text: 'A link', link: 'https://www.google.it', color: '0000ee', styles: [:underline] }] 41 | expected_options = { leading: TestUtils.adjust_leading } 42 | expected_extra = { bounding_box: nil, left_indent: 0 } 43 | 44 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/b_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::B do 4 | subject(:b) { described_class.new(:b, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | b.process_styles 11 | end 12 | 13 | it 'returns the expected styles for b tag' do 14 | expect(b.styles).to match(color: 'ffbb11', styles: [:bold]) 15 | end 16 | end 17 | 18 | describe 'tag rendering' do 19 | include_context 'with pdf wrapper' 20 | 21 | before { append_html_to_pdf(html) } 22 | 23 | context 'with a B tag' do 24 | let(:html) { 'Some sample content' } 25 | 26 | it 'sends the expected buffer elements to the pdf' do 27 | expect(pdf).to have_received(:puts).with( 28 | [{ size: TestUtils.default_font_size, styles: [:bold], text: "Some sample content" }], 29 | { leading: TestUtils.adjust_leading }, 30 | { bounding_box: nil, left_indent: 0 } 31 | ) 32 | end 33 | end 34 | 35 | context 'with a Strong tag' do 36 | let(:html) { 'Some sample content' } 37 | 38 | it 'sends the expected buffer elements to the pdf' do 39 | expect(pdf).to have_received(:puts).with( 40 | [{ size: TestUtils.default_font_size, styles: [:bold], text: "Some sample content" }], 41 | { leading: TestUtils.adjust_leading }, 42 | { bounding_box: nil, left_indent: 0 } 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/blockquote_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Blockquote do 4 | subject(:blockquote) { described_class.new(:blockquote, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | blockquote.process_styles 11 | end 12 | 13 | it 'returns the expected styles for blockquote tag' do 14 | expected_styles = { 15 | color: 'ffbb11', 16 | margin_bottom: PrawnHtml::Utils.convert_size(described_class::MARGIN_BOTTOM.to_s), 17 | margin_left: PrawnHtml::Utils.convert_size(described_class::MARGIN_LEFT.to_s), 18 | margin_top: PrawnHtml::Utils.convert_size(described_class::MARGIN_TOP.to_s) 19 | } 20 | expect(blockquote.styles).to match(expected_styles) 21 | end 22 | end 23 | 24 | describe '#block?' do 25 | subject(:block?) { blockquote.block? } 26 | 27 | it { is_expected.to be_truthy } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/body_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Body do 4 | subject(:body) { described_class.new(:body) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { body.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/br_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Br do 4 | subject(:br) { described_class.new(:br) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { br.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | describe '#custom_render' do 15 | subject(:custom_render) { br.custom_render(pdf, context) } 16 | 17 | let(:context) { instance_double(PrawnHtml::Context, last_text_node: false, previous_tag: nil) } 18 | let(:pdf) { instance_double(PrawnHtml::PdfWrapper, advance_cursor: true) } 19 | 20 | it "doesn't call advance_cursor on the pdf wrapper" do 21 | custom_render 22 | expect(pdf).not_to have_received(:advance_cursor) 23 | end 24 | 25 | context 'when the last node in the context is another br' do 26 | let(:context) { instance_double(PrawnHtml::Context, last_text_node: false, previous_tag: prev_tag) } 27 | let(:prev_tag) { PrawnHtml::Tags::Br.new(:br) } # rubocop:disable RSpec/DescribedClass 28 | 29 | it 'calls advance_cursor on the pdf wrapper' do 30 | custom_render 31 | expect(pdf).to have_received(:advance_cursor) 32 | end 33 | end 34 | 35 | context 'when the last node in the context is of type text' do 36 | let(:context) { instance_double(PrawnHtml::Context, last_text_node: true) } 37 | 38 | it "doesn't call advance_cursor on the pdf wrapper" do 39 | custom_render 40 | expect(pdf).not_to have_received(:advance_cursor) 41 | end 42 | end 43 | end 44 | 45 | describe 'tag rendering' do 46 | include_context 'with pdf wrapper' 47 | 48 | let(:html) { 'First line
Second line
Third line

Last line' } 49 | 50 | before { append_html_to_pdf(html) } 51 | 52 | it 'sends the expected buffer elements to the pdf', :aggregate_failures do 53 | expected_options = { leading: TestUtils.adjust_leading } 54 | expected_extra = { bounding_box: nil, left_indent: 0 } 55 | 56 | expected_text = { text: "First line", size: TestUtils.default_font_size } 57 | expect(pdf).to have_received(:puts).with([expected_text], expected_options, expected_extra).ordered 58 | 59 | expected_text = { text: "Second line", size: TestUtils.default_font_size } 60 | expect(pdf).to have_received(:puts).with([expected_text], expected_options, expected_extra).ordered 61 | 62 | expected_text = { text: "Third line", size: TestUtils.default_font_size } 63 | expect(pdf).to have_received(:puts).with([expected_text], expected_options, expected_extra).ordered 64 | 65 | expect(pdf).to have_received(:advance_cursor).with(described_class::BR_SPACING).ordered 66 | 67 | expected_text = { text: "Last line", size: TestUtils.default_font_size } 68 | expect(pdf).to have_received(:puts).with([expected_text], expected_options, expected_extra).ordered 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/code_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Code do 4 | subject(:code) { described_class.new(:code, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | code.process_styles 11 | end 12 | 13 | it 'returns the expected styles for code tag' do 14 | expect(code.styles).to match(color: 'ffbb11', font: 'Courier') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/del_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Del do 4 | subject(:del) { described_class.new(:del, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#styles' do 9 | subject(:styles) { del.styles } 10 | 11 | before do 12 | del.process_styles 13 | end 14 | 15 | it 'merges the callback property into styles' do 16 | expect(styles).to match(color: 'ffbb11', callback: ['StrikeThrough', nil]) 17 | end 18 | end 19 | 20 | describe 'tag rendering' do 21 | include_context 'with pdf wrapper' 22 | 23 | before { append_html_to_pdf(html) } 24 | 25 | context 'with a Del tag' do 26 | let(:html) { 'Some sample content' } 27 | 28 | it 'sends the expected buffer elements to the pdf' do 29 | expect(pdf).to have_received(:puts).with( 30 | [{ callback: anything, size: TestUtils.default_font_size, text: "Some sample content" }], 31 | { leading: TestUtils.adjust_leading }, 32 | { bounding_box: nil, left_indent: 0 } 33 | ) 34 | end 35 | end 36 | 37 | context 'with a S tag' do 38 | let(:html) { 'Some sample content' } 39 | 40 | it 'sends the expected buffer elements to the pdf' do 41 | expect(pdf).to have_received(:puts).with( 42 | [{ callback: anything, size: TestUtils.default_font_size, text: "Some sample content" }], 43 | { leading: TestUtils.adjust_leading }, 44 | { bounding_box: nil, left_indent: 0 } 45 | ) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/div_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Div do 4 | subject(:div) { described_class.new(:div) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { div.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | describe 'tag rendering' do 15 | include_context 'with pdf wrapper' 16 | 17 | let(:html) { '
Some sample content
' } 18 | 19 | before { append_html_to_pdf(html) } 20 | 21 | it 'sends the expected buffer elements to the pdf' do 22 | expect(pdf).to have_received(:puts).with( 23 | [{ size: TestUtils.default_font_size, text: "Some sample content" }], 24 | { leading: TestUtils.adjust_leading }, 25 | { bounding_box: nil, left_indent: 0 } 26 | ) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/h_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::H do 4 | subject(:h1) { described_class.new(:h1, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { h1.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | context 'without attributes' do 15 | before do 16 | h1.process_styles 17 | end 18 | 19 | it 'returns the expected styles for h1 tag' do 20 | expected_styles = { 21 | color: 'ffbb11', 22 | margin_bottom: PrawnHtml::Utils.convert_size(described_class::MARGINS_BOTTOM[:h1].to_s), 23 | margin_top: PrawnHtml::Utils.convert_size(described_class::MARGINS_TOP[:h1].to_s), 24 | size: PrawnHtml::Utils.convert_size(described_class::SIZES[:h1].to_s), 25 | styles: [:bold] 26 | } 27 | expect(h1.styles).to match(expected_styles) 28 | end 29 | end 30 | 31 | describe 'tag rendering' do 32 | include_context 'with pdf wrapper' 33 | 34 | let(:expected_buffer) { [{ size: size, styles: [:bold], text: 'Some sample content' }] } 35 | let(:expected_options) { { leading: TestUtils.adjust_leading(size) } } 36 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 37 | 38 | before { append_html_to_pdf(html) } 39 | 40 | context 'with some content in an element h1' do 41 | let(:html) { '

Some sample content

' } 42 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h1].to_s) } 43 | 44 | it 'sends the expected buffer elements to the pdf' do 45 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 46 | end 47 | end 48 | 49 | context 'with some content in an element h2' do 50 | let(:html) { '

Some sample content

' } 51 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h2].to_s) } 52 | 53 | it 'sends the expected buffer elements to Prawn pdf' do 54 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 55 | end 56 | end 57 | 58 | context 'with some content in an element h3' do 59 | let(:html) { '

Some sample content

' } 60 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h3].to_s) } 61 | 62 | it 'sends the expected buffer elements to Prawn pdf' do 63 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 64 | end 65 | end 66 | 67 | context 'with some content in an element h4' do 68 | let(:html) { '

Some sample content

' } 69 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h4].to_s) } 70 | 71 | it 'sends the expected buffer elements to Prawn pdf' do 72 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 73 | end 74 | end 75 | 76 | context 'with some content in an element h5' do 77 | let(:html) { '
Some sample content
' } 78 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h5].to_s) } 79 | 80 | it 'sends the expected buffer elements to Prawn pdf' do 81 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 82 | end 83 | end 84 | 85 | context 'with some content in an element h6' do 86 | let(:html) { '
Some sample content
' } 87 | let(:size) { PrawnHtml::Utils.convert_size(PrawnHtml::Tags::H::SIZES[:h6].to_s) } 88 | 89 | it 'sends the expected buffer elements to Prawn pdf' do 90 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/hr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Hr do 4 | subject(:hr) { described_class.new(:hr, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | let(:pdf) { instance_double(PrawnHtml::PdfWrapper, horizontal_rule: true) } 7 | 8 | it { expect(described_class).to be < PrawnHtml::Tag } 9 | 10 | describe '#block?' do 11 | subject(:block?) { hr.block? } 12 | 13 | it { is_expected.to be_truthy } 14 | end 15 | 16 | describe '#custom_render' do 17 | subject(:custom_render) { hr.custom_render(pdf, context) } 18 | 19 | before do 20 | hr.process_styles 21 | end 22 | 23 | let(:context) { nil } 24 | 25 | it 'calls horizontal_rule on the pdf wrapper' do 26 | custom_render 27 | expect(pdf).to have_received(:horizontal_rule).with(color: 'ffbb11', dash: nil) 28 | end 29 | 30 | context 'with a dash number set' do 31 | subject(:hr) { described_class.new(:hr, attributes: { 'data-dash' => '5' }) } 32 | 33 | it 'calls the dash methods around stroke', :aggregate_failures do 34 | custom_render 35 | expect(pdf).to have_received(:horizontal_rule).with(color: nil, dash: 5) 36 | end 37 | end 38 | 39 | context 'with a dash array set' do 40 | subject(:hr) { described_class.new(:hr, attributes: { 'data-dash' => '1, 2, 3' }) } 41 | 42 | it 'calls the dash methods around stroke', :aggregate_failures do 43 | custom_render 44 | expect(pdf).to have_received(:horizontal_rule).with(color: nil, dash: [1, 2, 3]) 45 | end 46 | end 47 | 48 | context 'with a color set via style attributes' do 49 | subject(:hr) { described_class.new(:hr, attributes: { 'style' => 'color: red' }) } 50 | 51 | it 'calls the color methods around stroke', :aggregate_failures do 52 | custom_render 53 | expect(pdf).to have_received(:horizontal_rule).with(color: 'ff0000', dash: nil) 54 | end 55 | end 56 | end 57 | 58 | context 'without attributes' do 59 | before do 60 | hr.process_styles 61 | end 62 | 63 | it 'returns the expected styles for hr tag' do 64 | expected_styles = { 65 | color: 'ffbb11', 66 | margin_bottom: PrawnHtml::Utils.convert_size(described_class::MARGIN_BOTTOM.to_s), 67 | margin_top: PrawnHtml::Utils.convert_size(described_class::MARGIN_TOP.to_s) 68 | } 69 | expect(hr.styles).to match(expected_styles) 70 | end 71 | end 72 | 73 | describe 'tag rendering' do 74 | include_context 'with pdf wrapper' 75 | 76 | let(:html) { 'Some content
More content' } 77 | 78 | before { append_html_to_pdf(html) } 79 | 80 | it 'sends the expected buffer elements to the pdf', :aggregate_failures do 81 | expect(pdf).to have_received(:puts).with( 82 | [{ size: TestUtils.default_font_size, text: "Some content" }], 83 | { leading: TestUtils.adjust_leading }, 84 | { bounding_box: nil, left_indent: 0 } 85 | ) 86 | expect(pdf).to have_received(:puts).with( 87 | [{ size: TestUtils.default_font_size, text: "More content" }], 88 | { leading: TestUtils.adjust_leading }, 89 | { bounding_box: nil, left_indent: 0 } 90 | ) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/i_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::I do 4 | subject(:i) { described_class.new(:i, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | i.process_styles 11 | end 12 | 13 | it 'returns the expected styles for i tag' do 14 | expect(i.styles).to match(color: 'ffbb11', styles: [:italic]) 15 | end 16 | end 17 | 18 | describe 'tag rendering' do 19 | include_context 'with pdf wrapper' 20 | 21 | before { append_html_to_pdf(html) } 22 | 23 | context 'with an I tag' do 24 | let(:html) { 'Some sample content' } 25 | 26 | it 'sends the expected buffer elements to the pdf' do 27 | expect(pdf).to have_received(:puts).with( 28 | [{ size: TestUtils.default_font_size, styles: [:italic], text: "Some sample content" }], 29 | { leading: TestUtils.adjust_leading }, 30 | { bounding_box: nil, left_indent: 0 } 31 | ) 32 | end 33 | end 34 | 35 | context 'with an Em tag' do 36 | let(:html) { 'Some sample content' } 37 | 38 | it 'sends the expected buffer elements to the pdf' do 39 | expect(pdf).to have_received(:puts).with( 40 | [{ size: TestUtils.default_font_size, styles: [:italic], text: "Some sample content" }], 41 | { leading: TestUtils.adjust_leading }, 42 | { bounding_box: nil, left_indent: 0 } 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/img_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Img do 4 | subject(:img) { described_class.new(:img, attributes: { 'src' => 'some_image_url' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { img.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | describe '#custom_render' do 15 | subject(:custom_render) { img.custom_render(pdf, context) } 16 | 17 | let(:context) { instance_double(PrawnHtml::Context, block_styles: {}) } 18 | let(:pdf) { instance_double(PrawnHtml::PdfWrapper, image: true) } 19 | 20 | it 'calls image on the pdf instance', :aggregate_failures do 21 | custom_render 22 | expect(context).to have_received(:block_styles) 23 | expect(pdf).to have_received(:image).with('some_image_url', {}) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/li_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Li do 4 | subject(:li) { described_class.new(:li, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { li.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | describe '#before_content' do 15 | subject(:before_content) { li.before_content } 16 | 17 | let(:context) { instance_double(PrawnHtml::Context) } 18 | let(:parent) { PrawnHtml::Tags::Ul.new(:ul) } 19 | 20 | before do 21 | li.parent = parent 22 | li.on_context_add(context) 23 | end 24 | 25 | it 'merges the before_content property into before_content' do 26 | expect(before_content).to eq('• ') 27 | end 28 | end 29 | 30 | describe 'when the parent is an ol element' do 31 | subject(:before_content) { li.before_content } 32 | 33 | let(:context) { instance_double(PrawnHtml::Context) } 34 | let(:parent) { PrawnHtml::Tags::Ol.new(:ol) } 35 | 36 | before do 37 | li.parent = parent 38 | li.on_context_add(context) 39 | end 40 | 41 | it 'sets the counter in before content' do 42 | expect(before_content).to eq('1. ') 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/mark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Mark do 4 | subject(:mark) { described_class.new(:mark, attributes: { 'style' => 'color: #ffbb11' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#styles' do 9 | subject(:styles) { mark.styles } 10 | 11 | before do 12 | mark.process_styles 13 | end 14 | 15 | it 'merges the callback property into styles' do 16 | expect(styles).to match(color: 'ffbb11', callback: ['Background', 'ffff00']) 17 | end 18 | end 19 | 20 | describe 'tag rendering' do 21 | include_context 'with pdf wrapper' 22 | 23 | let(:html) { 'Some sample content' } 24 | 25 | before { append_html_to_pdf(html) } 26 | 27 | it 'sends the expected buffer elements to the pdf' do 28 | expect(pdf).to have_received(:puts).with( 29 | [{ callback: anything, size: TestUtils.default_font_size, text: "Some sample content" }], 30 | { leading: TestUtils.adjust_leading }, 31 | { bounding_box: nil, left_indent: 0 } 32 | ) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/ol_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Ol do 4 | subject(:ol) { described_class.new(:ol, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | it 'sets the counter to zero' do 9 | expect(ol.counter).to be_zero 10 | end 11 | 12 | context 'without attributes' do 13 | before do 14 | ol.process_styles 15 | end 16 | 17 | it 'returns the expected styles for ol tag' do 18 | expected_styles = { 19 | color: 'ffbb11', 20 | margin_left: PrawnHtml::Utils.convert_size(described_class::MARGIN_LEFT.to_s), 21 | } 22 | expect(ol.styles).to match(expected_styles) 23 | end 24 | end 25 | 26 | describe '#block?' do 27 | subject(:block?) { ol.block? } 28 | 29 | it { is_expected.to be_truthy } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/p_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::P do 4 | subject(:p) { described_class.new(:p, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { p.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | context 'without attributes' do 15 | before do 16 | p.process_styles 17 | end 18 | 19 | it 'returns the expected styles for p tag' do 20 | expected_styles = { 21 | color: 'ffbb11', 22 | margin_bottom: PrawnHtml::Utils.convert_size(described_class::MARGIN_BOTTOM.to_s), 23 | margin_top: PrawnHtml::Utils.convert_size(described_class::MARGIN_TOP.to_s) 24 | } 25 | expect(p.styles).to match(expected_styles) 26 | end 27 | end 28 | 29 | describe 'tag rendering' do 30 | include_context 'with pdf wrapper' 31 | 32 | let(:html) { '

Some sample content

' } 33 | 34 | before { append_html_to_pdf(html) } 35 | 36 | it 'sends the expected buffer elements to the pdf' do 37 | expect(pdf).to have_received(:puts).with( 38 | [{ size: TestUtils.default_font_size, text: "Some sample content" }], 39 | { leading: TestUtils.adjust_leading }, 40 | { bounding_box: nil, left_indent: 0 } 41 | ) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/pre_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Pre do 4 | subject(:pre) { described_class.new(:pre, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | pre.process_styles 11 | end 12 | 13 | it 'returns the expected styles for pre tag' do 14 | expected_styles = { 15 | color: 'ffbb11', 16 | margin_bottom: PrawnHtml::Utils.convert_size(described_class::MARGIN_BOTTOM.to_s), 17 | margin_top: PrawnHtml::Utils.convert_size(described_class::MARGIN_TOP.to_s), 18 | font: 'Courier', 19 | white_space: :pre 20 | } 21 | expect(pre.styles).to match(expected_styles) 22 | end 23 | end 24 | 25 | describe '#block?' do 26 | subject(:block?) { pre.block? } 27 | 28 | it { is_expected.to be_truthy } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/small_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Small do 4 | subject(:small) { described_class.new(:small, attributes: { 'style' => 'color: ffbb11' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#update_styles' do 9 | subject(:update_styles) { small.update_styles(styles) } 10 | 11 | let(:styles) { { some_attr: 'some_value' } } 12 | 13 | it 'updates the argument styles reducing the default font size' do 14 | expect(update_styles).to eq(some_attr: 'some_value', size: 8.16) 15 | end 16 | 17 | context 'with a parent font size' do 18 | let(:styles) { { some_attr: 'some_value', size: 20 } } 19 | 20 | it 'updates the argument styles reducing the current font size' do 21 | expect(update_styles).to eq(some_attr: 'some_value', size: 17.0) 22 | end 23 | end 24 | end 25 | 26 | describe 'tag rendering' do 27 | include_context 'with pdf wrapper' 28 | 29 | let(:html) { 'Some sample content' } 30 | let(:size) { PrawnHtml::Context::DEFAULT_STYLES[:size] * 0.85 } 31 | 32 | before { append_html_to_pdf(html) } 33 | 34 | it 'sends the expected buffer elements to the pdf' do 35 | expect(pdf).to have_received(:puts).with( 36 | [{ size: size, text: "Some sample content" }], 37 | { leading: TestUtils.adjust_leading(size) }, 38 | { bounding_box: nil, left_indent: 0 } 39 | ) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/span_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Span do 4 | it { expect(described_class).to be < PrawnHtml::Tag } 5 | 6 | describe 'tag rendering' do 7 | include_context 'with pdf wrapper' 8 | 9 | let(:html) { 'Some sample content' } 10 | 11 | before { append_html_to_pdf(html) } 12 | 13 | it 'sends the expected buffer elements to the pdf' do 14 | expect(pdf).to have_received(:puts).with( 15 | [{ size: TestUtils.default_font_size, text: "Some sample content" }], 16 | { leading: TestUtils.adjust_leading }, 17 | { bounding_box: nil, left_indent: 0 } 18 | ) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/sub_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Sub do 4 | subject(:sub) { described_class.new(:sub, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | sub.process_styles 11 | end 12 | 13 | it 'returns the expected styles for sub tag' do 14 | expect(sub.styles).to match(color: 'ffbb11', styles: [:subscript]) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/sup_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Sup do 4 | subject(:sup) { described_class.new(:sup, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | sup.process_styles 11 | end 12 | 13 | it 'returns the expected styles for sup tag' do 14 | expect(sup.styles).to match(color: 'ffbb11', styles: [:superscript]) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/u_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::U do 4 | subject(:u) { described_class.new(:u, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | context 'without attributes' do 9 | before do 10 | u.process_styles 11 | end 12 | 13 | it 'returns the expected styles for u tag' do 14 | expect(u.styles).to match(color: 'ffbb11', styles: [:underline]) 15 | end 16 | end 17 | 18 | describe 'tag rendering' do 19 | include_context 'with pdf wrapper' 20 | 21 | before { append_html_to_pdf(html) } 22 | 23 | context 'with a U tag' do 24 | let(:html) { 'Some sample content' } 25 | 26 | it 'sends the expected buffer elements to the pdf' do 27 | expect(pdf).to have_received(:puts).with( 28 | [{ size: TestUtils.default_font_size, styles: [:underline], text: "Some sample content" }], 29 | { leading: TestUtils.adjust_leading }, 30 | { bounding_box: nil, left_indent: 0 } 31 | ) 32 | end 33 | end 34 | 35 | context 'with a Ins tag' do 36 | let(:html) { 'Some sample content' } 37 | 38 | it 'sends the expected buffer elements to the pdf' do 39 | expect(pdf).to have_received(:puts).with( 40 | [{ size: TestUtils.default_font_size, styles: [:underline], text: "Some sample content" }], 41 | { leading: TestUtils.adjust_leading }, 42 | { bounding_box: nil, left_indent: 0 } 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/units/prawn_html/tags/ul_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Tags::Ul do 4 | subject(:ul) { described_class.new(:ul, attributes: { 'style' => 'color: #fb1' }) } 5 | 6 | it { expect(described_class).to be < PrawnHtml::Tag } 7 | 8 | describe '#block?' do 9 | subject(:block?) { ul.block? } 10 | 11 | it { is_expected.to be_truthy } 12 | end 13 | 14 | context 'without attributes' do 15 | before do 16 | ul.process_styles 17 | end 18 | 19 | it 'returns the expected styles for ul tag' do 20 | expected_styles = { 21 | color: 'ffbb11', 22 | margin_left: PrawnHtml::Utils.convert_size(described_class::MARGIN_LEFT.to_s), 23 | } 24 | expect(ul.styles).to match(expected_styles) 25 | end 26 | end 27 | 28 | describe 'tag rendering' do 29 | include_context 'with pdf wrapper' 30 | 31 | let(:html) do 32 | <<~HTML 33 | 38 | HTML 39 | end 40 | let(:size) { TestUtils.default_font_size } 41 | let(:margin_left) do 42 | PrawnHtml::Utils.convert_size(PrawnHtml::Tags::Ul::MARGIN_LEFT.to_s) 43 | end 44 | 45 | before { append_html_to_pdf(html) } 46 | 47 | it 'sends the expected buffer elements to the pdf', :aggregate_failures do 48 | expect(pdf).to have_received(:puts).with( 49 | [{ size: size, text: "• First item" }], 50 | { indent_paragraphs: PrawnHtml::Tags::Li::INDENT_UL, leading: TestUtils.adjust_leading }, 51 | { bounding_box: nil, left_indent: margin_left } 52 | ) 53 | expect(pdf).to have_received(:puts).with( 54 | [{ size: size, text: "• Second item" }], 55 | { indent_paragraphs: PrawnHtml::Tags::Li::INDENT_UL, leading: TestUtils.adjust_leading }, 56 | { bounding_box: nil, left_indent: margin_left } 57 | ) 58 | expect(pdf).to have_received(:puts).with( 59 | [{ size: size, text: "• Third item" }], 60 | { indent_paragraphs: PrawnHtml::Tags::Li::INDENT_UL, leading: TestUtils.adjust_leading }, 61 | { bounding_box: nil, left_indent: margin_left } 62 | ) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/units/prawn_html/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml::Utils do 4 | describe '.callback_background' do 5 | subject(:callback_background) { described_class.callback_background(value) } 6 | 7 | context 'with a nil value' do 8 | let(:value) { nil } 9 | 10 | it { is_expected.to eq ['Background', nil] } 11 | end 12 | 13 | context 'with a color value' do 14 | let(:value) { 'red' } 15 | 16 | it { is_expected.to eq ['Background', 'ff0000'] } 17 | end 18 | end 19 | 20 | describe '.callback_strike_through' do 21 | subject(:callback_strike_through) { described_class.callback_strike_through(nil) } 22 | 23 | it { is_expected.to eq ['StrikeThrough', nil] } 24 | end 25 | 26 | describe '.convert_color' do 27 | subject(:convert_color) { described_class.convert_color(value) } 28 | 29 | context 'with a nil value' do 30 | let(:value) { nil } 31 | 32 | it { is_expected.to be_nil } 33 | end 34 | 35 | context 'with an invalid value' do 36 | let(:value) { ' unknown color ' } 37 | 38 | it { is_expected.to be_nil } 39 | end 40 | 41 | context 'with a 3 characters string value (ex. "#A80")' do 42 | let(:value) { ' #A80' } 43 | 44 | it { is_expected.to eq 'aa8800' } 45 | end 46 | 47 | context 'with a 6 characters string value (ex. "#12aB0f")' do 48 | let(:value) { '#12aB0f ' } 49 | 50 | it { is_expected.to eq '12ab0f' } 51 | end 52 | 53 | context 'with an RGB value (ex. "RGB(192, 0, 255)")' do 54 | let(:value) { 'RGB(192, 0, 255)' } 55 | 56 | it { is_expected.to eq 'c000ff' } 57 | end 58 | 59 | context 'with a color name (ex. "rebeccapurple")' do 60 | let(:value) { ' rebeccapurple ' } 61 | 62 | it { is_expected.to eq '663399' } 63 | end 64 | end 65 | 66 | describe '.convert_float' do 67 | subject(:convert_float) { described_class.convert_float(value) } 68 | 69 | context 'with a nil value' do 70 | let(:value) { nil } 71 | 72 | it { is_expected.to eq 0.0 } 73 | end 74 | 75 | context 'with a blank string value' do 76 | let(:value) { '' } 77 | 78 | it { is_expected.to eq 0.0 } 79 | end 80 | 81 | context 'with a decimal value (ex. "10.12345")' do 82 | let(:value) { '10.12345' } 83 | 84 | it { is_expected.to eq 10.1235 } 85 | end 86 | 87 | context 'with an integer value (ex. "12345")' do 88 | let(:value) { '12345' } 89 | 90 | it { is_expected.to eq 12_345.0 } 91 | end 92 | end 93 | 94 | describe '.convert_size' do 95 | subject(:convert_size) { described_class.convert_size(value) } 96 | 97 | context 'with a nil value' do 98 | let(:value) { nil } 99 | 100 | it { is_expected.to eq 0.0 } 101 | end 102 | 103 | context 'with a blank string value' do 104 | let(:value) { '' } 105 | 106 | it { is_expected.to eq 0.0 } 107 | end 108 | 109 | context 'with a pixel value (ex. "10.125")' do 110 | let(:value) { '10.125' } 111 | 112 | it { is_expected.to eq 6.075 } 113 | end 114 | 115 | context 'with a percentage value and a container size (ex. "50%" and 100.242424)' do 116 | subject(:convert_size) { described_class.convert_size(value, options: 100.242424) } 117 | 118 | let(:value) { '50%' } 119 | 120 | it { is_expected.to eq 50.1212 } 121 | end 122 | end 123 | 124 | describe '.convert_symbol' do 125 | subject(:convert_symbol) { described_class.convert_symbol(value) } 126 | 127 | context 'with a nil value' do 128 | let(:value) { nil } 129 | 130 | it { is_expected.to be_nil } 131 | end 132 | 133 | context 'with a blank string value' do 134 | let(:value) { '' } 135 | 136 | it { is_expected.to be_nil } 137 | end 138 | 139 | context 'with a string value (ex. "some_string")' do 140 | let(:value) { 'some_string' } 141 | 142 | it { is_expected.to eq :some_string } 143 | end 144 | end 145 | 146 | describe '.copy_value' do 147 | subject(:copy_value) { described_class.copy_value(value) } 148 | 149 | context 'with any value (ex. "some_string")' do 150 | let(:value) { 'some_string' } 151 | 152 | it { is_expected.to eq 'some_string' } 153 | end 154 | end 155 | 156 | describe '.filter_font_family' do 157 | subject(:filter_font_family) { described_class.filter_font_family(value) } 158 | 159 | context 'with any string (ex. "some_string")' do 160 | let(:value) { 'some_string' } 161 | 162 | it { is_expected.to eq 'some_string' } 163 | end 164 | 165 | context 'with a string with some spaces (ex. " some_string ")' do 166 | let(:value) { ' some_string ' } 167 | 168 | it { is_expected.to eq 'some_string' } 169 | end 170 | 171 | context 'with "inherit" value' do 172 | let(:value) { 'inherit' } 173 | 174 | it { is_expected.to be_nil } 175 | end 176 | end 177 | 178 | describe '.normalize_style' do 179 | subject(:normalize_style) { described_class.normalize_style(value, accepted_values) } 180 | 181 | let(:accepted_values) { [:bold, :italic] } 182 | 183 | context 'with an invalid value (ex. "some_string")' do 184 | let(:value) { 'some_string' } 185 | 186 | it { is_expected.to be_nil } 187 | end 188 | 189 | context 'with a valid value (ex. " bOlD ")' do 190 | let(:value) { ' bOlD ' } 191 | 192 | it { is_expected.to eq :bold } 193 | end 194 | end 195 | 196 | describe '.unquote' do 197 | subject(:unquote) { described_class.unquote(value) } 198 | 199 | context 'with a nil value' do 200 | let(:value) { nil } 201 | 202 | it { is_expected.to eq '' } 203 | end 204 | 205 | context 'with a blank string value' do 206 | let(:value) { '' } 207 | 208 | it { is_expected.to eq '' } 209 | end 210 | 211 | context 'with a string with some spaces (ex. " a string ")' do 212 | let(:value) { ' a string ' } 213 | 214 | it { is_expected.to eq 'a string' } 215 | end 216 | 217 | context 'with a string with some single quotes (ex. " \' a \' string \' ")' do 218 | let(:value) { " ' a ' string ' " } 219 | 220 | it { is_expected.to eq " a ' string " } 221 | end 222 | 223 | context 'with a string with some double quotes (ex. " \" a \" string \" ")' do 224 | let(:value) { ' " a " string " ' } 225 | 226 | it { is_expected.to eq ' a " string ' } 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /spec/units/prawn_html_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe PrawnHtml do 4 | describe '.append_html' do 5 | subject(:append_html) { described_class.append_html(pdf, html) } 6 | 7 | let(:pdf) { instance_double(Prawn::Document) } 8 | let(:html) { '
some html
' } 9 | let(:pdf_wrapper) { instance_double(PrawnHtml::PdfWrapper) } 10 | let(:html_parser) { instance_double(PrawnHtml::HtmlParser, process: true) } 11 | let(:renderer) { instance_double(PrawnHtml::DocumentRenderer) } 12 | 13 | before do 14 | allow(PrawnHtml::PdfWrapper).to receive(:new).and_return(pdf_wrapper) 15 | allow(PrawnHtml::DocumentRenderer).to receive(:new).and_return(renderer) 16 | allow(PrawnHtml::HtmlParser).to receive(:new).and_return(html_parser) 17 | end 18 | 19 | it 'creates an instance of PrawnHtml::HtmlParser and call process', :aggregate_failures do 20 | append_html 21 | expect(PrawnHtml::PdfWrapper).to have_received(:new).with(pdf) 22 | expect(PrawnHtml::DocumentRenderer).to have_received(:new).with(pdf_wrapper) 23 | expect(PrawnHtml::HtmlParser).to have_received(:new).with(renderer) 24 | expect(html_parser).to have_received(:process).with(html) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/units/styles_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Styles' do 4 | let(:pdf) { instance_double(PrawnHtml::PdfWrapper, advance_cursor: true, puts: true) } 5 | 6 | before do 7 | allow(pdf).to receive_messages(page_width: 540, page_height: 720) 8 | allow(PrawnHtml::PdfWrapper).to receive(:new).and_return(pdf) 9 | pdf_document = Prawn::Document.new(page_size: 'A4', page_layout: :portrait) 10 | PrawnHtml.append_html(pdf_document, html) 11 | end 12 | 13 | describe 'attribute color' do 14 | let(:html) { '
Some content...
' } 15 | 16 | let(:expected_buffer) { [{ color: 'ffbb11', size: TestUtils.default_font_size, text: "Some content..." }] } 17 | let(:expected_options) { { leading: TestUtils.adjust_leading } } 18 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 19 | 20 | it 'sends the expected buffer elements to Prawn pdf' do 21 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 22 | end 23 | end 24 | 25 | describe 'attribute font-family' do 26 | let(:html) { '
Some content...
' } 27 | 28 | let(:expected_buffer) { [{ font: 'Courier', size: TestUtils.default_font_size, text: "Some content..." }] } 29 | let(:expected_options) { { leading: TestUtils.adjust_leading(TestUtils.default_font_size, 'Courier') } } 30 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 31 | 32 | it 'sends the expected buffer elements to Prawn pdf' do 33 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 34 | end 35 | end 36 | 37 | describe 'attribute font-size' do 38 | let(:html) { '
Some content...
' } 39 | let(:size) { PrawnHtml::Utils.convert_size('20px') } 40 | 41 | let(:expected_buffer) { [{ size: size, text: "Some content..." }] } 42 | let(:expected_options) { { leading: TestUtils.adjust_leading(20 * PrawnHtml::PX) } } 43 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 44 | 45 | it 'sends the expected buffer elements to Prawn pdf' do 46 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 47 | end 48 | end 49 | 50 | describe 'attribute font-style' do 51 | let(:html) { '
Some content...
' } 52 | 53 | let(:expected_buffer) { [{ size: TestUtils.default_font_size, styles: [:italic], text: "Some content..." }] } 54 | let(:expected_options) { { leading: TestUtils.adjust_leading } } 55 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 56 | 57 | it 'sends the expected buffer elements to Prawn pdf' do 58 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 59 | end 60 | end 61 | 62 | describe 'attribute font-weight' do 63 | let(:html) { '
Some content...
' } 64 | 65 | let(:expected_buffer) { [{ size: TestUtils.default_font_size, styles: [:bold], text: "Some content..." }] } 66 | let(:expected_options) { { leading: TestUtils.adjust_leading } } 67 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 68 | 69 | it 'sends the expected buffer elements to Prawn pdf' do 70 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 71 | end 72 | end 73 | 74 | describe 'attribute letter-spacing' do 75 | let(:html) { '
aaa
bbb
ccc
' } 76 | let(:size) { TestUtils.default_font_size } 77 | 78 | let(:expected_options) { { leading: TestUtils.adjust_leading } } 79 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 80 | 81 | it 'sends the expected buffer elements to Prawn pdf', :aggregate_failures do 82 | expect(pdf).to have_received(:puts).with( 83 | [{ character_spacing: 1.5, size: size, text: 'aaa' }], expected_options, expected_extra 84 | ) 85 | expect(pdf).to have_received(:puts).with([{ size: size, text: ' bbb ' }], expected_options, expected_extra) 86 | expect(pdf).to have_received(:puts).with( 87 | [{ character_spacing: 2.0, size: size, text: 'ccc' }], expected_options, expected_extra 88 | ) 89 | end 90 | end 91 | 92 | describe 'attribute line-height' do 93 | let(:html) { '
Some content...
' } 94 | 95 | it 'sends the expected buffer elements to Prawn pdf' do 96 | expect(pdf).to have_received(:puts).with( 97 | [{ size: TestUtils.default_font_size, text: "Some content..." }], 98 | { leading: (12 * PrawnHtml::PX).round(5) }, 99 | { bounding_box: nil, left_indent: 0 } 100 | ) 101 | end 102 | end 103 | 104 | describe 'attribute margin-left' do 105 | let(:html) { '
Some content...
' } 106 | 107 | let(:expected_buffer) { [{ size: TestUtils.default_font_size, text: "Some content..." }] } 108 | let(:expected_options) { { leading: TestUtils.adjust_leading } } 109 | let(:expected_extra) { { bounding_box: nil, left_indent: 40 * PrawnHtml::PX } } 110 | 111 | it 'sends the expected buffer elements to Prawn pdf' do 112 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 113 | end 114 | end 115 | 116 | describe 'attribute margin-top' do 117 | let(:html) { '
Some content...
' } 118 | 119 | it 'sends the expected buffer elements to Prawn pdf' do 120 | expect(pdf).to have_received(:puts).with( 121 | [{ size: TestUtils.default_font_size, text: "Some content..." }], 122 | { leading: TestUtils.adjust_leading }, 123 | { bounding_box: nil, left_indent: 0 } 124 | ) 125 | end 126 | end 127 | 128 | describe 'attribute text-align' do 129 | context 'with some content left aligned' do 130 | let(:html) { '
Some content...
' } 131 | 132 | let(:expected_buffer) { [{ size: TestUtils.default_font_size, text: "Some content..." }] } 133 | let(:expected_options) { { align: :left, leading: TestUtils.adjust_leading } } 134 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 135 | 136 | it 'sends the expected buffer elements to Prawn pdf' do 137 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 138 | end 139 | end 140 | 141 | context 'with some content center aligned' do 142 | let(:html) { '
Some content...
' } 143 | 144 | let(:expected_buffer) { [{ size: TestUtils.default_font_size, text: "Some content..." }] } 145 | let(:expected_options) { { align: :center, leading: TestUtils.adjust_leading } } 146 | let(:expected_extra) { { bounding_box: nil, left_indent: 0 } } 147 | 148 | it 'sends the expected buffer elements to Prawn pdf' do 149 | expect(pdf).to have_received(:puts).with(expected_buffer, expected_options, expected_extra) 150 | end 151 | end 152 | end 153 | end 154 | --------------------------------------------------------------------------------