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