diff --git a/README.md b/README.md index 19bd0a9..d902013 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,9 @@ prawn-svg supports most but not all of the full SVG 1.1 specification. It curre - <style> (see CSS section below) - - `` referencing a JPEG or PNG image, with `http:`, `https:`, `data:image/jpeg;base64`, `data:image/png;base64` and `file:` schemes - (`file:` is disabled by default for security reasons, see Options section above) + - `` referencing a JPEG, PNG, or SVG image, with `http:`, `https:`, `data:image/jpeg;base64`, + `data:image/png;base64`, `data:image/svg+xml;base64` and `file:` schemes (`file:` is disabled by default for + security reasons, see Options section above) - <clipPath> diff --git a/lib/prawn-svg.rb b/lib/prawn-svg.rb index ca977dd..69cbcbd 100644 --- a/lib/prawn-svg.rb +++ b/lib/prawn-svg.rb @@ -21,6 +21,7 @@ require 'prawn/svg/pathable' require 'prawn/svg/elements' require 'prawn/svg/extension' +require 'prawn/svg/renderer' require 'prawn/svg/interface' require 'prawn/svg/css/font_family_parser' require 'prawn/svg/css/selector_parser' diff --git a/lib/prawn/svg/document.rb b/lib/prawn/svg/document.rb index b71ef3d..41fb229 100644 --- a/lib/prawn/svg/document.rb +++ b/lib/prawn/svg/document.rb @@ -16,7 +16,7 @@ class Prawn::SVG::Document :element_styles, :color_mode - def initialize(data, bounds, options, font_registry: nil, css_parser: CssParser::Parser.new) + def initialize(data, bounds, options, font_registry: nil, css_parser: CssParser::Parser.new, attribute_overrides: {}) @root = REXML::Document.new(data).root if @root.nil? @@ -41,7 +41,10 @@ def initialize(data, bounds, options, font_registry: nil, css_parser: CssParser: enable_file_with_root: options[:enable_file_requests_with_root] ) - @sizing = Prawn::SVG::Calculators::DocumentSizing.new(bounds, @root.attributes) + attributes = @root.attributes.dup + attribute_overrides.each { |key, value| attributes.add(REXML::Attribute.new(key, value)) } + + @sizing = Prawn::SVG::Calculators::DocumentSizing.new(bounds, attributes) calculate_sizing(requested_width: options[:width], requested_height: options[:height]) @element_styles = Prawn::SVG::CSS::Stylesheets.new(css_parser, root).load diff --git a/lib/prawn/svg/elements/image.rb b/lib/prawn/svg/elements/image.rb index a4e6f92..8349d32 100644 --- a/lib/prawn/svg/elements/image.rb +++ b/lib/prawn/svg/elements/image.rb @@ -3,29 +3,32 @@ class FakeIO def initialize(data) @data = data end + def read @data end - def rewind - end + + def rewind; end end + ImageData = Struct.new(:dimensions, :document) + def parse require_attributes 'width', 'height' - raise SkipElementQuietly if state.computed_properties.display == "none" + raise SkipElementQuietly if state.computed_properties.display == 'none' @url = href_attribute - if @url.nil? - raise SkipElementError, "image tag must have an href or xlink:href" - end + raise SkipElementError, 'image tag must have an href or xlink:href' if @url.nil? x = x(attributes['x'] || 0) y = y(attributes['y'] || 0) width = x_pixels(attributes['width']) height = y_pixels(attributes['height']) + preserveAspectRatio = attributes['preserveAspectRatio'] raise SkipElementQuietly if width.zero? || height.zero? + require_positive_value width, height @image = begin @@ -34,7 +37,9 @@ def parse raise SkipElementError, "Error retrieving URL #{@url}: #{e.message}" end - @aspect = Prawn::SVG::Calculators::AspectRatio.new(attributes['preserveAspectRatio'], [width, height], image_dimensions(@image)) + @image_data = process_image(@image, width, height, preserveAspectRatio) + + @aspect = Prawn::SVG::Calculators::AspectRatio.new(preserveAspectRatio, [width, height], @image_data.dimensions) @clip_x = x @clip_y = y @@ -49,15 +54,21 @@ def parse def apply if @aspect.slice? - add_call "save" - add_call "rectangle", [@clip_x, @clip_y], @clip_width, @clip_height - add_call "clip" + add_call 'save' + add_call 'rectangle', [@clip_x, @clip_y], @clip_width, @clip_height + add_call 'clip' end - options = {:width => @width, :height => @height, :at => [@x, @y]} + if (document = @image_data.document) + add_call_and_enter 'translate', @x, @y + add_call 'svg:render_sub_document', document + else + options = { width: @width, height: @height, at: [@x, @y] } + + add_call 'image', FakeIO.new(@image), options + end - add_call "image", FakeIO.new(@image), options - add_call "restore" if @aspect.slice? + add_call 'restore' if @aspect.slice? end def bounding_box @@ -66,15 +77,38 @@ def bounding_box protected - def image_dimensions(data) - unless (handler = find_image_handler(data)) - raise SkipElementError, 'Unsupported image type supplied to image tag' + def process_image(data, width, height, preserveAspectRatio) + if (handler = find_image_handler(data)) + image = handler.new(data) + ImageData.new([image.width.to_f, image.height.to_f], nil) + + elsif potentially_svg?(data) + document = Prawn::SVG::Document.new( + data, [width, height], { width: width, height: height }, + attribute_overrides: { 'preserveAspectRatio' => preserveAspectRatio } + ) + + dimensions = [document.sizing.output_width, document.sizing.output_height] + ImageData.new(dimensions, document) + + else + raise_invalid_image_type end - image = handler.new(data) - [image.width.to_f, image.height.to_f] + rescue Prawn::SVG::Document::InvalidSVGData + raise_invalid_image_type end def find_image_handler(data) - Prawn.image_handler.find(data) rescue nil + Prawn.image_handler.find(data) + rescue StandardError + nil + end + + def potentially_svg?(data) + data.include?(' @document.sizing.output_width, :height => @document.sizing.output_height) do - prawn.save_graphics_state do - clip_rectangle 0, 0, @document.sizing.output_width, @document.sizing.output_height - - calls = [] - root_element = Prawn::SVG::Elements::Root.new(@document, @document.root, calls) - root_element.process - - proc_creator(prawn, calls).call - end - end - end + @renderer.draw end def sizing @@ -63,183 +57,12 @@ def resize(width: nil, height: nil) end def position - @options[:at] || [x_based_on_requested_alignment, y_based_on_requested_alignment] + @renderer.position end def self.font_path # backwards support for when the font_path used to be stored on this class Prawn::SVG::FontRegistry.font_path end - - private - - def x_based_on_requested_alignment - case options[:position] - when :left, nil - 0 - when :center, :centre - (@document.sizing.bounds[0] - @document.sizing.output_width) / 2.0 - when :right - @document.sizing.bounds[0] - @document.sizing.output_width - when Numeric - options[:position] - else - raise ArgumentError, "options[:position] must be one of nil, :left, :right, :center or a number" - end - end - - def y_based_on_requested_alignment - case options[:vposition] - when nil - prawn.cursor - when :top - @document.sizing.bounds[1] - when :center, :centre - @document.sizing.bounds[1] - (@document.sizing.bounds[1] - @document.sizing.output_height) / 2.0 - when :bottom - @document.sizing.output_height - when Numeric - @document.sizing.bounds[1] - options[:vposition] - else - raise ArgumentError, "options[:vposition] must be one of nil, :top, :right, :bottom or a number" - end - end - - def proc_creator(prawn, calls) - Proc.new {issue_prawn_command(prawn, calls)} - end - - def issue_prawn_command(prawn, calls) - calls.each do |call, arguments, kwarguments, children| - skip = false - - rewrite_call_arguments(prawn, call, arguments, kwarguments) do - issue_prawn_command(prawn, children) if children.any? - skip = true - end - - if skip - # the call has been overridden - elsif children.empty? && call != 'transparent' # some prawn calls complain if they aren't supplied a block - if RUBY_VERSION >= '2.7' || !kwarguments.empty? - prawn.send(call, *arguments, **kwarguments) - else - prawn.send(call, *arguments) - end - else - if RUBY_VERSION >= '2.7' || !kwarguments.empty? - prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children)) - else - prawn.send(call, *arguments, &proc_creator(prawn, children)) - end - end - end - end - - def rewrite_call_arguments(prawn, call, arguments, kwarguments) - case call - when 'text_group' - @cursor = [0, document.sizing.output_height] - yield - - when 'draw_text' - text, options = arguments.first, kwarguments - - at = options.fetch(:at) - - at[0] = @cursor[0] if at[0] == :relative - at[1] = @cursor[1] if at[1] == :relative - - case options.delete(:dominant_baseline) - when 'middle' - height = prawn.font.height - at[1] -= height / 2.0 - @cursor = [at[0], at[1]] - end - - if offset = options.delete(:offset) - at[0] += offset[0] - at[1] -= offset[1] - end - - width = prawn.width_of(text, options.merge(kerning: true)) - - if stretch_to_width = options.delete(:stretch_to_width) - factor = stretch_to_width.to_f * 100 / width.to_f - prawn.add_content "#{factor} Tz" - width = stretch_to_width.to_f - end - - if pad_to_width = options.delete(:pad_to_width) - padding_required = pad_to_width.to_f - width.to_f - padding_per_character = padding_required / text.length.to_f - prawn.add_content "#{padding_per_character} Tc" - width = pad_to_width.to_f - end - - case options.delete(:text_anchor) - when 'middle' - at[0] -= width / 2 - @cursor = [at[0] + width / 2, at[1]] - when 'end' - at[0] -= width - @cursor = at.dup - else - @cursor = [at[0] + width, at[1]] - end - - decoration = options.delete(:decoration) - if decoration == 'underline' - prawn.save_graphics_state do - prawn.line_width 1 - prawn.line [at[0], at[1] - 1.25], [at[0] + width, at[1] - 1.25] - prawn.stroke - end - end - - when 'transformation_matrix' - left = prawn.bounds.absolute_left - top = prawn.bounds.absolute_top - arguments[4] += left - (left * arguments[0] + top * arguments[2]) - arguments[5] += top - (left * arguments[1] + top * arguments[3]) - - when 'clip' - prawn.add_content "W n" # clip to path - yield - - when 'save' - prawn.save_graphics_state - yield - - when 'restore' - prawn.restore_graphics_state - yield - - when "end_path" - yield - prawn.add_content "n" # end path - - when 'fill_and_stroke' - yield - # prawn (as at 2.0.1 anyway) uses 'b' for its fill_and_stroke. 'b' is 'h' (closepath) + 'B', and we - # never want closepath to be automatically run as it stuffs up many drawing operations, such as dashes - # and line caps, and makes paths close that we didn't ask to be closed when fill is specified. - even_odd = kwarguments[:fill_rule] == :even_odd - content = even_odd ? 'B*' : 'B' - prawn.add_content content - - when 'noop' - yield - end - end - - def clip_rectangle(x, y, width, height) - prawn.move_to x, y - prawn.line_to x + width, y - prawn.line_to x + width, y + height - prawn.line_to x, y + height - prawn.close_path - prawn.add_content "W n" # clip to path - end end end end diff --git a/lib/prawn/svg/loaders/data.rb b/lib/prawn/svg/loaders/data.rb index 05b3de2..146f7e4 100644 --- a/lib/prawn/svg/loaders/data.rb +++ b/lib/prawn/svg/loaders/data.rb @@ -2,14 +2,15 @@ module Prawn::SVG::Loaders class Data - REGEXP = %r[\Adata:image/(png|jpeg);base64(;[a-z0-9]+)*,]i + REGEXP = %r{\Adata:image/(png|jpeg|svg\+xml);base64(;[a-z0-9]+)*,}i def from_url(url) - return if url[0..4].downcase != "data:" + return if url[0..4].downcase != 'data:' matches = url.match(REGEXP) if matches.nil? - raise Prawn::SVG::UrlLoader::Error, "prawn-svg only supports base64-encoded image/png and image/jpeg data URLs" + raise Prawn::SVG::UrlLoader::Error, + 'prawn-svg only supports base64-encoded image/png, image/jpeg, and image/svg+xml data URLs' end Base64.decode64(matches.post_match) diff --git a/lib/prawn/svg/renderer.rb b/lib/prawn/svg/renderer.rb new file mode 100644 index 0000000..ff91236 --- /dev/null +++ b/lib/prawn/svg/renderer.rb @@ -0,0 +1,236 @@ +module Prawn + module SVG + class Renderer + attr_reader :prawn, :document, :options + + # + # Creates a Prawn::SVG object. + # + # +data+ is the SVG data to convert. +prawn+ is your Prawn::Document object. + # + # See README.md for the options that can be passed to this method. + # + def initialize(prawn, document, options) + @prawn = prawn + @document = document + @options = options + end + + # + # Draws the SVG to the Prawn::Document object. + # + def draw + if sizing.invalid? + document.warnings << 'Zero or negative sizing data means this SVG cannot be rendered' + return + end + + document.warnings.clear + + prawn.save_font do + prawn.bounding_box(position, width: sizing.output_width, height: sizing.output_height) do + prawn.save_graphics_state do + clip_rectangle 0, 0, sizing.output_width, sizing.output_height + + calls = [] + root_element = Prawn::SVG::Elements::Root.new(document, document.root, calls) + root_element.process + + proc_creator(prawn, calls).call + end + end + end + end + + def sizing + document.sizing + end + + def position + options[:at] || [x_based_on_requested_alignment, y_based_on_requested_alignment] + end + + private + + def x_based_on_requested_alignment + case options[:position] + when :left, nil + 0 + when :center, :centre + (sizing.bounds[0] - sizing.output_width) / 2.0 + when :right + sizing.bounds[0] - sizing.output_width + when Numeric + options[:position] + else + raise ArgumentError, 'options[:position] must be one of nil, :left, :right, :center or a number' + end + end + + def y_based_on_requested_alignment + case options[:vposition] + when nil + prawn.cursor + when :top + sizing.bounds[1] + when :center, :centre + sizing.bounds[1] - (sizing.bounds[1] - sizing.output_height) / 2.0 + when :bottom + sizing.output_height + when Numeric + sizing.bounds[1] - options[:vposition] + else + raise ArgumentError, 'options[:vposition] must be one of nil, :top, :right, :bottom or a number' + end + end + + def proc_creator(prawn, calls) + proc { issue_prawn_command(prawn, calls) } + end + + def issue_prawn_command(prawn, calls) + calls.each do |call, arguments, kwarguments, children| + skip = false + + rewrite_call_arguments(prawn, call, arguments, kwarguments) do + issue_prawn_command(prawn, children) if children.any? + skip = true + end + + if skip + # the call has been overridden + elsif children.empty? && call != 'transparent' # some prawn calls complain if they aren't supplied a block + if RUBY_VERSION >= '2.7' || !kwarguments.empty? + prawn.send(call, *arguments, **kwarguments) + else + prawn.send(call, *arguments) + end + elsif RUBY_VERSION >= '2.7' || !kwarguments.empty? + prawn.send(call, *arguments, **kwarguments, &proc_creator(prawn, children)) + else + prawn.send(call, *arguments, &proc_creator(prawn, children)) + end + end + end + + def rewrite_call_arguments(prawn, call, arguments, kwarguments) + case call + when 'text_group' + @cursor = [0, sizing.output_height] + yield + + when 'draw_text' + text = arguments.first + options = kwarguments + + at = options.fetch(:at) + + at[0] = @cursor[0] if at[0] == :relative + at[1] = @cursor[1] if at[1] == :relative + + case options.delete(:dominant_baseline) + when 'middle' + height = prawn.font.height + at[1] -= height / 2.0 + @cursor = [at[0], at[1]] + end + + if offset = options.delete(:offset) + at[0] += offset[0] + at[1] -= offset[1] + end + + width = prawn.width_of(text, options.merge(kerning: true)) + + if stretch_to_width = options.delete(:stretch_to_width) + factor = stretch_to_width.to_f * 100 / width.to_f + prawn.add_content "#{factor} Tz" + width = stretch_to_width.to_f + end + + if pad_to_width = options.delete(:pad_to_width) + padding_required = pad_to_width.to_f - width.to_f + padding_per_character = padding_required / text.length.to_f + prawn.add_content "#{padding_per_character} Tc" + width = pad_to_width.to_f + end + + case options.delete(:text_anchor) + when 'middle' + at[0] -= width / 2 + @cursor = [at[0] + width / 2, at[1]] + when 'end' + at[0] -= width + @cursor = at.dup + else + @cursor = [at[0] + width, at[1]] + end + + decoration = options.delete(:decoration) + if decoration == 'underline' + prawn.save_graphics_state do + prawn.line_width 1 + prawn.line [at[0], at[1] - 1.25], [at[0] + width, at[1] - 1.25] + prawn.stroke + end + end + + when 'transformation_matrix' + left = prawn.bounds.absolute_left + top = prawn.bounds.absolute_top + arguments[4] += left - (left * arguments[0] + top * arguments[2]) + arguments[5] += top - (left * arguments[1] + top * arguments[3]) + + when 'clip' + prawn.add_content 'W n' # clip to path + yield + + when 'save' + prawn.save_graphics_state + yield + + when 'restore' + prawn.restore_graphics_state + yield + + when 'end_path' + yield + prawn.add_content 'n' # end path + + when 'fill_and_stroke' + yield + # prawn (as at 2.0.1 anyway) uses 'b' for its fill_and_stroke. 'b' is 'h' (closepath) + 'B', and we + # never want closepath to be automatically run as it stuffs up many drawing operations, such as dashes + # and line caps, and makes paths close that we didn't ask to be closed when fill is specified. + even_odd = kwarguments[:fill_rule] == :even_odd + content = even_odd ? 'B*' : 'B' + prawn.add_content content + + when 'noop' + yield + + when 'svg:render_sub_document' + sub_document = arguments.first + sub_options = inheritable_options.merge({ at: [0, 0] }) + + Renderer.new(prawn, sub_document, sub_options).draw + document.warnings.concat(sub_document.warnings) + yield + end + end + + def inheritable_options + (options || {}).slice(Prawn::SVG::Interface::INHERITABLE_OPTIONS) + end + + def clip_rectangle(x, y, width, height) + prawn.move_to x, y + prawn.line_to x + width, y + prawn.line_to x + width, y + height + prawn.line_to x, y + height + prawn.close_path + prawn.add_content 'W n' # clip to path + end + end + end +end diff --git a/spec/sample_images/image_svg_embed.svg b/spec/sample_images/image_svg_embed.svg new file mode 100644 index 0000000..e8b2635 --- /dev/null +++ b/spec/sample_images/image_svg_embed.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spec/sample_svg/image_svg.svg b/spec/sample_svg/image_svg.svg new file mode 100644 index 0000000..a7e629d --- /dev/null +++ b/spec/sample_svg/image_svg.svg @@ -0,0 +1,9 @@ + + + + + + + + +