postsvg 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +533 -49
- data/CLAUDE.md +27 -3
- data/Gemfile +2 -2
- data/README.adoc +62 -4
- data/Rakefile +17 -0
- data/gemfiles/native-parity/Gemfile +8 -0
- data/lib/postsvg/cli.rb +10 -5
- data/lib/postsvg/graphics_context.rb +2 -1
- data/lib/postsvg/interpreter.rb +1 -2
- data/lib/postsvg/options.rb +1 -1
- data/lib/postsvg/parser/postscript_parser.rb +7 -7
- data/lib/postsvg/renderer.rb +7 -3
- data/lib/postsvg/svg/clip_path_registry.rb +3 -1
- data/lib/postsvg/svg/element.rb +4 -2
- data/lib/postsvg/svg/elements/ellipse.rb +2 -1
- data/lib/postsvg/svg/elements/svg.rb +2 -1
- data/lib/postsvg/svg/paint.rb +1 -1
- data/lib/postsvg/svg/parser.rb +3 -3
- data/lib/postsvg/svg/path_data/parser.rb +10 -6
- data/lib/postsvg/svg/stroke.rb +4 -2
- data/lib/postsvg/svg/transform_list.rb +1 -1
- data/lib/postsvg/svg.rb +1 -1
- data/lib/postsvg/svg_builder.rb +15 -11
- data/lib/postsvg/translation/arc_converter.rb +23 -14
- data/lib/postsvg/translation/bounding_box.rb +7 -4
- data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
- data/lib/postsvg/translation/handlers/ellipse_handler.rb +8 -3
- data/lib/postsvg/translation/handlers/group_handler.rb +2 -1
- data/lib/postsvg/translation/handlers/image_handler.rb +3 -2
- data/lib/postsvg/translation/handlers/line_handler.rb +6 -2
- data/lib/postsvg/translation/handlers/open_handler.rb +3 -2
- data/lib/postsvg/translation/handlers/path_handler.rb +82 -60
- data/lib/postsvg/translation/handlers/polygon_handler.rb +4 -2
- data/lib/postsvg/translation/handlers/polyline_handler.rb +5 -4
- data/lib/postsvg/translation/handlers/rect_handler.rb +10 -5
- data/lib/postsvg/translation/handlers/shared.rb +11 -11
- data/lib/postsvg/translation/handlers/svg_handler.rb +3 -2
- data/lib/postsvg/translation/handlers/text_handler.rb +17 -8
- data/lib/postsvg/translation/handlers.rb +2 -1
- data/lib/postsvg/translation/ps_renderer.rb +18 -4
- data/lib/postsvg/translation/record_emitter.rb +54 -0
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
- data/lib/postsvg/visitors/ps_visitor/color.rb +8 -5
- data/lib/postsvg/visitors/ps_visitor/common.rb +1 -2
- data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
- data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
- data/lib/postsvg/visitors/ps_visitor/dictionary.rb +8 -4
- data/lib/postsvg/visitors/ps_visitor/font.rb +15 -4
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +7 -5
- data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
- data/lib/postsvg/visitors/ps_visitor/path.rb +52 -33
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +45 -7
- data/lib/postsvg/visitors/ps_visitor.rb +18 -2
- data/lib/postsvg.rb +43 -38
- metadata +20 -39
- data/lib/postsvg/color.rb +0 -132
- data/lib/postsvg/format_number.rb +0 -22
- data/lib/postsvg/matrix.rb +0 -106
- data/lib/postsvg/model/literals/array.rb +0 -41
- data/lib/postsvg/model/literals/dictionary.rb +0 -34
- data/lib/postsvg/model/literals/hex.rb +0 -37
- data/lib/postsvg/model/literals/name.rb +0 -40
- data/lib/postsvg/model/literals/number.rb +0 -36
- data/lib/postsvg/model/literals/procedure.rb +0 -41
- data/lib/postsvg/model/literals/string.rb +0 -30
- data/lib/postsvg/model/literals.rb +0 -19
- data/lib/postsvg/model/operator.rb +0 -58
- data/lib/postsvg/model/operators/arithmetic.rb +0 -264
- data/lib/postsvg/model/operators/boolean.rb +0 -182
- data/lib/postsvg/model/operators/color.rb +0 -74
- data/lib/postsvg/model/operators/container.rb +0 -186
- data/lib/postsvg/model/operators/control_flow.rb +0 -119
- data/lib/postsvg/model/operators/device.rb +0 -21
- data/lib/postsvg/model/operators/dictionary.rb +0 -118
- data/lib/postsvg/model/operators/font.rb +0 -121
- data/lib/postsvg/model/operators/graphics_state.rb +0 -84
- data/lib/postsvg/model/operators/painting.rb +0 -29
- data/lib/postsvg/model/operators/path.rb +0 -169
- data/lib/postsvg/model/operators/stack.rb +0 -72
- data/lib/postsvg/model/operators/transformations.rb +0 -103
- data/lib/postsvg/model/operators.rb +0 -89
- data/lib/postsvg/model/program.rb +0 -68
- data/lib/postsvg/model/token.rb +0 -43
- data/lib/postsvg/model.rb +0 -17
- data/lib/postsvg/serializer.rb +0 -325
- data/lib/postsvg/source/ast_builder.rb +0 -308
- data/lib/postsvg/source/lexer.rb +0 -322
- data/lib/postsvg/source/operand_stack.rb +0 -55
- data/lib/postsvg/source.rb +0 -21
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
|
|
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
|
-
│
|
|
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`, `
|
|
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
data/README.adoc
CHANGED
|
@@ -225,8 +225,9 @@ equivalent PS `arc` operator.
|
|
|
225
225
|
|
|
226
226
|
=== Round-trip
|
|
227
227
|
|
|
228
|
-
The
|
|
229
|
-
|
|
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"
|
data/lib/postsvg/cli.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Postsvg
|
|
|
8
8
|
class CLI < Thor
|
|
9
9
|
package_name "postsvg"
|
|
10
10
|
|
|
11
|
-
desc "convert INPUT [OUTPUT]",
|
|
11
|
+
desc "convert INPUT [OUTPUT]",
|
|
12
|
+
"Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
|
|
12
13
|
long_desc <<~DESC
|
|
13
14
|
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
|
|
14
15
|
|
|
@@ -20,7 +21,8 @@ module Postsvg
|
|
|
20
21
|
$ postsvg convert input.eps > output.svg
|
|
21
22
|
DESC
|
|
22
23
|
def convert(input_path, output_path = nil)
|
|
23
|
-
handle_input(input_path, output_path, method(:read_ps_to_svg),
|
|
24
|
+
handle_input(input_path, output_path, method(:read_ps_to_svg),
|
|
25
|
+
direction: "PS/EPS -> SVG")
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
|
|
@@ -42,7 +44,8 @@ module Postsvg
|
|
|
42
44
|
$ postsvg to-ps input.svg > output.ps
|
|
43
45
|
DESC
|
|
44
46
|
def to_ps(input_path, output_path = nil)
|
|
45
|
-
handle_input(input_path, output_path, method(:read_svg_to_ps),
|
|
47
|
+
handle_input(input_path, output_path, method(:read_svg_to_ps),
|
|
48
|
+
direction: "SVG -> PS")
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
|
|
@@ -51,10 +54,12 @@ module Postsvg
|
|
|
51
54
|
suitable for embedding.
|
|
52
55
|
DESC
|
|
53
56
|
def to_eps(input_path, output_path = nil)
|
|
54
|
-
handle_input(input_path, output_path, method(:read_svg_to_eps),
|
|
57
|
+
handle_input(input_path, output_path, method(:read_svg_to_eps),
|
|
58
|
+
direction: "SVG -> EPS")
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
desc "batch INPUT_DIR [OUTPUT_DIR]",
|
|
61
|
+
desc "batch INPUT_DIR [OUTPUT_DIR]",
|
|
62
|
+
"Convert all PS/EPS/SVG files in a directory"
|
|
58
63
|
long_desc <<~DESC
|
|
59
64
|
Auto-detects direction by file extension:
|
|
60
65
|
|
|
@@ -60,7 +60,8 @@ module Postsvg
|
|
|
60
60
|
font_name: overrides.fetch(:font_name, @font_name),
|
|
61
61
|
font_size: overrides.fetch(:font_size, @font_size),
|
|
62
62
|
clip_paths: overrides.fetch(:clip_paths, @clip_paths),
|
|
63
|
-
last_text_position: overrides.fetch(:last_text_position,
|
|
63
|
+
last_text_position: overrides.fetch(:last_text_position,
|
|
64
|
+
@last_text_position),
|
|
64
65
|
fill_rule: overrides.fetch(:fill_rule, @fill_rule),
|
|
65
66
|
)
|
|
66
67
|
end
|
data/lib/postsvg/interpreter.rb
CHANGED
|
@@ -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)
|
data/lib/postsvg/options.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Postsvg
|
|
|
38
38
|
(str("\\") >> any) | (str(")").absent? >> any)
|
|
39
39
|
end
|
|
40
40
|
rule(:string) do
|
|
41
|
-
|
|
41
|
+
str("(") >> string_char.repeat.as(:string) >> str(")")
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Operators and keywords
|
|
@@ -49,16 +49,16 @@ module Postsvg
|
|
|
49
49
|
|
|
50
50
|
# Arrays
|
|
51
51
|
rule(:array) do
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
str("[") >> whitespace? >>
|
|
53
|
+
value.repeat.as(:array) >>
|
|
54
|
+
whitespace? >> str("]")
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Procedures (code blocks in curly braces)
|
|
58
58
|
rule(:procedure) do
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
str("{") >> whitespace? >>
|
|
60
|
+
statement.repeat.as(:procedure) >>
|
|
61
|
+
whitespace? >> str("}")
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Values
|
data/lib/postsvg/renderer.rb
CHANGED
|
@@ -13,7 +13,10 @@ module Postsvg
|
|
|
13
13
|
attr_reader :program, :options, :builder, :visitor
|
|
14
14
|
|
|
15
15
|
def initialize(program, options)
|
|
16
|
-
|
|
16
|
+
unless program.is_a?(Model::Program)
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"program must be a Model::Program"
|
|
19
|
+
end
|
|
17
20
|
|
|
18
21
|
@program = program
|
|
19
22
|
@options = options
|
|
@@ -25,7 +28,7 @@ module Postsvg
|
|
|
25
28
|
open_svg
|
|
26
29
|
wrap_y_flip do
|
|
27
30
|
visitor.visit_program(program)
|
|
28
|
-
rescue
|
|
31
|
+
rescue Postscript::QuitSignal
|
|
29
32
|
# PS +quit+ ends program execution; suppress and finalize SVG.
|
|
30
33
|
end
|
|
31
34
|
builder.close_svg
|
|
@@ -62,7 +65,8 @@ module Postsvg
|
|
|
62
65
|
[viewbox, width, height]
|
|
63
66
|
else
|
|
64
67
|
width, height = options.page_size
|
|
65
|
-
["0 0 #{FormatNumber.call(width)} #{FormatNumber.call(height)}", width,
|
|
68
|
+
["0 0 #{FormatNumber.call(width)} #{FormatNumber.call(height)}", width,
|
|
69
|
+
height]
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
|
|
@@ -34,7 +34,9 @@ module Postsvg
|
|
|
34
34
|
# Concatenate the d attributes of all child <path> elements
|
|
35
35
|
# in document order. Other shape children are converted to
|
|
36
36
|
# path data lazily; for now we only support <path>.
|
|
37
|
-
ds = node.xpath(".//*[local-name()='path']").
|
|
37
|
+
ds = node.xpath(".//*[local-name()='path']").filter_map do |p|
|
|
38
|
+
p["d"]
|
|
39
|
+
end
|
|
38
40
|
registry[id] = ds.join(" ") unless ds.empty?
|
|
39
41
|
end
|
|
40
42
|
new(registry)
|
data/lib/postsvg/svg/element.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Postsvg
|
|
|
6
6
|
# carry typed semantic fields; the base provides a uniform interface
|
|
7
7
|
# for the translation layer's dispatch.
|
|
8
8
|
#
|
|
9
|
-
# Element instances are immutable: built once from a
|
|
9
|
+
# Element instances are immutable: built once from a leptris node
|
|
10
10
|
# via the .from_node factory. The node is not retained.
|
|
11
11
|
#
|
|
12
12
|
# Every element exposes `children`, `transform`, `clip_path_id`,
|
|
@@ -62,7 +62,9 @@ module Postsvg
|
|
|
62
62
|
return subclass.from_node(node) if subclass
|
|
63
63
|
|
|
64
64
|
OpenElement.new(name: node.name,
|
|
65
|
-
children: node.element_children.map
|
|
65
|
+
children: node.element_children.map do |c|
|
|
66
|
+
from_node(c)
|
|
67
|
+
end)
|
|
66
68
|
end
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -9,7 +9,8 @@ module Postsvg
|
|
|
9
9
|
|
|
10
10
|
attr_reader :cx, :cy, :rx, :ry, :fill, :stroke_paint, :stroke
|
|
11
11
|
|
|
12
|
-
def initialize(cx:, cy:, rx:, ry:, fill:, stroke_paint:, stroke:,
|
|
12
|
+
def initialize(cx:, cy:, rx:, ry:, fill:, stroke_paint:, stroke:,
|
|
13
|
+
**rest)
|
|
13
14
|
super(**rest)
|
|
14
15
|
@cx = cx
|
|
15
16
|
@cy = cy
|
|
@@ -9,7 +9,8 @@ module Postsvg
|
|
|
9
9
|
|
|
10
10
|
attr_reader :viewbox, :width, :height, :children
|
|
11
11
|
|
|
12
|
-
def initialize(viewbox: nil, width: nil, height: nil, children: [],
|
|
12
|
+
def initialize(viewbox: nil, width: nil, height: nil, children: [],
|
|
13
|
+
**rest)
|
|
13
14
|
super(**rest)
|
|
14
15
|
@viewbox = viewbox
|
|
15
16
|
@width = width
|
data/lib/postsvg/svg/paint.rb
CHANGED
data/lib/postsvg/svg/parser.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "leptris"
|
|
4
4
|
|
|
5
5
|
module Postsvg
|
|
6
6
|
module Svg
|
|
7
|
-
# Parses an SVG document via
|
|
7
|
+
# Parses an SVG document via leptris and produces an Svg::Document.
|
|
8
8
|
# Pure: no I/O, no global state.
|
|
9
9
|
class Parser
|
|
10
10
|
def self.call(svg_string)
|
|
11
|
-
doc =
|
|
11
|
+
doc = Leptris::XML.parse(svg_string, options: Leptris::XML::ParseOptions.noblanks)
|
|
12
12
|
root = doc.root
|
|
13
13
|
raise TranslationError, "no root element in SVG" unless root
|
|
14
14
|
|
|
@@ -10,7 +10,7 @@ module Postsvg
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
COMMAND_RE = /([MmLlHhVvCcSsQqTtAaZz])/
|
|
13
|
-
ARG_RE = /(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)
|
|
13
|
+
ARG_RE = /(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/
|
|
14
14
|
|
|
15
15
|
def parse(text)
|
|
16
16
|
return [] if text.nil? || text.empty?
|
|
@@ -36,7 +36,11 @@ module Postsvg
|
|
|
36
36
|
commands << Command.new(opcode: opcode, args: args)
|
|
37
37
|
i += consumed
|
|
38
38
|
# After moveto, implicit lineto for subsequent tuples.
|
|
39
|
-
opcode =
|
|
39
|
+
opcode = if opcode == "M"
|
|
40
|
+
"L"
|
|
41
|
+
else
|
|
42
|
+
(opcode == "m" ? "l" : opcode)
|
|
43
|
+
end
|
|
40
44
|
# For H/V/Z, no implicit repetition.
|
|
41
45
|
break if %w[H V Z h v z].include?(opcode)
|
|
42
46
|
end
|
|
@@ -57,12 +61,12 @@ module Postsvg
|
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def tokenize(text)
|
|
60
|
-
text.to_s.gsub(
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
text.to_s.gsub("-", " -")
|
|
65
|
+
.scan(/([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/)
|
|
66
|
+
.flatten.compact
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z
|
|
69
|
+
NUMBER_ONLY_RE = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/
|
|
66
70
|
|
|
67
71
|
def take_args(tokens, start, count)
|
|
68
72
|
args = []
|
data/lib/postsvg/svg/stroke.rb
CHANGED
|
@@ -14,10 +14,12 @@ module Postsvg
|
|
|
14
14
|
if dasharray_raw && dasharray_raw != "none"
|
|
15
15
|
AttributeParser.number_list(dasharray_raw)
|
|
16
16
|
end
|
|
17
|
-
dashoffset = AttributeParser.number(node["stroke-dashoffset"],
|
|
17
|
+
dashoffset = AttributeParser.number(node["stroke-dashoffset"],
|
|
18
|
+
default: 0.0)
|
|
18
19
|
linecap = (node["stroke-linecap"] || "butt").to_sym
|
|
19
20
|
linejoin = (node["stroke-linejoin"] || "miter").to_sym
|
|
20
|
-
miterlimit = AttributeParser.number(node["stroke-miterlimit"],
|
|
21
|
+
miterlimit = AttributeParser.number(node["stroke-miterlimit"],
|
|
22
|
+
default: 10.0)
|
|
21
23
|
new(width: width, dasharray: dasharray, dashoffset: dashoffset,
|
|
22
24
|
linecap: linecap, linejoin: linejoin, miterlimit: miterlimit)
|
|
23
25
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Postsvg
|
|
4
4
|
module Svg
|
|
5
5
|
# Parsed SVG transform attribute. Produces a list of
|
|
6
|
-
#
|
|
6
|
+
# Postscript::Matrix instances that, multiplied in order, give the
|
|
7
7
|
# composite CTM for the element.
|
|
8
8
|
class TransformList
|
|
9
9
|
attr_reader :matrices
|
data/lib/postsvg/svg.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Postsvg
|
|
4
|
-
# SVG domain model. Built once from a
|
|
4
|
+
# SVG domain model. Built once from a leptris-parsed document;
|
|
5
5
|
# consumed by the Translation layer (SVG -> PS).
|
|
6
6
|
#
|
|
7
7
|
# Svg::* value objects know nothing about PostScript. Their job is
|
data/lib/postsvg/svg_builder.rb
CHANGED
|
@@ -11,9 +11,9 @@ module Postsvg
|
|
|
11
11
|
# - <defs> block orders clipPaths, gradients, then patterns by
|
|
12
12
|
# registration order so output is reproducible.
|
|
13
13
|
class SvgBuilder
|
|
14
|
-
CLIP_PREFIX = "clip"
|
|
15
|
-
GRADIENT_PREFIX = "grad"
|
|
16
|
-
PATTERN_PREFIX = "pattern"
|
|
14
|
+
CLIP_PREFIX = "clip"
|
|
15
|
+
GRADIENT_PREFIX = "grad"
|
|
16
|
+
PATTERN_PREFIX = "pattern"
|
|
17
17
|
|
|
18
18
|
def initialize
|
|
19
19
|
@buffer = String.new(capacity: 4096)
|
|
@@ -132,7 +132,7 @@ module Postsvg
|
|
|
132
132
|
def path(d:, mode:, color: nil, stroke_color: nil,
|
|
133
133
|
stroke_width: nil, line_cap: nil, line_join: nil,
|
|
134
134
|
dash: nil, clip_path_id: nil, fill_id: nil)
|
|
135
|
-
attrs =
|
|
135
|
+
attrs = "d=\"#{d}\""
|
|
136
136
|
|
|
137
137
|
attrs << case mode
|
|
138
138
|
when :fill
|
|
@@ -140,7 +140,8 @@ module Postsvg
|
|
|
140
140
|
when :stroke
|
|
141
141
|
%( fill="none" stroke="#{color_attr(stroke_color || color)}")
|
|
142
142
|
when :fill_and_stroke
|
|
143
|
-
"#{fill_attr(color,
|
|
143
|
+
"#{fill_attr(color,
|
|
144
|
+
fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
|
|
144
145
|
else
|
|
145
146
|
%( fill="none" stroke="none")
|
|
146
147
|
end
|
|
@@ -184,7 +185,10 @@ module Postsvg
|
|
|
184
185
|
|
|
185
186
|
def close_svg
|
|
186
187
|
raise RenderError, "svg not open" unless @svg_open
|
|
187
|
-
|
|
188
|
+
if @group_depth.positive?
|
|
189
|
+
raise RenderError,
|
|
190
|
+
"unclosed groups: #{@group_depth}"
|
|
191
|
+
end
|
|
188
192
|
|
|
189
193
|
@buffer << "<defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
|
|
190
194
|
@buffer << @defs_buffer
|
|
@@ -239,11 +243,11 @@ module Postsvg
|
|
|
239
243
|
|
|
240
244
|
def escape(text)
|
|
241
245
|
text.to_s
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
.gsub("&", "&")
|
|
247
|
+
.gsub("<", "<")
|
|
248
|
+
.gsub(">", ">")
|
|
249
|
+
.gsub('"', """)
|
|
250
|
+
.gsub("'", "'")
|
|
247
251
|
end
|
|
248
252
|
end
|
|
249
253
|
end
|
|
@@ -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
|
|
@@ -26,8 +26,8 @@ module Postsvg
|
|
|
26
26
|
# Step 1: rotate and scale (x1,y1) into the unit-circle frame.
|
|
27
27
|
dx = (x1 - x2) / 2.0
|
|
28
28
|
dy = (y1 - y2) / 2.0
|
|
29
|
-
x1p =
|
|
30
|
-
y1p = -sin_phi * dx + cos_phi * dy
|
|
29
|
+
x1p = (cos_phi * dx) + (sin_phi * dy)
|
|
30
|
+
y1p = (-sin_phi * dx) + (cos_phi * dy)
|
|
31
31
|
|
|
32
32
|
# Ensure radii are large enough; if not, scale them up.
|
|
33
33
|
rx_sq = rx * rx
|
|
@@ -44,17 +44,17 @@ module Postsvg
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Step 2: compute center (cx', cy') in the rotated frame.
|
|
47
|
-
sign =
|
|
48
|
-
num = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq
|
|
49
|
-
den = rx_sq * y1p_sq + ry_sq * x1p_sq
|
|
47
|
+
sign = large_arc == sweep ? -1 : 1
|
|
48
|
+
num = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq)
|
|
49
|
+
den = (rx_sq * y1p_sq) + (ry_sq * x1p_sq)
|
|
50
50
|
den = EPSILON if den.abs < EPSILON
|
|
51
51
|
coef = sign * Math.sqrt([num / den, 0.0].max)
|
|
52
52
|
cxp = coef * (rx * y1p) / ry
|
|
53
53
|
cyp = -coef * (ry * x1p) / rx
|
|
54
54
|
|
|
55
55
|
# Step 3: rotate (cx', cy') back into original frame.
|
|
56
|
-
cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
|
|
57
|
-
cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
|
|
56
|
+
cx = (cos_phi * cxp) - (sin_phi * cyp) + ((x1 + x2) / 2.0)
|
|
57
|
+
cy = (sin_phi * cxp) + (cos_phi * cyp) + ((y1 + y2) / 2.0)
|
|
58
58
|
|
|
59
59
|
# Step 4: compute angles.
|
|
60
60
|
ux = (x1p - cxp) / rx
|
|
@@ -70,16 +70,25 @@ 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
|
-
|
|
77
|
-
len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
|
|
75
|
+
len = vector_length(ux, uy, vx, vy)
|
|
78
76
|
return 0.0 if len < EPSILON
|
|
79
77
|
|
|
80
|
-
cos_val = (
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
cos_val = (dot_product(ux, uy, vx, vy) / len).clamp(-1.0, 1.0)
|
|
79
|
+
cross_sign(ux, uy, vx, vy) * Math.acos(cos_val)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def vector_length(ux, uy, vx, vy)
|
|
83
|
+
Math.sqrt(((ux * ux) + (uy * uy)) * ((vx * vx) + (vy * vy)))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dot_product(ux, uy, vx, vy)
|
|
87
|
+
(ux * vx) + (uy * vy)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cross_sign(ux, uy, vx, vy)
|
|
91
|
+
((ux * vy) - (uy * vx)).negative? ? -1.0 : 1.0
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
end
|
|
@@ -27,11 +27,14 @@ module Postsvg
|
|
|
27
27
|
def expand(x_range, y_range)
|
|
28
28
|
return self if x_range.nil? || y_range.nil?
|
|
29
29
|
|
|
30
|
-
new_min_x = [(empty? ? x_range.begin : @min_x),
|
|
30
|
+
new_min_x = [(empty? ? x_range.begin : @min_x),
|
|
31
|
+
x_range.begin].compact.min
|
|
31
32
|
new_max_x = [(empty? ? x_range.end : @max_x), x_range.end].compact.max
|
|
32
|
-
new_min_y = [(empty? ? y_range.begin : @min_y),
|
|
33
|
+
new_min_y = [(empty? ? y_range.begin : @min_y),
|
|
34
|
+
y_range.begin].compact.min
|
|
33
35
|
new_max_y = [(empty? ? y_range.end : @max_y), y_range.end].compact.max
|
|
34
|
-
BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x,
|
|
36
|
+
BoundingBox.new(min_x: new_min_x, min_y: new_min_y, max_x: new_max_x,
|
|
37
|
+
max_y: new_max_y)
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def to_a
|
|
@@ -51,7 +54,7 @@ module Postsvg
|
|
|
51
54
|
|
|
52
55
|
"%s %s %s %s" % [
|
|
53
56
|
FormatNumber.call(@min_x), FormatNumber.call(@min_y),
|
|
54
|
-
FormatNumber.call(@max_x), FormatNumber.call(@max_y)
|
|
57
|
+
FormatNumber.call(@max_x), FormatNumber.call(@max_y)
|
|
55
58
|
]
|
|
56
59
|
end
|
|
57
60
|
end
|