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.
- checksums.yaml +7 -0
- data/.rubocop.yml +19 -0
- data/.rubocop_todo.yml +141 -0
- data/Gemfile +15 -0
- data/LICENSE +25 -0
- data/README.adoc +473 -0
- data/Rakefile +10 -0
- data/docs/POSTSCRIPT.adoc +13 -0
- data/docs/postscript/fundamentals.adoc +356 -0
- data/docs/postscript/graphics-model.adoc +406 -0
- data/docs/postscript/implementation-notes.adoc +314 -0
- data/docs/postscript/index.adoc +153 -0
- data/docs/postscript/operators/arithmetic.adoc +461 -0
- data/docs/postscript/operators/control-flow.adoc +230 -0
- data/docs/postscript/operators/dictionary.adoc +191 -0
- data/docs/postscript/operators/graphics-state.adoc +528 -0
- data/docs/postscript/operators/index.adoc +288 -0
- data/docs/postscript/operators/painting.adoc +475 -0
- data/docs/postscript/operators/path-construction.adoc +553 -0
- data/docs/postscript/operators/stack-manipulation.adoc +374 -0
- data/docs/postscript/operators/transformations.adoc +479 -0
- data/docs/postscript/svg-mapping.adoc +369 -0
- data/exe/postsvg +6 -0
- data/lib/postsvg/cli.rb +103 -0
- data/lib/postsvg/colors.rb +33 -0
- data/lib/postsvg/converter.rb +214 -0
- data/lib/postsvg/errors.rb +11 -0
- data/lib/postsvg/graphics_state.rb +158 -0
- data/lib/postsvg/interpreter.rb +891 -0
- data/lib/postsvg/matrix.rb +106 -0
- data/lib/postsvg/parser/postscript_parser.rb +87 -0
- data/lib/postsvg/parser/transform.rb +21 -0
- data/lib/postsvg/parser.rb +18 -0
- data/lib/postsvg/path_builder.rb +101 -0
- data/lib/postsvg/svg_generator.rb +78 -0
- data/lib/postsvg/tokenizer.rb +161 -0
- data/lib/postsvg/version.rb +5 -0
- data/lib/postsvg.rb +78 -0
- data/postsvg.gemspec +38 -0
- data/scripts/regenerate_fixtures.rb +28 -0
- 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
|
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
|