postsvg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +19 -0
  3. data/.rubocop_todo.yml +141 -0
  4. data/Gemfile +15 -0
  5. data/LICENSE +25 -0
  6. data/README.adoc +473 -0
  7. data/Rakefile +10 -0
  8. data/docs/POSTSCRIPT.adoc +13 -0
  9. data/docs/postscript/fundamentals.adoc +356 -0
  10. data/docs/postscript/graphics-model.adoc +406 -0
  11. data/docs/postscript/implementation-notes.adoc +314 -0
  12. data/docs/postscript/index.adoc +153 -0
  13. data/docs/postscript/operators/arithmetic.adoc +461 -0
  14. data/docs/postscript/operators/control-flow.adoc +230 -0
  15. data/docs/postscript/operators/dictionary.adoc +191 -0
  16. data/docs/postscript/operators/graphics-state.adoc +528 -0
  17. data/docs/postscript/operators/index.adoc +288 -0
  18. data/docs/postscript/operators/painting.adoc +475 -0
  19. data/docs/postscript/operators/path-construction.adoc +553 -0
  20. data/docs/postscript/operators/stack-manipulation.adoc +374 -0
  21. data/docs/postscript/operators/transformations.adoc +479 -0
  22. data/docs/postscript/svg-mapping.adoc +369 -0
  23. data/exe/postsvg +6 -0
  24. data/lib/postsvg/cli.rb +103 -0
  25. data/lib/postsvg/colors.rb +33 -0
  26. data/lib/postsvg/converter.rb +214 -0
  27. data/lib/postsvg/errors.rb +11 -0
  28. data/lib/postsvg/graphics_state.rb +158 -0
  29. data/lib/postsvg/interpreter.rb +891 -0
  30. data/lib/postsvg/matrix.rb +106 -0
  31. data/lib/postsvg/parser/postscript_parser.rb +87 -0
  32. data/lib/postsvg/parser/transform.rb +21 -0
  33. data/lib/postsvg/parser.rb +18 -0
  34. data/lib/postsvg/path_builder.rb +101 -0
  35. data/lib/postsvg/svg_generator.rb +78 -0
  36. data/lib/postsvg/tokenizer.rb +161 -0
  37. data/lib/postsvg/version.rb +5 -0
  38. data/lib/postsvg.rb +78 -0
  39. data/postsvg.gemspec +38 -0
  40. data/scripts/regenerate_fixtures.rb +28 -0
  41. metadata +118 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Matrix transformation class for PostScript coordinate transformations
5
+ # Implements affine transformation matrix [a b c d e f]
6
+ class Matrix
7
+ attr_accessor :a, :b, :c, :d, :e, :f
8
+
9
+ def initialize(a: 1, b: 0, c: 0, d: 1, e: 0, f: 0)
10
+ @a = a
11
+ @b = b
12
+ @c = c
13
+ @d = d
14
+ @e = e
15
+ @f = f
16
+ end
17
+
18
+ def multiply(matrix)
19
+ result = Matrix.new
20
+ result.a = (@a * matrix.a) + (@c * matrix.b)
21
+ result.b = (@b * matrix.a) + (@d * matrix.b)
22
+ result.c = (@a * matrix.c) + (@c * matrix.d)
23
+ result.d = (@b * matrix.c) + (@d * matrix.d)
24
+ result.e = (@a * matrix.e) + (@c * matrix.f) + @e
25
+ result.f = (@b * matrix.e) + (@d * matrix.f) + @f
26
+ result
27
+ end
28
+
29
+ def translate(tx, ty)
30
+ multiply(Matrix.new(e: tx, f: ty))
31
+ end
32
+
33
+ def scale(sx, sy)
34
+ multiply(Matrix.new(a: sx, d: sy))
35
+ end
36
+
37
+ def rotate(degrees)
38
+ radians = degrees * Math::PI / 180.0
39
+ m = Matrix.new
40
+ m.a = Math.cos(radians)
41
+ m.b = Math.sin(radians)
42
+ m.c = -Math.sin(radians)
43
+ m.d = Math.cos(radians)
44
+ multiply(m)
45
+ end
46
+
47
+ def skew_x(angle)
48
+ radians = angle * Math::PI / 180.0
49
+ multiply(Matrix.new(c: Math.tan(radians)))
50
+ end
51
+
52
+ def skew_y(angle)
53
+ radians = angle * Math::PI / 180.0
54
+ multiply(Matrix.new(b: Math.tan(radians)))
55
+ end
56
+
57
+ def to_transform_string
58
+ "matrix(#{@a} #{@b} #{@c} #{@d} #{@e} #{@f})"
59
+ end
60
+
61
+ def apply_point(x, y)
62
+ {
63
+ x: (x * @a) + (y * @c) + @e,
64
+ y: (x * @b) + (y * @d) + @f,
65
+ }
66
+ end
67
+
68
+ def decompose
69
+ scale_x = Math.hypot(@a, @b)
70
+ scale_y = ((@a * @d) - (@b * @c)) / scale_x
71
+
72
+ rotation = Math.atan2(@b, @a) * (180.0 / Math::PI)
73
+
74
+ skew_x = Math.atan2((@a * @c) + (@b * @d), scale_x * scale_x)
75
+ skew_y = Math.atan2((@a * @b) + (@c * @d), scale_y * scale_y)
76
+
77
+ {
78
+ translate: { x: @e, y: @f },
79
+ scale: { x: scale_x, y: scale_y },
80
+ rotate: rotation,
81
+ skew: {
82
+ x: skew_x * (180.0 / Math::PI),
83
+ y: skew_y * (180.0 / Math::PI),
84
+ },
85
+ }
86
+ end
87
+
88
+ def invert
89
+ det = (@a * @d) - (@b * @c)
90
+ return Matrix.new if det.abs < 1e-10
91
+
92
+ inv = Matrix.new
93
+ inv.a = @d / det
94
+ inv.b = -@b / det
95
+ inv.c = -@c / det
96
+ inv.d = @a / det
97
+ inv.e = ((@c * @f) - (@d * @e)) / det
98
+ inv.f = ((@b * @e) - (@a * @f)) / det
99
+ inv
100
+ end
101
+
102
+ def identity?
103
+ @a == 1 && @b.zero? && @c.zero? && @d == 1 && @e.zero? && @f.zero?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Postsvg
6
+ class Parser
7
+ # Parslet-based PostScript parser
8
+ class PostscriptParser < Parslet::Parser
9
+ # Whitespace and comments
10
+ rule(:space) { match('\s').repeat(1) }
11
+ rule(:space?) { space.maybe }
12
+ rule(:comment) do
13
+ str("%") >> (str("\n").absent? >> any).repeat >> str("\n").maybe
14
+ end
15
+ rule(:whitespace) { (space | comment).repeat }
16
+ rule(:whitespace?) { whitespace.maybe }
17
+
18
+ # Numbers
19
+ rule(:sign) { match("[+-]") }
20
+ rule(:digit) { match("[0-9]") }
21
+ rule(:integer) do
22
+ (sign.maybe >> digit.repeat(1)).as(:integer)
23
+ end
24
+ rule(:float) do
25
+ (sign.maybe >> digit.repeat(1) >> str(".") >> digit.repeat(1)).as(:float) |
26
+ (sign.maybe >> str(".") >> digit.repeat(1)).as(:float)
27
+ end
28
+ rule(:number) { float | integer }
29
+
30
+ # Names (identifiers starting with /)
31
+ rule(:name_char) { match('[a-zA-Z0-9_\-.]') }
32
+ rule(:name) do
33
+ (str("/") >> name_char.repeat(1)).as(:name)
34
+ end
35
+
36
+ # Strings (in parentheses, handling escaped characters)
37
+ rule(:string_char) do
38
+ (str("\\") >> any) | (str(")").absent? >> any)
39
+ end
40
+ rule(:string) do
41
+ (str("(") >> string_char.repeat.as(:string) >> str(")"))
42
+ end
43
+
44
+ # Operators and keywords
45
+ rule(:operator_char) { match("[a-zA-Z0-9]") }
46
+ rule(:operator) do
47
+ operator_char.repeat(1).as(:operator)
48
+ end
49
+
50
+ # Arrays
51
+ rule(:array) do
52
+ (str("[") >> whitespace? >>
53
+ value.repeat.as(:array) >>
54
+ whitespace? >> str("]"))
55
+ end
56
+
57
+ # Procedures (code blocks in curly braces)
58
+ rule(:procedure) do
59
+ (str("{") >> whitespace? >>
60
+ statement.repeat.as(:procedure) >>
61
+ whitespace? >> str("}"))
62
+ end
63
+
64
+ # Values
65
+ rule(:value) do
66
+ whitespace? >> (
67
+ procedure |
68
+ array |
69
+ string |
70
+ name |
71
+ number |
72
+ operator
73
+ ) >> whitespace?
74
+ end
75
+
76
+ # Statements
77
+ rule(:statement) { value }
78
+
79
+ # Root rule - a PostScript program is a sequence of statements
80
+ rule(:program) do
81
+ whitespace? >> statement.repeat.as(:program) >> whitespace?
82
+ end
83
+
84
+ root(:program)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Postsvg
6
+ class Parser
7
+ # Transforms parsed PostScript AST into Ruby data structures
8
+ class Transform < Parslet::Transform
9
+ rule(integer: simple(:i)) { i.to_i }
10
+ rule(float: simple(:f)) { f.to_f }
11
+ rule(string: simple(:s)) { s.to_s }
12
+ rule(name: simple(:n)) { { type: :name, value: n.to_s.sub(%r{^/}, "") } }
13
+ rule(operator: simple(:o)) { { type: :operator, value: o.to_s } }
14
+ rule(array: sequence(:items)) { { type: :array, value: items } }
15
+ rule(procedure: subtree(:items)) { { type: :procedure, value: items } }
16
+ rule(program: subtree(:statements)) do
17
+ { type: :program, statements: statements }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+ require_relative "parser/postscript_parser"
5
+ require_relative "parser/transform"
6
+
7
+ module Postsvg
8
+ # Main parser interface for PostScript/EPS files
9
+ class Parser
10
+ def self.parse(ps_content)
11
+ parser = PostscriptParser.new
12
+ tree = parser.parse(ps_content)
13
+ Transform.new.apply(tree)
14
+ rescue Parslet::ParseFailed => e
15
+ raise ParseError, "Failed to parse PostScript: #{e.message}"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matrix"
4
+
5
+ module Postsvg
6
+ # Builds SVG path strings with optional coordinate transformations
7
+ class PathBuilder
8
+ attr_reader :parts
9
+
10
+ def initialize
11
+ @parts = []
12
+ @use_local_coords = false
13
+ @ctm = nil
14
+ end
15
+
16
+ def set_transform_mode(use_local, transform = nil)
17
+ @use_local_coords = use_local
18
+ @ctm = transform
19
+ end
20
+
21
+ def move_to(x, y)
22
+ p = transform_point(x, y)
23
+ @parts << "M #{num_fmt(p[:x])} #{num_fmt(p[:y])}"
24
+ end
25
+
26
+ def move_to_rel(dx, dy)
27
+ p = transform_point(dx, dy)
28
+ @parts << "m #{num_fmt(p[:x])} #{num_fmt(p[:y])}"
29
+ end
30
+
31
+ def line_to(x, y)
32
+ p = transform_point(x, y)
33
+ @parts << "L #{num_fmt(p[:x])} #{num_fmt(p[:y])}"
34
+ end
35
+
36
+ def line_to_rel(dx, dy)
37
+ p = transform_point(dx, dy)
38
+ @parts << "l #{num_fmt(p[:x])} #{num_fmt(p[:y])}"
39
+ end
40
+
41
+ def curve_to(x1, y1, x2, y2, x, y)
42
+ p1 = transform_point(x1, y1)
43
+ p2 = transform_point(x2, y2)
44
+ p = transform_point(x, y)
45
+ @parts << "C #{num_fmt(p1[:x])} #{num_fmt(p1[:y])} " \
46
+ "#{num_fmt(p2[:x])} #{num_fmt(p2[:y])} " \
47
+ "#{num_fmt(p[:x])} #{num_fmt(p[:y])}"
48
+ end
49
+
50
+ def curve_to_rel(dx1, dy1, dx2, dy2, dx, dy)
51
+ p1 = transform_point(dx1, dy1)
52
+ p2 = transform_point(dx2, dy2)
53
+ p = transform_point(dx, dy)
54
+ @parts << "c #{num_fmt(p1[:x])} #{num_fmt(p1[:y])} " \
55
+ "#{num_fmt(p2[:x])} #{num_fmt(p2[:y])} " \
56
+ "#{num_fmt(p[:x])} #{num_fmt(p[:y])}"
57
+ end
58
+
59
+ def ellipse_to(rx, ry, rotation, large_arc, sweep, x, y)
60
+ p = transform_point(x, y)
61
+ @parts << "A #{num_fmt(rx)} #{num_fmt(ry)} #{num_fmt(rotation)} " \
62
+ "#{large_arc} #{sweep} #{num_fmt(p[:x])} #{num_fmt(p[:y])}"
63
+ end
64
+
65
+ def close
66
+ @parts << "Z"
67
+ end
68
+
69
+ def to_path
70
+ @parts.join(" ")
71
+ end
72
+
73
+ def length
74
+ @parts.length
75
+ end
76
+
77
+ def clear
78
+ @parts = []
79
+ end
80
+
81
+ def reset
82
+ new_path = PathBuilder.new
83
+ new_path.set_transform_mode(@use_local_coords, @ctm)
84
+ new_path
85
+ end
86
+
87
+ private
88
+
89
+ def transform_point(x, y)
90
+ if !@use_local_coords && @ctm
91
+ @ctm.apply_point(x, y)
92
+ else
93
+ { x: x, y: y }
94
+ end
95
+ end
96
+
97
+ def num_fmt(n)
98
+ format("%.3f", n).sub(/\.?0+$/, "")
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Generates SVG output from PostScript graphics operations
5
+ class SvgGenerator
6
+ def initialize
7
+ @paths = []
8
+ end
9
+
10
+ def add_path(path, graphics_state, operation)
11
+ return if path.empty?
12
+
13
+ svg_path = {
14
+ d: build_path_data(path),
15
+ operation: operation,
16
+ stroke: graphics_state.stroke_color_hex,
17
+ fill: graphics_state.fill_color_hex,
18
+ stroke_width: graphics_state.line_width,
19
+ }
20
+
21
+ @paths << svg_path
22
+ end
23
+
24
+ def generate(width:, height:, viewbox:)
25
+ svg_parts = []
26
+ svg_parts << '<?xml version="1.0" encoding="UTF-8"?>'
27
+ svg_parts << %(<svg xmlns="http://www.w3.org/2000/svg" )
28
+ svg_parts << %(width="#{width}" height="#{height}" )
29
+ svg_parts << %(viewBox="#{viewbox}">)
30
+
31
+ @paths.each do |path|
32
+ svg_parts << build_path_element(path)
33
+ end
34
+
35
+ svg_parts << "</svg>"
36
+ svg_parts.join("\n")
37
+ end
38
+
39
+ private
40
+
41
+ def build_path_data(path)
42
+ path_commands = []
43
+
44
+ path.each do |segment|
45
+ case segment[:type]
46
+ when :moveto
47
+ path_commands << "M #{segment[:x]} #{segment[:y]}"
48
+ when :lineto
49
+ path_commands << "L #{segment[:x]} #{segment[:y]}"
50
+ when :curveto
51
+ path_commands << "C #{segment[:x1]} #{segment[:y1]}, " \
52
+ "#{segment[:x2]} #{segment[:y2]}, " \
53
+ "#{segment[:x3]} #{segment[:y3]}"
54
+ when :closepath
55
+ path_commands << "Z"
56
+ end
57
+ end
58
+
59
+ path_commands.join(" ")
60
+ end
61
+
62
+ def build_path_element(path)
63
+ attrs = [%(d="#{path[:d]}")]
64
+
65
+ case path[:operation]
66
+ when :stroke
67
+ attrs << %(fill="none")
68
+ attrs << %(stroke="#{path[:stroke]}")
69
+ attrs << %(stroke-width="#{path[:stroke_width]}")
70
+ when :fill
71
+ attrs << %(fill="#{path[:fill]}")
72
+ attrs << %(stroke="none")
73
+ end
74
+
75
+ %(<path #{attrs.join(' ')} />)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ # Token structure
5
+ Token = Struct.new(:type, :value, keyword_init: true)
6
+
7
+ # Tokenizes PostScript code into tokens
8
+ class Tokenizer
9
+ def self.tokenize(ps_code)
10
+ # Remove comments
11
+ ps = ps_code.gsub(/%[^\n\r]*/, " ")
12
+
13
+ tokens = []
14
+ index = 0
15
+
16
+ while index < ps.length
17
+ # Skip whitespace
18
+ index += 1 while index < ps.length && ps[index].match?(/\s/)
19
+ break if index >= ps.length
20
+
21
+ # Try to match different token types
22
+ token, new_index = match_token(ps, index)
23
+ if token
24
+ tokens << token
25
+ index = new_index
26
+ else
27
+ # Skip invalid character
28
+ index += 1
29
+ end
30
+ end
31
+
32
+ tokens
33
+ end
34
+
35
+ def self.match_token(ps, index)
36
+ # String: (foo) or (a\)b)
37
+ return match_string(ps, index) if ps[index] == "("
38
+
39
+ # Number: 12 .2 3e4
40
+ if (match = ps[index..].match(/\A-?(?:\d+\.\d+|\d+\.|\.\d+|\d+)(?:[eE][+-]?\d+)?/))
41
+ return [Token.new(type: "number", value: match[0]),
42
+ index + match[0].length]
43
+ end
44
+
45
+ # Braces: { }
46
+ return [Token.new(type: "brace", value: ps[index]), index + 1] if ["{",
47
+ "}"].include?(ps[index])
48
+
49
+ # Brackets: [ ]
50
+ return [Token.new(type: "bracket", value: ps[index]), index + 1] if ["[",
51
+ "]"].include?(ps[index])
52
+
53
+ # Dict markers: << >>
54
+ return [Token.new(type: "dict", value: ps[index, 2]), index + 2] if [
55
+ "<<", ">>"
56
+ ].include?(ps[index, 2])
57
+
58
+ # Hex strings: <...>
59
+ if ps[index] == "<" && ps[index + 1] != "<"
60
+ return match_hex_string(ps,
61
+ index)
62
+ end
63
+
64
+ # Names/Operators: /foo or foo
65
+ if (match = ps[index..].match(%r{\A/?[A-Za-z_\-.?*][A-Za-z0-9_\-.?*]*}))
66
+ value = match[0]
67
+ if value.start_with?("/")
68
+ return [Token.new(type: "name", value: value[1..]),
69
+ index + value.length]
70
+ end
71
+
72
+ return [Token.new(type: "operator", value: value), index + value.length]
73
+
74
+ end
75
+
76
+ nil
77
+ end
78
+
79
+ def self.match_string(ps, index)
80
+ result = +""
81
+ i = index + 1 # Skip opening (
82
+ depth = 1
83
+
84
+ while i < ps.length && depth.positive?
85
+ case ps[i]
86
+ when "\\"
87
+ # Escape sequence
88
+ i += 1
89
+ break if i >= ps.length
90
+
91
+ case ps[i]
92
+ when "n" then result << "\n"
93
+ when "r" then result << "\r"
94
+ when "t" then result << "\t"
95
+ when "b" then result << "\b"
96
+ when "f" then result << "\f"
97
+ when "(" then result << "("
98
+ when ")" then result << ")"
99
+ when "\\" then result << "\\"
100
+ when " " then result << " "
101
+ else
102
+ # Octal: \ddd
103
+ if ps[i].between?("0", "7")
104
+ octal = ps[i]
105
+ i += 1
106
+ if i < ps.length && ps[i] >= "0" && ps[i] <= "7"
107
+ octal += ps[i]
108
+ i += 1
109
+ if i < ps.length && ps[i] >= "0" && ps[i] <= "7"
110
+ octal += ps[i]
111
+ i += 1
112
+ end
113
+ end
114
+ code = octal.to_i(8)
115
+ code = 255 if code > 255
116
+ result << code.chr
117
+ i -= 1 # Will be incremented at end of loop
118
+ else
119
+ result << ps[i]
120
+ end
121
+ end
122
+ when "("
123
+ depth += 1
124
+ result << ps[i]
125
+ when ")"
126
+ depth -= 1
127
+ if depth.zero?
128
+ return [Token.new(type: "string", value: result),
129
+ i + 1]
130
+ end
131
+
132
+ result << ps[i]
133
+ else
134
+ result << ps[i]
135
+ end
136
+ i += 1
137
+ end
138
+
139
+ [Token.new(type: "string", value: result), i]
140
+ end
141
+
142
+ def self.match_hex_string(ps, index)
143
+ i = index + 1
144
+ hex_content = +""
145
+
146
+ while i < ps.length && ps[i] != ">"
147
+ hex_content << ps[i] unless ps[i].match?(/\s/)
148
+ i += 1
149
+ end
150
+
151
+ # Convert hex to string
152
+ str = +""
153
+ (0...hex_content.length).step(2) do |j|
154
+ byte = hex_content[j, 2]
155
+ str << byte.to_i(16).chr
156
+ end
157
+
158
+ [Token.new(type: "hexstring", value: str), i + 1]
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postsvg
4
+ VERSION = "0.1.0"
5
+ end
data/lib/postsvg.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "postsvg/version"
4
+ require_relative "postsvg/errors"
5
+ require_relative "postsvg/tokenizer"
6
+ require_relative "postsvg/interpreter"
7
+
8
+ module Postsvg
9
+ class << self
10
+ # Convert PostScript content to SVG
11
+ def convert(ps_content)
12
+ bbox = extract_bounding_box(ps_content)
13
+ tokens = Tokenizer.tokenize(ps_content)
14
+
15
+ interpreter = Interpreter.new
16
+ svg_out = interpreter.interpret(tokens, bbox)
17
+
18
+ generate_svg(svg_out, bbox)
19
+ end
20
+
21
+ # Convert PostScript file to SVG
22
+ def convert_file(input_path, output_path = nil)
23
+ ps_content = File.read(input_path)
24
+ svg_content = convert(ps_content)
25
+
26
+ File.write(output_path, svg_content) if output_path
27
+
28
+ svg_content
29
+ end
30
+
31
+ private
32
+
33
+ def extract_bounding_box(ps_content)
34
+ match = ps_content.match(/%%BoundingBox:\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/)
35
+ return nil unless match
36
+
37
+ {
38
+ llx: match[1].to_f,
39
+ lly: match[2].to_f,
40
+ urx: match[3].to_f,
41
+ ury: match[4].to_f,
42
+ }
43
+ end
44
+
45
+ def generate_svg(svg_out, bbox)
46
+ if bbox
47
+ width = bbox[:urx] - bbox[:llx]
48
+ height = bbox[:ury] - bbox[:lly]
49
+ view_box = "viewBox=\"#{num_fmt(bbox[:llx])} #{num_fmt(bbox[:lly])} #{num_fmt(width)} #{num_fmt(height)}\" " \
50
+ "width=\"#{num_fmt(width)}\" height=\"#{num_fmt(height)}\""
51
+ else
52
+ width = 1920
53
+ height = 1080
54
+ view_box = "viewBox=\"0 0 #{width} #{height}\" width=\"#{width}\" height=\"#{height}\""
55
+ end
56
+
57
+ defs = svg_out[:defs].empty? ? "" : "<defs>\n#{svg_out[:defs].join("\n")}\n</defs>"
58
+ shapes = svg_out[:element_shapes].join("\n")
59
+ texts = svg_out[:element_texts].join("\n")
60
+ body = "<g transform=\"translate(0 #{height}) scale(1 -1)\">\n#{shapes}\n#{texts}</g>"
61
+
62
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" \
63
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" #{view_box}>\n" \
64
+ "#{defs}\n" \
65
+ "#{body}\n" \
66
+ "</svg>"
67
+ end
68
+
69
+ def num_fmt(n)
70
+ # Format number, removing unnecessary decimals
71
+ if n == n.to_i
72
+ n.to_i.to_s
73
+ else
74
+ format("%.3f", n).sub(/\.?0+$/, "")
75
+ end
76
+ end
77
+ end
78
+ end
data/postsvg.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/postsvg/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "postsvg"
7
+ spec.version = Postsvg::VERSION
8
+ spec.authors = ["Ribose Inc."]
9
+ spec.email = ["open.source@ribose.com"]
10
+
11
+ spec.summary = "Pure Ruby PostScript/EPS to SVG converter"
12
+ spec.description = <<~HEREDOC
13
+ Postsvg provides a pure Ruby library for converting PostScript (PS) and
14
+ Encapsulated PostScript (EPS) files to clean,
15
+ standards-compliant Scalable Vector Graphics (SVG) format output.
16
+ HEREDOC
17
+
18
+ spec.homepage = "https://github.com/metanorma/postsvg"
19
+ spec.license = "BSD-2-Clause"
20
+ spec.required_ruby_version = ">= 3.0.0"
21
+
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+ spec.metadata["source_code_uri"] = "https://github.com/metanorma/postsvg"
24
+ spec.metadata["changelog_uri"] = "https://github.com/metanorma/postsvg"
25
+ spec.metadata["rubygems_mfa_required"] = "true"
26
+
27
+ spec.files = Dir.chdir(__dir__) do
28
+ `git ls-files -z`.split("\x0").reject do |f|
29
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
30
+ end
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_dependency "parslet"
37
+ spec.add_dependency "thor"
38
+ end