Skip to content

Commit

Permalink
Merge pull request #175 from tonymarklove/gradient-transforms
Browse files Browse the repository at this point in the history
Improved gradient support
  • Loading branch information
mogest authored Dec 24, 2024
2 parents 822a864 + bf4fc0f commit 4da7475
Show file tree
Hide file tree
Showing 16 changed files with 731 additions and 170 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ prawn-svg supports most but not all of the full SVG 1.1 specification. It curre
- `<marker>`

- `<linearGradient>` and `<radialGradient>` are implemented on Prawn 2.2.0+ with attributes `gradientUnits` and
`gradientTransform` (`spreadMethod` and `stop-opacity` are unimplemented.)
`gradientTransform`

- `<switch>` and `<foreignObject>`, although prawn-svg cannot handle any data that is not SVG so `<foreignObject>`
tags are always ignored.
Expand Down
5 changes: 2 additions & 3 deletions lib/prawn-svg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/prawn/svg/attributes/transform.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 2 additions & 5 deletions lib/prawn/svg/elements/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 'svg:render_gradient', type.to_sym, **result.gradient_arguments(self)
true
end
end

Expand Down
163 changes: 96 additions & 67 deletions lib/prawn/svg/elements/gradient.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -92,41 +92,44 @@ 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'))
spread_method = spread_method.to_sym
@wrap = [:pad, :reflect, :repeat].include?(spread_method) ? spread_method : :pad
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'
Expand All @@ -142,14 +145,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

Expand All @@ -160,17 +163,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
23 changes: 0 additions & 23 deletions lib/prawn/svg/extensions/additional_gradient_transforms.rb

This file was deleted.

Loading

0 comments on commit 4da7475

Please sign in to comment.