├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .rspec
├── .rubocop.yml
├── CODE_OF_CONDUCT.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
├── console
└── setup
├── lib
└── prawn
│ ├── markup.rb
│ └── markup
│ ├── builders
│ ├── list_builder.rb
│ ├── nestable_builder.rb
│ └── table_builder.rb
│ ├── elements
│ ├── cell.rb
│ ├── item.rb
│ └── list.rb
│ ├── interface.rb
│ ├── processor.rb
│ ├── processor
│ ├── blocks.rb
│ ├── headings.rb
│ ├── images.rb
│ ├── inputs.rb
│ ├── lists.rb
│ ├── tables.rb
│ └── text.rb
│ ├── support
│ ├── hash_merger.rb
│ ├── normalizer.rb
│ └── size_converter.rb
│ └── version.rb
├── prawn-markup.gemspec
└── spec
├── fixtures
├── DejaVuSans.ttf
├── logo.png
└── showcase.html
├── pdf_helpers.rb
├── prawn
├── markup
│ ├── interface_spec.rb
│ ├── normalizer_spec.rb
│ ├── processor
│ │ ├── blocks_spec.rb
│ │ ├── headings_spec.rb
│ │ ├── images_spec.rb
│ │ ├── inputs_spec.rb
│ │ ├── lists_spec.rb
│ │ ├── tables_spec.rb
│ │ └── text_spec.rb
│ ├── processor_spec.rb
│ └── showcase_spec.rb
└── markup_spec.rb
└── spec_helper.rb
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main # create pull request to run actions on other branches
7 | pull_request:
8 |
9 | jobs:
10 | test:
11 | name: Test
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | ruby-version: ["3.1", "3.2", "3.3"]
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Set up Ruby
20 | uses: ruby/setup-ruby@v1
21 | with:
22 | ruby-version: ${{ matrix.ruby-version }}
23 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
24 | - name: Run tests
25 | run: bundle exec rake
26 |
27 | lint:
28 | name: Lint
29 | runs-on: ubuntu-latest
30 | steps:
31 | - uses: actions/checkout@v4
32 | - name: Set up Ruby
33 | uses: ruby/setup-ruby@v1
34 | with:
35 | ruby-version: 3.2
36 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
37 | - name: Run rubocop
38 | run: bundle exec rubocop
39 |
40 | coverage:
41 | name: Coverage
42 | runs-on: ubuntu-latest
43 | if: ${{ !github.event.pull_request.head.repo.fork }}
44 | steps:
45 | - uses: actions/checkout@v4
46 | - name: Set up Ruby
47 | uses: ruby/setup-ruby@v1
48 | with:
49 | ruby-version: 3.2
50 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
51 | - uses: paambaati/codeclimate-action@v9.0.0
52 | env:
53 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
54 | with:
55 | coverageCommand: bundle exec rake
56 | coverageLocations: |
57 | ${{github.workspace}}/spec/coverage/coverage.json:simplecov
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /spec/coverage/
9 | /tmp/
10 |
11 | # rspec failure tracking
12 | .rspec_status
13 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 | --require spec_helper
4 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | DisplayCopNames: true
3 | NewCops: enable
4 | SuggestExtensions: false
5 | TargetRubyVersion: 3.1
6 | Exclude:
7 | - "*.gemspec"
8 | - spec/**/*
9 | - vendor/**/*
10 |
11 | Metrics/AbcSize:
12 | Severity: error
13 |
14 | Metrics/ClassLength:
15 | Max: 250
16 | Severity: error
17 |
18 | Metrics/CyclomaticComplexity:
19 | Severity: error
20 |
21 | Metrics/MethodLength:
22 | Max: 10
23 | Severity: error
24 |
25 | Metrics/ModuleLength:
26 | Max: 150
27 | Severity: error
28 |
29 | Metrics/ParameterLists:
30 | Max: 4
31 | Severity: warning
32 |
33 | Layout/LineLength:
34 | Max: 100
35 | Severity: error
36 |
37 | # We thinks that's fine for specs
38 | Layout/EmptyLinesAroundBlockBody:
39 | Enabled: false
40 |
41 | # We thinks that's fine
42 | Layout/EmptyLinesAroundClassBody:
43 | Enabled: false
44 |
45 | # We thinks that's fine
46 | Layout/EmptyLinesAroundModuleBody:
47 | Enabled: false
48 |
49 | # Keep for now, easier with superclass definitions
50 | Style/ClassAndModuleChildren:
51 | Enabled: false
52 |
53 | # The ones we use must exist for the entire class hierarchy.
54 | Style/ClassVars:
55 | Enabled: false
56 |
57 | # Well, well, well
58 | Style/Documentation:
59 | Enabled: false
60 |
61 | # Keep single line bodys for if and unless
62 | Style/IfUnlessModifier:
63 | Enabled: false
64 |
65 | # We think that's the developers choice
66 | Style/GuardClause:
67 | Enabled: false
68 |
69 | # Backward compatibility
70 | Style/OptionalBooleanParameter:
71 | Enabled: false
72 |
73 | # Backwards compatibility
74 | Style/SafeNavigation:
75 | Enabled: false
76 |
77 | # Backwards compatibility
78 | Style/SlicingWithRange:
79 | Enabled: false
80 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at info@puzzle.ch. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | # Specify your gem's dependencies in prawn-markup.gemspec
6 | gemspec
7 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | prawn-markup (1.1.0)
5 | nokogiri
6 | prawn
7 | prawn-table
8 |
9 | GEM
10 | remote: https://rubygems.org/
11 | specs:
12 | Ascii85 (2.0.1)
13 | afm (0.2.2)
14 | ast (2.4.2)
15 | bigdecimal (3.1.9)
16 | byebug (11.1.3)
17 | diff-lcs (1.6.0)
18 | docile (1.4.1)
19 | hashery (2.1.2)
20 | json (2.10.2)
21 | language_server-protocol (3.17.0.4)
22 | lint_roller (1.1.0)
23 | matrix (0.4.2)
24 | nokogiri (1.18.8-x86_64-linux-gnu)
25 | racc (~> 1.4)
26 | parallel (1.26.3)
27 | parser (3.3.7.1)
28 | ast (~> 2.4.1)
29 | racc
30 | pdf-core (0.10.0)
31 | pdf-inspector (1.3.0)
32 | pdf-reader (>= 1.0, < 3.0.a)
33 | pdf-reader (2.14.1)
34 | Ascii85 (>= 1.0, < 3.0, != 2.0.0)
35 | afm (~> 0.2.1)
36 | hashery (~> 2.0)
37 | ruby-rc4
38 | ttfunk
39 | prawn (2.5.0)
40 | matrix (~> 0.4)
41 | pdf-core (~> 0.10.0)
42 | ttfunk (~> 1.8)
43 | prawn-table (0.2.2)
44 | prawn (>= 1.3.0, < 3.0.0)
45 | racc (1.8.1)
46 | rainbow (3.1.1)
47 | rake (13.2.1)
48 | regexp_parser (2.10.0)
49 | rspec (3.13.0)
50 | rspec-core (~> 3.13.0)
51 | rspec-expectations (~> 3.13.0)
52 | rspec-mocks (~> 3.13.0)
53 | rspec-core (3.13.3)
54 | rspec-support (~> 3.13.0)
55 | rspec-expectations (3.13.3)
56 | diff-lcs (>= 1.2.0, < 2.0)
57 | rspec-support (~> 3.13.0)
58 | rspec-mocks (3.13.2)
59 | diff-lcs (>= 1.2.0, < 2.0)
60 | rspec-support (~> 3.13.0)
61 | rspec-support (3.13.2)
62 | rubocop (1.72.2)
63 | json (~> 2.3)
64 | language_server-protocol (~> 3.17.0.2)
65 | lint_roller (~> 1.1.0)
66 | parallel (~> 1.10)
67 | parser (>= 3.3.0.2)
68 | rainbow (>= 2.2.2, < 4.0)
69 | regexp_parser (>= 2.9.3, < 3.0)
70 | rubocop-ast (>= 1.38.0, < 2.0)
71 | ruby-progressbar (~> 1.7)
72 | unicode-display_width (>= 2.4.0, < 4.0)
73 | rubocop-ast (1.38.0)
74 | parser (>= 3.3.1.0)
75 | ruby-progressbar (1.13.0)
76 | ruby-rc4 (0.1.5)
77 | simplecov (0.22.0)
78 | docile (~> 1.1)
79 | simplecov-html (~> 0.11)
80 | simplecov_json_formatter (~> 0.1)
81 | simplecov-html (0.13.1)
82 | simplecov_json_formatter (0.1.4)
83 | ttfunk (1.8.0)
84 | bigdecimal (~> 3.1)
85 | unicode-display_width (3.1.4)
86 | unicode-emoji (~> 4.0, >= 4.0.4)
87 | unicode-emoji (4.0.4)
88 |
89 | PLATFORMS
90 | x86_64-linux
91 |
92 | DEPENDENCIES
93 | bundler
94 | byebug
95 | matrix
96 | pdf-inspector
97 | prawn-markup!
98 | rake
99 | rspec
100 | rubocop
101 | simplecov
102 |
103 | BUNDLED WITH
104 | 2.2.31
105 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Puzzle ITC GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Prawn::Markup
2 |
3 | [](https://github.com/puzzle/prawn-markup/actions/workflows/build.yml)
4 | [](https://codeclimate.com/github/puzzle/prawn-markup/maintainability)
5 | [](https://codeclimate.com/github/puzzle/prawn-markup/test_coverage)
6 |
7 | Adds simple HTML snippets into [Prawn](http://prawnpdf.org)-generated PDFs. All elements are layouted vertically using Prawn's formatting options. A major use case for this gem is to include WYSIWYG-generated HTML parts into server-generated PDF documents.
8 |
9 | This gem does not and will never convert entire HTML + CSS pages to PDF. Use [wkhtmltopdf](https://wkhtmltopdf.org/) for that. Have a look at the details of the [Supported HTML](#supported-html).
10 |
11 | ## Installation
12 |
13 | Add this line to your application's Gemfile:
14 |
15 | ```ruby
16 | gem 'prawn-markup'
17 | ```
18 |
19 | And then execute:
20 |
21 | $ bundle
22 |
23 | Or install it yourself as:
24 |
25 | $ gem install prawn-markup
26 |
27 | ## Usage
28 |
29 | In your prawn Document, add HTML like this:
30 |
31 | ```ruby
32 | doc = Prawn::Document.new
33 | doc.markup('
Hello World
KTHXBYE
')
34 | ```
35 |
36 | ## Supported HTML
37 |
38 | This gem parses the given HTML and layouts the following elements in a vertical order:
39 |
40 | - Text blocks: `p`, `div`, `ol`, `ul`, `li`, `hr`, `br`
41 | - Text semantics: `a`, `b`, `strong`, `i`, `em`, `u`, `s`, `del`, `sub`, `sup`, `color`
42 | - Headings: `h1`, `h2`, `h3`, `h4`, `h5`, `h6`
43 | - Tables: `table`, `tr`, `td`, `th`
44 | - Media: `img`, `iframe`
45 | - Inputs: `type=checkbox`, `type=radio`
46 |
47 | All other elements are ignored, their content is added to the parent element. With a few exceptions, no CSS is processed. One exception is the `width` property of `img`, `td` and `th`, which may contain values in `cm`, `mm`, `px`, `pt`, `%` or `auto`. Another exception is the `rgb` or `cmyk` properties of the Prawn-specific `color` tag.
48 |
49 | If no explicit loader is given (see below), images are loaded from `http(s)` addresses or may be contained in the `src` attribute as base64 encoded data URIs. Prawn only supports `PNG` and `JPG`.
50 |
51 | ## Example
52 |
53 | Have a look at [showcase.html](spec/fixtures/showcase.html), which is rendered by the corresponding [spec](spec/prawn/markup/showcase_spec.rb). Uncomment the `lookatit` call there to directly open the generated PDF when running the spec with `spec spec/prawn/markup/showcase_spec.rb`.
54 |
55 | ## Formatting Options
56 |
57 | To customize element formatting, do:
58 |
59 | ```ruby
60 | doc = Prawn::Document.new
61 | # set options for the entire document
62 | doc.markup_options = {
63 | text: { font: 'Times' },
64 | table: { header: { style: :bold, background_color: 'FFFFDD' } }
65 | }
66 | # set additional options for each single call
67 | doc.markup('
Hello World
KTHXBYE
', text: { align: :center })
68 | ```
69 |
70 | Options may be set for `text`, `heading[1-6]`, `table` (subkeys `cell` and `header`) and `list` (subkeys `content` and `bullet`).
71 |
72 | Text and heading options include all keys from Prawns [#text](https://prawnpdf.org/docs/prawn/2.5.0/Prawn/Text.html#text-instance_method) method: `font`, `size`, `color`, `style`, `align`, `valign`, `leading`,`direction`, `character_spacing`, `indent_paragraphs`, `kerning`, `mode`.
73 |
74 | Tables and lists are rendered with [prawn-table](https://github.com/prawnpdf/prawn-table) and have the following additional options: `padding`, `borders`, `border_width`, `border_color`, `background_color`, `border_lines`, `rotate`, `overflow`, `min_font_size`. Options from `text` may be overridden.
75 |
76 | Beside these options handled by Prawn / prawn-table, the following values may be customized:
77 |
78 | - `:text`
79 | - `:preprocessor`: A proc/callable that is called each time before a chunk of text is rendered.
80 | - `:margin_bottom`: Margin after each `
`, `
`, `
` or `
`. Defaults to about half a line.
81 | - `:treat_empty_paragraph_as_new_line`: Boolean flag to set a new line if paragraph is empty.
82 | - `:heading1-6`
83 | - `:margin_top`: Margin before a heading. Default is 0.
84 | - `:margin_bottom`: Margin after a heading. Default is 0.
85 | - `:table`
86 | - `:placeholder`
87 | - `:too_large`: If the table content does not fit into the current bounding box, this text/callable is rendered instead. Defaults to '[table content too large]'.
88 | - `:subtable_too_large`: If the content of a subtable cannot be fitted into the table, this text is rendered instead. Defaults to '[nested tables with automatic width are not supported]'.
89 | - `:list`
90 | - `:vertical_margin`: Margin at the top and the bottom of a list. Default is 5.
91 | - `:bullet`
92 | - `:char`: The text used as bullet in unordered lists. Default is '•'.
93 | - `:margin`: Margin before the bullet. Default is 10.
94 | - `:content`
95 | - `:margin`: Margin between the bullet and the content. Default is 10.
96 | - `:placeholder`
97 | - `:too_large`: If the list content does not fit into the current bounding box, this text/callable is rendered instead. Defaults to '[list content too large]'.
98 | - `:image`
99 | - `:loader`: A callable that accepts the `src` attribute as an argument an returns a value understood by Prawn's `image` method (e.g. an `IO` object). If no loader is configured or if it returns `nil`, `http(s)` URLs and base64 encoded data URIs are loaded.
100 | - `:placeholder`: If an image is not supported, this text/callable is rendered instead. Defaults to '[unsupported image]'.
101 | - `:iframe`
102 | - `:placeholder`: If the HTML contains IFrames, this text/callable is rendered instead.
103 | A callable gets the URL of the IFrame as an argument. Defaults to ignore iframes.
104 | - `:input`
105 | - `:symbol_font`: A special font to print checkboxes and radios. Prawn's standard fonts do not support special unicode characters. Do not forget to update the document's `font_families`.
106 | - `:symbol_font_size`: The size of the special font to print checkboxes and radios.
107 | - `:checkbox`
108 | - `:checked`: The char to print for a checked checkbox. Default is '☑'.
109 | - `:unchecked`: The char to print for an unchecked checkbox. Default is '☐'.
110 | - `:radio`
111 | - `:checked`: The char to print for a checked radio. Default is '◉'.
112 | - `:unchecked`: The char to print for an unchecked radio. Default is '○'.
113 | - `:link`
114 | - `:color`: The link color, which can be specified in either RGB hex format or 4-value CMYK.
115 | - `:underline`: Specifies whether the link should be underlined. Default is false.
116 |
117 | ## Development
118 |
119 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
120 |
121 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
122 |
123 | ## Contributing
124 |
125 | Bug reports and pull requests are welcome on GitHub at https://github.com/puzzle/prawn-markup. For pull requests, add specs, make sure all of them pass and fix all rubocop issues.
126 |
127 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
128 |
129 | ## License
130 |
131 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
132 |
133 | ## Code of Conduct
134 |
135 | Everyone interacting in the Prawn::Markup project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/puzzle/prawn-markup/blob/main/CODE_OF_CONDUCT.md).
136 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'bundler/gem_tasks'
4 | require 'rspec/core/rake_task'
5 |
6 | RSpec::Core::RakeTask.new(:spec)
7 |
8 | task default: :spec
9 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require 'bundler/setup'
5 | require 'prawn/markup'
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | # (If you use this, don't forget to add pry to your Gemfile!)
11 | # require "pry"
12 | # Pry.start
13 |
14 | require 'irb'
15 | IRB.start(__FILE__)
16 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/lib/prawn/markup.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'prawn'
4 | require 'prawn/measurement_extensions'
5 | require 'prawn/table'
6 | require 'nokogiri'
7 | require 'prawn/markup/support/hash_merger'
8 | require 'prawn/markup/support/size_converter'
9 | require 'prawn/markup/support/normalizer'
10 | require 'prawn/markup/elements/item'
11 | require 'prawn/markup/elements/cell'
12 | require 'prawn/markup/elements/list'
13 | require 'prawn/markup/builders/nestable_builder'
14 | require 'prawn/markup/builders/list_builder'
15 | require 'prawn/markup/builders/table_builder'
16 | require 'prawn/markup/processor'
17 | require 'prawn/markup/interface'
18 | require 'prawn/markup/version'
19 |
20 | module Prawn
21 | module Markup
22 | end
23 | end
24 |
25 | Prawn::Document.extensions << Prawn::Markup::Interface
26 |
--------------------------------------------------------------------------------
/lib/prawn/markup/builders/list_builder.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Builders
6 | class ListBuilder < NestableBuilder
7 | BULLET_CHAR = '•'
8 | BULLET_MARGIN = 10
9 | CONTENT_MARGIN = 10
10 | VERTICAL_MARGIN = 5
11 |
12 | def initialize(pdf, list, total_width, options = {})
13 | super(pdf, total_width, options)
14 | @list = list
15 | @column_widths = compute_column_widths
16 | end
17 |
18 | def make(main = false)
19 | pdf.make_table(convert_list, list_table_options) do |t|
20 | t.columns(0).style(column_cell_style(:bullet))
21 | t.columns(1).style(column_cell_style(:content))
22 | set_paddings(t, main)
23 | end
24 | end
25 |
26 | def draw
27 | # fix https://github.com/prawnpdf/prawn-table/issues/120
28 | pdf.font_size(column_cell_style(:content)[:size] || pdf.font_size) do
29 | make(true).draw
30 | end
31 | end
32 |
33 | private
34 |
35 | attr_reader :list, :column_widths
36 |
37 | def list_table_options
38 | {
39 | column_widths: column_widths,
40 | cell_style: { border_width: 0, inline_format: true }
41 | }
42 | end
43 |
44 | def set_paddings(table, main)
45 | set_row_padding(table, [0, 0, padding_bottom])
46 | if main
47 | set_row_padding(table.rows(0), [vertical_margin, 0, padding_bottom])
48 | set_row_padding(table.rows(-1), [0, 0, padding_bottom + vertical_margin])
49 | else
50 | set_row_padding(table.rows(-1), [0, 0, 0])
51 | end
52 | end
53 |
54 | def set_row_padding(row, padding)
55 | row.columns(0).padding = [*padding, bullet_margin]
56 | row.columns(1).padding = [*padding, content_margin]
57 | end
58 |
59 | def convert_list
60 | list.items.map.with_index do |item, i|
61 | if item.single?
62 | [bullet(i + 1), normalize_list_item_node(item.nodes.first)]
63 | else
64 | [bullet(i + 1), list_item_table(item)]
65 | end
66 | end
67 | end
68 |
69 | def list_item_table(item)
70 | data = item.nodes.map { |n| [normalize_list_item_node(n)] }
71 | style = column_cell_style(:content)
72 | .merge(borders: [], padding: [0, 0, padding_bottom, 0], inline_format: true)
73 | pdf.make_table(data, cell_style: style, column_widths: [content_width]) do
74 | rows(-1).padding = [0, 0, 0, 0]
75 | end
76 | end
77 |
78 | def normalize_list_item_node(node)
79 | normalizer = "item_node_for_#{type_key(node)}"
80 | if respond_to?(normalizer, true)
81 | send(normalizer, node)
82 | else
83 | ''
84 | end
85 | end
86 |
87 | def item_node_for_list(node)
88 | # sublist
89 | ListBuilder.new(pdf, node, content_width, options).make
90 | end
91 |
92 | def item_node_for_hash(node)
93 | normalize_cell_hash(node, content_width)
94 | end
95 |
96 | def item_node_for_string(node)
97 | node
98 | end
99 |
100 | def content_width
101 | column_widths.last && (column_widths.last - content_margin)
102 | end
103 |
104 | def compute_column_widths
105 | return [] if list.items.empty?
106 |
107 | bullet_width = bullet_text_width + bullet_margin
108 | text_width = total_width && (total_width - bullet_width)
109 | [bullet_width, text_width]
110 | end
111 |
112 | def bullet_text_width
113 | largest_string = bullet('0' * list.items.size.digits.size)
114 | font = bullet_font
115 | encoded = font.normalize_encoding(largest_string)
116 | font_size = column_cell_style(:bullet)[:size] || pdf.font_size
117 | font.compute_width_of(encoded, size: font_size)
118 | end
119 |
120 | def bullet_font
121 | style = column_cell_style(:bullet)
122 | font_name = style[:font] || pdf.font.family
123 | pdf.find_font(font_name, style: style[:font_style])
124 | end
125 |
126 | # option accessors
127 |
128 | def bullet(index)
129 | list.ordered ? "#{index}." : (column_cell_style(:bullet)[:char] || BULLET_CHAR)
130 | end
131 |
132 | # margin before bullet
133 | def bullet_margin
134 | column_cell_style(:bullet)[:margin] || BULLET_MARGIN
135 | end
136 |
137 | # margin between bullet and content
138 | def content_margin
139 | column_cell_style(:content)[:margin] || CONTENT_MARGIN
140 | end
141 |
142 | # margin at the top and the bottom of the list
143 | def vertical_margin
144 | list_options[:vertical_margin] || VERTICAL_MARGIN
145 | end
146 |
147 | # vertical padding between list items
148 | def padding_bottom
149 | column_cell_style(:content)[:leading] || 0
150 | end
151 |
152 | def column_cell_style(key)
153 | @column_cell_styles ||= {}
154 | @column_cell_styles[key] ||=
155 | extract_text_cell_style(options[:text] || {}).merge(list_options[key])
156 | end
157 |
158 | def list_options
159 | @list_options ||= HashMerger.deep(default_list_options, options[:list] || {})
160 | end
161 |
162 | def default_list_options
163 | {
164 | content: {},
165 | bullet: { align: :right }
166 | }
167 | end
168 | end
169 | end
170 | end
171 | end
172 |
--------------------------------------------------------------------------------
/lib/prawn/markup/builders/nestable_builder.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Builders
6 | class NestableBuilder
7 | TEXT_STYLE_OPTIONS = %i[font size style font_style color text_color
8 | kerning leading align min_font_size overflow rotate
9 | rotate_around single_line valign].freeze
10 |
11 | def initialize(pdf, total_width, options = {})
12 | @pdf = pdf
13 | @total_width = total_width
14 | @options = options
15 | end
16 |
17 | private
18 |
19 | attr_reader :pdf, :total_width, :options
20 |
21 | def normalize_cell_hash(hash, cell_width, style_options = {})
22 | if hash.key?(:image)
23 | compute_image_width(hash, cell_width)
24 | else
25 | style_options.merge(hash)
26 | end
27 | end
28 |
29 | def text_options
30 | options[:text] || {}
31 | end
32 |
33 | def compute_image_width(hash, max_width)
34 | hash.dup.tap do |image_hash|
35 | image_hash.delete(:width)
36 | image_hash[:image_width] = SizeConverter.new(max_width).parse(hash[:width])
37 | natural_width, _height = natural_image_dimensions(image_hash)
38 | if max_width && (max_width < natural_width)
39 | image_hash[:fit] = [max_width, 999_999]
40 | end
41 | rescue Prawn::Errors::UnsupportedImageType
42 | image_hash.clear
43 | end
44 | end
45 |
46 | def natural_image_dimensions(hash)
47 | _obj, info = pdf.build_image_object(hash[:image])
48 | info.calc_image_dimensions(width: hash[:image_width])
49 | end
50 |
51 | def extract_text_cell_style(hash)
52 | TEXT_STYLE_OPTIONS
53 | .each_with_object({}) { |key, h| h[key] = hash[key] }
54 | .tap { |options| convert_style_options(options) }
55 | end
56 |
57 | def convert_style_options(hash)
58 | hash[:font_style] ||= hash.delete(:style)
59 | hash[:text_color] ||= hash.delete(:color)
60 | end
61 |
62 | def type_key(object)
63 | path = object.class.name.to_s
64 | i = path.rindex('::')
65 | if i
66 | path[(i + 2)..-1].downcase
67 | else
68 | path.downcase
69 | end
70 | end
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/prawn/markup/builders/table_builder.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Builders
6 | class TableBuilder < NestableBuilder
7 | FAILOVER_STRATEGIES = %i[equal_widths subtable_placeholders].freeze
8 |
9 | DEFAULT_CELL_PADDING = 5
10 |
11 | MIN_COL_WIDTH = 1.cm
12 |
13 | def initialize(pdf, cells, total_width, options = {})
14 | super(pdf, total_width, options)
15 | @cells = cells
16 | @column_widths = []
17 | end
18 |
19 | def make
20 | compute_column_widths
21 | pdf.make_table(convert_cells, prawn_table_options)
22 | end
23 |
24 | def draw
25 | # fix https://github.com/prawnpdf/prawn-table/issues/120
26 | pdf.font_size(table_options[:cell][:size] || pdf.font_size) do
27 | make.draw
28 | end
29 | rescue Prawn::Errors::CannotFit => e
30 | if failover_on_error
31 | draw
32 | else
33 | raise e
34 | end
35 | end
36 |
37 | private
38 |
39 | attr_reader :cells, :column_widths, :failover_strategy
40 |
41 | def prawn_table_options
42 | static_prawn_table_options.tap do |options|
43 | options[:width] = total_width
44 | options[:header] = cells.first && cells.first.all?(&:header)
45 | options[:column_widths] = column_widths
46 | end
47 | end
48 |
49 | def static_prawn_table_options
50 | table_options.dup.tap do |options|
51 | options.delete(:placeholder)
52 | options.delete(:header)
53 | TEXT_STYLE_OPTIONS.each { |key| options[:cell].delete(key) }
54 | options[:cell_style] = options.delete(:cell)
55 | end
56 | end
57 |
58 | def convert_cells
59 | cells.map do |row|
60 | row.map.with_index do |cell, col|
61 | convert_cell(cell, col)
62 | end
63 | end
64 | end
65 |
66 | def convert_cell(cell, col)
67 | style_options = table_options[cell.header ? :header : :cell]
68 | if cell.single?
69 | normalize_cell_node(cell.nodes.first, column_content_width(col), style_options)
70 | else
71 | cell_table(cell, column_content_width(col), style_options)
72 | end
73 | end
74 |
75 | def column_content_width(col)
76 | width = column_widths[col]
77 | width -= horizontal_padding if width
78 | width
79 | end
80 |
81 | # cell with multiple nodes is represented as single-column table
82 | def cell_table(cell, width, style_options)
83 | data = cell.nodes.map { |n| [normalize_cell_node(n, width, style_options)] }
84 | pdf.make_table(data,
85 | width: width,
86 | cell_style: {
87 | padding: [0, 0, 0, 0],
88 | borders: [],
89 | border_width: 0,
90 | inline_format: true
91 | })
92 | end
93 |
94 | def normalize_cell_node(node, width, style_options = {})
95 | normalizer = "cell_node_for_#{type_key(node)}"
96 | if respond_to?(normalizer, true)
97 | send(normalizer, node, width, style_options)
98 | else
99 | ''
100 | end
101 | end
102 |
103 | def cell_node_for_list(node, width, _style_options = {})
104 | opts = options.merge(text: extract_text_cell_style(table_options[:cell]))
105 | subtable(width) do
106 | ListBuilder.new(pdf, node, width, opts).make(true)
107 | end
108 | end
109 |
110 | def cell_node_for_array(node, width, _style_options = {})
111 | subtable(width) do
112 | TableBuilder.new(pdf, node, width, options).make
113 | end
114 | end
115 |
116 | def cell_node_for_hash(node, width, style_options = {})
117 | normalize_cell_hash(node, width, style_options)
118 | end
119 |
120 | def cell_node_for_string(node, _width, style_options = {})
121 | style_options.merge(content: node)
122 | end
123 |
124 | def subtable(width)
125 | if width.nil? && failover_strategy == :subtable_placeholders
126 | { content: table_options[:placeholder][:subtable_too_large] }
127 | else
128 | yield
129 | end
130 | end
131 |
132 | def normalize_cell_hash(node, width, style_options)
133 | if width.nil? && total_width
134 | width = total_width - column_width_sum - ((columns_without_width - 1) * MIN_COL_WIDTH)
135 | end
136 | super
137 | end
138 |
139 | def compute_column_widths
140 | parse_given_widths
141 | if total_width
142 | add_missing_widths
143 | stretch_to_total_width
144 | end
145 | end
146 |
147 | def parse_given_widths
148 | return if cells.empty?
149 |
150 | @column_widths = Array.new(cells.first.size)
151 | converter = SizeConverter.new(total_width)
152 | cells.each do |row|
153 | row.each_with_index do |cell, col|
154 | @column_widths[col] ||= converter.parse(cell.width)
155 | end
156 | end
157 | end
158 |
159 | def add_missing_widths
160 | missing_count = columns_without_width
161 | if missing_count == 1 ||
162 | (missing_count > 1 && failover_strategy == :equal_widths)
163 | distribute_remaing_width(missing_count)
164 | end
165 | end
166 |
167 | def columns_without_width
168 | column_widths.count(&:nil?)
169 | end
170 |
171 | def column_width_sum
172 | column_widths.compact.inject(:+) || 0
173 | end
174 |
175 | def distribute_remaing_width(count)
176 | equal_width = (total_width - column_width_sum) / count.to_f
177 | return if equal_width.negative?
178 |
179 | column_widths.map! { |width| width || equal_width }
180 | end
181 |
182 | def stretch_to_total_width
183 | sum = column_width_sum
184 | if columns_without_width.zero? && sum < total_width
185 | increase_widths(sum)
186 | elsif sum > total_width
187 | decrease_widths(sum)
188 | end
189 | end
190 |
191 | def increase_widths(sum)
192 | diff = total_width - sum
193 | column_widths.map! { |w| w + (w / sum * diff) }
194 | end
195 |
196 | def decrease_widths(sum)
197 | sum += columns_without_width * MIN_COL_WIDTH
198 | diff = sum - total_width
199 | column_widths.map! { |w| w ? [w - (w / sum * diff), 0].max : nil }
200 | end
201 |
202 | def failover_on_error
203 | if failover_strategy == FAILOVER_STRATEGIES.last
204 | @failover_strategy = nil
205 | else
206 | index = FAILOVER_STRATEGIES.index(failover_strategy) || -1
207 | @failover_strategy = FAILOVER_STRATEGIES[index + 1]
208 | end
209 | end
210 |
211 | def horizontal_padding
212 | @horizontal_padding ||= begin
213 | padding = table_options[:cell][:padding] || ([DEFAULT_CELL_PADDING] * 4)
214 | padding.is_a?(Array) ? padding[1] + padding[3] : padding
215 | end
216 | end
217 |
218 | def table_options
219 | @table_options ||= build_table_options
220 | end
221 |
222 | def build_table_options
223 | HashMerger.deep(default_table_options, options[:table] || {}).tap do |opts|
224 | build_cell_options(opts)
225 | build_header_options(opts)
226 | end
227 | end
228 |
229 | def build_cell_options(opts)
230 | HashMerger.enhance(opts, :cell, extract_text_cell_style(options[:text] || {}))
231 | convert_style_options(opts[:cell])
232 | end
233 |
234 | def build_header_options(opts)
235 | HashMerger.enhance(opts, :header, opts[:cell])
236 | convert_style_options(opts[:header])
237 | end
238 |
239 | def default_table_options
240 | {
241 | cell: {
242 | inline_format: true,
243 | padding: [DEFAULT_CELL_PADDING] * 4
244 | },
245 | header: {},
246 | placeholder: {
247 | subtable_too_large: '[nested tables with automatic width are not supported]'
248 | }
249 | }
250 | end
251 | end
252 | end
253 | end
254 | end
255 |
--------------------------------------------------------------------------------
/lib/prawn/markup/elements/cell.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Elements
6 | class Cell < Item
7 | attr_reader :header, :width
8 |
9 | def initialize(header: false, width: 'auto')
10 | super()
11 | @header = header
12 | @width = width
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/prawn/markup/elements/item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Elements
6 | class Item
7 | attr_reader :nodes
8 |
9 | def initialize
10 | @nodes = []
11 | end
12 |
13 | def single?
14 | nodes.size <= 1
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/prawn/markup/elements/list.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Elements
6 | class List
7 | attr_reader :ordered, :items
8 |
9 | def initialize(ordered)
10 | @ordered = ordered
11 | @items = []
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/prawn/markup/interface.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Interface
6 | attr_writer :markup_options
7 |
8 | def markup(html, options = {})
9 | options = HashMerger.deep(markup_options, options)
10 | Processor.new(self, options).parse(html)
11 | end
12 |
13 | def markup_options
14 | @markup_options ||= {}
15 | end
16 |
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | # Processes known HTML tags. Unknown tags are ignored.
6 | class Processor < Nokogiri::XML::SAX::Document
7 | class Error < StandardError; end
8 |
9 | class << self
10 | def known_elements
11 | @@known_elments ||= []
12 | end
13 |
14 | def logger
15 | @@logger
16 | end
17 |
18 | def logger=(logger)
19 | @@logger = logger
20 | end
21 | end
22 |
23 | self.logger = defined?(Rails) ? Rails.logger : nil
24 |
25 | require 'prawn/markup/processor/text'
26 | require 'prawn/markup/processor/blocks'
27 | require 'prawn/markup/processor/headings'
28 | require 'prawn/markup/processor/images'
29 | require 'prawn/markup/processor/inputs'
30 | require 'prawn/markup/processor/tables'
31 | require 'prawn/markup/processor/lists'
32 |
33 | prepend Prawn::Markup::Processor::Text
34 | prepend Prawn::Markup::Processor::Blocks
35 | prepend Prawn::Markup::Processor::Headings
36 | prepend Prawn::Markup::Processor::Images
37 | prepend Prawn::Markup::Processor::Inputs
38 | prepend Prawn::Markup::Processor::Tables
39 | prepend Prawn::Markup::Processor::Lists
40 |
41 | def initialize(pdf, options = {})
42 | super()
43 | @pdf = pdf
44 | @options = options
45 | end
46 |
47 | def parse(html)
48 | return if html.to_s.strip.empty?
49 |
50 | reset
51 | html = Prawn::Markup::Normalizer.new(html).normalize
52 | Nokogiri::HTML::SAX::Parser.new(self, html.encoding&.to_s).parse(html) do |ctx|
53 | ctx.recovery = true
54 | end
55 | end
56 |
57 | def start_element(name, attrs = [])
58 | stack.push(name: name, attrs: attrs.to_h)
59 | send("start_#{name}") if known_element?(name) && respond_to?("start_#{name}", true)
60 | end
61 |
62 | def end_element(name)
63 | send("end_#{name}") if respond_to?("end_#{name}", true)
64 | stack.pop
65 | end
66 |
67 | def characters(string)
68 | # entities will be replaced again later by inline_format
69 | append_text(string.gsub('&', '&').gsub('<', '<').gsub('>', '>'))
70 | end
71 |
72 | def error(string)
73 | logger.info("SAX parsing error: #{string.strip}") if logger
74 | end
75 |
76 | def warning(string)
77 | logger.info("SAX parsing warning: #{string.strip}") if logger
78 | end
79 |
80 | private
81 |
82 | attr_reader :pdf, :stack, :text_buffer, :bottom_margin, :options
83 |
84 | def reset
85 | @stack = []
86 | @text_buffer = +''
87 | end
88 |
89 | def known_element?(name)
90 | self.class.known_elements.include?(name)
91 | end
92 |
93 | def append_text(string)
94 | text_buffer.concat(string)
95 | end
96 |
97 | def buffered_text?
98 | !text_buffer.strip.empty?
99 | end
100 |
101 | def dump_text
102 | text = process_text(text_buffer.dup)
103 | text_buffer.clear
104 | text
105 | end
106 |
107 | def put_bottom_margin(value)
108 | @bottom_margin = value
109 | end
110 |
111 | def inside_container?
112 | false
113 | end
114 |
115 | def current_attrs
116 | stack.last[:attrs]
117 | end
118 |
119 | def process_text(text)
120 | if options[:text] && options[:text][:preprocessor]
121 | options[:text][:preprocessor].call(text)
122 | else
123 | text
124 | end
125 | end
126 |
127 | def style_properties
128 | style = current_attrs['style']
129 | if style
130 | style
131 | .split(';')
132 | .map { |p| p.split(':', 2).map(&:strip) }
133 | .select { |tuple| tuple.size == 2 }
134 | .to_h
135 | else
136 | {}
137 | end
138 | end
139 |
140 | def placeholder_value(keys, *args)
141 | placeholder = dig_options(*keys)
142 | return if placeholder.nil?
143 |
144 | if placeholder.respond_to?(:call)
145 | placeholder.call(*args)
146 | else
147 | placeholder.to_s
148 | end
149 | end
150 |
151 | def dig_options(*keys)
152 | keys.inject(options) { |opts, key| opts ? opts[key] : nil }
153 | end
154 |
155 | def logger
156 | self.class.logger
157 | end
158 | end
159 | end
160 | end
161 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/blocks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Blocks
6 | def self.prepended(base)
7 | base.known_elements.push('p', 'br', 'div', 'hr')
8 | end
9 |
10 | def start_br
11 | append_text("\n")
12 | end
13 |
14 | def start_p
15 | handle_text_element
16 | end
17 |
18 | def end_p
19 | if inside_container?
20 | append_new_line
21 | append_text("\n")
22 | else
23 | add_paragraph
24 | end
25 | end
26 |
27 | def start_div
28 | handle_text_element
29 | end
30 |
31 | def end_div
32 | handle_text_element
33 | end
34 |
35 | def start_hr
36 | return if inside_container?
37 |
38 | put_bottom_margin(nil)
39 | add_current_text
40 | pdf.move_down(hr_vertical_margin_top)
41 | pdf.stroke_horizontal_rule
42 | pdf.move_down(hr_vertical_margin_bottom)
43 | end
44 |
45 | def end_document
46 | add_current_text
47 | end
48 |
49 | private
50 |
51 | def handle_text_element
52 | if inside_container?
53 | append_new_line
54 | else
55 | add_current_text
56 | end
57 | end
58 |
59 | def append_new_line
60 | append_text("\n") if buffered_text? && text_buffer[-1] != "\n"
61 | end
62 |
63 | def add_paragraph
64 | text = dump_text
65 | text.gsub!(/[^\n]/, '') if text.strip.empty?
66 | return if text.empty? && !treat_empty_paragraph_as_new_line?
67 |
68 | add_bottom_margin
69 | add_formatted_text(text.empty? ? "\n" : text, text_options)
70 | put_bottom_margin(text_margin_bottom)
71 | end
72 |
73 | def add_current_text(options = text_options)
74 | add_bottom_margin
75 | return unless buffered_text?
76 |
77 | string = dump_text
78 | string.strip!
79 | add_formatted_text(string, options)
80 | end
81 |
82 | def add_bottom_margin
83 | if @bottom_margin
84 | pdf.move_down(@bottom_margin)
85 | @bottom_margin = nil
86 | end
87 | end
88 |
89 | def add_formatted_text(string, options = text_options)
90 | with_font(options) do
91 | pdf.text(string, options)
92 | end
93 | end
94 |
95 | def with_font(options)
96 | pdf.font(options[:font] || pdf.font.family,
97 | size: options[:size],
98 | style: options[:style]) do
99 | return yield
100 | end
101 | end
102 |
103 | def hr_vertical_margin_top
104 | @hr_vertical_margin_top ||=
105 | (text_options[:size] || pdf.font_size) / 2.0
106 | end
107 |
108 | def hr_vertical_margin_bottom
109 | @hr_vertical_margin_bottom ||= with_font(text_options) do
110 | hr_vertical_margin_top +
111 | pdf.font.descender +
112 | text_leading -
113 | pdf.line_width
114 | end
115 | end
116 |
117 | def reset
118 | super
119 | text_margin_bottom # pre-calculate
120 | end
121 |
122 | def text_margin_bottom
123 | options[:text] ||= {}
124 | options[:text][:margin_bottom] ||= default_text_margin_bottom
125 | end
126 |
127 | def default_text_margin_bottom
128 | text_line_gap +
129 | text_descender +
130 | text_leading
131 | end
132 |
133 | def text_line_gap
134 | @text_line_gap ||= with_font(text_options) { pdf.font.line_gap }
135 | end
136 |
137 | def text_descender
138 | @text_descender ||= with_font(text_options) { pdf.font.descender }
139 | end
140 |
141 | def text_leading
142 | text_options[:leading] || pdf.default_leading
143 | end
144 |
145 | def text_options
146 | @text_options ||= HashMerger.deep(default_text_options, options[:text] || {})
147 | end
148 |
149 | def default_text_options
150 | {
151 | inline_format: true
152 | }
153 | end
154 |
155 | def treat_empty_paragraph_as_new_line?
156 | text_options[:treat_empty_paragraph_as_new_line] || false
157 | end
158 | end
159 | end
160 | end
161 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/headings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Headings
6 | def self.prepended(base)
7 | base.known_elements.push('h1', 'h2', 'h3', 'h4', 'h5', 'h6')
8 | end
9 |
10 | (1..6).each do |i|
11 | define_method("start_h#{i}") do
12 | start_heading(i)
13 | end
14 |
15 | define_method("end_h#{i}") do
16 | end_heading(i)
17 | end
18 | end
19 |
20 | def start_heading(level)
21 | if current_table
22 | add_cell_text_node(current_cell)
23 | elsif current_list
24 | add_cell_text_node(current_list_item)
25 | else
26 | add_current_text if buffered_text?
27 | pdf.move_down(current_heading_margin_top(level))
28 | end
29 | end
30 |
31 | def end_heading(level)
32 | options = heading_options(level)
33 | if current_table
34 | add_cell_text_node(current_cell, options)
35 | elsif current_list
36 | add_cell_text_node(current_list_item, options)
37 | else
38 | add_current_text(options)
39 | put_bottom_margin(options[:margin_bottom])
40 | end
41 | end
42 |
43 | private
44 |
45 | def current_heading_margin_top(level)
46 | top_margin = heading_options(level)[:margin_top] || 0
47 | margin = [top_margin, @bottom_margin || 0].max
48 | put_bottom_margin(nil)
49 | margin
50 | end
51 |
52 | def heading_options(level)
53 | @heading_options ||= {}
54 | @heading_options[level] ||= default_options_with_size(level)
55 | end
56 |
57 | def default_options_with_size(level)
58 | default = text_options.dup
59 | default[:size] ||= pdf.font_size
60 | default[:size] *= 2.5 - (level * 0.25)
61 | HashMerger.deep(default, options[:"heading#{level}"] || {})
62 | end
63 |
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/images.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'open-uri'
4 |
5 | module Prawn
6 | module Markup
7 | module Processor::Images
8 | ALLOWED_IMAGE_TYPES = %w[image/png image/jpeg].freeze
9 |
10 | def self.prepended(base)
11 | base.known_elements.push('img', 'iframe')
12 | end
13 |
14 | def start_img
15 | src = current_attrs['src']
16 | return if src.to_s.strip.empty?
17 |
18 | add_current_text
19 | add_image_or_placeholder(src)
20 | end
21 |
22 | def start_iframe
23 | placeholder = iframe_placeholder
24 | append_text("\n#{placeholder}\n") if placeholder
25 | end
26 |
27 | private
28 |
29 | def add_image_or_placeholder(src)
30 | img = image_properties(src)
31 | if img
32 | add_image(img)
33 | else
34 | append_text("\n#{invalid_image_placeholder}\n")
35 | end
36 | end
37 |
38 | def add_image(img)
39 | # parse width in the current context
40 | img[:width] = SizeConverter.new(pdf.bounds.width).parse(style_properties['width'])
41 | pdf.image(img.delete(:image), img)
42 | put_bottom_margin(text_margin_bottom)
43 | rescue Prawn::Errors::UnsupportedImageType
44 | append_text("\n#{invalid_image_placeholder}\n")
45 | end
46 |
47 | def image_properties(src)
48 | img = load_image(src)
49 | if img
50 | props = style_properties
51 | {
52 | image: img,
53 | width: props['width'],
54 | position: convert_float_to_position(props['float'])
55 | }
56 | end
57 | end
58 |
59 | def load_image(src)
60 | custom_load_image(src) ||
61 | decode_base64_image(src) ||
62 | load_remote_image(src)
63 | end
64 |
65 | def custom_load_image(src)
66 | if options[:image] && options[:image][:loader]
67 | options[:image][:loader].call(src)
68 | end
69 | end
70 |
71 | def decode_base64_image(src)
72 | match = src.match(/^data:(.*?);(.*?),(.*)$/)
73 | if match && ALLOWED_IMAGE_TYPES.include?(match[1])
74 | StringIO.new(Base64.decode64(match[3]))
75 | end
76 | end
77 |
78 | def load_remote_image(src)
79 | if src =~ %r{^https?:/}
80 | begin
81 | URI.parse(src).open
82 | rescue StandardError # OpenURI::HTTPError, SocketError or anything else
83 | nil
84 | end
85 | end
86 | end
87 |
88 | def convert_float_to_position(float)
89 | { nil => nil,
90 | 'none' => nil,
91 | 'left' => :left,
92 | 'right' => :right }[float]
93 | end
94 |
95 | def invalid_image_placeholder
96 | placeholder_value(%i[image placeholder]) || '[unsupported image]'
97 | end
98 |
99 | def iframe_placeholder
100 | placeholder_value(%i[iframe placeholder], current_attrs['src'])
101 | end
102 | end
103 | end
104 | end
105 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/inputs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Inputs
6 |
7 | DEFAULT_CHECKABLE_CHARS = {
8 | checkbox: {
9 | checked: '☑',
10 | unchecked: '☐'
11 | },
12 | radio: {
13 | checked: '◉',
14 | unchecked: '○'
15 | }
16 | }.freeze
17 |
18 | def self.prepended(base)
19 | base.known_elements.push('input')
20 | end
21 |
22 | def start_input
23 | type = current_attrs['type'].to_sym
24 | if DEFAULT_CHECKABLE_CHARS.keys.include?(type)
25 | append_checked_symbol(type)
26 | end
27 | end
28 |
29 | private
30 |
31 | def append_checked_symbol(type)
32 | char = checkable_symbol(type)
33 | append_text(build_font_tag(char))
34 | end
35 |
36 | def checkable_symbol(type)
37 | state = current_attrs.key?('checked') ? :checked : :unchecked
38 | dig_options(:input, type, state) || DEFAULT_CHECKABLE_CHARS[type][state]
39 | end
40 |
41 | def symbol_font_options
42 | @symbol_font_options ||= {
43 | name: dig_options(:input, :symbol_font),
44 | size: dig_options(:input, :symbol_font_size)
45 | }.compact
46 | end
47 |
48 | def build_font_tag(content)
49 | return content if symbol_font_options.empty?
50 |
51 | out = +''
56 | out << content
57 | out << ''
58 | end
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/lists.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Lists
6 | def self.prepended(base)
7 | base.known_elements.push('ol', 'ul', 'li')
8 | end
9 |
10 | def start_ol
11 | start_list(true)
12 | end
13 |
14 | def start_ul
15 | start_list(false)
16 | end
17 |
18 | def start_list(ordered)
19 | if current_list
20 | add_cell_text_node(current_list_item)
21 | elsif current_table
22 | add_cell_text_node(current_cell)
23 | else
24 | add_current_text
25 | end
26 | @list_stack.push(Elements::List.new(ordered))
27 | end
28 |
29 | def end_list
30 | list = list_stack.pop
31 | append_list(list) unless list.items.empty?
32 | end
33 | alias end_ol end_list
34 | alias end_ul end_list
35 |
36 | def start_li
37 | return unless current_list
38 |
39 | current_list.items << Elements::Item.new
40 | end
41 |
42 | def end_li
43 | return unless current_list
44 |
45 | add_cell_text_node(current_list_item)
46 | end
47 |
48 | def start_img
49 | if current_list
50 | add_cell_image(current_list_item)
51 | else
52 | super
53 | end
54 | end
55 |
56 | private
57 |
58 | attr_reader :list_stack
59 |
60 | def reset
61 | @list_stack = []
62 | super
63 | end
64 |
65 | def current_list
66 | list_stack.last
67 | end
68 |
69 | def current_list_item
70 | items = current_list.items
71 | items << Elements::Item.new if items.empty?
72 | items.last
73 | end
74 |
75 | def inside_container?
76 | super || current_list
77 | end
78 |
79 | def append_list(list)
80 | if list_stack.empty?
81 | if current_table
82 | current_cell.nodes << list
83 | else
84 | add_list(list)
85 | end
86 | else
87 | current_list_item.nodes << list
88 | end
89 | end
90 |
91 | def add_list(list)
92 | pdf.move_up(additional_cell_padding_top)
93 | draw_list(list)
94 | put_bottom_margin(text_margin_bottom + additional_cell_padding_top)
95 | rescue Prawn::Errors::CannotFit => e
96 | append_text(list_too_large_placeholder(e))
97 | end
98 |
99 | def draw_list(list)
100 | Builders::ListBuilder.new(pdf, list, pdf.bounds.width, options).draw
101 | end
102 |
103 | def list_too_large_placeholder(error)
104 | placeholder_value(%i[list placeholder too_large], error) || '[list content too large]'
105 | end
106 | end
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/tables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Tables
6 | def self.prepended(base)
7 | base.known_elements.push('table', 'tr', 'td', 'th')
8 | end
9 |
10 | def start_table
11 | if current_table
12 | add_cell_text_node(current_cell)
13 | else
14 | add_current_text
15 | end
16 | table_stack.push([])
17 | end
18 |
19 | def end_table
20 | data = table_stack.pop
21 | return if data.empty? || data.all?(&:empty?)
22 |
23 | if table_stack.empty?
24 | add_table(data)
25 | else
26 | current_cell.nodes << data
27 | end
28 | end
29 |
30 | def start_tr
31 | return unless current_table
32 |
33 | current_table << []
34 | end
35 |
36 | def start_td
37 | return unless current_table
38 |
39 | current_table.last << Elements::Cell.new(width: style_properties['width'])
40 | end
41 |
42 | def start_th
43 | return unless current_table
44 |
45 | current_table.last << Elements::Cell.new(width: style_properties['width'], header: true)
46 | end
47 |
48 | def end_td
49 | if current_table
50 | add_cell_text_node(current_cell)
51 | else
52 | add_current_text
53 | end
54 | end
55 | alias end_th end_td
56 |
57 | def start_img
58 | if current_table
59 | add_cell_image(current_cell)
60 | else
61 | super
62 | end
63 | end
64 |
65 | private
66 |
67 | attr_reader :table_stack
68 |
69 | def reset
70 | @table_stack = []
71 | super
72 | end
73 |
74 | def current_table
75 | table_stack.last
76 | end
77 |
78 | def current_cell
79 | current_table.last.last
80 | end
81 |
82 | def inside_container?
83 | super || current_table
84 | end
85 |
86 | def add_cell_text_node(cell, options = {})
87 | return unless buffered_text?
88 |
89 | # only allow on supported options of prawn-table; See https://github.com/prawnpdf/prawn-table/blob/master/manual/table/cell_text.rb
90 | options = options.slice(*%i[font font_style inline_format kerning leading min_font_size size
91 | overflow rotate rotate_around single_line text_color valign])
92 |
93 | cell.nodes << options.merge(content: dump_text.strip)
94 | end
95 |
96 | def add_cell_image(cell)
97 | src = current_attrs['src']
98 | return if src.to_s.strip.empty?
99 |
100 | add_cell_text_node(cell)
101 | img = image_properties(src)
102 | (cell.nodes << img) || invalid_image_placeholder
103 | end
104 |
105 | def add_table(cells)
106 | draw_table(cells)
107 | put_bottom_margin(text_margin_bottom + additional_cell_padding_top + text_leading)
108 | rescue Prawn::Errors::CannotFit => e
109 | append_text(table_too_large_placeholder(e))
110 | end
111 |
112 | def draw_table(cells)
113 | Builders::TableBuilder.new(pdf, cells, pdf.bounds.width, options).draw
114 | end
115 |
116 | def table_too_large_placeholder(error)
117 | placeholder_value(%i[table placeholder too_large], error) || '[table content too large]'
118 | end
119 |
120 | def additional_cell_padding_top
121 | # as used in Prawn::Table::Cell::Text#draw_content move_down
122 | (text_line_gap + text_descender) / 2
123 | end
124 | end
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/lib/prawn/markup/processor/text.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module Processor::Text
6 | def self.prepended(base)
7 | base.known_elements.push(
8 | 'a', 'b', 'strong', 'i', 'em', 'u', 'strikethrough', 'strike', 's', 'del',
9 | 'sub', 'sup', 'color', 'font'
10 | )
11 | end
12 |
13 | def start_a
14 | start_u if link_options[:underline]
15 | append_color_tag(link_options[:color]) if link_options[:color]
16 | append_text("")
17 | end
18 | alias start_link start_a
19 |
20 | def end_a
21 | append_text('')
22 | end_color if link_options[:color]
23 | end_u if link_options[:underline]
24 | end
25 | alias end_link end_a
26 |
27 | def link_options
28 | @link_options ||= HashMerger.deep(default_link_options, options[:link] || {})
29 | end
30 |
31 | def default_link_options
32 | {
33 | color: nil,
34 | underline: false
35 | }
36 | end
37 |
38 | def start_b
39 | append_text('')
40 | end
41 | alias start_strong start_b
42 |
43 | def end_b
44 | append_text('')
45 | end
46 | alias end_strong end_b
47 |
48 | def start_i
49 | append_text('')
50 | end
51 | alias start_em start_i
52 |
53 | def end_i
54 | append_text('')
55 | end
56 | alias end_em end_i
57 |
58 | def start_u
59 | append_text('')
60 | end
61 |
62 | def end_u
63 | append_text('')
64 | end
65 |
66 | def start_strikethrough
67 | append_text('')
68 | end
69 | alias start_s start_strikethrough
70 | alias start_strike start_strikethrough
71 | alias start_del start_strikethrough
72 |
73 | def end_strikethrough
74 | append_text('')
75 | end
76 | alias end_s end_strikethrough
77 | alias end_strike end_strikethrough
78 | alias end_del end_strikethrough
79 |
80 | def start_sub
81 | append_text('')
82 | end
83 |
84 | def end_sub
85 | append_text('')
86 | end
87 |
88 | def start_sup
89 | append_text('')
90 | end
91 |
92 | def end_sup
93 | append_text('')
94 | end
95 |
96 | def start_color
97 | rgb, c, m, y, k = current_attrs.values_at('rgb', 'c', 'm', 'y', 'k')
98 |
99 | if [c, m, y, k].all?
100 | append_color_tag([c, m, y, k])
101 | else
102 | append_color_tag(rgb)
103 | end
104 | end
105 |
106 | def end_color
107 | append_text('')
108 | end
109 |
110 | def start_font
111 | font_attrs = current_attrs
112 | .slice('size', 'name', 'character_spacing')
113 | .reduce('') { |acc, (key, val)| "#{acc} #{key}=\"#{val}\"" }
114 |
115 | append_text("")
116 | end
117 |
118 | def end_font
119 | append_text('')
120 | end
121 |
122 | def append_color_tag(color)
123 | if color.is_a?(Array)
124 | c, m, y, k = color
125 | append_text("")
126 | else
127 | append_text("")
128 | end
129 | end
130 | end
131 | end
132 | end
133 |
--------------------------------------------------------------------------------
/lib/prawn/markup/support/hash_merger.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | module HashMerger
6 | def self.deep(hash, other)
7 | hash.merge(other) do |_key, this_val, other_val|
8 | if this_val.is_a?(Hash) && other_val.is_a?(Hash)
9 | deep(this_val, other_val)
10 | else
11 | other_val
12 | end
13 | end
14 | end
15 |
16 | def self.enhance(options, key, hash)
17 | options[key] = hash.merge(options[key])
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/prawn/markup/support/normalizer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | # Normalizes HTML markup:
6 | # * assert that self-closing tags are always closed
7 | # * replace html entities with their UTF-8 correspondent string
8 | # * normalize white space
9 | # * wrap entire content into tag
10 | class Normalizer
11 | SELF_CLOSING_ELEMENTS = %w[br img hr].freeze
12 |
13 | REPLACE_ENTITIES = {
14 | nbsp: ' '
15 | }.freeze
16 |
17 | attr_reader :html
18 |
19 | def initialize(html)
20 | @html = html.dup
21 | end
22 |
23 | def normalize
24 | close_self_closing_elements
25 | normalize_spaces
26 | replace_html_entities
27 | "#{html}"
28 | end
29 |
30 | private
31 |
32 | def close_self_closing_elements
33 | html.gsub!(/<(#{SELF_CLOSING_ELEMENTS.join('|')})[^>]*>/i) do |tag|
34 | tag[-1] = '/>' unless tag.end_with?('/>')
35 | tag
36 | end
37 | end
38 |
39 | def normalize_spaces
40 | html.gsub!(/\s+/, ' ')
41 | end
42 |
43 | def replace_html_entities
44 | REPLACE_ENTITIES.each do |entity, string|
45 | html.gsub!(/{entity};/, string)
46 | end
47 | end
48 |
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/prawn/markup/support/size_converter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | class SizeConverter
6 | attr_reader :max
7 |
8 | def initialize(max)
9 | @max = max
10 | end
11 |
12 | def parse(width)
13 | return nil if width.to_s.strip.empty? || width.to_s == 'auto'
14 |
15 | points = convert(width)
16 | max ? [points, max].min : points
17 | end
18 |
19 | def convert(string)
20 | value = string.to_f
21 | if string.end_with?('%')
22 | max ? value * max / 100.0 : nil
23 | elsif string.end_with?('cm')
24 | value.cm
25 | elsif string.end_with?('mm')
26 | value.mm
27 | else
28 | value
29 | end
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/prawn/markup/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Prawn
4 | module Markup
5 | VERSION = '1.1.0'
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/prawn-markup.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path('lib', __dir__)
2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3 | require 'prawn/markup/version'
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = 'prawn-markup'
7 | spec.version = Prawn::Markup::VERSION
8 | spec.authors = ['Pascal Zumkehr']
9 | spec.email = ['zumkehr@puzzle.ch']
10 |
11 | spec.summary = 'Parse simple HTML markup to include in Prawn PDFs'
12 | spec.description = 'Adds simple HTML snippets into Prawn-generated PDFs. ' \
13 | 'All elements are layouted vertically using Prawn\'s formatting ' \
14 | 'options. A major use case for this gem is to include ' \
15 | 'WYSIWYG-generated HTML parts into server-generated PDF documents.'
16 | spec.homepage = 'https://github.com/puzzle/prawn-markup'
17 | spec.license = 'MIT'
18 |
19 | spec.files = `git ls-files -z`.split("\x0").reject do |f|
20 | f.match(%r{^(((spec|bin)/)|\.|Gemfile|Rakefile)})
21 | end
22 | spec.bindir = 'exe'
23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24 | spec.require_paths = ['lib']
25 |
26 | spec.add_dependency 'nokogiri'
27 | spec.add_dependency 'prawn'
28 | spec.add_dependency 'prawn-table'
29 |
30 | spec.add_development_dependency 'bundler'
31 | spec.add_development_dependency 'byebug'
32 | spec.add_development_dependency 'matrix'
33 | spec.add_development_dependency 'pdf-inspector'
34 | spec.add_development_dependency 'rake'
35 | spec.add_development_dependency 'rspec'
36 | spec.add_development_dependency 'rubocop'
37 | spec.add_development_dependency 'simplecov'
38 | end
39 |
--------------------------------------------------------------------------------
/spec/fixtures/DejaVuSans.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/puzzle/prawn-markup/5fa4532fc20fb2bb9c4c82992fbec6021b23b68f/spec/fixtures/DejaVuSans.ttf
--------------------------------------------------------------------------------
/spec/fixtures/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/puzzle/prawn-markup/5fa4532fc20fb2bb9c4c82992fbec6021b23b68f/spec/fixtures/logo.png
--------------------------------------------------------------------------------
/spec/fixtures/showcase.html:
--------------------------------------------------------------------------------
1 |
Prawn Markup Showcase
2 |
3 |
Text features
4 |
5 |
paragraphs are nicely separated from each other.
6 |
7 |
Margin is or will be customizable at some point.
8 |
9 |
10 |
11 | Strong or emphasized formatting as well as
12 | links are supported.
13 |
14 |
15 | and horizontal lines!
16 |
17 |
Table features
18 |
Also tables are nice:
19 |
20 |
21 |
22 |
No
23 |
Description
24 |
State
25 |
26 |
27 |
1
28 |
Headers and custom widths are supported
29 |
Done
30 |
31 |
32 |
2
33 |
34 |
35 |
36 |
subtables
37 |
so
38 |
39 |
40 |
very
41 |
crazy
42 |
43 |
44 |
45 |
Done
46 |
47 |
48 |
3
49 |
50 | Even lists
51 |
52 |
one
53 |
two
54 |
55 |
56 |
Check
57 |
58 |
59 |
4
60 |
61 | and Images:
62 |
66 |
67 |
Yieha
68 |
69 |
70 | This table has text underneath
71 |
72 |
73 |
List features
74 |
Unordered
75 | Here may be text, too
76 |
77 |
78 | Lorem ipsum doler sit amet. Lorem ipsum doler sit amet. Lorem ipsum doler
79 | sit amet. Lorem ipsum doler sit amet. Lorem ipsum doler sit amet. Lorem
80 | ipsum doler sit amet.
81 |
82 |
anything
83 |
84 |
Ordered
85 |
86 |
87 | Lorem ipsum doler sit amet. Lorem ipsum doler sit amet. Lorem ipsum doler
88 | sit amet. Lorem ipsum doler sit amet. Lorem ipsum doler sit amet. Lorem
89 | ipsum doler sit amet.
90 |
')
20 |
21 | expect(text.strings).to eq(['hello world', 'kthxbye'])
22 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
23 | end
24 |
25 | it 'merges both options' do
26 | doc.markup_options[:text] = { size: 8, style: :italic }
27 | doc.markup('
hello world
kthxbye
', text: { size: font_size })
28 |
29 | expect(text.strings).to eq(['hello world', 'kthxbye'])
30 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
31 | end
32 |
33 | end
34 |
--------------------------------------------------------------------------------
/spec/prawn/markup/normalizer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe Prawn::Markup::Normalizer do
4 | it 'wraps text into root tags' do
5 | expect(normalize('hello world')).to eq('hello world')
6 | end
7 |
8 | it 'wraps html into root tags' do
9 | expect(normalize('
hello world
')).to eq('
hello world
')
10 | end
11 |
12 | it 'closes self-closing tags' do
13 | expect(normalize('1 23')).to eq('1 23')
14 | end
15 |
16 | it 'replaces html entities' do
17 | expect(normalize('2 3')).to eq('2 3')
18 | end
19 |
20 | def normalize(html)
21 | Prawn::Markup::Normalizer.new(html).normalize
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/blocks_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Blocks do
5 | include_context 'pdf_helpers'
6 |
7 | it 'ignores additional whitespace' do
8 | processor.parse("
hello world
and \n you\n
")
9 | expect(text.strings).to eq(['hello world', 'and you'])
10 | expect(top_positions).to eq([top, top - line].map(&:round))
11 | end
12 |
13 | it 'creates margin between paragraphs' do
14 | processor.parse('
hello
world
')
15 | expect(text.strings).to eq(%w[hello world])
16 | expect(top_positions).to eq([top, top - line - p_gap].map(&:round))
17 | end
18 |
19 | it 'empty paragraphs are ignored' do
20 | processor.parse('
hello
world
')
21 | expect(top_positions).to eq([top, top - line - p_gap].map(&:round))
22 | end
23 |
24 | it 'empty paragraphs in divs are ignored' do
25 | processor.parse('
hello
world
')
26 | expect(text.strings).to eq(%w[hello world])
27 | expect(top_positions).to eq([top, top - 1 * line].map(&:round))
28 | end
29 |
30 | it 'paragraphs with only breaks are created' do
31 | processor.parse('
hello
world
')
32 | expect(text.strings).to eq(%w[hello world])
33 | expect(top_positions).to eq([top, top - 2 * (line + p_gap)].map(&:round))
34 | end
35 |
36 | it 'creates new line for breaks' do
37 | processor.parse('hello world')
38 | expect(text.strings).to eq(%w[hello world])
39 | expect(top_positions).to eq([top, top - line].map(&:round))
40 | end
41 |
42 | it 'creates new line between divs' do
43 | processor.parse('
hello
world
')
44 | expect(text.strings).to eq(%w[hello world])
45 | expect(top_positions).to eq([top, top - line].map(&:round))
46 | end
47 |
48 | it 'creates new line for paragraphs in divs' do
49 | processor.parse('
hello
world
')
50 | expect(text.strings).to eq(%w[hello world])
51 | expect(top_positions).to eq([top, top - line].map(&:round))
52 | end
53 |
54 | it 'does not double lines for nested divs' do
55 | processor.parse('
hello
world
markup
gone')
56 | expect(text.strings).to eq(%w[hello world markup gone])
57 | expect(top_positions).to eq([top, top - line, top - 2 * line, top - 3 * line].map(&:round))
58 | end
59 |
60 | it 'ignores line breaks' do
61 | processor.parse("hello\t you, \n world")
62 | expect(text.strings).to eq(['hello you, world'])
63 | expect(top_positions).to eq([top].map(&:round))
64 | end
65 |
66 | it 'creates horizontal line' do
67 | processor.parse('helloworld')
68 | expect(text.strings).to eq(%w[hello world])
69 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
70 | end
71 |
72 | it 'creates horizontal line between paragraphs' do
73 | processor.parse('
hello
world
')
74 | expect(text.strings).to eq(%w[hello world])
75 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
76 | end
77 |
78 | context 'with options' do
79 | let(:font_size) { 10 }
80 | let(:leading) { 4 }
81 | let(:options) do
82 | {
83 | text: {
84 | leading: leading,
85 | size: font_size,
86 | margin_bottom: 0,
87 | preprocessor: ->(text) { text.upcase },
88 | treat_empty_paragraph_as_new_line: true
89 | }
90 | }
91 | end
92 |
93 | it 'creates new line for breaks' do
94 | processor.parse('hello world')
95 | expect(text.strings).to eq(%w[HELLO WORLD])
96 | expect(top_positions).to eq([top, top - line].map(&:round))
97 | end
98 |
99 | it 'creates horizontal line' do
100 | processor.parse('helloworld')
101 | expect(text.strings).to eq(%w[HELLO WORLD])
102 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
103 | end
104 |
105 | it 'adds no space between paragraphs' do
106 | processor.parse('
hello
world
')
107 | expect(text.strings).to eq(%w[HELLO WORLD])
108 | expect(top_positions).to eq([top, top - line].map(&:round))
109 | end
110 |
111 | it "treats empty paragraphs as new line if configured" do
112 | processor.parse("
hello
world
")
113 | expect(text.strings).to eq(%w[HELLO WORLD])
114 | expect(top_positions).to eq([top, top - 2 * line].map(&:round))
115 | end
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/headings_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Headings do
5 |
6 | include_context 'pdf_helpers'
7 |
8 | it 'creates various headings' do
9 | processor.parse('
hello
world
bla
earthlings
blu
universe
bli
')
10 | expect(text.strings).to eq(%w[hello world bla earthlings blu universe bli])
11 | # values copied from visually controlled run
12 | expect(top_positions).to eq([737, 708, 688, 663, 645, 623, 603])
13 | end
14 |
15 | it 'inline formatting in headings' do
16 | processor.parse('
hello world
bla bla bla
Subtitle
blu blu
')
17 | expect(text.strings).to eq(['hello ', 'world', 'bla bla bla', 'Subtitle', 'blu blu'])
18 | # values copied from visually controlled run
19 | expect(top_positions).to eq([737, 737, 716, 694, 675])
20 | end
21 |
22 | it 'headings in tables are basically supported' do
23 | processor.parse('
hello
world and universe and everything
subtitle
')
24 | expect(text.strings).to eq(['hello', 'world and universe and everything', 'subtitle'])
25 | # values copied from visually controlled run
26 | expect(top_positions).to eq([729, 709, 731])
27 | end
28 |
29 | context 'with options' do
30 | let(:options) do
31 | {
32 | heading1: { size: 36, style: :bold, margin_top: 10, margin_bottom: 5 },
33 | heading2: { size: 24, style: :bold_italic, margin_top: 20, margin_bottom: 5 }
34 | }
35 | end
36 |
37 | it 'customizes different headings' do
38 | processor.parse('
hello
world
bla
earthlings
blu
universe
bli
')
39 | expect(text.strings).to eq(%w[hello world bla earthlings blu universe bli])
40 | # values copied from visually controlled run
41 | expect(top_positions).to eq([720, 666, 641, 620, 605, 562, 537])
42 | end
43 | end
44 |
45 | end
46 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/images_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Images do
5 | include_context 'pdf_helpers'
6 |
7 | LOGO_DIMENSION = [100, 38].freeze
8 |
9 | it 'renders an image on own line' do
10 | processor.parse("hello world")
11 |
12 | scaled_height = 50.mm * LOGO_DIMENSION.last / LOGO_DIMENSION.first
13 | expect(text.strings).to eq(%w[hello world])
14 | expect(left_positions).to eq([left, left])
15 | expect(top_positions).to eq([top, top - line - scaled_height - p_gap].map(&:round))
16 | end
17 |
18 | it 'renders placeholder for unknown format' do
19 | processor.parse('hello world')
20 |
21 | expect(text.strings).to eq(['hello', '[unsupported image]', 'world'])
22 | expect(left_positions).to eq([left, left, left])
23 | expect(top_positions).to eq([top, top - line, top - 2 * line].map(&:round))
24 | end
25 |
26 | it 'renders image for remote src' do
27 | processor.parse('
hello
world
')
28 |
29 | expect(text.strings).to eq(['hello', 'world'])
30 | expect(left_positions).to eq([left, left])
31 | expect(top_positions).to eq([top, top - line - LOGO_DIMENSION.last - p_gap - 5].map(&:round))
32 | end
33 |
34 | it 'renders not existing image for remote src' do
35 | processor.parse('
hello
world
')
36 | expect(text.strings).to eq(['hello', '[unsupported image]', 'world'])
37 | end
38 |
39 | it 'renders not existing host for remote src' do
40 | processor.parse('
hello
world
')
41 | expect(text.strings).to eq(['hello', '[unsupported image]', 'world'])
42 | end
43 |
44 | it 'renders unsupported image for remote src' do
45 | processor.parse('
hello
world
')
46 | expect(text.strings).to eq(['hello', '[unsupported image]', 'world'])
47 | end
48 |
49 | it 'renders nothing for images without source' do
50 | processor.parse('hello world')
51 |
52 | expect(text.strings).to eq(['hello world'])
53 | expect(top_positions).to eq([top].map(&:round))
54 | end
55 |
56 | it 'renders nothing for iframes' do
57 | processor.parse('hello world')
58 |
59 | expect(text.strings).to eq(['hello world'])
60 | expect(top_positions).to eq([top].map(&:round))
61 | end
62 |
63 | context 'with placeholder' do
64 | let(:options) { { iframe: { placeholder: ->(url) { "[embeded content: #{url}]" } } } }
65 |
66 | it 'renders placeholder for iframes if given' do
67 | processor.parse('hello world')
68 |
69 | expect(text.strings).to eq(['hello ', '[embeded content: ', 'http://vimeo.com/42', ']', 'world'])
70 | expect(top_positions).to eq([top, *([top - line] * 3), top - 2 * line].map(&:round))
71 | end
72 | end
73 |
74 | context 'with custom loader' do
75 | let(:loader) do
76 | ->(src) do
77 | match = src.match(/^fix:(.*?)$/)
78 | "spec/fixtures/#{match[1]}" if match
79 | end
80 | end
81 |
82 | let(:options) { { image: { loader: loader } } }
83 |
84 | it 'render image with custom loader' do
85 | processor.parse('
hello
world
')
86 |
87 | expect(left_positions).to eq([left, left])
88 | expect(top_positions).to eq([top, top - line - LOGO_DIMENSION.last - p_gap - 5].map(&:round))
89 | end
90 |
91 | it 'falls back to default if loader returns nil' do
92 | processor.parse("
hello
world
")
93 |
94 | expect(left_positions).to eq([left, left])
95 | expect(top_positions).to eq([top, top - line - LOGO_DIMENSION.last - p_gap - 5].map(&:round))
96 | end
97 |
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/inputs_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Inputs do
5 | include_context 'pdf_helpers'
6 |
7 | let(:options) { { input: { symbol_font: 'DejaVu', radio: { unchecked: '❍' } } } }
8 |
9 | before do
10 | doc.font_families.update('DejaVu' => {
11 | normal: 'spec/fixtures/DejaVuSans.ttf'
12 | })
13 | end
14 |
15 | it 'handles checkboxes' do
16 | processor.parse(' One Two')
17 | expect(text.strings).to eq(['☑', ' One', '☐', ' Two'])
18 | expect(font_names).to eq(%w[DejaVuSans Helvetica DejaVuSans Helvetica])
19 | end
20 |
21 | it 'handles checkboxes in tables' do
22 | processor.parse('
One
' \
23 | '
Two
')
24 | expect(text.strings).to eq(['☐', 'One', '☑', 'Two'])
25 | end
26 |
27 | it 'handles radios' do
28 | processor.parse(' One Two')
29 | expect(text.strings).to eq(['◉', ' One', '❍', ' Two'])
30 | expect(font_names).to eq(%w[DejaVuSans Helvetica DejaVuSans Helvetica])
31 | end
32 |
33 | it 'ignores text inputs' do
34 | processor.parse('Eingabe: ')
35 | expect(text.strings).to eq(['Eingabe:'])
36 | end
37 |
38 | private
39 |
40 | def font_names
41 | text.font_settings.map { |h| h[:name].to_s.gsub(/^[1-9a-z]{6}\+/, '') }
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/lists_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Lists do
5 | include_context 'pdf_helpers'
6 |
7 | let(:bullet_left) { left + bullet_margin + bullet_padding }
8 | let(:desc_left) { left + bullet_margin + bullet_width + content_margin }
9 | let(:list_top) { top - line - list_vertical_margin }
10 |
11 | it 'creates an unordered list' do
12 | processor.parse('hello
' ) * 10 +
78 | ''
79 | )
80 | expect(text.strings.size).to eq(200) # many, not just placeholder
81 | end
82 |
83 | # regression spec for https://github.com/puzzle/prawn-markup/issues/38
84 | it 'creates longer list with correct bullet indent' do
85 | processor.parse('' + ( '
Item
' * 11) + '')
86 | ltop = top - list_vertical_margin
87 | expect(top_positions).to eq(11.times.flat_map { |i| [(ltop - i * line).round] * 2 })
88 | end
89 |
90 | # See https://bugzilla.gnome.org/show_bug.cgi?id=759987
91 | # Fixed with newer version
92 | it 'creates a large nested list with direct children sublists (invalid html)' do
93 | processor.parse(
94 | '
hello
first
second
' \
95 | '
sub 1
sub 2 has a lot of text spaning more than two lines at least' \
96 | 'or probably even some more and then we go on and on and on and on
sub 3
' \
97 | '
third has a lot of text spaning more than two lines at least' \
98 | 'or probably even some more and then we go on and on and on and on
')
195 |
196 | expect(text.strings).to eq(['•', '•', 'Bla'])
197 | end
198 |
199 | it 'does nothing for empty list' do
200 | processor.parse('
')
201 |
202 | expect(text.strings).to be_empty
203 | end
204 |
205 | it 'renders bullet for list with empty items' do
206 | processor.parse('')
207 |
208 | expect(text.strings).to eq(['1.'])
209 | end
210 |
211 | it 'renders nothing for items without list' do
212 | processor.parse('
')
163 |
164 | expect(text.strings).to eq(['Col One', 'Col Two', 'two'])
165 | expect(left_positions).to eq([first_col_left, 328, 328])
166 | expect(images.size).to eq(1)
167 | expect(images.first.hash[:Width]).to eq(100)
168 | end
169 |
170 | it 'creates images with text inside tables' do
171 | processor.parse('
Col One
Col Two
' \
172 | "Here comes an image: That was nice, not?" \
173 | '
two
')
174 |
175 | expect(text.strings).to eq(['Col One', 'Col Two', 'Here comes an image:', 'That was nice, not?', 'two'])
176 | expect(left_positions).to eq([first_col_left, 334, first_col_left, first_col_left, 334])
177 | expect(images.size).to eq(1)
178 | expect(images.first.hash[:Width]).to eq(100)
179 | end
180 |
181 | it 'uses column widths' do
182 | processor.parse(
183 | '
Col One
Col Two
Col Three
' \
184 | '
hello world has very much text hello world has very much text' \
185 | ' hello world has very much text hello world has very much text' \
186 | ' hello world has very much text
Two
Three
'
187 | )
188 |
189 | second_left = first_col_left + 3.cm
190 | third_left = first_col_left + 0.6 * content_width
191 | expect(left_positions)
192 | .to eq([first_col_left, second_left, third_left,
193 | # each line in cell 2/1 creates an own string
194 | first_col_left, first_col_left, first_col_left, first_col_left, first_col_left,
195 | first_col_left, first_col_left, first_col_left, first_col_left, first_col_left,
196 | first_col_left, first_col_left, first_col_left, first_col_left, first_col_left,
197 | second_left, third_left].map(&:round))
198 | end
199 |
200 | it 'limits images to column width' do
201 | processor.parse(
202 | '
Col One
Col Two
' \
203 | "" \
204 | '
two
'
205 | )
206 | expect(text.strings).to eq(['Col One', 'Col Two', 'two'])
207 | expect(left_positions).to eq([first_col_left, first_col_left + 2.cm,
208 | first_col_left + 2.cm].map(&:round))
209 | expect(images.size).to eq(1)
210 | end
211 |
212 | it 'limits images to maximum column width if none given' do
213 | processor.parse(
214 | '
Col One
Col Two
' \
215 | "" \
216 | '
two
'
217 | )
218 |
219 | expect(text.strings).to eq(['Col One', 'Col Two', 'two'])
220 | expect(left_positions).to eq([first_col_left, 529, 529].map(&:round))
221 | expect(images.size).to eq(1)
222 | end
223 |
224 | it 'skips not existing image in table' do
225 | processor.parse(
226 | '
Col One
Col Two
' \
227 | "" \
228 | '
two
'
229 | )
230 |
231 | expect(text.strings).to eq(['Col One', 'Col Two', 'two'])
232 | expect(images.size).to eq(0)
233 | end
234 |
235 | it 'skips unsupported image in table' do
236 | processor.parse(
237 | '
Col One
Col Two
' \
238 | "" \
239 | '
two
'
240 | )
241 |
242 | expect(text.strings).to eq(['Col One', 'Col Two', 'two'])
243 | expect(images.size).to eq(0)
244 | end
245 |
246 | it 'uses equal widths for large contents if none are given' do
247 | processor.parse('
Col One
Col Two
' \
248 | '
hello world has very much text hello world has very much text' \
249 | ' hello world has very much text hello world has very much text' \
250 | ' hello world has very much text
' \
251 | '
hey ho, i use space
space i use, too
' \
252 | '
my fellow on the right is empty. at least, i am very contentfull. ' \
253 | 'so long that i am surely wrapped to multiple lines.
' \
254 | '
')
255 |
256 | expect(left_positions[0..1])
257 | .to eq([first_col_left, first_col_left + 0.5 * content_width].map(&:round))
258 | end
259 |
260 | it 'grow widths proportionally to total width' do
261 | processor.parse(
262 | '
Col One
Col Two
' \
263 | '
One
Two
'
264 | )
265 |
266 | expect(left_positions).to eq([first_col_left, 201].map(&:round) * 2)
267 | end
268 |
269 | it 'reduce widths proportionally to total_width' do
270 | processor.parse(
271 | '
Col One
Col Two
Col Three
' \
272 | '
One
Two
Three
'
273 | )
274 |
275 | expect(left_positions[0..2]).to eq([first_col_left, 329, 557].map(&:round))
276 | end
277 |
278 | it 'percentual widths in nested tables cannot be processed without parent widths' do
279 | processor.parse(
280 | '
Col One
' \
281 | '
half
half
' \
282 | '
Col Three
'
283 | )
284 |
285 | expect(left_positions).to eq([first_col_left, 220, 249, 400].map(&:round))
286 | end
287 |
288 |
289 | it 'renders placeholder if subtable cannot be fitted' do
290 | cell = "
#{'bla blablablabla bla blabla' * 10}
"
291 | html = "
#{cell * 3}
#{cell * 6}
#{cell * 3}
"
292 | processor.parse(html)
293 |
294 | expect(text.strings).to include('bla')
295 | expect(text.strings).to include('[nested')
296 | end
297 |
298 | it 'does nothing for empty table' do
299 | processor.parse('
')
300 |
301 | expect(text.strings).to be_empty
302 | expect(PDF::Inspector::Graphics::Line.analyze(pdf).points.size).to eq(0)
303 | end
304 |
305 | it 'does nothing for table with empty rows' do
306 | processor.parse('
')
307 |
308 | expect(text.strings).to be_empty
309 | expect(PDF::Inspector::Graphics::Line.analyze(pdf).points.size).to eq(0)
310 | end
311 |
312 | it 'renders empty table with empty cells' do
313 | processor.parse('
')
314 |
315 | expect(text.strings).to be_empty
316 | expect(PDF::Inspector::Graphics::Line.analyze(pdf).points.size).to eq(16)
317 | end
318 |
319 | it 'renders plain text for trs without table tag' do
320 | processor.parse('
Hello
')
321 |
322 | expect(text.strings).to eq(['Hello'])
323 | expect(PDF::Inspector::Graphics::Line.analyze(pdf).points.size).to eq(0)
324 | end
325 |
326 | it 'renders plain text for tds without table tag' do
327 | processor.parse('
Hello
')
328 |
329 | expect(text.strings).to eq(['Hello'])
330 | expect(PDF::Inspector::Graphics::Line.analyze(pdf).points.size).to eq(0)
331 | end
332 |
333 | context 'options' do
334 | context 'for text' do
335 | let(:leading) { 15 }
336 | let(:font_size) { 15 }
337 | let(:additional_cell_padding_top) do
338 | doc.font_size(font_size) { return p_gap / 2 }
339 | end
340 | let(:options) { { text: { size: font_size, style: :bold, leading: leading, margin_bottom: 0 } } }
341 |
342 | it 'are used in cells and headers' do
343 | processor.parse('
Col One
Col Two
' \
344 | '
hello
world
')
345 | expect(text.strings).to eq(['Col One', 'Col Two', 'hello', 'world'])
346 | expect(text.font_settings).to eq([{ name: :'Helvetica-Bold', size: 15 }] * 4)
347 | end
348 |
349 | it 'adds some vertical spacing after table' do
350 | processor.parse('
before table
hello
world
more here
')
351 | expect(text.strings).to eq(['before table', 'hello', 'world', 'more here'])
352 | expect(left_positions).to eq([left, first_col_left, 310, left])
353 | row_top = top - line - table_padding - additional_cell_padding_top - 1 # border width
354 | expect(top_positions).to eq([
355 | top,
356 | row_top, row_top,
357 | row_top - line - table_padding
358 | ].map(&:round))
359 | end
360 | end
361 |
362 | context 'for cells' do
363 | let(:font_size) { 40 }
364 | let(:leading) { 10 }
365 | let(:table_padding) { 2 }
366 | let(:options) do
367 | {
368 | text: { size: font_size, style: :bold, leading: leading },
369 | table: { cell: { font: 'Courier', size: 14, text_color: 'FF0000', border_width: 1, padding: table_padding } }
370 | }
371 | end
372 | let(:additional_cell_padding_top) do
373 | doc.font('Courier', size: 14) { return (doc.font.descender + doc.font.line_gap) / 2 }
374 | end
375 | let(:cell_line) do
376 | doc.font('Courier', size: 14) { return doc.font.height_at(14) }
377 | end
378 |
379 | it 'are used in cells and headers' do
380 | processor.parse('
')
435 | expect(text.strings).to eq(['[table content too large]'])
436 | end
437 | end
438 |
439 | context 'with invalid style attribute' do
440 | it 'parses the text and raises no error' do
441 | processor.parse('
bananas are great
')
442 | expect(text.strings).to eq(['bananas are great'])
443 | end
444 | end
445 |
446 | end
447 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor/text_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor::Text do
5 | include_context 'pdf_helpers'
6 |
7 | it 'handles inline formatting' do
8 | processor.parse('very importantstuff and regular one. ' \
9 | '1m3 H2O water ')
10 | expect(text.strings).to eq(['very ', 'important', ' ', 'stuff', ' and regular one. 1m',
11 | '3', ' H', '2', 'O ', 'water'])
12 | expect(text.font_settings.map { |h| h[:name] })
13 | .to eq(%i[Helvetica-Bold Helvetica-BoldOblique Helvetica Helvetica Helvetica Helvetica
14 | Helvetica Helvetica Helvetica Helvetica])
15 | end
16 |
17 | it 'creates links' do
18 | processor.parse('hello world')
19 | expect(text.strings).to eq(['hello ', 'world'])
20 | expect(top_positions).to eq([top, top].map(&:round))
21 | end
22 |
23 | it 'handles prawn color tag for rgb' do
24 | processor.parse('hello world')
25 | expect(text.strings).to eq(['hello ', 'world'])
26 | end
27 |
28 | it 'handles prawn color tag for cmyk' do
29 | processor.parse('hello world')
30 | expect(text.strings).to eq(['hello ', 'world'])
31 | end
32 |
33 | it 'handles prawn font name' do
34 | processor.parse('hello world')
35 | expect(text.strings).to eq(['hello ', 'world'])
36 | end
37 |
38 | it 'handles prawn font size' do
39 | processor.parse('hello world')
40 | expect(text.strings).to eq(['hello ', 'world'])
41 | end
42 |
43 | it 'handles prawn font character_spacing' do
44 | processor.parse('hello world')
45 | expect(text.strings).to eq(['hello ', 'world'])
46 | end
47 |
48 | it 'handles prawn font multiple attributes' do
49 | processor.parse('hello world')
50 | expect(text.strings).to eq(['hello ', 'world'])
51 | end
52 |
53 | context 'with_options' do
54 | let(:options) do
55 | {
56 | link: {
57 | color: "AAAAAA",
58 | underline: true,
59 | }
60 | }
61 | end
62 |
63 | it 'creates links with provided options' do
64 | processor.parse('hello world')
65 | expect(text.strings).to eq(['hello ', 'world'])
66 | expect(top_positions).to eq([top, top].map(&:round))
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/spec/prawn/markup/processor_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe Prawn::Markup::Processor do
5 | include_context 'pdf_helpers'
6 |
7 | it 'parses simple text' do
8 | processor.parse('hello')
9 | expect(text.strings).to eq(['hello'])
10 | end
11 |
12 | it 'parses simple html' do
13 | processor.parse('
hello
world
')
14 | expect(text.strings).to eq(%w[hello world])
15 | expect(left_positions).to eq([left, left])
16 | expect(top_positions).to eq([top, top - line - p_gap].map(&:round))
17 | end
18 |
19 | it 'renders entities correctly' do
20 | processor.parse('1 < 2 & 2 > 1 & & ä ä')
21 | expect(text.strings).to eq(['1 < 2 & 2 > 1 & & ä ä'])
22 | end
23 |
24 | it 'handles empty attributes' do
25 | processor.parse('bold content and more')
26 | expect(text.strings).to eq(['bold content', ' and more'])
27 | end
28 |
29 | it 'handles non-matching tags' do
30 | processor.parse('
is not closed')
31 | expect(text.strings).to eq(['is not closed'])
32 | end
33 |
34 | it 'handles invalid html' do
35 | processor.parse('here istext < with unescaped > & other ')
36 | expect(text.strings).to eq(['here ', 'is', ' text < with unescaped > & other'])
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/prawn/markup/showcase_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'pdf_helpers'
3 |
4 | RSpec.describe 'Showcase' do
5 | include_context 'pdf_helpers'
6 |
7 | it 'renders showcase' do
8 | html = File.read('spec/fixtures/showcase.html')
9 | doc.font_families.update('DejaVu' => {
10 | normal: 'spec/fixtures/DejaVuSans.ttf'
11 | })
12 | doc.markup_options = {
13 | heading1: { margin_top: 30, margin_bottom: 15 },
14 | heading2: { margin_top: 24, margin_bottom: 10, style: :italic },
15 | heading3: { margin_top: 20, margin_bottom: 5 },
16 | table: {
17 | header: { background_color: 'DDDDDD', style: :italic }
18 | },
19 | iframe: {
20 | placeholder: ->(src) { "Embedded content: #{src}" }
21 | },
22 | input: { symbol_font: 'DejaVu', symbol_font_size: 16 },
23 | link: { color: "AA0000", underline: true }
24 | }
25 | doc.markup(html)
26 | # lookatit
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/prawn/markup_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Prawn::Markup do
2 | it 'has a version number' do
3 | expect(Prawn::Markup::VERSION).not_to be nil
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require 'simplecov'
2 | SimpleCov.start
3 | SimpleCov.coverage_dir 'spec/coverage'
4 |
5 | require 'bundler/setup'
6 | require 'prawn/markup'
7 |
8 | require 'logger'
9 | Prawn::Markup::Processor.logger = Logger.new(STDOUT)
10 |
11 | RSpec.configure do |config|
12 | # Enable flags like --only-failures and --next-failure
13 | config.example_status_persistence_file_path = '.rspec_status'
14 |
15 | # Disable RSpec exposing methods globally on `Module` and `main`
16 | config.disable_monkey_patching!
17 |
18 | config.expect_with :rspec do |c|
19 | c.syntax = :expect
20 | end
21 | end
22 |
--------------------------------------------------------------------------------