postsvg 0.1.0 → 0.3.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 +4 -4
- 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 +104 -57
- data/lib/postsvg/color.rb +132 -0
- data/lib/postsvg/errors.rb +68 -3
- data/lib/postsvg/format_number.rb +22 -0
- data/lib/postsvg/graphics_context.rb +80 -0
- data/lib/postsvg/graphics_stack.rb +43 -0
- data/lib/postsvg/model/literals/array.rb +41 -0
- data/lib/postsvg/model/literals/dictionary.rb +34 -0
- data/lib/postsvg/model/literals/hex.rb +37 -0
- data/lib/postsvg/model/literals/name.rb +40 -0
- data/lib/postsvg/model/literals/number.rb +36 -0
- data/lib/postsvg/model/literals/procedure.rb +41 -0
- data/lib/postsvg/model/literals/string.rb +30 -0
- data/lib/postsvg/model/literals.rb +19 -0
- data/lib/postsvg/model/operator.rb +58 -0
- data/lib/postsvg/model/operators/arithmetic.rb +264 -0
- data/lib/postsvg/model/operators/boolean.rb +182 -0
- data/lib/postsvg/model/operators/color.rb +74 -0
- data/lib/postsvg/model/operators/container.rb +186 -0
- data/lib/postsvg/model/operators/control_flow.rb +119 -0
- data/lib/postsvg/model/operators/device.rb +21 -0
- data/lib/postsvg/model/operators/dictionary.rb +118 -0
- data/lib/postsvg/model/operators/font.rb +121 -0
- data/lib/postsvg/model/operators/graphics_state.rb +84 -0
- data/lib/postsvg/model/operators/painting.rb +29 -0
- data/lib/postsvg/model/operators/path.rb +169 -0
- data/lib/postsvg/model/operators/stack.rb +72 -0
- data/lib/postsvg/model/operators/transformations.rb +103 -0
- data/lib/postsvg/model/operators.rb +89 -0
- data/lib/postsvg/model/program.rb +68 -0
- data/lib/postsvg/model/token.rb +43 -0
- data/lib/postsvg/model.rb +17 -0
- data/lib/postsvg/options.rb +29 -0
- data/lib/postsvg/renderer.rb +85 -0
- data/lib/postsvg/serializer.rb +325 -0
- data/lib/postsvg/source/ast_builder.rb +308 -0
- data/lib/postsvg/source/lexer.rb +322 -0
- data/lib/postsvg/source/operand_stack.rb +55 -0
- data/lib/postsvg/source.rb +21 -0
- data/lib/postsvg/svg/attribute_parser.rb +45 -0
- data/lib/postsvg/svg/clip_path_registry.rb +44 -0
- data/lib/postsvg/svg/document.rb +22 -0
- data/lib/postsvg/svg/element.rb +84 -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 +38 -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 +39 -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 +82 -0
- data/lib/postsvg/svg/path_data.rb +17 -0
- data/lib/postsvg/svg/stroke.rb +31 -0
- data/lib/postsvg/svg/transform_list.rb +59 -0
- data/lib/postsvg/svg.rb +22 -0
- data/lib/postsvg/svg_builder.rb +249 -0
- data/lib/postsvg/translation/arc_converter.rb +86 -0
- data/lib/postsvg/translation/bounding_box.rb +59 -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 +31 -0
- data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
- data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
- data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
- data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
- data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
- data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
- data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
- data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
- data/lib/postsvg/translation/handlers/shared.rb +110 -0
- data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
- data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
- data/lib/postsvg/translation/handlers.rb +25 -0
- data/lib/postsvg/translation/ps_renderer.rb +105 -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 +53 -0
- data/lib/postsvg/visitors/ps_visitor/common.rb +66 -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 +89 -0
- data/lib/postsvg/visitors/ps_visitor/font.rb +93 -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 +112 -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 +93 -59
- data/lychee.toml +86 -0
- metadata +216 -11
- data/postsvg.gemspec +0 -38
data/exe/postsvg
CHANGED
data/lib/postsvg/cli.rb
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
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]", "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
|
|
10
12
|
long_desc <<~DESC
|
|
11
|
-
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG
|
|
13
|
+
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
|
|
12
14
|
|
|
13
|
-
If OUTPUT is not specified,
|
|
15
|
+
If OUTPUT is not specified, SVG is written to stdout.
|
|
14
16
|
|
|
15
17
|
Examples:
|
|
16
18
|
|
|
@@ -18,43 +20,48 @@ module Postsvg
|
|
|
18
20
|
$ postsvg convert input.eps > output.svg
|
|
19
21
|
DESC
|
|
20
22
|
def convert(input_path, output_path = nil)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
exit 1
|
|
24
|
-
end
|
|
23
|
+
handle_input(input_path, output_path, method(:read_ps_to_svg), direction: "PS/EPS -> SVG")
|
|
24
|
+
end
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
|
|
27
|
+
long_desc <<~DESC
|
|
28
|
+
Alias for `convert`. Reads PS or EPS, writes SVG.
|
|
29
|
+
DESC
|
|
30
|
+
def to_svg(input_path, output_path = nil)
|
|
31
|
+
convert(input_path, output_path)
|
|
32
|
+
end
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
exit 1
|
|
43
|
-
end
|
|
34
|
+
desc "to-ps INPUT [OUTPUT]", "Convert SVG to PostScript"
|
|
35
|
+
long_desc <<~DESC
|
|
36
|
+
Convert an SVG file to PostScript source. If OUTPUT is not specified,
|
|
37
|
+
PS source is written to stdout.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
|
|
41
|
+
$ postsvg to-ps input.svg output.ps
|
|
42
|
+
$ postsvg to-ps input.svg > output.ps
|
|
43
|
+
DESC
|
|
44
|
+
def to_ps(input_path, output_path = nil)
|
|
45
|
+
handle_input(input_path, output_path, method(:read_svg_to_ps), direction: "SVG -> PS")
|
|
44
46
|
end
|
|
45
47
|
|
|
46
|
-
desc "
|
|
47
|
-
"Convert all PS/EPS files in a directory"
|
|
48
|
+
desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
|
|
48
49
|
long_desc <<~DESC
|
|
49
|
-
|
|
50
|
+
Like to-ps but emits an EPSF-3.0 header and a single-page program
|
|
51
|
+
suitable for embedding.
|
|
52
|
+
DESC
|
|
53
|
+
def to_eps(input_path, output_path = nil)
|
|
54
|
+
handle_input(input_path, output_path, method(:read_svg_to_eps), direction: "SVG -> EPS")
|
|
55
|
+
end
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
desc "batch INPUT_DIR [OUTPUT_DIR]", "Convert all PS/EPS/SVG files in a directory"
|
|
58
|
+
long_desc <<~DESC
|
|
59
|
+
Auto-detects direction by file extension:
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
.ps, .eps -> .svg
|
|
62
|
+
.svg -> .ps
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
$ postsvg batch ps_files/ svg_files/
|
|
64
|
+
Output goes to OUTPUT_DIR if given, otherwise to INPUT_DIR.
|
|
58
65
|
DESC
|
|
59
66
|
def batch(input_dir, output_dir = nil)
|
|
60
67
|
unless Dir.exist?(input_dir)
|
|
@@ -64,34 +71,15 @@ module Postsvg
|
|
|
64
71
|
|
|
65
72
|
Dir.mkdir(output_dir) if output_dir && !Dir.exist?(output_dir)
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
files = Dir.glob(pattern, File::FNM_CASEFOLD)
|
|
69
|
-
|
|
74
|
+
files = Dir.glob(File.join(input_dir, "*.{ps,eps,svg}"), File::FNM_CASEFOLD)
|
|
70
75
|
if files.empty?
|
|
71
|
-
say "No PS or
|
|
76
|
+
say "No PS, EPS, or SVG files found in #{input_dir}", :yellow
|
|
72
77
|
return
|
|
73
78
|
end
|
|
74
79
|
|
|
75
80
|
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
|
|
81
|
+
files.each do |path|
|
|
82
|
+
convert_in_batch(path, input_dir, output_dir)
|
|
95
83
|
end
|
|
96
84
|
end
|
|
97
85
|
|
|
@@ -99,5 +87,64 @@ module Postsvg
|
|
|
99
87
|
def version
|
|
100
88
|
say "postsvg version #{Postsvg::VERSION}"
|
|
101
89
|
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def handle_input(input_path, output_path, reader, direction:)
|
|
94
|
+
unless File.exist?(input_path)
|
|
95
|
+
say "Error: Input file '#{input_path}' not found", :red
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
output = reader.call(File.read(input_path))
|
|
100
|
+
if output_path
|
|
101
|
+
File.write(output_path, output)
|
|
102
|
+
say "Converted #{input_path} -> #{output_path} (#{direction})", :green
|
|
103
|
+
else
|
|
104
|
+
puts output
|
|
105
|
+
end
|
|
106
|
+
rescue Postsvg::Error => e
|
|
107
|
+
say "Conversion error: #{e.message}", :red
|
|
108
|
+
exit 2
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
say "Unexpected error: #{e.message}", :red
|
|
111
|
+
say e.backtrace.join("\n"), :red if options[:verbose]
|
|
112
|
+
exit 2
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_ps_to_svg(content)
|
|
116
|
+
Postsvg.to_svg(content)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_svg_to_ps(content)
|
|
120
|
+
Postsvg.to_ps(content)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def read_svg_to_eps(content)
|
|
124
|
+
Postsvg.to_eps(content)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def convert_in_batch(path, input_dir, output_dir)
|
|
128
|
+
ext = File.extname(path).downcase
|
|
129
|
+
basename = File.basename(path, ".*")
|
|
130
|
+
out_ext =
|
|
131
|
+
case ext
|
|
132
|
+
when ".svg" then ".ps"
|
|
133
|
+
else ".svg"
|
|
134
|
+
end
|
|
135
|
+
out_dir = output_dir || input_dir
|
|
136
|
+
out_path = File.join(out_dir, "#{basename}#{out_ext}")
|
|
137
|
+
|
|
138
|
+
content = File.read(path)
|
|
139
|
+
output =
|
|
140
|
+
case ext
|
|
141
|
+
when ".svg" then Postsvg.to_ps(content)
|
|
142
|
+
else Postsvg.to_svg(content)
|
|
143
|
+
end
|
|
144
|
+
File.write(out_path, output)
|
|
145
|
+
say " #{path} -> #{out_path}", :green
|
|
146
|
+
rescue Postsvg::Error => e
|
|
147
|
+
say " #{path}: #{e.message}", :red
|
|
148
|
+
end
|
|
102
149
|
end
|
|
103
150
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Immutable RGB color value object. Use the constructors (+.rgb+,
|
|
5
|
+
# +.gray+, +.cmyk+, +.parse+) instead of +new+ when constructing from
|
|
6
|
+
# other color models; they normalize to integer [0, 255] channels.
|
|
7
|
+
class Color
|
|
8
|
+
include Comparable
|
|
9
|
+
|
|
10
|
+
attr_reader :red, :green, :blue
|
|
11
|
+
|
|
12
|
+
def self.clamp_byte(value)
|
|
13
|
+
Integer === value ? value.clamp(0, 255) : value.to_i.clamp(0, 255)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.scale_unit_to_byte(value)
|
|
17
|
+
(value.clamp(0.0, 1.0) * 255.0).round
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(red, green, blue)
|
|
21
|
+
@red = Color.clamp_byte(red)
|
|
22
|
+
@green = Color.clamp_byte(green)
|
|
23
|
+
@blue = Color.clamp_byte(blue)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# RGB triple in PS-native [0.0, 1.0] floats.
|
|
28
|
+
def self.rgb(r, g, b)
|
|
29
|
+
new(scale_unit_to_byte(r), scale_unit_to_byte(g), scale_unit_to_byte(b))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Gray in [0.0, 1.0].
|
|
33
|
+
def self.gray(level)
|
|
34
|
+
v = scale_unit_to_byte(level)
|
|
35
|
+
new(v, v, v)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CMYK in [0.0, 1.0]. Conversion matches PLRM §8.2.4 simplified
|
|
39
|
+
# formula; not colorimetrically exact.
|
|
40
|
+
def self.cmyk(c, m, y, k)
|
|
41
|
+
new(
|
|
42
|
+
scale_unit_to_byte((1 - c) * (1 - k)),
|
|
43
|
+
scale_unit_to_byte((1 - m) * (1 - k)),
|
|
44
|
+
scale_unit_to_byte((1 - y) * (1 - k)),
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parse CSS / SVG color notations: #rgb, #rrggbb, rgb(r, g, b),
|
|
49
|
+
# rgb(r g b / a), named colors (X11 subset).
|
|
50
|
+
def self.parse(text)
|
|
51
|
+
return text if text.is_a?(Color)
|
|
52
|
+
|
|
53
|
+
case text.to_s.downcase
|
|
54
|
+
when /\A#([0-9a-f]{6})\z/
|
|
55
|
+
hex = ::Regexp.last_match(1)
|
|
56
|
+
new(hex[0, 2].to_i(16), hex[2, 2].to_i(16), hex[4, 2].to_i(16))
|
|
57
|
+
when /\A#([0-9a-f]{3})\z/
|
|
58
|
+
hex = ::Regexp.last_match(1)
|
|
59
|
+
new((hex[0] * 2).to_i(16), (hex[1] * 2).to_i(16), (hex[2] * 2).to_i(16))
|
|
60
|
+
when /\argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
|
|
61
|
+
new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
|
62
|
+
when /\argb\(\s*(\d+)\s+(\d+)\s+(\d+)/
|
|
63
|
+
new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
|
64
|
+
when "none", "transparent"
|
|
65
|
+
nil
|
|
66
|
+
else
|
|
67
|
+
rgb = NAMED_COLORS[text.to_s]
|
|
68
|
+
return new(*rgb) if rgb
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "unrecognized color: #{text.inspect}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def gray?
|
|
75
|
+
red == green && green == blue
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def gray_level
|
|
79
|
+
(red + green + blue) / 765.0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_rgb_unit
|
|
83
|
+
[red / 255.0, green / 255.0, blue / 255.0]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_svg
|
|
87
|
+
format("#%02x%02x%02x", red, green, blue)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_ps_rgb
|
|
91
|
+
to_rgb_unit.map { |c| FormatNumber.call(c) }.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_ps_gray
|
|
95
|
+
raise ArgumentError, "color is not gray" unless gray?
|
|
96
|
+
|
|
97
|
+
FormatNumber.call(gray_level)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def <=>(other)
|
|
101
|
+
return nil unless other.is_a?(Color)
|
|
102
|
+
|
|
103
|
+
[red, green, blue] <=> [other.red, other.green, other.blue]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
[red, green, blue].hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def eql?(other)
|
|
111
|
+
other.is_a?(Color) && other.red == red && other.green == green && other.blue == blue
|
|
112
|
+
end
|
|
113
|
+
alias == eql?
|
|
114
|
+
|
|
115
|
+
NAMED_COLORS = {
|
|
116
|
+
"black" => [0, 0, 0], "white" => [255, 255, 255],
|
|
117
|
+
"red" => [255, 0, 0], "green" => [0, 128, 0], "blue" => [0, 0, 255],
|
|
118
|
+
"yellow" => [255, 255, 0], "cyan" => [0, 255, 255],
|
|
119
|
+
"magenta" => [255, 0, 255], "gray" => [128, 128, 128],
|
|
120
|
+
"grey" => [128, 128, 128], "silver" => [192, 192, 192],
|
|
121
|
+
"maroon" => [128, 0, 0], "olive" => [128, 128, 0],
|
|
122
|
+
"navy" => [0, 0, 128], "purple" => [128, 0, 128],
|
|
123
|
+
"teal" => [0, 128, 128], "lime" => [0, 255, 0],
|
|
124
|
+
"aqua" => [0, 255, 255], "fuchsia" => [255, 0, 255],
|
|
125
|
+
"orange" => [255, 165, 0], "pink" => [255, 192, 203],
|
|
126
|
+
"brown" => [165, 42, 42],
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
BLACK = Color.new(0, 0, 0).freeze
|
|
130
|
+
WHITE = Color.new(255, 255, 255).freeze
|
|
131
|
+
end
|
|
132
|
+
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,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Single source of truth for formatting numbers in both directions.
|
|
5
|
+
#
|
|
6
|
+
# - Integers print without a trailing ".0".
|
|
7
|
+
# - Floats print to 4 decimals, trailing zeros stripped.
|
|
8
|
+
# - -0 normalizes to 0 (so viewBoxes don't read "0 -0 100 100").
|
|
9
|
+
# - +Inf / -Inf / NaN raise ArgumentError; callers should validate.
|
|
10
|
+
module FormatNumber
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(value)
|
|
14
|
+
raise ArgumentError, "non-finite number: #{value.inspect}" unless value.finite?
|
|
15
|
+
|
|
16
|
+
normalized = value.zero? ? 0.0 : value
|
|
17
|
+
return normalized.to_i.to_s if normalized == normalized.to_i
|
|
18
|
+
|
|
19
|
+
format("%.4f", normalized).sub(/\.?0+$/, "")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
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, @last_text_position),
|
|
64
|
+
fill_rule: overrides.fetch(:fill_rule, @fill_rule),
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def push_clip_path(path_d)
|
|
69
|
+
with(clip_paths: @clip_paths + [path_d])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def identity_ctm?
|
|
73
|
+
@ctm.identity?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clipped?
|
|
77
|
+
!@clip_paths.empty?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Bracketed array literal: +[1 2 3]+. Elements may be any
|
|
7
|
+
# literal or operator.
|
|
8
|
+
class ArrayLiteral
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
attr_reader :elements
|
|
12
|
+
|
|
13
|
+
def initialize(elements = [])
|
|
14
|
+
@elements = elements.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&block)
|
|
19
|
+
@elements.each(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def length = @elements.length
|
|
23
|
+
def empty? = @elements.empty?
|
|
24
|
+
def [](idx) = @elements[idx]
|
|
25
|
+
|
|
26
|
+
def accept(visitor, ctx)
|
|
27
|
+
visitor.visit_array(self, ctx)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.is_a?(ArrayLiteral) && other.elements == @elements
|
|
32
|
+
end
|
|
33
|
+
alias eql? ==
|
|
34
|
+
|
|
35
|
+
def hash
|
|
36
|
+
@elements.hash
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Inline dictionary literal: +<< /Key (value) /Num 42 >>+.
|
|
7
|
+
class Dictionary
|
|
8
|
+
attr_reader :entries
|
|
9
|
+
|
|
10
|
+
def initialize(entries = {})
|
|
11
|
+
@entries = entries.dup.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](key) = @entries[key]
|
|
16
|
+
def key?(key) = @entries.key?(key)
|
|
17
|
+
def each(&block) = @entries.each(&block)
|
|
18
|
+
|
|
19
|
+
def accept(visitor, ctx)
|
|
20
|
+
visitor.visit_dictionary(self, ctx)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
other.is_a?(Dictionary) && other.entries == @entries
|
|
25
|
+
end
|
|
26
|
+
alias eql? ==
|
|
27
|
+
|
|
28
|
+
def hash
|
|
29
|
+
@entries.hash
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Hex-string literal: +<DEADBEEF>+.
|
|
7
|
+
class HexLiteral
|
|
8
|
+
attr_reader :value
|
|
9
|
+
|
|
10
|
+
def initialize(value)
|
|
11
|
+
@value = value.to_s
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raw bytes encoded by the hex literal.
|
|
16
|
+
def bytes
|
|
17
|
+
hex = value.delete("^0-9a-fA-F")
|
|
18
|
+
hex << "0" if hex.length.odd?
|
|
19
|
+
hex.chars.each_slice(2).map { |pair| pair.join.to_i(16) }.pack("C*")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def accept(visitor, ctx)
|
|
23
|
+
visitor.visit_hex(self, ctx)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.is_a?(HexLiteral) && other.value == value
|
|
28
|
+
end
|
|
29
|
+
alias eql? ==
|
|
30
|
+
|
|
31
|
+
def hash
|
|
32
|
+
value.hash
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|