postsvg 0.2.3 → 0.2.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0de930d655e83ae56095a0a297a654f9d5fbf68e394e0bf133f4f6a0851ca8a6
4
- data.tar.gz: d6a2f211f000b43a0ad4b6ff95ed86400b2a2869d25fd0c56394658927bf7c13
3
+ metadata.gz: 5f7f5030dcfb8b5b1f0cc990113f3e01c4dcd361f4c2adba3be90c887bddcd14
4
+ data.tar.gz: 8adfa25722485014bc085b0b04967da252c987ebd55535cae1e203a3ea847241
5
5
  SHA512:
6
- metadata.gz: 447ab36ac29c352d4356f41eaabce61c941abc223726fdbee7f6ae0f6a878ac41a297b16f392cf3b0fb1e50d17b1917e5376f6d88de79667490829d0cd64b077
7
- data.tar.gz: 2c851d97ddae78e810c21efee254a0d52f8b89a9bbab871feb5c09436f854a9847841e4bb8483335b091d432c172dec855bcab30cdd4bcb3a06d64098595ef14
6
+ metadata.gz: 1387c2ac6841339ed6fe3c7f3027b6be26acca97e7cc3cc1c1b5a391113e2088c2ebc384f483993def51b0330a9d59afb07e10c4e9b39113875348203f257fbb
7
+ data.tar.gz: 68b2738560bd07c41688418463ca2f989004ae8cdab533a65774b241245d34d8868f2f5ae2b46b743d076ed5c5e17db2842b485f259fb4bce1b196fc121ad7bf
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## What this is
6
6
 
7
- `postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses Nokogiri. There is no Ghostscript, Inkscape, or other external renderer.
7
+ `postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses leptris. There is no Ghostscript, Inkscape, or other external renderer.
8
8
 
9
9
  Code architecture mirrors `emfsvg`: a typed `Model::Program` value object is the single source of truth, with a Renderer / Visitor visiting it to emit SVG, and a Serializer walking it to emit PS.
10
10
 
@@ -27,6 +27,30 @@ bundle exec ruby exe/postsvg batch INPUT_DIR [OUTPUT_DIR]
27
27
  bundle exec ruby exe/postsvg version
28
28
  ```
29
29
 
30
+ ## Round-trip contract
31
+
32
+ The `round_trip` tag selects the model comparison, SVG summary comparison,
33
+ and byte-pinned emitted-PostScript golden checks:
34
+
35
+ ```bash
36
+ bundle exec rspec --tag round_trip
37
+ bundle exec rake round_trip:regenerate_goldens
38
+ ```
39
+
40
+ These specs also run in the normal CI suite. The regeneration task rewrites
41
+ the `.ps.golden` beside every SVG listed in
42
+ `spec/fixtures/round_trip/manifest.yml`. Regenerate only for intentional output
43
+ changes, inspect the diffs, and include them in a reviewed PR; never edit
44
+ goldens by hand.
45
+
46
+ Manifest `known_gap` and `non_goal` markers are **strict pending**, scoped to
47
+ the `model` or `svg` leg. They do not skip comparisons: an unexpected pass
48
+ fails the suite and requires reviewing/removing the marker. `known_gap`
49
+ identifies a bug requiring follow-up; `non_goal` records intentional loss
50
+ (currently gradient paint flattening). Unmarked legs must pass, and golden
51
+ checks apply even to marked fixtures. See the README Round-trip section for
52
+ the supported subset, comparison precision and parser-callable parity hook.
53
+
30
54
  ## Architecture
31
55
 
32
56
  ```
@@ -54,7 +78,7 @@ PS/EPS bytes ──▶│ Postsvg::Lexer │
54
78
  │ │
55
79
  ▼ │
56
80
  SVG string │
57
- │ Nokogiri │
81
+ │ leptris │
58
82
  ▼ │
59
83
  ┌─────────────────┐ │
60
84
  │ Postsvg:: │ │
@@ -163,7 +187,7 @@ Errors: `Postsvg::ParseError`, `LexError`, `SyntaxError`, `RenderError`, `StackU
163
187
 
164
188
  ## Dependencies
165
189
 
166
- - `parslet`, `thor`, `nokogiri`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
190
+ - `parslet`, `thor`, `leptris`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
167
191
 
168
192
  ## Limitations
169
193
 
data/Gemfile CHANGED
@@ -5,7 +5,7 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  gem "canon"
8
- gem "leptris", "~> 1.9.362"
8
+ gem "leptris", ">= 1.9.399.0"
9
9
  gem "rake"
10
10
  gem "rspec"
11
11
  gem "rubocop"
data/README.adoc CHANGED
@@ -225,8 +225,9 @@ equivalent PS `arc` operator.
225
225
 
226
226
  === Round-trip
227
227
 
228
- The same `Model::Program` value object sits behind both directions, so
229
- round-trip is a function composition:
228
+ The round-trip contract covers a pinned SVG corpus, with per-fixture
229
+ expectations in link:spec/fixtures/round_trip/manifest.yml[manifest.yml].
230
+ The conversion is a function composition:
230
231
 
231
232
  [source,ruby]
232
233
  ----
@@ -236,10 +237,67 @@ original_svg = File.read("input.svg")
236
237
  ps = Postsvg.to_ps(original_svg)
237
238
  round_tripped_svg = Postsvg.to_svg(ps)
238
239
 
239
- # round_tripped_svg preserves the geometry of original_svg for the
240
- # supported subset of SVG elements.
241
240
  ----
242
241
 
242
+ The gate checks two legs independently:
243
+
244
+ * *Normalized model diff*: `RoundTrip::Harness` compares the program built
245
+ from SVG with the program parsed from its serialized PostScript. The
246
+ normalizer compares operators and operand values, rounding floats to the
247
+ serializer's four decimal places.
248
+ * *SVG summary diff*: `RoundTrip::SvgSummary` compares the original SVG with
249
+ the returned SVG as painted geometry in document coordinates. It checks
250
+ fill, stroke, stroke width, line caps and joins, transforms, and text
251
+ content, position, font family and size. Numbers are rounded to two decimal
252
+ places; equivalent shape/path spellings and the renderer's Y-flip wrapper
253
+ are normalized. This is not a byte-for-byte SVG or pixel-rendering test.
254
+
255
+ Covered families are shapes (rectangles, polygons, polylines, line and curve
256
+ paths), line strokes, arcs (circles, ellipses and path arcs), gray/RGB fills,
257
+ combined fill and stroke, stroke styles, text, transforms (translation,
258
+ rotation, matrices and nested groups), and gradients. Coverage does not mean
259
+ every family preserves everything: the manifest records known SVG-leg gaps
260
+ for polyline fill, path arcs and matrix shear. Text checks do not promise font
261
+ metrics or glyph fidelity. Features outside the corpus are not guaranteed by
262
+ this gate.
263
+
264
+ The manifest is authoritative for exceptions on each leg. `known_gap` marks
265
+ a bug; `non_goal` marks intentional loss. Linear and radial gradient `url()`
266
+ paint is intentionally flattened to a plain fill (currently black), so gradient
267
+ preservation is a non-goal even though the model leg passes. Both markers are
268
+ strict pending: the comparison still runs, and an unexpected pass fails so
269
+ the marker must be reviewed and removed when appropriate.
270
+
271
+ The `round_trip` spec tag selects the contract and emitted-PostScript golden
272
+ checks, which also run in the normal CI spec suite. Every manifest fixture has
273
+ a byte-pinned `.ps.golden`; even marked fixtures must match their golden.
274
+ For an intentional output change, regenerate with:
275
+
276
+ [source,sh]
277
+ ----
278
+ bundle exec rake round_trip:regenerate_goldens
279
+ ----
280
+
281
+ Review the resulting diffs and submit them with the change in a reviewed PR.
282
+ Do not edit goldens by hand or regenerate them merely to silence a regression.
283
+
284
+ For `postscript#4` parser parity, the test-support hook
285
+ `RoundTrip::Harness.call(svg, parser: candidate_parser)` accepts a callable
286
+ that receives serialized PS source and returns a compatible program. It
287
+ defaults to `Postscript::Source.parse` and returns an empty diff on a match,
288
+ or indexed expected/actual normalized records on a mismatch. This lets a
289
+ candidate parser reuse the same model contract without changing the public
290
+ conversion API.
291
+
292
+ The model contract also runs every manifest fixture with
293
+ `Postscript::Source.parse(source, engine: :native)`, comparing normalized
294
+ programs against both Ruby parsing and the emitted model. Run it with
295
+ `bundle exec rspec spec/postsvg/round_trip_contract_spec.rb --tag native`.
296
+ When the installed postscript lacks the native API or its optional parsanol
297
+ native engine is unavailable, examples skip with an explicit reason. These
298
+ skips do not establish native parity; see
299
+ `spec/fixtures/round_trip/README.adoc` for prerequisites and marker behavior.
300
+
243
301
  == Command-line interface
244
302
 
245
303
  [[cli]]
data/Rakefile CHANGED
@@ -3,12 +3,29 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
  require "rubocop/rake_task"
6
+ require "yaml"
6
7
 
7
8
  RSpec::Core::RakeTask.new(:spec)
8
9
  RuboCop::RakeTask.new
9
10
 
10
11
  task default: %i[spec rubocop]
11
12
 
13
+ namespace :round_trip do
14
+ desc "Regenerate pinned PostScript output for the round-trip corpus"
15
+ task :regenerate_goldens do
16
+ require "postsvg"
17
+
18
+ root = File.expand_path("spec/fixtures/round_trip", __dir__)
19
+ manifest = YAML.safe_load_file(File.join(root, "manifest.yml"))
20
+
21
+ manifest.fetch("fixtures").each do |entry|
22
+ svg_path = File.join(root, entry.fetch("file"))
23
+ golden_path = svg_path.sub(/\.svg\z/, ".ps.golden")
24
+ File.binwrite(golden_path, Postsvg.to_ps(File.read(svg_path)))
25
+ end
26
+ end
27
+ end
28
+
12
29
  # Link checking tasks
13
30
  namespace :check do
14
31
  desc "Check internal documentation links only (recommended for CI)"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile "../../Gemfile"
4
+
5
+ # Drop this CI-only overlay once postscript releases NativeEngine and
6
+ # parsanol/parsanol-ruby#354 ships a fix for previously baked grammars.
7
+ gem "parsanol", "1.3.109"
8
+ gem "postscript", git: "https://github.com/claricle/postscript.git", branch: "main"
@@ -334,9 +334,8 @@ module Postsvg
334
334
  end
335
335
 
336
336
  def flush_path(mode)
337
- # rubocop:disable Style/ZeroLengthPredicate
337
+ # rubocop:disable-next Style/ZeroLengthPredicate
338
338
  return if @path.length.zero?
339
- # rubocop:enable Style/ZeroLengthPredicate
340
339
 
341
340
  d = @path.to_path
342
341
  path_str = emit_svg_path(d, @g_state, mode)
@@ -11,7 +11,7 @@ module Postsvg
11
11
 
12
12
  module_function
13
13
 
14
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
14
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
15
15
  def endpoint_to_center(x1:, y1:, rx:, ry:, x_axis_rotation:,
16
16
  large_arc:, sweep:, x2:, y2:)
17
17
  rx = rx.abs
@@ -70,7 +70,6 @@ module Postsvg
70
70
 
71
71
  [cx, cy, rx, ry, phi, theta1 * 180.0 / Math::PI, theta2 * 180.0 / Math::PI]
72
72
  end
73
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
74
73
 
75
74
  def angle_between(ux, uy, vx, vy)
76
75
  len = vector_length(ux, uy, vx, vy)
@@ -9,8 +9,8 @@ module Postsvg
9
9
  #
10
10
  # - M/m moveto (relative repeats as l)
11
11
  # - L/l lineto
12
- # - H/h vertical line
13
- # - V/v horizontal line
12
+ # - H/h horizontal line
13
+ # - V/v vertical line
14
14
  # - C/c cubic bezier
15
15
  # - S/s smooth cubic (reflects previous control point)
16
16
  # - Q/q quadratic bezier (converted to cubic)
@@ -50,7 +50,7 @@ module Postsvg
50
50
  when "H", "h"
51
51
  handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "H")
52
52
  when "V", "v"
53
- handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "V")
53
+ handle_hv(cmd, state, context, horizontal: false)
54
54
  when "C", "c"
55
55
  handle_cubic(cmd, state, smooth, context)
56
56
  when "S", "s"
@@ -177,6 +177,10 @@ module Postsvg
177
177
 
178
178
  def self.handle_smooth_quadratic(cmd, state, smooth, context)
179
179
  x3, y3 = cmd.args
180
+ if cmd.opcode == "t"
181
+ x3 += state.current_x
182
+ y3 += state.current_y
183
+ end
180
184
  qx, qy =
181
185
  if smooth.smoothable
182
186
  [reflect_x(smooth.control_x, state),
@@ -190,14 +194,7 @@ module Postsvg
190
194
  c1y = p0y + ((2.0 / 3.0) * (qy - p0y))
191
195
  c2x = x3 + ((2.0 / 3.0) * (qx - x3))
192
196
  c2y = y3 + ((2.0 / 3.0) * (qy - y3))
193
- if cmd.opcode == "T"
194
- state.curve_to(x3, y3)
195
- else
196
- state.curve_to_rel(x3 - p0x, y3 - p0y) # treat as rel target
197
- # Re-anchor to absolute target for emission below.
198
- state.current_x = p0x + x3
199
- state.current_y = p0y + y3
200
- end
197
+ state.curve_to(x3, y3)
201
198
  context.emitter.emit(Model::Operators::Path::Curveto.new(
202
199
  x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: state.current_x, y3: state.current_y,
203
200
  ))
@@ -211,6 +208,13 @@ module Postsvg
211
208
  # W3C-endorsed endpoint-to-center conversion.
212
209
  def self.handle_arc(cmd, state, context)
213
210
  rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y = cmd.args
211
+ if cmd.opcode == "a"
212
+ x += state.current_x
213
+ y += state.current_y
214
+ end
215
+ # SVG omits arcs whose endpoints coincide.
216
+ return if [x, y] == [state.current_x, state.current_y]
217
+
214
218
  cx, cy, normalized_rx, normalized_ry, psi, theta1, theta2 =
215
219
  Postsvg::Translation::ArcConverter.endpoint_to_center(
216
220
  x1: state.current_x, y1: state.current_y,
@@ -218,27 +222,29 @@ module Postsvg
218
222
  large_arc: large_arc_flag != 0, sweep: sweep_flag != 0,
219
223
  x2: x, y2: y
220
224
  )
221
- # Emit arc with center, radius, angle range. For elliptical
222
- # arcs (rx != ry or non-zero rotation), emit scale/rotate
223
- # transforms around the arc.
225
+ # Restore only the CTM: grestore would discard the appended arc.
226
+ arc_class = sweep_flag.zero? ? Model::Operators::Path::Arcn : Model::Operators::Path::Arc
224
227
  if x_axis_rotation.abs > 1e-6 || (rx - ry).abs > 1e-6
225
- context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
228
+ context.emitter.emit(Model::Operators::Transformations::Matrix.new)
229
+ context.emitter.emit(Model::Operators::Transformations::Currentmatrix.new)
226
230
  context.emitter.emit(Model::Operators::Transformations::Concat.new(matrix: [
227
231
  Math.cos(psi), Math.sin(psi), -Math.sin(psi), Math.cos(psi), cx, cy
228
232
  ]))
229
233
  context.emitter.emit(Model::Operators::Transformations::Scale.new(
230
234
  sx: normalized_rx, sy: normalized_ry,
231
235
  ))
232
- context.emitter.emit(Model::Operators::Path::Arc.new(
236
+ context.emitter.emit(arc_class.new(
233
237
  x: 0, y: 0, radius: 1.0, angle1: theta1, angle2: theta2,
234
238
  ))
235
- context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
239
+ context.emitter.emit(Model::Operators::Transformations::Setmatrix.new(
240
+ matrix: Model::Computed.new(operator_keyword: "currentmatrix"),
241
+ ))
236
242
  else
237
- context.emitter.emit(Model::Operators::Path::Arc.new(
243
+ context.emitter.emit(arc_class.new(
238
244
  x: cx, y: cy, radius: normalized_rx, angle1: theta1, angle2: theta2,
239
245
  ))
240
246
  end
241
- state.move_to(x, y)
247
+ state.line_to(x, y)
242
248
  end
243
249
 
244
250
  # ---- helpers ----
@@ -9,7 +9,6 @@ module Postsvg
9
9
  def self.call(element, context)
10
10
  context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
11
11
  emit_transform(element, context)
12
- emit_stroke(element, context)
13
12
  context.emitter.emit(Model::Operators::Path::Newpath.new)
14
13
  points = element.points
15
14
  points.each_slice(2).with_index do |(x, y), idx|
@@ -21,7 +20,7 @@ module Postsvg
21
20
  y: y))
22
21
  end
23
22
  end
24
- context.emitter.emit(Model::Operators::Painting::Stroke.new)
23
+ emit_paint(element, context)
25
24
  context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
26
25
  xs = points.each_slice(2).map(&:first)
27
26
  ys = points.each_slice(2).map(&:last)
@@ -8,7 +8,9 @@ module Postsvg
8
8
  # /<font-family> findfont
9
9
  # <font-size> scalefont
10
10
  # setfont
11
- # <x> <y> moveto
11
+ # <x> <y> translate
12
+ # 1 -1 scale
13
+ # 0 0 moveto
12
14
  # (text) show
13
15
  #
14
16
  # Real font metrics (kerning, ligatures, glyph substitution) are
@@ -32,8 +34,14 @@ module Postsvg
32
34
  context.emitter.emit(Model::Operators::Font::Setfont.new(
33
35
  font: Model::Computed.new(operator_keyword: "scalefont"),
34
36
  ))
35
- context.emitter.emit(Model::Operators::Path::Moveto.new(x: element.x,
36
- y: element.y))
37
+ # The document transform converts SVG's downward y axis to PS.
38
+ # Restore upward font space around the baseline without undoing
39
+ # the authored text/group transforms or moving the anchor.
40
+ context.emitter.emit(Model::Operators::Transformations::Translate.new(
41
+ tx: element.x, ty: element.y,
42
+ ))
43
+ context.emitter.emit(Model::Operators::Transformations::Scale.new(sx: 1, sy: -1))
44
+ context.emitter.emit(Model::Operators::Path::Moveto.new(x: 0, y: 0))
37
45
  context.emitter.emit(Model::Operators::Font::Show.new(text: element.content.to_s))
38
46
 
39
47
  context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
@@ -72,8 +72,9 @@ module Postsvg
72
72
 
73
73
  def compute_viewport_bbox
74
74
  if document.viewbox
75
- llx, lly, urx, ury = document.viewbox
76
- BoundingBox.new(min_x: llx, min_y: lly, max_x: urx, max_y: ury)
75
+ x, y, width, height = document.viewbox
76
+ BoundingBox.new(min_x: x, min_y: y, max_x: x + width,
77
+ max_y: y + height)
77
78
  elsif document.width && document.height
78
79
  BoundingBox.new(min_x: 0, min_y: 0, max_x: document.width,
79
80
  max_y: document.height)
@@ -86,6 +87,17 @@ module Postsvg
86
87
  registry.translate(element, context)
87
88
  end
88
89
 
90
+ # SVG's y axis points down; PostScript's points up. Convert once at
91
+ # the document boundary, before any authored element transforms.
92
+ def coordinate_conversion(bbox)
93
+ emitter = RecordEmitter.new
94
+ emitter.emit(Model::Operators::Transformations::Translate.new(
95
+ tx: 0, ty: bbox[3] - bbox[1],
96
+ ))
97
+ emitter.emit(Model::Operators::Transformations::Scale.new(sx: 1, sy: -1))
98
+ emitter.records
99
+ end
100
+
89
101
  def build_program(emitter, context)
90
102
  bbox = context.bounding_box
91
103
  header_bbox =
@@ -99,7 +111,8 @@ module Postsvg
99
111
  epsf: eps,
100
112
  title: "Translated by Postsvg #{::Postsvg::VERSION}",
101
113
  )
102
- Model::Program.new(header: header, body: emitter.records)
114
+ Model::Program.new(header: header,
115
+ body: coordinate_conversion(header_bbox) + emitter.records)
103
116
  end
104
117
  end
105
118
  end
@@ -7,6 +7,54 @@ module Postsvg
7
7
  # surface so handlers don't all need to know how the program is
8
8
  # stored.
9
9
  class RecordEmitter
10
+ NUMBER_OPERANDS = lambda do |record|
11
+ record.operand_values.map { |value| Model::Literals::Number.new(value) }
12
+ end
13
+ NO_OPERANDS = ->(_record) { [] }
14
+
15
+ OPERAND_BUILDERS = {
16
+ Model::Operators::Color::Setgray => NUMBER_OPERANDS,
17
+ Model::Operators::Color::Setrgbcolor => NUMBER_OPERANDS,
18
+ Model::Operators::GraphicsState::Setlinewidth => NUMBER_OPERANDS,
19
+ Model::Operators::Path::Moveto => NUMBER_OPERANDS,
20
+ Model::Operators::Path::Rmoveto => lambda do |record|
21
+ [Model::Literals::Number.new(record.dx), Model::Literals::Number.new(record.dy)]
22
+ end,
23
+ Model::Operators::Path::Lineto => NUMBER_OPERANDS,
24
+ Model::Operators::Path::Rlineto => NUMBER_OPERANDS,
25
+ Model::Operators::Path::Curveto => NUMBER_OPERANDS,
26
+ Model::Operators::Path::Rcurveto => NUMBER_OPERANDS,
27
+ Model::Operators::Path::Arc => NUMBER_OPERANDS,
28
+ Model::Operators::Path::Arcn => NUMBER_OPERANDS,
29
+ Model::Operators::Transformations::Translate => NUMBER_OPERANDS,
30
+ Model::Operators::Transformations::Scale => NUMBER_OPERANDS,
31
+ Model::Operators::Font::Findfont => lambda do |record|
32
+ # findfont accepts strings as well as names; whitespace must not
33
+ # split a family name into separate PostScript tokens.
34
+ if record.name.match?(/\s/)
35
+ [Model::Literals::StringLiteral.new(record.name)]
36
+ else
37
+ [Model::Literals::Name.new(record.name, literal: true)]
38
+ end
39
+ end,
40
+ Model::Operators::Font::Scalefont => lambda do |record|
41
+ [Model::Literals::Number.new(record.size)]
42
+ end,
43
+ Model::Operators::Font::Show => lambda do |record|
44
+ [Model::Literals::StringLiteral.new(record.text)]
45
+ end,
46
+ Model::Operators::GraphicsState::Setlinecap => lambda do |record|
47
+ [Model::Literals::Number.new(record.cap_code)]
48
+ end,
49
+ Model::Operators::GraphicsState::Setlinejoin => lambda do |record|
50
+ [Model::Literals::Number.new(record.join_code)]
51
+ end,
52
+ Model::Operators::Transformations::Concat => lambda do |record|
53
+ elements = record.matrix.map { |value| Model::Literals::Number.new(value) }
54
+ [Model::Literals::ArrayLiteral.new(elements)]
55
+ end,
56
+ }.freeze
57
+
10
58
  attr_reader :records
11
59
 
12
60
  def initialize
@@ -14,6 +62,12 @@ module Postsvg
14
62
  end
15
63
 
16
64
  def emit(record)
65
+ # Operators built by the svg -> ps handlers carry their
66
+ # operands as attributes; the serializer writes keywords only
67
+ # and expects the operands as separate literal statements
68
+ # ahead of the keyword (the shape the PS parse path produces).
69
+ # Splice them in so the record stream round-trips.
70
+ @records.concat(OPERAND_BUILDERS.fetch(record.class, NO_OPERANDS).call(record))
17
71
  @records << record
18
72
  self
19
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Postsvg
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.6"
5
5
  end
@@ -32,6 +32,7 @@ module Postsvg
32
32
 
33
33
  def visit_scalefont(op, _ctx)
34
34
  size = op.size.to_i
35
+ consume_numeric_operands(op.size)
35
36
  font = @stack.pop
36
37
  base = if font.is_a?(FontRef)
37
38
  font
@@ -7,15 +7,17 @@ module Postsvg
7
7
  # dash attribute setters.
8
8
  module GraphicsState
9
9
  def visit_gsave(_op, _ctx)
10
+ @saved_paths << @path.dup
10
11
  @graphics.gsave
11
12
  end
12
13
 
13
14
  def visit_grestore(_op, _ctx)
15
+ @path = @saved_paths.pop if @graphics.depth > 1 && !@saved_paths.empty?
14
16
  @graphics.grestore
15
17
  end
16
18
 
17
19
  def visit_grestoreall(_op, _ctx)
18
- @graphics.grestore while @graphics.depth > 1
20
+ visit_grestore(nil, nil) while @graphics.depth > 1
19
21
  end
20
22
 
21
23
  def visit_setlinewidth(op, _ctx)
@@ -25,7 +25,7 @@ module Postsvg
25
25
  def visit_clip(_op, _ctx)
26
26
  return if @path.empty?
27
27
 
28
- path_d = @path.to_path_d
28
+ path_d = @path.to_path_d(@graphics.current.ctm)
29
29
  clip_id = @builder.register_clip_path(path_d)
30
30
  @graphics.update(clip_paths: @graphics.current.clip_paths + [clip_id])
31
31
  @path.reset
@@ -43,7 +43,7 @@ module Postsvg
43
43
  def flush_path(mode)
44
44
  return if @path.empty?
45
45
 
46
- d = @path.to_path_d
46
+ d = @path.to_path_d(@graphics.current.ctm)
47
47
  ctx = @graphics.current
48
48
  transform_str = build_transform_string(ctx)
49
49
  clip_id = ctx.clip_paths.last
@@ -5,20 +5,23 @@ module Postsvg
5
5
  class PsVisitor
6
6
  # Path construction operator handlers. Each appends a command to
7
7
  # +@path+ (PathState). CTM-aware: when the current CTM is not
8
- # identity, the renderer will wrap emitted paths in a
9
- # <g transform=>. Points are stored in user-space coordinates;
10
- # the visitor delegates to the SvgBuilder for transform emission.
8
+ # identity, the renderer wraps painted paths in a <g transform=>.
9
+ # Arcs retain their construction CTM and are mapped into the paint CTM
10
+ # at emission, whether or not the temporary transform was restored.
11
+ # Other mid-path CTM changes retain the existing paint-time behavior.
11
12
  module Path
12
13
  def visit_newpath(_op, _ctx)
13
14
  @path.reset
14
15
  end
15
16
 
16
17
  def visit_moveto(op, _ctx)
18
+ @path.matrix ||= @graphics.current.ctm
17
19
  @path.move_to(op.x, op.y)
18
20
  @graphics.update(last_text_position: { x: op.x, y: op.y })
19
21
  end
20
22
 
21
23
  def visit_rmoveto(op, _ctx)
24
+ @path.matrix ||= @graphics.current.ctm
22
25
  @path.move_to_rel(op.dx, op.dy)
23
26
  pos = @graphics.current.last_text_position
24
27
  base_x = pos ? pos[:x] : 0
@@ -66,50 +69,61 @@ module Postsvg
66
69
  # are parametrised by center, radius, angle range. SVG arcs
67
70
  # are parametrised by endpoint, radii, large-arc/sweep flags.
68
71
  def append_arc(cx, cy, radius, angle1, angle2, sweep:)
72
+ consume_numeric_operands(cx, cy, radius, angle1, angle2)
69
73
  r = radius.abs
70
74
  return if r.zero?
71
75
 
72
- start_rad = angle1 * Math::PI / 180.0
73
- start_x = cx + (r * Math.cos(start_rad))
74
- start_y = cy + (r * Math.sin(start_rad))
75
- end_rad = angle2 * Math::PI / 180.0
76
- end_x = cx + (r * Math.cos(end_rad))
77
- end_y = cy + (r * Math.sin(end_rad))
78
-
79
- # If the current path is empty, the arc implicitly starts
80
- # with a moveto to its starting point.
81
- if (@path.empty? || last_command_starts_with?("M")) && @path.empty?
82
- @path.move_to(start_x, start_y)
76
+ construction_matrix = @graphics.current.ctm
77
+ @path.matrix ||= construction_matrix
78
+ point_matrix = @path.matrix.invert.multiply(construction_matrix)
79
+ end_x, end_y = arc_point(point_matrix, cx, cy, r, angle2)
80
+ starts_path = @path.empty?
81
+ command = lambda do |paint_matrix|
82
+ matrix = paint_matrix.invert.multiply(construction_matrix)
83
+ render_arc(cx, cy, r, angle1, angle2, sweep: sweep,
84
+ matrix: matrix, starts_path: starts_path)
83
85
  end
86
+ @path.append_deferred(command, end_x, end_y)
87
+ end
84
88
 
85
- delta = (angle2 - angle1) % 360
86
- delta += 360 if delta.negative?
87
- large_arc = delta > 180
88
-
89
- # SVG sweep flag is 1 when the arc is drawn clockwise.
90
- svg_sweep = if sweep
91
- delta.positive? ? 1 : 0
92
- else
93
- 0
94
- end
89
+ def render_arc(cx, cy, radius, angle1, angle2, sweep:, matrix:, starts_path:)
90
+ path = PathState.new
91
+ start_x, start_y = arc_point(matrix, cx, cy, radius, angle1)
92
+ end_x, end_y = arc_point(matrix, cx, cy, radius, angle2)
93
+ path.move_to(start_x, start_y) if starts_path
94
+ delta = sweep ? (angle2 - angle1) % 360 : (angle1 - angle2) % 360
95
+ rx, ry, rotation = arc_radii(matrix, radius)
96
+ reflected = ((matrix.a * matrix.d) - (matrix.b * matrix.c)).negative?
97
+ svg_sweep = sweep != reflected
95
98
  if delta.abs < 1e-6
96
- # Full circle: emit two arcs.
97
- mid_angle = angle1 + 180
98
- mid_rad = mid_angle * Math::PI / 180.0
99
- mid_x = cx + (r * Math.cos(mid_rad))
100
- mid_y = cy + (r * Math.sin(mid_rad))
101
- @path.arc_to(r, r, 0, 0, svg_sweep, mid_x, mid_y)
102
- @path.arc_to(r, r, 0, 0, svg_sweep, start_x, start_y)
103
- @path.close
99
+ # Full circles need two arcs; retain the corpus's large-arc spelling.
100
+ mid_x, mid_y = arc_point(matrix, cx, cy, radius, angle1 + 180)
101
+ path.arc_to(rx, ry, rotation, true, svg_sweep, mid_x, mid_y)
102
+ path.arc_to(rx, ry, rotation, true, svg_sweep, start_x, start_y)
103
+ path.close
104
104
  else
105
- @path.arc_to(r, r, 0, large_arc ? 1 : 0, svg_sweep, end_x, end_y)
105
+ path.arc_to(rx, ry, rotation, delta > 180, svg_sweep, end_x, end_y)
106
106
  end
107
+ path.to_path_d
107
108
  end
108
109
 
109
- def last_command_starts_with?(prefix)
110
- return false if @path.commands.empty?
110
+ def arc_point(matrix, cx, cy, radius, angle)
111
+ radians = angle * Math::PI / 180.0
112
+ matrix.apply_point(cx + (radius * Math.cos(radians)),
113
+ cy + (radius * Math.sin(radians))).values_at(:x, :y)
114
+ end
111
115
 
112
- @path.commands.last.start_with?(prefix)
116
+ # Singular values give the ellipse radii even when its frame is rotated
117
+ # or sheared. The principal eigenvector gives its SVG axis rotation.
118
+ def arc_radii(matrix, radius)
119
+ xx = (matrix.a**2) + (matrix.c**2)
120
+ xy = (matrix.a * matrix.b) + (matrix.c * matrix.d)
121
+ yy = (matrix.b**2) + (matrix.d**2)
122
+ root = Math.hypot(xx - yy, 2 * xy)
123
+ rx = radius * Math.sqrt([(xx + yy + root) / 2, 0].max)
124
+ ry = radius * Math.sqrt([(xx + yy - root) / 2, 0].max)
125
+ rotation = root < 1e-12 ? 0.0 : Math.atan2(2 * xy, xx - yy) * 90 / Math::PI
126
+ [rx, ry, rotation]
113
127
  end
114
128
  end
115
129
  end
@@ -6,12 +6,19 @@ module Postsvg
6
6
  # Coordinate transformation operator handlers. Each returns a
7
7
  # new CTM via Matrix composition and updates the GraphicsContext.
8
8
  module Transformations
9
+ # A matrix this visitor produced (`matrix`, `currentmatrix`). The
10
+ # runtime stack also holds values nothing popped (`def` operands,
11
+ # unresolved names, path operands), so only these are trusted as a
12
+ # matrix the parser could not see.
13
+ class StackMatrix < Array; end
14
+
9
15
  def visit_translate(op, _ctx)
10
16
  new_ctm = @graphics.current.ctm.translate(op.tx.to_f, op.ty.to_f)
11
17
  @graphics.update(ctm: new_ctm)
12
18
  end
13
19
 
14
20
  def visit_scale(op, _ctx)
21
+ consume_numeric_operands(op.sx, op.sy)
15
22
  new_ctm = @graphics.current.ctm.scale(op.sx.to_f, op.sy.to_f)
16
23
  @graphics.update(ctm: new_ctm)
17
24
  end
@@ -22,7 +29,7 @@ module Postsvg
22
29
  end
23
30
 
24
31
  def visit_concat(op, _ctx)
25
- matrix = matrix_from_operand(op.matrix)
32
+ matrix = matrix_from_operand(matrix_operand(op))
26
33
  return unless matrix
27
34
 
28
35
  new_ctm = @graphics.current.ctm.multiply(matrix)
@@ -30,16 +37,17 @@ module Postsvg
30
37
  end
31
38
 
32
39
  def visit_matrix(_op, _ctx)
33
- @stack << [1, 0, 0, 1, 0, 0]
40
+ @stack << StackMatrix[1, 0, 0, 1, 0, 0]
34
41
  end
35
42
 
36
43
  def visit_currentmatrix(_op, _ctx)
44
+ @stack.pop if @stack.last.is_a?(StackMatrix)
37
45
  ctm = @graphics.current.ctm
38
- @stack << [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
46
+ @stack << StackMatrix[ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
39
47
  end
40
48
 
41
49
  def visit_setmatrix(op, _ctx)
42
- matrix = matrix_from_operand(op.matrix)
50
+ matrix = matrix_from_operand(matrix_operand(op))
43
51
  return unless matrix
44
52
 
45
53
  @graphics.update(ctm: matrix)
@@ -85,16 +93,46 @@ module Postsvg
85
93
 
86
94
  private
87
95
 
96
+ # Parsed literal operands remain on the visitor stack. Consume only
97
+ # an exact numeric suffix, leaving directly constructed operators alone.
98
+ def consume_numeric_operands(*values)
99
+ @stack.pop(values.length) if @stack.last(values.length) == values
100
+ end
101
+
102
+ # A literal matrix was also pushed by visit_array; consume that copy
103
+ # so a later operator does not apply it again. A computed matrix is
104
+ # taken from the stack only when this visitor produced it.
105
+ def matrix_operand(op)
106
+ unless op.matrix.is_a?(Model::Computed)
107
+ @stack.pop if literal_on_stack?(op.matrix)
108
+ return op.matrix
109
+ end
110
+
111
+ @stack.pop if @stack.last.is_a?(StackMatrix)
112
+ end
113
+
114
+ def literal_on_stack?(matrix)
115
+ matrix.is_a?(Model::Literals::ArrayLiteral) && @stack.last.equal?(matrix.elements)
116
+ end
117
+
118
+ # Arrays on the runtime stack hold either plain numbers or parsed
119
+ # Number literals. Anything other than six numbers is not a matrix.
88
120
  def matrix_from_operand(operand)
89
121
  case operand
90
122
  when Matrix then operand
123
+ when Model::Literals::ArrayLiteral then matrix_from_operand(operand.elements)
91
124
  when Array
92
- return nil unless operand.length == 6
125
+ numbers = operand.map { |element| matrix_number(element) }
126
+ return nil unless numbers.length == 6 && numbers.all?
93
127
 
94
- Matrix.new(a: operand[0], b: operand[1], c: operand[2],
95
- d: operand[3], e: operand[4], f: operand[5])
128
+ Matrix.new(a: numbers[0], b: numbers[1], c: numbers[2],
129
+ d: numbers[3], e: numbers[4], f: numbers[5])
96
130
  end
97
131
  end
132
+
133
+ def matrix_number(element)
134
+ numeric_value(element) if element.is_a?(Numeric) || element.is_a?(Model::Literals::Number)
135
+ end
98
136
  end
99
137
  end
100
138
  end
@@ -36,6 +36,7 @@ module Postsvg
36
36
  @graphics = graphics
37
37
  @stack = [] # operand stack for execution semantics
38
38
  @path = PathState.new
39
+ @saved_paths = []
39
40
  @dict_stack = [{}] # runtime dict stack (mirrors PLRM dict stack)
40
41
  @recursion_depth = 0
41
42
  end
@@ -116,6 +117,7 @@ module Postsvg
116
117
  # geometry bookkeeping.
117
118
  class PathState
118
119
  attr_reader :commands, :current_x, :current_y
120
+ attr_accessor :matrix
119
121
 
120
122
  def initialize
121
123
  @commands = []
@@ -127,6 +129,11 @@ module Postsvg
127
129
  @commands.empty?
128
130
  end
129
131
 
132
+ def initialize_copy(other)
133
+ super
134
+ @commands = other.commands.map(&:dup)
135
+ end
136
+
130
137
  def move_to(x, y)
131
138
  @commands << "M #{fmt(x)} #{fmt(y)}"
132
139
  @current_x = x.to_f
@@ -170,18 +177,27 @@ module Postsvg
170
177
  @current_y = y.to_f
171
178
  end
172
179
 
180
+ def append_deferred(command, x, y)
181
+ @commands << command
182
+ @current_x = x
183
+ @current_y = y
184
+ end
185
+
173
186
  def close
174
187
  @commands << "Z"
175
188
  end
176
189
 
177
190
  def reset
191
+ @matrix = nil
178
192
  @commands = []
179
193
  @current_x = 0.0
180
194
  @current_y = 0.0
181
195
  end
182
196
 
183
- def to_path_d
184
- @commands.join(" ")
197
+ def to_path_d(paint_matrix = @matrix || Matrix.new)
198
+ @commands.map do |command|
199
+ command.is_a?(Proc) ? command.call(paint_matrix) : command
200
+ end.join(" ")
185
201
  end
186
202
 
187
203
  def fmt(value)
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: postsvg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-16 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: leptris
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 1.9.362
19
+ version: 1.9.399.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 1.9.362
26
+ version: 1.9.399.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: parslet
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -186,6 +186,7 @@ files:
186
186
  - docs/troubleshooting.adoc
187
187
  - docs/validation.adoc
188
188
  - exe/postsvg
189
+ - gemfiles/native-parity/Gemfile
189
190
  - lib/postsvg.rb
190
191
  - lib/postsvg/cli.rb
191
192
  - lib/postsvg/colors.rb