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.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/CLAUDE.md +173 -0
  4. data/Gemfile +2 -3
  5. data/README.adoc +456 -179
  6. data/Rakefile +100 -0
  7. data/TODO.roadmap/00-architecture.md +139 -0
  8. data/TODO.roadmap/01-autoload-migration.md +39 -0
  9. data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
  10. data/TODO.roadmap/03-domain-model.md +66 -0
  11. data/TODO.roadmap/04-lexer.md +40 -0
  12. data/TODO.roadmap/05-parser.md +43 -0
  13. data/TODO.roadmap/06-graphics-state.md +45 -0
  14. data/TODO.roadmap/07-matrix-and-color.md +37 -0
  15. data/TODO.roadmap/08-svg-builder.md +51 -0
  16. data/TODO.roadmap/09-renderer.md +52 -0
  17. data/TODO.roadmap/10-visitor.md +58 -0
  18. data/TODO.roadmap/11-operator-coverage.md +103 -0
  19. data/TODO.roadmap/12-svg-domain.md +62 -0
  20. data/TODO.roadmap/13-translation-handlers.md +69 -0
  21. data/TODO.roadmap/14-ps-serializer.md +49 -0
  22. data/TODO.roadmap/15-cli-and-public-api.md +47 -0
  23. data/TODO.roadmap/16-specs.md +84 -0
  24. data/TODO.roadmap/17-docs-sync.md +47 -0
  25. data/TODO.roadmap/18-performance-and-determinism.md +47 -0
  26. data/TODO.roadmap/19-error-model.md +45 -0
  27. data/TODO.roadmap/20-font-and-text.md +46 -0
  28. data/TODO.roadmap/21-images.md +45 -0
  29. data/TODO.roadmap/22-forms-and-resources.md +25 -0
  30. data/TODO.roadmap/23-level2-level3.md +52 -0
  31. data/TODO.roadmap/24-ci-and-release.md +42 -0
  32. data/TODO.roadmap/README.md +77 -0
  33. data/docs/.gitignore +29 -0
  34. data/docs/CHANGELOG.md +114 -0
  35. data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
  36. data/docs/DEPLOYMENT.md +456 -0
  37. data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
  38. data/docs/DOCUMENTATION_PLAN.md +425 -0
  39. data/docs/FINAL_SUMMARY.md +657 -0
  40. data/docs/Gemfile +15 -0
  41. data/docs/README.md +327 -0
  42. data/docs/_config.yml +99 -0
  43. data/docs/advanced-topics.adoc +370 -0
  44. data/docs/api-reference/colors.adoc +705 -0
  45. data/docs/api-reference/converter.adoc +699 -0
  46. data/docs/api-reference/execution-context.adoc +1210 -0
  47. data/docs/api-reference/graphics-state.adoc +1070 -0
  48. data/docs/api-reference/interpreter.adoc +810 -0
  49. data/docs/api-reference/matrix.adoc +1179 -0
  50. data/docs/api-reference/path-builder.adoc +1284 -0
  51. data/docs/api-reference/postsvg-module.adoc +388 -0
  52. data/docs/api-reference/svg-generator.adoc +891 -0
  53. data/docs/api-reference/tokenizer.adoc +925 -0
  54. data/docs/api-reference.adoc +221 -0
  55. data/docs/architecture/command-registry.adoc +1191 -0
  56. data/docs/architecture/conversion-pipeline.adoc +746 -0
  57. data/docs/architecture/design-decisions.adoc +999 -0
  58. data/docs/architecture/generator-stage.adoc +1115 -0
  59. data/docs/architecture/graphics-state-model.adoc +1089 -0
  60. data/docs/architecture/interpreter-stage.adoc +1125 -0
  61. data/docs/architecture/parser-stage.adoc +1051 -0
  62. data/docs/architecture.adoc +354 -0
  63. data/docs/cli-reference/batch-command.adoc +616 -0
  64. data/docs/cli-reference/check-command.adoc +677 -0
  65. data/docs/cli-reference/cli-options.adoc +802 -0
  66. data/docs/cli-reference/convert-command.adoc +462 -0
  67. data/docs/cli-reference/version-command.adoc +296 -0
  68. data/docs/cli-reference.adoc +317 -0
  69. data/docs/concepts/conversion-pipeline.adoc +903 -0
  70. data/docs/concepts/coordinate-systems.adoc +836 -0
  71. data/docs/concepts/graphics-state.adoc +861 -0
  72. data/docs/concepts/path-operations.adoc +1076 -0
  73. data/docs/concepts/postscript-language.adoc +859 -0
  74. data/docs/concepts/svg-generation.adoc +937 -0
  75. data/docs/concepts.adoc +198 -0
  76. data/docs/contributing.adoc +443 -0
  77. data/docs/development.adoc +420 -0
  78. data/docs/faq.adoc +493 -0
  79. data/docs/getting-started/basic-usage.adoc +538 -0
  80. data/docs/getting-started/common-workflows.adoc +577 -0
  81. data/docs/getting-started/first-conversion.adoc +492 -0
  82. data/docs/getting-started/installation.adoc +534 -0
  83. data/docs/getting-started.adoc +94 -0
  84. data/docs/index.adoc +248 -0
  85. data/docs/optimization.adoc +196 -0
  86. data/docs/ps2svg_compatibility.adoc +149 -0
  87. data/docs/quick-reference.adoc +453 -0
  88. data/docs/sitemap.adoc +337 -0
  89. data/docs/troubleshooting.adoc +486 -0
  90. data/docs/validation.adoc +772 -0
  91. data/exe/postsvg +1 -0
  92. data/lib/postsvg/cli.rb +104 -57
  93. data/lib/postsvg/color.rb +132 -0
  94. data/lib/postsvg/errors.rb +68 -3
  95. data/lib/postsvg/format_number.rb +22 -0
  96. data/lib/postsvg/graphics_context.rb +80 -0
  97. data/lib/postsvg/graphics_stack.rb +43 -0
  98. data/lib/postsvg/model/literals/array.rb +41 -0
  99. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  100. data/lib/postsvg/model/literals/hex.rb +37 -0
  101. data/lib/postsvg/model/literals/name.rb +40 -0
  102. data/lib/postsvg/model/literals/number.rb +36 -0
  103. data/lib/postsvg/model/literals/procedure.rb +41 -0
  104. data/lib/postsvg/model/literals/string.rb +30 -0
  105. data/lib/postsvg/model/literals.rb +19 -0
  106. data/lib/postsvg/model/operator.rb +58 -0
  107. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  108. data/lib/postsvg/model/operators/boolean.rb +182 -0
  109. data/lib/postsvg/model/operators/color.rb +74 -0
  110. data/lib/postsvg/model/operators/container.rb +186 -0
  111. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  112. data/lib/postsvg/model/operators/device.rb +21 -0
  113. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  114. data/lib/postsvg/model/operators/font.rb +121 -0
  115. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  116. data/lib/postsvg/model/operators/painting.rb +29 -0
  117. data/lib/postsvg/model/operators/path.rb +169 -0
  118. data/lib/postsvg/model/operators/stack.rb +72 -0
  119. data/lib/postsvg/model/operators/transformations.rb +103 -0
  120. data/lib/postsvg/model/operators.rb +89 -0
  121. data/lib/postsvg/model/program.rb +68 -0
  122. data/lib/postsvg/model/token.rb +43 -0
  123. data/lib/postsvg/model.rb +17 -0
  124. data/lib/postsvg/options.rb +29 -0
  125. data/lib/postsvg/renderer.rb +85 -0
  126. data/lib/postsvg/serializer.rb +325 -0
  127. data/lib/postsvg/source/ast_builder.rb +308 -0
  128. data/lib/postsvg/source/lexer.rb +322 -0
  129. data/lib/postsvg/source/operand_stack.rb +55 -0
  130. data/lib/postsvg/source.rb +21 -0
  131. data/lib/postsvg/svg/attribute_parser.rb +45 -0
  132. data/lib/postsvg/svg/clip_path_registry.rb +44 -0
  133. data/lib/postsvg/svg/document.rb +22 -0
  134. data/lib/postsvg/svg/element.rb +84 -0
  135. data/lib/postsvg/svg/elements/circle.rb +36 -0
  136. data/lib/postsvg/svg/elements/clip_path.rb +26 -0
  137. data/lib/postsvg/svg/elements/defs.rb +24 -0
  138. data/lib/postsvg/svg/elements/ellipse.rb +38 -0
  139. data/lib/postsvg/svg/elements/group.rb +37 -0
  140. data/lib/postsvg/svg/elements/image.rb +35 -0
  141. data/lib/postsvg/svg/elements/line.rb +36 -0
  142. data/lib/postsvg/svg/elements/path.rb +32 -0
  143. data/lib/postsvg/svg/elements/polygon.rb +12 -0
  144. data/lib/postsvg/svg/elements/polyline.rb +32 -0
  145. data/lib/postsvg/svg/elements/rect.rb +44 -0
  146. data/lib/postsvg/svg/elements/svg.rb +39 -0
  147. data/lib/postsvg/svg/elements/text.rb +42 -0
  148. data/lib/postsvg/svg/elements.rb +31 -0
  149. data/lib/postsvg/svg/paint.rb +34 -0
  150. data/lib/postsvg/svg/parser.rb +39 -0
  151. data/lib/postsvg/svg/path_data/command.rb +27 -0
  152. data/lib/postsvg/svg/path_data/parser.rb +82 -0
  153. data/lib/postsvg/svg/path_data.rb +17 -0
  154. data/lib/postsvg/svg/stroke.rb +31 -0
  155. data/lib/postsvg/svg/transform_list.rb +59 -0
  156. data/lib/postsvg/svg.rb +22 -0
  157. data/lib/postsvg/svg_builder.rb +249 -0
  158. data/lib/postsvg/translation/arc_converter.rb +86 -0
  159. data/lib/postsvg/translation/bounding_box.rb +59 -0
  160. data/lib/postsvg/translation/context.rb +34 -0
  161. data/lib/postsvg/translation/handler_registry.rb +38 -0
  162. data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
  163. data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
  164. data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
  165. data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
  166. data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
  167. data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
  168. data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
  169. data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
  170. data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
  171. data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
  172. data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
  173. data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
  174. data/lib/postsvg/translation/handlers/shared.rb +110 -0
  175. data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
  176. data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
  177. data/lib/postsvg/translation/handlers.rb +25 -0
  178. data/lib/postsvg/translation/ps_renderer.rb +105 -0
  179. data/lib/postsvg/translation/record_emitter.rb +35 -0
  180. data/lib/postsvg/translation.rb +17 -0
  181. data/lib/postsvg/version.rb +1 -1
  182. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
  183. data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
  184. data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
  185. data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
  186. data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
  187. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
  188. data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
  189. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
  190. data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
  191. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
  192. data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
  193. data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
  194. data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
  195. data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
  196. data/lib/postsvg/visitors/ps_visitor.rb +208 -0
  197. data/lib/postsvg/visitors.rb +9 -0
  198. data/lib/postsvg.rb +93 -59
  199. data/lychee.toml +86 -0
  200. metadata +216 -11
  201. data/postsvg.gemspec +0 -38
data/exe/postsvg CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../lib/postsvg"
4
5
  require_relative "../lib/postsvg/cli"
5
6
 
6
7
  Postsvg::CLI.start(ARGV)
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 for postsvg
6
+ # Command-line interface. Thor-based. Auto-detects conversion
7
+ # direction by file extension in +batch+.
8
8
  class CLI < Thor
9
- desc "convert INPUT [OUTPUT]", "Convert PostScript/EPS file to SVG"
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 format.
13
+ Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
12
14
 
13
- If OUTPUT is not specified, the output will be written to stdout.
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
- unless File.exist?(input_path)
22
- say "Error: Input file '#{input_path}' not found", :red
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
- begin
27
- ps_content = File.read(input_path)
28
- svg_output = Postsvg.convert(ps_content)
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
- if output_path
31
- File.write(output_path, svg_output)
32
- say "Successfully converted #{input_path} to #{output_path}", :green
33
- else
34
- puts svg_output
35
- end
36
- rescue Postsvg::Error => e
37
- say "Conversion error: #{e.message}", :red
38
- exit 1
39
- rescue StandardError => e
40
- say "Unexpected error: #{e.message}", :red
41
- say e.backtrace.join("\n"), :red if options[:verbose]
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 "batch INPUT_DIR [OUTPUT_DIR]",
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
- Convert all .ps and .eps files in INPUT_DIR to SVG format.
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
- If OUTPUT_DIR is specified, SVG files will be written there.
52
- Otherwise, SVG files will be written to the same directory as the input files.
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
- Examples:
61
+ .ps, .eps -> .svg
62
+ .svg -> .ps
55
63
 
56
- $ postsvg batch ps_files/
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
- pattern = File.join(input_dir, "*.{ps,eps}")
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 EPS files found in #{input_dir}", :yellow
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
- files.each do |input_path|
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
@@ -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
- class ParseError < Error; end
8
+ # Lexing or parsing failed. Carries +source_position+ when known.
9
+ class ParseError < Error
10
+ attr_reader :source_position
7
11
 
8
- class ConversionError < Error; end
12
+ def initialize(message, source_position: nil)
13
+ @source_position = source_position
14
+ super(message)
15
+ end
16
+ end
9
17
 
10
- class UnsupportedOperatorError < Error; end
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