diff --git a/README.md b/README.md index 96a9524..9566ef4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ prawn-svg supports most but not all of the full SVG 1.1 specification. It curre - `` - `` and `` are implemented on Prawn 2.2.0+ with attributes `gradientUnits` and - `gradientTransform` (`spreadMethod` and `stop-opacity` are unimplemented.) + `gradientTransform` - `` and ``, although prawn-svg cannot handle any data that is not SVG so `` tags are always ignored. diff --git a/lib/prawn-svg.rb b/lib/prawn-svg.rb index ed120d7..4c83018 100644 --- a/lib/prawn-svg.rb +++ b/lib/prawn-svg.rb @@ -10,6 +10,7 @@ require 'prawn/svg/calculators/aspect_ratio' require 'prawn/svg/calculators/document_sizing' require 'prawn/svg/calculators/pixels' +require 'prawn/svg/transform_utils' require 'prawn/svg/transform_parser' require 'prawn/svg/url_loader' require 'prawn/svg/loaders/data' @@ -30,12 +31,10 @@ require 'prawn/svg/ttf' require 'prawn/svg/font' require 'prawn/svg/gradients' +require 'prawn/svg/gradient_renderer' require 'prawn/svg/document' require 'prawn/svg/state' -require 'prawn/svg/extensions/additional_gradient_transforms' -Prawn::Document.prepend Prawn::SVG::Extensions::AdditionalGradientTransforms - module Prawn Svg = SVG # backwards compatibility end diff --git a/lib/prawn/svg/attributes/transform.rb b/lib/prawn/svg/attributes/transform.rb index a7b702b..ab2a903 100644 --- a/lib/prawn/svg/attributes/transform.rb +++ b/lib/prawn/svg/attributes/transform.rb @@ -2,7 +2,7 @@ module Prawn::SVG::Attributes::Transform def parse_transform_attribute_and_call return unless (transform = attributes['transform']) - matrix = parse_transform_attribute(transform) + matrix = matrix_for_pdf(parse_transform_attribute(transform)) add_call_and_enter 'transformation_matrix', *matrix unless matrix == [1, 0, 0, 1, 0, 0] end end diff --git a/lib/prawn/svg/elements/base.rb b/lib/prawn/svg/elements/base.rb index dbf0724..099ec4a 100644 --- a/lib/prawn/svg/elements/base.rb +++ b/lib/prawn/svg/elements/base.rb @@ -192,11 +192,8 @@ def apply_colors add_call "#{type}_color", result.value true when Prawn::SVG::Elements::Gradient - arguments = result.gradient_arguments(self) - if arguments - add_call "#{type}_gradient", **arguments - true - end + add_call "#{type}_svg_gradient", **result.gradient_arguments(self) + true end end diff --git a/lib/prawn/svg/elements/gradient.rb b/lib/prawn/svg/elements/gradient.rb index 89c478a..d0c39fd 100644 --- a/lib/prawn/svg/elements/gradient.rb +++ b/lib/prawn/svg/elements/gradient.rb @@ -1,6 +1,6 @@ class Prawn::SVG::Elements::Gradient < Prawn::SVG::Elements::Base attr_reader :parent_gradient - attr_reader :x1, :y1, :x2, :y2, :cx, :cy, :fx, :fy, :radius, :units, :stops, :transform_matrix + attr_reader :x1, :y1, :x2, :y2, :cx, :cy, :r, :fx, :fy, :fr, :units, :stops, :transform_matrix, :wrap TAG_NAME_TO_TYPE = { 'linearGradient' => :linear, @@ -12,6 +12,9 @@ def parse raise SkipElementQuietly if attributes['id'].nil? @parent_gradient = document.gradients[href_attribute[1..]] if href_attribute && href_attribute[0] == '#' + @transform_matrix = Matrix.identity(3) + @wrap = :pad + assert_compatible_prawn_version load_gradient_configuration load_coordinates @@ -23,14 +26,29 @@ def parse end def gradient_arguments(element) - # Passing in a transformation matrix to the apply_transformations option is supported - # by a monkey patch installed by prawn-svg. Prawn only sees this as a truthy variable. - # - # See Prawn::SVG::Extensions::AdditionalGradientTransforms for details. - base_arguments = { stops: stops, apply_transformations: transform_matrix || true } - - arguments = specific_gradient_arguments(element) - arguments&.merge(base_arguments) + bbox = element.bounding_box + + if type == :radial + { + from: [fx, fy], + r1: fr, + to: [cx, cy], + r2: r, + stops: stops, + matrix: matrix_for_bounding_box(*bbox), + wrap: wrap, + bounding_box: bbox + } + else + { + from: [x1, y1], + to: [x2, y2], + stops: stops, + matrix: matrix_for_bounding_box(*bbox), + wrap: wrap, + bounding_box: bbox + } + end end def derive_attribute(name) @@ -39,45 +57,27 @@ def derive_attribute(name) private - def specific_gradient_arguments(element) + def matrix_for_bounding_box(bounding_x1, bounding_y1, bounding_x2, bounding_y2) if units == :bounding_box - bounding_x1, bounding_y1, bounding_x2, bounding_y2 = element.bounding_box - return if bounding_y2.nil? - width = bounding_x2 - bounding_x1 height = bounding_y1 - bounding_y2 - end - - case [type, units] - when [:linear, :bounding_box] - from = [bounding_x1 + (width * x1), bounding_y1 - (height * y1)] - to = [bounding_x1 + (width * x2), bounding_y1 - (height * y2)] - { from: from, to: to } - - when [:linear, :user_space] - { from: [x1, y1], to: [x2, y2] } - - when [:radial, :bounding_box] - center = [bounding_x1 + (width * cx), bounding_y1 - (height * cy)] - focus = [bounding_x1 + (width * fx), bounding_y1 - (height * fy)] - - # NOTE: Chrome, at least, implements radial bounding box radiuses as - # having separate X and Y components, so in bounding box mode their - # gradients come out as ovals instead of circles. PDF radial shading - # doesn't have the option to do this, and it's confusing why the - # Chrome user space gradients don't apply the same logic anyway. - hypot = Math.sqrt((width * width) + (height * height)) - { from: focus, r1: 0, to: center, r2: radius * hypot } - - when [:radial, :user_space] - { from: [fx, fy], r1: 0, to: [cx, cy], r2: radius } + bounding_box_to_user_space_matrix = Matrix[ + [width, 0.0, bounding_x1], + [0.0, height, document.sizing.output_height - bounding_y1], + [0.0, 0.0, 1.0] + ] + svg_to_pdf_matrix * bounding_box_to_user_space_matrix * transform_matrix else - raise 'unexpected type/unit system' + svg_to_pdf_matrix * transform_matrix end end + def svg_to_pdf_matrix + @svg_to_pdf_matrix ||= Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, document.sizing.output_height], [0.0, 0.0, 1.0]] + end + def type TAG_NAME_TO_TYPE.fetch(name) end @@ -92,41 +92,43 @@ def load_gradient_configuration @units = derive_attribute('gradientUnits') == 'userSpaceOnUse' ? :user_space : :bounding_box if (transform = derive_attribute('gradientTransform')) - @transform_matrix = parse_transform_attribute(transform) + @transform_matrix = parse_transform_attribute(transform, space: :svg) end - if (spread_method = derive_attribute('spreadMethod')) && spread_method != 'pad' - warnings << "prawn-svg only currently supports the 'pad' spreadMethod attribute value" + if (spread_method = derive_attribute('spreadMethod')) + @wrap = spread_method.to_sym end end def load_coordinates case [type, units] when [:linear, :bounding_box] - @x1 = parse_zero_to_one(derive_attribute('x1'), 0) - @y1 = parse_zero_to_one(derive_attribute('y1'), 0) - @x2 = parse_zero_to_one(derive_attribute('x2'), 1) - @y2 = parse_zero_to_one(derive_attribute('y2'), 0) + @x1 = percentage_or_proportion(derive_attribute('x1'), 0.0) + @y1 = percentage_or_proportion(derive_attribute('y1'), 0.0) + @x2 = percentage_or_proportion(derive_attribute('x2'), 1.0) + @y2 = percentage_or_proportion(derive_attribute('y2'), 0.0) when [:linear, :user_space] @x1 = x(derive_attribute('x1')) - @y1 = y(derive_attribute('y1')) + @y1 = y_pixels(derive_attribute('y1')) @x2 = x(derive_attribute('x2')) - @y2 = y(derive_attribute('y2')) + @y2 = y_pixels(derive_attribute('y2')) when [:radial, :bounding_box] - @cx = parse_zero_to_one(derive_attribute('cx'), 0.5) - @cy = parse_zero_to_one(derive_attribute('cy'), 0.5) - @fx = parse_zero_to_one(derive_attribute('fx'), cx) - @fy = parse_zero_to_one(derive_attribute('fy'), cy) - @radius = parse_zero_to_one(derive_attribute('r'), 0.5) + @cx = percentage_or_proportion(derive_attribute('cx'), 0.5) + @cy = percentage_or_proportion(derive_attribute('cy'), 0.5) + @r = percentage_or_proportion(derive_attribute('r'), 0.5) + @fx = percentage_or_proportion(derive_attribute('fx'), cx) + @fy = percentage_or_proportion(derive_attribute('fy'), cy) + @fr = percentage_or_proportion(derive_attribute('fr'), 0.0) when [:radial, :user_space] @cx = x(derive_attribute('cx') || '50%') - @cy = y(derive_attribute('cy') || '50%') + @cy = y_pixels(derive_attribute('cy') || '50%') + @r = pixels(derive_attribute('r') || '50%') @fx = x(derive_attribute('fx') || derive_attribute('cx')) - @fy = y(derive_attribute('fy') || derive_attribute('cy')) - @radius = pixels(derive_attribute('r') || '50%') + @fy = y_pixels(derive_attribute('fy') || derive_attribute('cy')) + @fr = pixels(derive_attribute('fr') || '0%') else raise 'unexpected type/unit system' @@ -142,14 +144,14 @@ def load_stops element.name == 'stop' && element.attributes['offset'] end - @stops = stop_elements.each.with_object([]) do |child, result| - offset = parse_zero_to_one(child.attributes['offset']) + @stops = stop_elements.each_with_object([]) do |child, result| + offset = percentage_or_proportion(child.attributes['offset']).clamp(0.0, 1.0) # Offsets must be strictly increasing (SVG 13.2.4) - offset = result.last.first if result.last && result.last.first > offset + offset = result.last[:offset] if result.last && result.last[:offset] > offset if (color = Prawn::SVG::Color.css_color_to_prawn_color(child.properties.stop_color)) - result << [offset, color] + result << { offset: offset, color: color, opacity: parse_opacity(child.properties.stop_opacity) } end end @@ -160,17 +162,43 @@ def load_stops @stops = parent_gradient.stops else - stops.unshift([0, stops.first.last]) if stops.first.first.positive? - stops.push([1, stops.last.last]) if stops.last.first < 1 + if stops.first[:offset].positive? + start_stop = stops.first.dup + start_stop[:offset] = 0 + stops.unshift(start_stop) + end + + if stops.last[:offset] < 1 + end_stop = stops.last.dup + end_stop[:offset] = 1 + stops.push(end_stop) + end end end - def parse_zero_to_one(string, default = 0) + def percentage_or_proportion(string, default = 0) string = string.to_s.strip - return default if string == '' + percentage = false + + if string[-1] == '%' + percentage = true + string = string[0..-2] + end + + value = Float(string, exception: false) + return default unless value + + if percentage + value / 100.0 + else + value + end + end + + def parse_opacity(string) + value = Float(string, exception: false) + return 1.0 unless value - value = string.to_f - value /= 100.0 if string[-1..] == '%' - [0.0, value, 1.0].sort[1] + value.clamp(0.0, 1.0) end end diff --git a/lib/prawn/svg/extension.rb b/lib/prawn/svg/extension.rb index 4947b6d..7a5504c 100644 --- a/lib/prawn/svg/extension.rb +++ b/lib/prawn/svg/extension.rb @@ -20,6 +20,14 @@ def svg(data, options = {}, &block) { warnings: svg.document.warnings, width: svg.document.sizing.output_width, height: svg.document.sizing.output_height } end + + def fill_svg_gradient(**kwarguments) + Prawn::SVG::GradientRenderer.new(self, :fill, **kwarguments).draw + end + + def stroke_svg_gradient(**kwarguments) + Prawn::SVG::GradientRenderer.new(self, :stroke, **kwarguments).draw + end end end end diff --git a/lib/prawn/svg/extensions/additional_gradient_transforms.rb b/lib/prawn/svg/extensions/additional_gradient_transforms.rb deleted file mode 100644 index f7ab2c6..0000000 --- a/lib/prawn/svg/extensions/additional_gradient_transforms.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Prawn::SVG::Extensions - module AdditionalGradientTransforms - def gradient_coordinates(gradient) - # As of Prawn 2.2.0, apply_transformations is used as purely a boolean. - # - # Here we're using it to optionally pass in a 6-tuple transformation matrix that gets applied to the - # gradient. This should be added to Prawn properly, and then this monkey patch will not be necessary. - - if gradient.apply_transformations.is_a?(Array) - x1, y1, x2, y2, transformation = super - a, b, c, d, e, f = transformation - na, nb, nc, nd, ne, nf = gradient.apply_transformations - - matrix = Matrix[[a, c, e], [b, d, f], [0, 0, 1]] * Matrix[[na, nc, ne], [nb, nd, nf], [0, 0, 1]] - new_transformation = matrix.to_a[0..1].transpose.flatten - - [x1, y1, x2, y2, new_transformation] - else - super - end - end - end -end diff --git a/lib/prawn/svg/gradient_renderer.rb b/lib/prawn/svg/gradient_renderer.rb new file mode 100644 index 0000000..64fff02 --- /dev/null +++ b/lib/prawn/svg/gradient_renderer.rb @@ -0,0 +1,310 @@ +class Prawn::SVG::GradientRenderer + include Prawn::SVG::TransformUtils + + def initialize(prawn, draw_type, from:, to:, stops:, matrix: nil, r1: nil, r2: nil, wrap: :pad, bounding_box: nil) + @prawn = prawn + @draw_type = draw_type + @from = from + @to = to + @bounding_box = bounding_box + + if r1 + @shading_type = 3 + @coordinates = [*from, r1, *to, r2] + else + @shading_type = 2 + @coordinates = [*from, *to] + end + + @stop_offsets, @color_stops, @opacity_stops = process_stop_arguments(stops) + @gradient_matrix = matrix ? load_matrix(matrix) : Matrix.identity(3) + @wrap = wrap + end + + def draw + # If we need transparency, add an ExtGState to the page and enable it. + if opacity_stops + prawn.page.ext_gstates["PSVG-ExtGState-#{key}"] = create_transparency_graphics_state + prawn.renderer.add_content("/PSVG-ExtGState-#{key} gs") + end + + # Add pattern to the PDF page resources dictionary. + prawn.page.resources[:Pattern] ||= {} + prawn.page.resources[:Pattern]["PSVG-Pattern-#{key}"] = create_gradient_pattern + + # Finally set the pattern with the drawing operator for fill/stroke. + prawn.send(:set_color_space, draw_type, :Pattern) + draw_operator = draw_type == :fill ? 'scn' : 'SCN' + prawn.renderer.add_content("/PSVG-Pattern-#{key} #{draw_operator}") + end + + private + + attr_reader :prawn, :draw_type, :shading_type, :coordinates, :from, :to, + :stop_offsets, :color_stops, :opacity_stops, :gradient_matrix, :wrap, :bounding_box + + def key + @key ||= Digest::SHA1.hexdigest([ + draw_type, shading_type, coordinates, stop_offsets, color_stops, opacity_stops, gradient_matrix + ].join) + end + + def process_stop_arguments(stops) + stop_offsets = [] + color_stops = [] + opacity_stops = [] + + transparency = false + + stops.each do |stop| + opacity = stop[:opacity] || 1.0 + + transparency = true if opacity < 1 + + stop_offsets << stop[:offset] + color_stops << prawn.send(:normalize_color, stop[:color]) + opacity_stops << [opacity] + end + + opacity_stops = nil unless transparency + + [stop_offsets, color_stops, opacity_stops] + end + + def create_transparency_graphics_state + prawn.renderer.min_version(1.4) + + repeat_count, repeat_offset, transform = compute_wrapping(wrap, from, to, current_pdf_translation) + + transparency_group = prawn.ref!( + Type: :XObject, + Subtype: :Form, + BBox: prawn.state.page.dimensions, + Group: { + Type: :Group, + S: :Transparency, + I: true, + CS: :DeviceGray + }, + Resources: { + Pattern: { + 'TGP01' => { + PatternType: 2, + Matrix: matrix_for_pdf(transform), + Shading: { + ShadingType: shading_type, + ColorSpace: :DeviceGray, + Coords: coordinates, + Domain: [0, repeat_count], + Function: create_shading_function(stop_offsets, opacity_stops, wrap, repeat_count, repeat_offset), + Extend: [true, true] + } + } + } + } + ) + + transparency_group.stream << begin + box = PDF::Core.real_params(prawn.state.page.dimensions) + + <<~CMDS.strip + /Pattern cs + /TGP01 scn + #{box} re + f + CMDS + end + + prawn.ref!( + Type: :ExtGState, + SMask: { + Type: :Mask, + S: :Luminosity, + G: transparency_group + }, + AIS: false + ) + end + + def create_gradient_pattern + repeat_count, repeat_offset, transform = compute_wrapping(wrap, from, to, current_pdf_transform) + + prawn.ref!( + PatternType: 2, + Matrix: matrix_for_pdf(transform), + Shading: { + ShadingType: shading_type, + ColorSpace: prawn.send(:color_space, color_stops.first), + Coords: coordinates, + Domain: [0, repeat_count], + Function: create_shading_function(stop_offsets, color_stops, wrap, repeat_count, repeat_offset), + Extend: [true, true] + } + ) + end + + def create_shading_function(offsets, stop_values, wrap = :pad, repeat_count = 1, repeat_offset = 0) + gradient_func = create_shading_function_for_stops(offsets, stop_values) + + # Return the gradient function if there is no need to repeat. + return gradient_func if wrap == :pad + + even_odd_encode = wrap == :reflect ? [[0, 1], [1, 0]] : [[0, 1], [0, 1]] + encode = repeat_count.times.flat_map { |num| even_odd_encode[(num + repeat_offset) % 2] } + + prawn.ref!( + FunctionType: 3, # stitching function + Domain: [0, repeat_count], + Functions: Array.new(repeat_count, gradient_func), + Bounds: Range.new(1, repeat_count - 1).to_a, + Encode: encode + ) + end + + def create_shading_function_for_stops(offsets, stop_values) + linear_funcs = stop_values.each_cons(2).map do |c0, c1| + prawn.ref!(FunctionType: 2, Domain: [0.0, 1.0], C0: c0, C1: c1, N: 1.0) + end + + # If there's only two stops, we can use the single shader. + return linear_funcs.first if linear_funcs.length == 1 + + # Otherwise we stitch the multiple shaders together. + prawn.ref!( + FunctionType: 3, # stitching function + Domain: [0.0, 1.0], + Functions: linear_funcs, + Bounds: offsets[1..-2], + Encode: [0.0, 1.0] * linear_funcs.length + ) + end + + def current_pdf_transform + @current_pdf_transform ||= load_matrix( + prawn.current_transformation_matrix_with_translation(*prawn.bounds.anchor) + ) + end + + def current_pdf_translation + @current_pdf_translation ||= begin + bounds_x, bounds_y = prawn.bounds.anchor + Matrix[[1, 0, bounds_x], [0, 1, bounds_y], [0, 0, 1]] + end + end + + def bounding_box_corners(matrix) + if bounding_box + transformed_corners(gradient_matrix.inverse, *bounding_box) + else + transformed_corners(matrix.inverse, *prawn_bounding_box) + end + end + + def prawn_bounding_box + [*prawn.bounds.top_left, *prawn.bounds.bottom_right] + end + + def transformed_corners(matrix, left, top, right, bottom) + [ + matrix * Vector[left, top, 1.0], + matrix * Vector[left, bottom, 1.0], + matrix * Vector[right, top, 1.0], + matrix * Vector[right, bottom, 1.0] + ] + end + + def compute_wrapping(wrap, from, to, page_transform) + matrix = page_transform * gradient_matrix + + return [1, 0, matrix] if wrap == :pad + + from = Vector[from[0], from[1], 1.0] + to = Vector[to[0], to[1], 1.0] + + # Transform the bounding box into gradient space where lines are straight + # and circles are round. + box_corners = bounding_box_corners(matrix) + + repeat_count, repeat_offset, delta = if shading_type == 2 # Linear + project_bounding_box_for_linear(from, to, box_corners) + else # Radial + project_bounding_box_for_radial(from, to, box_corners) + end + + repeat_count = [repeat_count, 50].min + + wrap_transform = translation_matrix(delta[0], delta[1]) * + translation_matrix(from[0], from[1]) * + scale_matrix(repeat_count) * + translation_matrix(-from[0], -from[1]) + + [repeat_count, repeat_offset, matrix * wrap_transform] + end + + def project_bounding_box_for_linear(from, to, box_corners) + ab = to - from + + # Project each corner of the bounding box onto the line made by the + # gradient. The formula for projecting a point C onto a line formed from + # point A to point B is as follows: + # + # AB = B - A + # AC = C - A + # t = (AB dot AC) / (AB dot AB) + # P = A + (AB * t) + # + # We don't actually need the final point P, we only need the parameter "t", + # so that we know how many times to repeat the gradient. + t_for_corners = box_corners.map do |corner| + ac = corner - from + ab.dot(ac) / ab.dot(ab) + end + + t_min, t_max = t_for_corners.minmax + + repeat_count = (t_max - t_min).ceil + 1 + + shift_count = t_min.floor + delta = ab * shift_count + repeat_offset = shift_count % 2 + + [repeat_count, repeat_offset, delta] + end + + def project_bounding_box_for_radial(from, to, box_corners) + r1 = coordinates[2] + r2 = coordinates[5] + + # For radial gradients, the approach is similar. We need to find "t" to + # know how far along the gradient line each corner of the bounding box + # lies. Only this time we need to solve the simultaneous equation for the + # point on both inner and outer circles. + # + # You can find the derivation for this here: + # https://github.com/libpixman/pixman/blob/85467ec308f8621a5410c007491797b7b1847601/pixman/pixman-radial-gradient.c#L162-L241 + # + # Do this for all 4 corners and pick the biggest number to repeat. + t = box_corners.reduce(1) do |max, corner| + cdx, cdy = *(to - from) + pdx, pdy = *(corner - from) + dr = r2 - r1 + + a = cdx.abs2 + cdy.abs2 - dr.abs2 + b = (pdx * cdx) + (pdy * cdy) + (r1 * dr) + c = pdx.abs2 + pdy.abs2 - r1.abs2 + det_root = Math.sqrt(b.abs2 - (a * c)) + + t0 = (b + det_root) / a + t1 = (b - det_root) / a + + [t0, t1, max].max + end + + repeat_count = t.ceil + + delta = [0.0, 0.0] + repeat_offset = 0 + + [repeat_count, repeat_offset, delta] + end +end diff --git a/lib/prawn/svg/properties.rb b/lib/prawn/svg/properties.rb index 8ae481f..f83ab60 100644 --- a/lib/prawn/svg/properties.rb +++ b/lib/prawn/svg/properties.rb @@ -32,6 +32,7 @@ class Prawn::SVG::Properties 'opacity' => Config.new('1', false), 'overflow' => Config.new('visible', false, %w[inherit visible hidden scroll auto], true), 'stop-color' => Config.new('black', false, %w[inherit none currentColor]), + 'stop-opacity' => Config.new('1', false), 'stroke' => Config.new('none', true, %w[inherit none currentColor]), 'stroke-dasharray' => Config.new('none', true, %w[inherit none]), 'stroke-linecap' => Config.new('butt', true, %w[inherit butt round square], true), diff --git a/lib/prawn/svg/transform_parser.rb b/lib/prawn/svg/transform_parser.rb index 0c1321e..23332ed 100644 --- a/lib/prawn/svg/transform_parser.rb +++ b/lib/prawn/svg/transform_parser.rb @@ -63,7 +63,7 @@ def parse_transform_attribute(transform, space: :pdf) end end - matrix.to_a[0..1].transpose.flatten + matrix end private diff --git a/spec/prawn/svg/attributes/transform_spec.rb b/spec/prawn/svg/attributes/transform_spec.rb index 181660e..d491f66 100644 --- a/spec/prawn/svg/attributes/transform_spec.rb +++ b/spec/prawn/svg/attributes/transform_spec.rb @@ -20,7 +20,8 @@ def initialize let(:transform) { 'translate(-5.5)' } it 'passes the transform and executes the returned matrix' do - expect(element).to receive(:parse_transform_attribute).with(transform).and_return([1, 2, 3, 4, 5, 6]) + expect(element).to receive(:parse_transform_attribute).with(transform) + expect(element).to receive(:matrix_for_pdf).and_return([1, 2, 3, 4, 5, 6]) expect(element).to receive(:add_call_and_enter).with('transformation_matrix', 1, 2, 3, 4, 5, 6) element.attributes['transform'] = transform @@ -32,7 +33,8 @@ def initialize let(:transform) { 'translate(0)' } it 'does not execute any commands' do - expect(element).to receive(:parse_transform_attribute).with(transform).and_return([1, 0, 0, 1, 0, 0]) + expect(element).to receive(:parse_transform_attribute).with(transform) + expect(element).to receive(:matrix_for_pdf).and_return([1, 0, 0, 1, 0, 0]) expect(element).not_to receive(:add_call_and_enter) element.attributes['transform'] = transform @@ -43,6 +45,7 @@ def initialize context 'when transform is blank' do it 'does nothing' do expect(element).not_to receive(:parse_transform_attribute) + expect(element).not_to receive(:matrix_for_pdf) expect(element).not_to receive(:add_call_and_enter) subject diff --git a/spec/prawn/svg/elements/gradient_spec.rb b/spec/prawn/svg/elements/gradient_spec.rb index f3cf34d..3351c9f 100644 --- a/spec/prawn/svg/elements/gradient_spec.rb +++ b/spec/prawn/svg/elements/gradient_spec.rb @@ -27,17 +27,20 @@ it 'returns correct gradient arguments for an element' do arguments = element.gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [100.0, 100.0], - to: [120.0, 0.0], - stops: [[0, 'ff0000'], [0.25, 'ff0000'], [0.5, 'ffffff'], [0.75, '0000ff'], [1, '0000ff']], - apply_transformations: true + from: [0.0, 0.0], + to: [0.2, 1.0], + wrap: :pad, + matrix: Matrix[[100.0, 0.0, 100.0], [0.0, -100.0, 100.0], [0.0, 0.0, 1.0]], + bounding_box: [100, 100, 200, 0], + stops: [ + { offset: 0, color: 'ff0000', opacity: 1.0 }, + { offset: 0.25, color: 'ff0000', opacity: 1.0 }, + { offset: 0.5, color: 'ffffff', opacity: 1.0 }, + { offset: 0.75, color: '0000ff', opacity: 1.0 }, + { offset: 1, color: '0000ff', opacity: 1.0 } + ] ) end - - it "returns nil if the element doesn't have a bounding box" do - arguments = element.gradient_arguments(double(bounding_box: nil)) - expect(arguments).to be nil - end end describe 'object bounding box with radial gradient' do @@ -58,12 +61,20 @@ it 'returns correct gradient arguments for an element' do arguments = element.gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [150, 80], - to: [100, 80], - r1: 0, - r2: Math.sqrt(((0.8 * 100)**2) + ((0.8 * 100)**2)), - stops: [[0, 'ff0000'], [0.25, 'ff0000'], [0.5, 'ffffff'], [0.75, '0000ff'], [1, '0000ff']], - apply_transformations: true + from: [0.5, 0.2], + to: [0.0, 0.2], + r1: 0, + r2: 0.8, + wrap: :pad, + matrix: Matrix[[100.0, 0.0, 100.0], [0.0, -100.0, 100.0], [0.0, 0.0, 1.0]], + bounding_box: [100, 100, 200, 0], + stops: [ + { offset: 0, color: 'ff0000', opacity: 1.0 }, + { offset: 0.25, color: 'ff0000', opacity: 1.0 }, + { offset: 0.5, color: 'ffffff', opacity: 1.0 }, + { offset: 0.75, color: '0000ff', opacity: 1.0 }, + { offset: 1, color: '0000ff', opacity: 1.0 } + ] ) end end @@ -79,12 +90,14 @@ end it 'returns correct gradient arguments for an element' do - arguments = element.gradient_arguments(double) + arguments = element.gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [100.0, 100.0], - to: [200.0, 0.0], - stops: [[0, 'ff0000'], [1, '0000ff']], - apply_transformations: true + from: [100.0, 500.0], + to: [200.0, 600.0], + stops: [{ offset: 0, color: 'ff0000', opacity: 1.0 }, { offset: 1, color: '0000ff', opacity: 1.0 }], + matrix: Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, 600.0], [0.0, 0.0, 1.0]], + wrap: :pad, + bounding_box: [100, 100, 200, 0] ) end end @@ -100,14 +113,16 @@ end it 'returns correct gradient arguments for an element' do - arguments = element.gradient_arguments(double) + arguments = element.gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [100.0, 100.0], - to: [200.0, 0.0], - r1: 0, - r2: 150, - stops: [[0, 'ff0000'], [1, '0000ff']], - apply_transformations: true + from: [100.0, 500.0], + to: [200.0, 600.0], + r1: 0, + r2: 150.0, + stops: [{ offset: 0, color: 'ff0000', opacity: 1.0 }, { offset: 1, color: '0000ff', opacity: 1.0 }], + matrix: Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, 600.0], [0.0, 0.0, 1.0]], + wrap: :pad, + bounding_box: [100, 100, 200, 0] ) end end @@ -115,7 +130,7 @@ context 'when gradientTransform is specified' do let(:svg) do <<-SVG - + @@ -123,13 +138,15 @@ end it 'passes in the transform via the apply_transformations option' do - arguments = element.gradient_arguments(double(bounding_box: [0, 0, 10, 10])) + arguments = element.gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [0, 0], - to: [10, 10], - stops: [[0, 'ff0000'], [1, '0000ff']], - apply_transformations: [2, 0, 0, 2, 10, 0] + from: [0.0, 0.0], + to: [1.0, 1.0], + stops: [{ offset: 0, color: 'ff0000', opacity: 1.0 }, { offset: 1, color: '0000ff', opacity: 1.0 }], + matrix: Matrix[[200.0, 0.0, 150.0], [0.0, -200.0, 100.0], [0.0, 0.0, 1.0]], + wrap: :pad, + bounding_box: [100, 100, 200, 0] ) end end @@ -151,20 +168,24 @@ end it 'correctly inherits the attributes from the parent element' do - arguments = document.gradients['flag-2'].gradient_arguments(double) + arguments = document.gradients['flag-2'].gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [150.0, 100.0], - to: [220.0, 0.0], - stops: [[0, 'ff0000'], [1, '0000ff']], - apply_transformations: true + from: [150.0, 500.0], + to: [220.0, 600.0], + stops: [{ offset: 0, color: 'ff0000', opacity: 1.0 }, { offset: 1, color: '0000ff', opacity: 1.0 }], + matrix: Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, 600.0], [0.0, 0.0, 1.0]], + wrap: :pad, + bounding_box: [100, 100, 200, 0] ) - arguments = document.gradients['flag-3'].gradient_arguments(double) + arguments = document.gradients['flag-3'].gradient_arguments(double(bounding_box: [100, 100, 200, 0])) expect(arguments).to eq( - from: [170.0, 100.0], - to: [220.0, 0.0], - stops: [[0, 'ff0000'], [1, '0000ff']], - apply_transformations: true + from: [170.0, 500.0], + to: [220.0, 600.0], + stops: [{ offset: 0, color: 'ff0000', opacity: 1.0 }, { offset: 1, color: '0000ff', opacity: 1.0 }], + matrix: Matrix[[1.0, 0.0, 0.0], [0.0, -1.0, 600.0], [0.0, 0.0, 1.0]], + wrap: :pad, + bounding_box: [100, 100, 200, 0] ) end end diff --git a/spec/prawn/svg/transform_parser_spec.rb b/spec/prawn/svg/transform_parser_spec.rb index 12265ad..79a4260 100644 --- a/spec/prawn/svg/transform_parser_spec.rb +++ b/spec/prawn/svg/transform_parser_spec.rb @@ -30,27 +30,27 @@ def _sizing context 'with no transform' do let(:transform) { '' } - it { is_expected.to eq [1, 0, 0, 1, 0, 0] } + it { is_expected.to eq Matrix[[1, 0, 0], [0, 1, 0], [0, 0, 1]] } end context 'with translate' do let(:transform) { 'translate(10 20)' } - it { is_expected.to eq [1, 0, 0, 1, 10, -20] } + it { is_expected.to eq Matrix[[1, 0, 10.0], [0, 1, -20.0], [0, 0, 1.0]] } end context 'with single argument translate' do let(:transform) { 'translate(10)' } - it { is_expected.to eq [1, 0, 0, 1, 10, 0] } + it { is_expected.to eq Matrix[[1, 0, 10.0], [0, 1, 0.0], [0, 0, 1.0]] } end context 'with translateX' do let(:transform) { 'translateX(10)' } - it { is_expected.to eq [1, 0, 0, 1, 10, 0] } + it { is_expected.to eq Matrix[[1, 0, 10.0], [0, 1, 0.0], [0, 0, 1.0]] } end context 'with translateY' do let(:transform) { 'translateY(10)' } - it { is_expected.to eq [1, 0, 0, 1, 0, -10] } + it { is_expected.to eq Matrix[[1, 0, 0.0], [0, 1, -10.0], [0, 0, 1.0]] } end let(:sin30) { Math.sin(30 * Math::PI / 180.0) } @@ -59,36 +59,36 @@ def _sizing context 'with single argument rotate' do let(:transform) { 'rotate(30)' } - it { is_expected.to eq [cos30, -sin30, sin30, cos30, 0, 0] } + it { is_expected.to eq Matrix[[cos30, sin30, 0], [-sin30, cos30, 0], [0.0, 0.0, 1]] } end context 'with triple argument rotate' do let(:transform) { 'rotate(30 100 200)' } - it { is_expected.to eq [cos30, -sin30, sin30, cos30, 113.39745962155611, 23.205080756887753] } + it { is_expected.to eq Matrix[[cos30, sin30, 113.39745962155611], [-sin30, cos30, 23.205080756887753], [0.0, 0.0, 1.0]] } end context 'with scale' do let(:transform) { 'scale(1.5)' } - it { is_expected.to eq [1.5, 0, 0, 1.5, 0, 0] } + it { is_expected.to eq Matrix[[1.5, 0.0, 0], [0.0, 1.5, 0], [0.0, 0.0, 1]] } end context 'with skewX' do let(:transform) { 'skewX(30)' } - it { is_expected.to eq [1, 0, -tan30, 1, 0, 0] } + it { is_expected.to eq Matrix[[1, -tan30, 0], [0, 1.0, 0], [0, 0.0, 1]] } end context 'with skewY' do let(:transform) { 'skewY(30)' } - it { is_expected.to eq [1, -tan30, 0, 1, 0, 0] } + it { is_expected.to eq Matrix[[1.0, 0, 0], [-tan30, 1, 0], [0.0, 0, 1]] } end context 'with matrix' do let(:transform) { 'matrix(1 2 3 4 5 6)' } - it { is_expected.to eq [1, -2, -3, 4, 5, -6] } + it { is_expected.to eq Matrix[[1.0, -3.0, 5.0], [-2.0, 4.0, -6.0], [0.0, 0.0, 1.0]] } end context 'with multiple' do let(:transform) { 'scale(2) translate(7) scale(3)' } - it { is_expected.to eq [6, 0, 0, 6, 14, 0] } + it { is_expected.to eq Matrix[[6.0, 0.0, 14.0], [0.0, 6.0, 0.0], [0.0, 0.0, 1.0]] } end end diff --git a/spec/prawn/svg/transform_utils_spec.rb b/spec/prawn/svg/transform_utils_spec.rb index 62b0deb..db26657 100644 --- a/spec/prawn/svg/transform_utils_spec.rb +++ b/spec/prawn/svg/transform_utils_spec.rb @@ -40,10 +40,21 @@ end describe '#rotation_matrix' do - it '' do - p = Vector[1, 0, 1] - mat = subject.rotation_matrix(45 * Math::PI / 180.0) - puts mat * p + let(:angle) { 45 * Math::PI / 180.0 } + let(:inv_root_2) { 0.707 } + + context 'in PDF space' do + it 'returns the expected matrix' do + matrix = Matrix[[inv_root_2, inv_root_2, 0], [-inv_root_2, inv_root_2, 0], [0, 0, 1]] + expect(subject.rotation_matrix(angle).round(3)).to eq(matrix) + end + end + + context 'in SVG space' do + it 'returns the expected matrix' do + matrix = Matrix[[inv_root_2, -inv_root_2, 0], [inv_root_2, inv_root_2, 0], [0, 0, 1]] + expect(subject.rotation_matrix(angle, space: :svg).round(3)).to eq(matrix) + end end end end