postsvg 0.1.0 → 0.2.1
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 +4 -4
- data/.rubocop_todo.yml +533 -49
- data/CHANGELOG.md +105 -0
- data/CLAUDE.md +173 -0
- data/Gemfile +2 -3
- data/README.adoc +456 -179
- data/Rakefile +100 -0
- data/TODO.roadmap/00-architecture.md +139 -0
- data/TODO.roadmap/01-autoload-migration.md +39 -0
- data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
- data/TODO.roadmap/03-domain-model.md +66 -0
- data/TODO.roadmap/04-lexer.md +40 -0
- data/TODO.roadmap/05-parser.md +43 -0
- data/TODO.roadmap/06-graphics-state.md +45 -0
- data/TODO.roadmap/07-matrix-and-color.md +37 -0
- data/TODO.roadmap/08-svg-builder.md +51 -0
- data/TODO.roadmap/09-renderer.md +52 -0
- data/TODO.roadmap/10-visitor.md +58 -0
- data/TODO.roadmap/11-operator-coverage.md +103 -0
- data/TODO.roadmap/12-svg-domain.md +62 -0
- data/TODO.roadmap/13-translation-handlers.md +69 -0
- data/TODO.roadmap/14-ps-serializer.md +49 -0
- data/TODO.roadmap/15-cli-and-public-api.md +47 -0
- data/TODO.roadmap/16-specs.md +84 -0
- data/TODO.roadmap/17-docs-sync.md +47 -0
- data/TODO.roadmap/18-performance-and-determinism.md +47 -0
- data/TODO.roadmap/19-error-model.md +45 -0
- data/TODO.roadmap/20-font-and-text.md +46 -0
- data/TODO.roadmap/21-images.md +45 -0
- data/TODO.roadmap/22-forms-and-resources.md +25 -0
- data/TODO.roadmap/23-level2-level3.md +52 -0
- data/TODO.roadmap/24-ci-and-release.md +42 -0
- data/TODO.roadmap/README.md +77 -0
- data/docs/.gitignore +29 -0
- data/docs/CHANGELOG.md +114 -0
- data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
- data/docs/DEPLOYMENT.md +456 -0
- data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
- data/docs/DOCUMENTATION_PLAN.md +425 -0
- data/docs/FINAL_SUMMARY.md +657 -0
- data/docs/Gemfile +15 -0
- data/docs/README.md +327 -0
- data/docs/_config.yml +99 -0
- data/docs/advanced-topics.adoc +370 -0
- data/docs/api-reference/colors.adoc +705 -0
- data/docs/api-reference/converter.adoc +699 -0
- data/docs/api-reference/execution-context.adoc +1210 -0
- data/docs/api-reference/graphics-state.adoc +1070 -0
- data/docs/api-reference/interpreter.adoc +810 -0
- data/docs/api-reference/matrix.adoc +1179 -0
- data/docs/api-reference/path-builder.adoc +1284 -0
- data/docs/api-reference/postsvg-module.adoc +388 -0
- data/docs/api-reference/svg-generator.adoc +891 -0
- data/docs/api-reference/tokenizer.adoc +925 -0
- data/docs/api-reference.adoc +221 -0
- data/docs/architecture/command-registry.adoc +1191 -0
- data/docs/architecture/conversion-pipeline.adoc +746 -0
- data/docs/architecture/design-decisions.adoc +999 -0
- data/docs/architecture/generator-stage.adoc +1115 -0
- data/docs/architecture/graphics-state-model.adoc +1089 -0
- data/docs/architecture/interpreter-stage.adoc +1125 -0
- data/docs/architecture/parser-stage.adoc +1051 -0
- data/docs/architecture.adoc +354 -0
- data/docs/cli-reference/batch-command.adoc +616 -0
- data/docs/cli-reference/check-command.adoc +677 -0
- data/docs/cli-reference/cli-options.adoc +802 -0
- data/docs/cli-reference/convert-command.adoc +462 -0
- data/docs/cli-reference/version-command.adoc +296 -0
- data/docs/cli-reference.adoc +317 -0
- data/docs/concepts/conversion-pipeline.adoc +903 -0
- data/docs/concepts/coordinate-systems.adoc +836 -0
- data/docs/concepts/graphics-state.adoc +861 -0
- data/docs/concepts/path-operations.adoc +1076 -0
- data/docs/concepts/postscript-language.adoc +859 -0
- data/docs/concepts/svg-generation.adoc +937 -0
- data/docs/concepts.adoc +198 -0
- data/docs/contributing.adoc +443 -0
- data/docs/development.adoc +420 -0
- data/docs/faq.adoc +493 -0
- data/docs/getting-started/basic-usage.adoc +538 -0
- data/docs/getting-started/common-workflows.adoc +577 -0
- data/docs/getting-started/first-conversion.adoc +492 -0
- data/docs/getting-started/installation.adoc +534 -0
- data/docs/getting-started.adoc +94 -0
- data/docs/index.adoc +248 -0
- data/docs/optimization.adoc +196 -0
- data/docs/ps2svg_compatibility.adoc +149 -0
- data/docs/quick-reference.adoc +453 -0
- data/docs/sitemap.adoc +337 -0
- data/docs/troubleshooting.adoc +486 -0
- data/docs/validation.adoc +772 -0
- data/exe/postsvg +1 -0
- data/lib/postsvg/cli.rb +109 -57
- data/lib/postsvg/errors.rb +68 -3
- data/lib/postsvg/graphics_context.rb +81 -0
- data/lib/postsvg/graphics_stack.rb +43 -0
- data/lib/postsvg/options.rb +29 -0
- data/lib/postsvg/parser/postscript_parser.rb +7 -7
- data/lib/postsvg/renderer.rb +89 -0
- data/lib/postsvg/svg/attribute_parser.rb +45 -0
- data/lib/postsvg/svg/clip_path_registry.rb +46 -0
- data/lib/postsvg/svg/document.rb +22 -0
- data/lib/postsvg/svg/element.rb +86 -0
- data/lib/postsvg/svg/elements/circle.rb +36 -0
- data/lib/postsvg/svg/elements/clip_path.rb +26 -0
- data/lib/postsvg/svg/elements/defs.rb +24 -0
- data/lib/postsvg/svg/elements/ellipse.rb +39 -0
- data/lib/postsvg/svg/elements/group.rb +37 -0
- data/lib/postsvg/svg/elements/image.rb +35 -0
- data/lib/postsvg/svg/elements/line.rb +36 -0
- data/lib/postsvg/svg/elements/path.rb +32 -0
- data/lib/postsvg/svg/elements/polygon.rb +12 -0
- data/lib/postsvg/svg/elements/polyline.rb +32 -0
- data/lib/postsvg/svg/elements/rect.rb +44 -0
- data/lib/postsvg/svg/elements/svg.rb +40 -0
- data/lib/postsvg/svg/elements/text.rb +42 -0
- data/lib/postsvg/svg/elements.rb +31 -0
- data/lib/postsvg/svg/paint.rb +34 -0
- data/lib/postsvg/svg/parser.rb +39 -0
- data/lib/postsvg/svg/path_data/command.rb +27 -0
- data/lib/postsvg/svg/path_data/parser.rb +86 -0
- data/lib/postsvg/svg/path_data.rb +17 -0
- data/lib/postsvg/svg/stroke.rb +33 -0
- data/lib/postsvg/svg/transform_list.rb +59 -0
- data/lib/postsvg/svg.rb +22 -0
- data/lib/postsvg/svg_builder.rb +253 -0
- data/lib/postsvg/translation/arc_converter.rb +96 -0
- data/lib/postsvg/translation/bounding_box.rb +62 -0
- data/lib/postsvg/translation/context.rb +34 -0
- data/lib/postsvg/translation/handler_registry.rb +38 -0
- data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
- data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
- data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
- data/lib/postsvg/translation/handlers/ellipse_handler.rb +36 -0
- data/lib/postsvg/translation/handlers/group_handler.rb +22 -0
- data/lib/postsvg/translation/handlers/image_handler.rb +21 -0
- data/lib/postsvg/translation/handlers/line_handler.rb +27 -0
- data/lib/postsvg/translation/handlers/open_handler.rb +19 -0
- data/lib/postsvg/translation/handlers/path_handler.rb +372 -0
- data/lib/postsvg/translation/handlers/polygon_handler.rb +35 -0
- data/lib/postsvg/translation/handlers/polyline_handler.rb +33 -0
- data/lib/postsvg/translation/handlers/rect_handler.rb +32 -0
- data/lib/postsvg/translation/handlers/shared.rb +110 -0
- data/lib/postsvg/translation/handlers/svg_handler.rb +26 -0
- data/lib/postsvg/translation/handlers/text_handler.rb +57 -0
- data/lib/postsvg/translation/handlers.rb +26 -0
- data/lib/postsvg/translation/ps_renderer.rb +106 -0
- data/lib/postsvg/translation/record_emitter.rb +35 -0
- data/lib/postsvg/translation.rb +17 -0
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
- data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
- data/lib/postsvg/visitors/ps_visitor/color.rb +56 -0
- data/lib/postsvg/visitors/ps_visitor/common.rb +65 -0
- data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
- data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
- data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
- data/lib/postsvg/visitors/ps_visitor/dictionary.rb +93 -0
- data/lib/postsvg/visitors/ps_visitor/font.rb +103 -0
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
- data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
- data/lib/postsvg/visitors/ps_visitor/path.rb +117 -0
- data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
- data/lib/postsvg/visitors/ps_visitor.rb +208 -0
- data/lib/postsvg/visitors.rb +9 -0
- data/lib/postsvg.rb +96 -57
- data/lychee.toml +86 -0
- metadata +197 -12
- data/lib/postsvg/matrix.rb +0 -106
- data/postsvg.gemspec +0 -38
data/exe/postsvg
CHANGED
data/lib/postsvg/cli.rb
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
-
require_relative "../postsvg"
|
|
5
4
|
|
|
6
5
|
module Postsvg
|
|
7
|
-
# Command-line interface
|
|
6
|
+
# Command-line interface. Thor-based. Auto-detects conversion
|
|
7
|
+
# direction by file extension in +batch+.
|
|
8
8
|
class CLI < Thor
|
|
9
|
-
|
|
9
|
+
package_name "postsvg"
|
|
10
|
+
|
|
11
|
+
desc "convert INPUT [OUTPUT]",
|
|
12
|
+
"Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
|
|
10
13
|
long_desc <<~DESC
|
|
11
|
-
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG
|
|
14
|
+
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
|
|
12
15
|
|
|
13
|
-
If OUTPUT is not specified,
|
|
16
|
+
If OUTPUT is not specified, SVG is written to stdout.
|
|
14
17
|
|
|
15
18
|
Examples:
|
|
16
19
|
|
|
@@ -18,43 +21,52 @@ module Postsvg
|
|
|
18
21
|
$ postsvg convert input.eps > output.svg
|
|
19
22
|
DESC
|
|
20
23
|
def convert(input_path, output_path = nil)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
24
|
+
handle_input(input_path, output_path, method(:read_ps_to_svg),
|
|
25
|
+
direction: "PS/EPS -> SVG")
|
|
26
|
+
end
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
|
|
29
|
+
long_desc <<~DESC
|
|
30
|
+
Alias for `convert`. Reads PS or EPS, writes SVG.
|
|
31
|
+
DESC
|
|
32
|
+
def to_svg(input_path, output_path = nil)
|
|
33
|
+
convert(input_path, output_path)
|
|
34
|
+
end
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
36
|
+
desc "to-ps INPUT [OUTPUT]", "Convert SVG to PostScript"
|
|
37
|
+
long_desc <<~DESC
|
|
38
|
+
Convert an SVG file to PostScript source. If OUTPUT is not specified,
|
|
39
|
+
PS source is written to stdout.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
|
|
43
|
+
$ postsvg to-ps input.svg output.ps
|
|
44
|
+
$ postsvg to-ps input.svg > output.ps
|
|
45
|
+
DESC
|
|
46
|
+
def to_ps(input_path, output_path = nil)
|
|
47
|
+
handle_input(input_path, output_path, method(:read_svg_to_ps),
|
|
48
|
+
direction: "SVG -> PS")
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
desc "
|
|
47
|
-
"Convert all PS/EPS files in a directory"
|
|
51
|
+
desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
|
|
48
52
|
long_desc <<~DESC
|
|
49
|
-
|
|
53
|
+
Like to-ps but emits an EPSF-3.0 header and a single-page program
|
|
54
|
+
suitable for embedding.
|
|
55
|
+
DESC
|
|
56
|
+
def to_eps(input_path, output_path = nil)
|
|
57
|
+
handle_input(input_path, output_path, method(:read_svg_to_eps),
|
|
58
|
+
direction: "SVG -> EPS")
|
|
59
|
+
end
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
desc "batch INPUT_DIR [OUTPUT_DIR]",
|
|
62
|
+
"Convert all PS/EPS/SVG files in a directory"
|
|
63
|
+
long_desc <<~DESC
|
|
64
|
+
Auto-detects direction by file extension:
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
.ps, .eps -> .svg
|
|
67
|
+
.svg -> .ps
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
$ postsvg batch ps_files/ svg_files/
|
|
69
|
+
Output goes to OUTPUT_DIR if given, otherwise to INPUT_DIR.
|
|
58
70
|
DESC
|
|
59
71
|
def batch(input_dir, output_dir = nil)
|
|
60
72
|
unless Dir.exist?(input_dir)
|
|
@@ -64,34 +76,15 @@ module Postsvg
|
|
|
64
76
|
|
|
65
77
|
Dir.mkdir(output_dir) if output_dir && !Dir.exist?(output_dir)
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
files = Dir.glob(pattern, File::FNM_CASEFOLD)
|
|
69
|
-
|
|
79
|
+
files = Dir.glob(File.join(input_dir, "*.{ps,eps,svg}"), File::FNM_CASEFOLD)
|
|
70
80
|
if files.empty?
|
|
71
|
-
say "No PS or
|
|
81
|
+
say "No PS, EPS, or SVG files found in #{input_dir}", :yellow
|
|
72
82
|
return
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
say "Found #{files.size} file(s) to convert", :cyan
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
basename = File.basename(input_path, ".*")
|
|
79
|
-
output_path = if output_dir
|
|
80
|
-
File.join(output_dir, "#{basename}.svg")
|
|
81
|
-
else
|
|
82
|
-
File.join(input_dir, "#{basename}.svg")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
begin
|
|
86
|
-
ps_content = File.read(input_path)
|
|
87
|
-
svg_output = Postsvg.convert(ps_content)
|
|
88
|
-
File.write(output_path, svg_output)
|
|
89
|
-
say " ✓ #{input_path} → #{output_path}", :green
|
|
90
|
-
rescue Postsvg::Error => e
|
|
91
|
-
say " ✗ #{input_path}: #{e.message}", :red
|
|
92
|
-
rescue StandardError => e
|
|
93
|
-
say " ✗ #{input_path}: Unexpected error: #{e.message}", :red
|
|
94
|
-
end
|
|
86
|
+
files.each do |path|
|
|
87
|
+
convert_in_batch(path, input_dir, output_dir)
|
|
95
88
|
end
|
|
96
89
|
end
|
|
97
90
|
|
|
@@ -99,5 +92,64 @@ module Postsvg
|
|
|
99
92
|
def version
|
|
100
93
|
say "postsvg version #{Postsvg::VERSION}"
|
|
101
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def handle_input(input_path, output_path, reader, direction:)
|
|
99
|
+
unless File.exist?(input_path)
|
|
100
|
+
say "Error: Input file '#{input_path}' not found", :red
|
|
101
|
+
exit 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
output = reader.call(File.read(input_path))
|
|
105
|
+
if output_path
|
|
106
|
+
File.write(output_path, output)
|
|
107
|
+
say "Converted #{input_path} -> #{output_path} (#{direction})", :green
|
|
108
|
+
else
|
|
109
|
+
puts output
|
|
110
|
+
end
|
|
111
|
+
rescue Postsvg::Error => e
|
|
112
|
+
say "Conversion error: #{e.message}", :red
|
|
113
|
+
exit 2
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
say "Unexpected error: #{e.message}", :red
|
|
116
|
+
say e.backtrace.join("\n"), :red if options[:verbose]
|
|
117
|
+
exit 2
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read_ps_to_svg(content)
|
|
121
|
+
Postsvg.to_svg(content)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read_svg_to_ps(content)
|
|
125
|
+
Postsvg.to_ps(content)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def read_svg_to_eps(content)
|
|
129
|
+
Postsvg.to_eps(content)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def convert_in_batch(path, input_dir, output_dir)
|
|
133
|
+
ext = File.extname(path).downcase
|
|
134
|
+
basename = File.basename(path, ".*")
|
|
135
|
+
out_ext =
|
|
136
|
+
case ext
|
|
137
|
+
when ".svg" then ".ps"
|
|
138
|
+
else ".svg"
|
|
139
|
+
end
|
|
140
|
+
out_dir = output_dir || input_dir
|
|
141
|
+
out_path = File.join(out_dir, "#{basename}#{out_ext}")
|
|
142
|
+
|
|
143
|
+
content = File.read(path)
|
|
144
|
+
output =
|
|
145
|
+
case ext
|
|
146
|
+
when ".svg" then Postsvg.to_ps(content)
|
|
147
|
+
else Postsvg.to_svg(content)
|
|
148
|
+
end
|
|
149
|
+
File.write(out_path, output)
|
|
150
|
+
say " #{path} -> #{out_path}", :green
|
|
151
|
+
rescue Postsvg::Error => e
|
|
152
|
+
say " #{path}: #{e.message}", :red
|
|
153
|
+
end
|
|
102
154
|
end
|
|
103
155
|
end
|
data/lib/postsvg/errors.rb
CHANGED
|
@@ -1,11 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Postsvg
|
|
4
|
+
# Base class for every Postsvg error. rescue this to catch anything
|
|
5
|
+
# Postsvg raises.
|
|
4
6
|
class Error < StandardError; end
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
# Lexing or parsing failed. Carries +source_position+ when known.
|
|
9
|
+
class ParseError < Error
|
|
10
|
+
attr_reader :source_position
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
def initialize(message, source_position: nil)
|
|
13
|
+
@source_position = source_position
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
# Lexer hit a character or construct it could not consume.
|
|
19
|
+
class LexError < ParseError; end
|
|
20
|
+
|
|
21
|
+
# Tokens did not form a valid PostScript program (unbalanced braces,
|
|
22
|
+
# missing operands, malformed literal).
|
|
23
|
+
class SyntaxError < ParseError; end
|
|
24
|
+
|
|
25
|
+
# PS -> SVG execution failed.
|
|
26
|
+
class RenderError < Error
|
|
27
|
+
attr_reader :operator_name
|
|
28
|
+
|
|
29
|
+
def initialize(message, operator_name: nil)
|
|
30
|
+
@operator_name = operator_name
|
|
31
|
+
super(message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# An operator tried to pop more values than the stack held.
|
|
36
|
+
class StackUnderflowError < RenderError; end
|
|
37
|
+
|
|
38
|
+
# An operator referenced a name that has no definition in any
|
|
39
|
+
# active dictionary.
|
|
40
|
+
class UndefinedOperatorError < RenderError; end
|
|
41
|
+
|
|
42
|
+
# A procedure invocation chain exceeded the depth limit.
|
|
43
|
+
class RecursionLimitError < RenderError; end
|
|
44
|
+
|
|
45
|
+
# Output exceeded the configured byte limit.
|
|
46
|
+
class SizeLimitError < RenderError; end
|
|
47
|
+
|
|
48
|
+
# SVG -> PS translation failed.
|
|
49
|
+
class TranslationError < Error
|
|
50
|
+
attr_reader :element_name
|
|
51
|
+
|
|
52
|
+
def initialize(message, element_name: nil)
|
|
53
|
+
@element_name = element_name
|
|
54
|
+
super(message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Encountered an SVG element type with no registered handler.
|
|
59
|
+
class UnsupportedElementError < TranslationError; end
|
|
60
|
+
|
|
61
|
+
# A reference (clip-path, gradient, pattern) could not be resolved.
|
|
62
|
+
class UnresolvedReferenceError < TranslationError; end
|
|
63
|
+
|
|
64
|
+
# Model -> PS source serialization failed.
|
|
65
|
+
class SerializeError < Error; end
|
|
66
|
+
|
|
67
|
+
# Legacy aliases kept so older rescue clauses still match.
|
|
68
|
+
ConversionError = RenderError
|
|
69
|
+
UnsupportedOperatorError = UndefinedOperatorError
|
|
70
|
+
|
|
71
|
+
# Internal control-flow signals used by the visitor to unwind
|
|
72
|
+
# loops and stop program execution. These are NOT user-facing
|
|
73
|
+
# errors — they are caught by the Renderer.
|
|
74
|
+
class ExitSignal < StandardError; end
|
|
75
|
+
class QuitSignal < StandardError; end
|
|
11
76
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Immutable graphics state snapshot. Mutations return new instances
|
|
5
|
+
# via +with(...)+. The GraphicsStack pushes/pops these.
|
|
6
|
+
#
|
|
7
|
+
# Fields mirror PLRM §7.2 graphics state operators. Optional fields
|
|
8
|
+
# are +nil+ meaning "not set in this state"; the renderer falls back
|
|
9
|
+
# to defaults at emit time.
|
|
10
|
+
class GraphicsContext
|
|
11
|
+
attr_reader :ctm, :fill_color, :stroke_color, :stroke_width,
|
|
12
|
+
:line_cap, :line_join, :miter_limit, :dash,
|
|
13
|
+
:font_name, :font_size, :clip_paths, :last_text_position,
|
|
14
|
+
:fill_rule
|
|
15
|
+
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
ctm: Matrix.new,
|
|
18
|
+
fill_color: Color::BLACK,
|
|
19
|
+
stroke_color: Color::BLACK,
|
|
20
|
+
stroke_width: 1.0,
|
|
21
|
+
line_cap: :butt,
|
|
22
|
+
line_join: :miter,
|
|
23
|
+
miter_limit: 10.0,
|
|
24
|
+
dash: nil,
|
|
25
|
+
font_name: "Helvetica",
|
|
26
|
+
font_size: 12.0,
|
|
27
|
+
clip_paths: [],
|
|
28
|
+
last_text_position: nil,
|
|
29
|
+
fill_rule: :nonzero,
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(**fields)
|
|
33
|
+
merged = DEFAULTS.merge(fields)
|
|
34
|
+
@ctm = merged[:ctm]
|
|
35
|
+
@fill_color = merged[:fill_color]
|
|
36
|
+
@stroke_color = merged[:stroke_color]
|
|
37
|
+
@stroke_width = merged[:stroke_width]
|
|
38
|
+
@line_cap = merged[:line_cap]
|
|
39
|
+
@line_join = merged[:line_join]
|
|
40
|
+
@miter_limit = merged[:miter_limit]
|
|
41
|
+
@dash = merged[:dash]
|
|
42
|
+
@font_name = merged[:font_name]
|
|
43
|
+
@font_size = merged[:font_size]
|
|
44
|
+
@clip_paths = merged[:clip_paths].freeze
|
|
45
|
+
@last_text_position = merged[:last_text_position]
|
|
46
|
+
@fill_rule = merged[:fill_rule]
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with(**overrides)
|
|
51
|
+
GraphicsContext.new(
|
|
52
|
+
ctm: overrides.fetch(:ctm, @ctm),
|
|
53
|
+
fill_color: overrides.fetch(:fill_color, @fill_color),
|
|
54
|
+
stroke_color: overrides.fetch(:stroke_color, @stroke_color),
|
|
55
|
+
stroke_width: overrides.fetch(:stroke_width, @stroke_width),
|
|
56
|
+
line_cap: overrides.fetch(:line_cap, @line_cap),
|
|
57
|
+
line_join: overrides.fetch(:line_join, @line_join),
|
|
58
|
+
miter_limit: overrides.fetch(:miter_limit, @miter_limit),
|
|
59
|
+
dash: overrides.fetch(:dash, @dash),
|
|
60
|
+
font_name: overrides.fetch(:font_name, @font_name),
|
|
61
|
+
font_size: overrides.fetch(:font_size, @font_size),
|
|
62
|
+
clip_paths: overrides.fetch(:clip_paths, @clip_paths),
|
|
63
|
+
last_text_position: overrides.fetch(:last_text_position,
|
|
64
|
+
@last_text_position),
|
|
65
|
+
fill_rule: overrides.fetch(:fill_rule, @fill_rule),
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def push_clip_path(path_d)
|
|
70
|
+
with(clip_paths: @clip_paths + [path_d])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def identity_ctm?
|
|
74
|
+
@ctm.identity?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clipped?
|
|
78
|
+
!@clip_paths.empty?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# LIFO stack of immutable GraphicsContext snapshots. +gsave+ pushes
|
|
5
|
+
# a duplicate of the current top; +grestore+ pops. Popping an empty
|
|
6
|
+
# stack is a no-op (mirrors PLRM §7.2.2).
|
|
7
|
+
class GraphicsStack
|
|
8
|
+
def initialize(initial: GraphicsContext.new)
|
|
9
|
+
@stack = [initial]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def current
|
|
13
|
+
@stack.last
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def push
|
|
17
|
+
@stack.push(current)
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
alias gsave push
|
|
21
|
+
|
|
22
|
+
def pop
|
|
23
|
+
return current if @stack.size == 1
|
|
24
|
+
|
|
25
|
+
@stack.pop
|
|
26
|
+
current
|
|
27
|
+
end
|
|
28
|
+
alias grestore pop
|
|
29
|
+
|
|
30
|
+
def depth
|
|
31
|
+
@stack.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def replace(new_state)
|
|
35
|
+
@stack[-1] = new_state
|
|
36
|
+
new_state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def update(**overrides)
|
|
40
|
+
replace(current.with(**overrides))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Shared options for both directions. Frozen on construction.
|
|
5
|
+
class Options
|
|
6
|
+
attr_reader :eps, :width, :height, :viewbox_override, :verbose, :page_size
|
|
7
|
+
|
|
8
|
+
DEFAULT_PAGE_SIZE = [595, 842].freeze # A4 in PostScript points
|
|
9
|
+
|
|
10
|
+
def initialize(eps: false, width: nil, height: nil, viewbox_override: nil,
|
|
11
|
+
verbose: false, page_size: nil)
|
|
12
|
+
@eps = eps
|
|
13
|
+
@width = width
|
|
14
|
+
@height = height
|
|
15
|
+
@viewbox_override = viewbox_override
|
|
16
|
+
@verbose = verbose
|
|
17
|
+
@page_size = page_size || DEFAULT_PAGE_SIZE
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{
|
|
23
|
+
eps: @eps, width: @width, height: @height,
|
|
24
|
+
viewbox_override: @viewbox_override, verbose: @verbose,
|
|
25
|
+
page_size: @page_size
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -38,7 +38,7 @@ module Postsvg
|
|
|
38
38
|
(str("\\") >> any) | (str(")").absent? >> any)
|
|
39
39
|
end
|
|
40
40
|
rule(:string) do
|
|
41
|
-
|
|
41
|
+
str("(") >> string_char.repeat.as(:string) >> str(")")
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Operators and keywords
|
|
@@ -49,16 +49,16 @@ module Postsvg
|
|
|
49
49
|
|
|
50
50
|
# Arrays
|
|
51
51
|
rule(:array) do
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
str("[") >> whitespace? >>
|
|
53
|
+
value.repeat.as(:array) >>
|
|
54
|
+
whitespace? >> str("]")
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Procedures (code blocks in curly braces)
|
|
58
58
|
rule(:procedure) do
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
str("{") >> whitespace? >>
|
|
60
|
+
statement.repeat.as(:procedure) >>
|
|
61
|
+
whitespace? >> str("}")
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Values
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Top-level orchestrator for PS -> SVG. Owns lifecycle: BoundingBox
|
|
5
|
+
# pre-scan, builder open/close, visitor dispatch.
|
|
6
|
+
class Renderer
|
|
7
|
+
MAX_OUTPUT_BYTES = 100 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
def self.call(program, options = Options.new)
|
|
10
|
+
new(program, options).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :program, :options, :builder, :visitor
|
|
14
|
+
|
|
15
|
+
def initialize(program, options)
|
|
16
|
+
unless program.is_a?(Model::Program)
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"program must be a Model::Program"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@program = program
|
|
22
|
+
@options = options
|
|
23
|
+
@builder = SvgBuilder.new
|
|
24
|
+
@visitor = Visitors::PsVisitor.new(builder: @builder)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
open_svg
|
|
29
|
+
wrap_y_flip do
|
|
30
|
+
visitor.visit_program(program)
|
|
31
|
+
rescue Postscript::QuitSignal
|
|
32
|
+
# PS +quit+ ends program execution; suppress and finalize SVG.
|
|
33
|
+
end
|
|
34
|
+
builder.close_svg
|
|
35
|
+
result = builder.to_s
|
|
36
|
+
enforce_size_limit(result)
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def open_svg
|
|
43
|
+
viewbox, width, height = compute_viewbox
|
|
44
|
+
builder.open_svg(viewbox: viewbox, width: width, height: height)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wrap_y_flip
|
|
48
|
+
builder.open_y_flip_group
|
|
49
|
+
yield
|
|
50
|
+
builder.close_group
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Determine viewBox from (1) explicit override, (2) header
|
|
54
|
+
# BoundingBox / HiResBoundingBox, (3) default A4 page size.
|
|
55
|
+
def compute_viewbox
|
|
56
|
+
return options_viewbox if options_viewbox
|
|
57
|
+
|
|
58
|
+
bbox = program.header.hires_bounding_box || program.header.bounding_box
|
|
59
|
+
if bbox
|
|
60
|
+
llx, lly, urx, ury = bbox
|
|
61
|
+
width = urx - llx
|
|
62
|
+
height = ury - lly
|
|
63
|
+
viewbox = "#{FormatNumber.call(llx)} #{FormatNumber.call(lly)} " \
|
|
64
|
+
"#{FormatNumber.call(width)} #{FormatNumber.call(height)}"
|
|
65
|
+
[viewbox, width, height]
|
|
66
|
+
else
|
|
67
|
+
width, height = options.page_size
|
|
68
|
+
["0 0 #{FormatNumber.call(width)} #{FormatNumber.call(height)}", width,
|
|
69
|
+
height]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def options_viewbox
|
|
74
|
+
return nil unless options.viewbox_override
|
|
75
|
+
|
|
76
|
+
vb = options.viewbox_override
|
|
77
|
+
return vb if vb.is_a?(Array) && vb.length == 4
|
|
78
|
+
|
|
79
|
+
[vb, nil, nil]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def enforce_size_limit(result)
|
|
83
|
+
return unless result.bytesize > MAX_OUTPUT_BYTES
|
|
84
|
+
|
|
85
|
+
raise SizeLimitError,
|
|
86
|
+
"SVG output #{result.bytesize} bytes exceeds limit #{MAX_OUTPUT_BYTES}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Helpers for parsing presentation attributes into typed values.
|
|
6
|
+
# Used by Element.from_node factories.
|
|
7
|
+
module AttributeParser
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Parse a length attribute ("10", "10px", "10pt", "50%").
|
|
11
|
+
# Returns a Float. Percentage values resolve to nil — the caller
|
|
12
|
+
# must know the reference dimension.
|
|
13
|
+
def length(text)
|
|
14
|
+
return nil if text.nil? || text.empty?
|
|
15
|
+
|
|
16
|
+
match = text.strip.match(/\A(-?\d+(?:\.\d+)?)(px|pt|in|cm|mm|em|ex|%)?\z/)
|
|
17
|
+
return nil unless match
|
|
18
|
+
|
|
19
|
+
return nil if match[2] == "%"
|
|
20
|
+
|
|
21
|
+
value = match[1].to_f
|
|
22
|
+
case match[2]
|
|
23
|
+
when "pt" then value * (1.0 / 72.0) * 72.0 # PS uses points natively
|
|
24
|
+
when "in" then value * 72.0
|
|
25
|
+
when "cm" then value * (72.0 / 2.54)
|
|
26
|
+
when "mm" then value * (72.0 / 25.4)
|
|
27
|
+
else value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def number_list(text)
|
|
32
|
+
return [] if text.nil? || text.empty?
|
|
33
|
+
|
|
34
|
+
text.scan(/-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/).map(&:to_f)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def number(text, default: nil)
|
|
38
|
+
return default if text.nil? || text.empty?
|
|
39
|
+
|
|
40
|
+
match = text.strip.match(/\A(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\z/)
|
|
41
|
+
match ? match[1].to_f : default
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Indexes <clipPath id="..."> definitions in a document so handlers
|
|
6
|
+
# can resolve url(#id) references without re-walking the tree.
|
|
7
|
+
class ClipPathRegistry
|
|
8
|
+
attr_reader :by_id
|
|
9
|
+
|
|
10
|
+
def initialize(by_id = {})
|
|
11
|
+
@by_id = by_id.dup.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.empty
|
|
16
|
+
@empty ||= new({})
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def lookup(id)
|
|
20
|
+
@by_id[id]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def key?(id)
|
|
24
|
+
@by_id.key?(id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build a registry by scanning +root+ for <clipPath> children.
|
|
28
|
+
def self.from_node(root)
|
|
29
|
+
registry = {}
|
|
30
|
+
root.xpath(".//*[local-name()='clipPath']").each do |node|
|
|
31
|
+
id = node["id"]
|
|
32
|
+
next unless id
|
|
33
|
+
|
|
34
|
+
# Concatenate the d attributes of all child <path> elements
|
|
35
|
+
# in document order. Other shape children are converted to
|
|
36
|
+
# path data lazily; for now we only support <path>.
|
|
37
|
+
ds = node.xpath(".//*[local-name()='path']").filter_map do |p|
|
|
38
|
+
p["d"]
|
|
39
|
+
end
|
|
40
|
+
registry[id] = ds.join(" ") unless ds.empty?
|
|
41
|
+
end
|
|
42
|
+
new(registry)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Root value object produced by Svg::Parser. Carries the SVG root
|
|
6
|
+
# element, the document's viewBox / width / height (parsed once),
|
|
7
|
+
# and the ClipPathRegistry assembled from <defs>.
|
|
8
|
+
class Document
|
|
9
|
+
attr_reader :root_element, :viewbox, :width, :height, :clip_paths
|
|
10
|
+
|
|
11
|
+
def initialize(root_element:, viewbox: nil, width: nil, height: nil,
|
|
12
|
+
clip_paths: ClipPathRegistry.empty)
|
|
13
|
+
@root_element = root_element
|
|
14
|
+
@viewbox = viewbox
|
|
15
|
+
@width = width
|
|
16
|
+
@height = height
|
|
17
|
+
@clip_paths = clip_paths
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|