postsvg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +19 -0
  3. data/.rubocop_todo.yml +141 -0
  4. data/Gemfile +15 -0
  5. data/LICENSE +25 -0
  6. data/README.adoc +473 -0
  7. data/Rakefile +10 -0
  8. data/docs/POSTSCRIPT.adoc +13 -0
  9. data/docs/postscript/fundamentals.adoc +356 -0
  10. data/docs/postscript/graphics-model.adoc +406 -0
  11. data/docs/postscript/implementation-notes.adoc +314 -0
  12. data/docs/postscript/index.adoc +153 -0
  13. data/docs/postscript/operators/arithmetic.adoc +461 -0
  14. data/docs/postscript/operators/control-flow.adoc +230 -0
  15. data/docs/postscript/operators/dictionary.adoc +191 -0
  16. data/docs/postscript/operators/graphics-state.adoc +528 -0
  17. data/docs/postscript/operators/index.adoc +288 -0
  18. data/docs/postscript/operators/painting.adoc +475 -0
  19. data/docs/postscript/operators/path-construction.adoc +553 -0
  20. data/docs/postscript/operators/stack-manipulation.adoc +374 -0
  21. data/docs/postscript/operators/transformations.adoc +479 -0
  22. data/docs/postscript/svg-mapping.adoc +369 -0
  23. data/exe/postsvg +6 -0
  24. data/lib/postsvg/cli.rb +103 -0
  25. data/lib/postsvg/colors.rb +33 -0
  26. data/lib/postsvg/converter.rb +214 -0
  27. data/lib/postsvg/errors.rb +11 -0
  28. data/lib/postsvg/graphics_state.rb +158 -0
  29. data/lib/postsvg/interpreter.rb +891 -0
  30. data/lib/postsvg/matrix.rb +106 -0
  31. data/lib/postsvg/parser/postscript_parser.rb +87 -0
  32. data/lib/postsvg/parser/transform.rb +21 -0
  33. data/lib/postsvg/parser.rb +18 -0
  34. data/lib/postsvg/path_builder.rb +101 -0
  35. data/lib/postsvg/svg_generator.rb +78 -0
  36. data/lib/postsvg/tokenizer.rb +161 -0
  37. data/lib/postsvg/version.rb +5 -0
  38. data/lib/postsvg.rb +78 -0
  39. data/postsvg.gemspec +38 -0
  40. data/scripts/regenerate_fixtures.rb +28 -0
  41. metadata +118 -0
@@ -0,0 +1,891 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matrix"
4
+ require_relative "path_builder"
5
+ require_relative "colors"
6
+ require_relative "tokenizer"
7
+
8
+ module Postsvg
9
+ # Stack-based PostScript interpreter
10
+ class Interpreter
11
+ FILL_ONLY = { stroke: false, fill: true }.freeze
12
+ STROKE_ONLY = { stroke: true, fill: false }.freeze
13
+ FILL_AND_STROKE = { stroke: true, fill: true }.freeze
14
+
15
+ DEFAULT_IMAGE = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy" \
16
+ "53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIj4KICA" \
17
+ "8dGV4dCB4PSIyNCIgeT0iMTk4IiBmaWxsPSJ3aGl0ZSIgZm9udC1zaXplPSIxM" \
18
+ "jgiPkltYWdlbTwvdGV4dD4KICA8dGV4dCB4PSIyNCIgeT0iMjk4IiBmaWxsPSJ" \
19
+ "3aGl0ZSIgZm9udC1zaXplPSIxMjgiPk5vdDwvdGV4dD4KICA8dGV4dCB4PSIyN" \
20
+ "CIgeT0iMzk4IiBmaWxsPSJ3aGl0ZSIgZm9udC1zaXplPSIxMjgiPkZvdW5kPC9" \
21
+ "0ZXh0Pgo8L3N2Zz4K"
22
+
23
+ PATH_TERMINATORS = %w[stroke fill show moveto newpath].freeze
24
+ PATH_CONTINUATORS = %w[lineto curveto rlineto rcurveto].freeze
25
+ STATE_MODIFIERS = %w[
26
+ setrgbcolor setgray setcmykcolor setlinewidth setlinecap
27
+ setlinejoin setdash translate scale rotate
28
+ ].freeze
29
+
30
+ def initialize
31
+ @stack = []
32
+ @g_stack = []
33
+ @g_state = default_graphics_state
34
+ @path = PathBuilder.new
35
+ @current_x = 0
36
+ @current_y = 0
37
+ @id_counter = 0
38
+ @svg_out = { defs: [], element_shapes: [], element_texts: [] }
39
+ @global_dict = {}
40
+ @dict_stack = [@global_dict]
41
+
42
+ # Initialize path with correct transform mode
43
+ update_path_transform_mode
44
+ end
45
+
46
+ def interpret(tokens, _bounding_box = nil)
47
+ i = 0
48
+ while i < tokens.length
49
+ token = tokens[i]
50
+
51
+ case token.type
52
+ when "number"
53
+ @stack << token.value.to_f
54
+ when "string", "hexstring", "name"
55
+ @stack << token.value
56
+ when "brace"
57
+ if token.value == "{"
58
+ proc_result = parse_procedure(tokens, i + 1)
59
+ @stack << { type: "procedure", body: proc_result[:procedure] }
60
+ i = proc_result[:next_index] - 1
61
+ end
62
+ when "bracket"
63
+ if token.value == "["
64
+ array_result = parse_array(tokens, i + 1)
65
+ @stack << array_result[:array]
66
+ i = array_result[:next_index] - 1
67
+ end
68
+ when "dict"
69
+ if token.value == "<<"
70
+ dict_result = parse_dict(tokens, i + 1)
71
+ @stack << dict_result[:dict]
72
+ i = dict_result[:next_index] - 1
73
+ end
74
+ when "operator"
75
+ execute_operator(token.value, tokens, i)
76
+ end
77
+
78
+ i += 1
79
+ end
80
+
81
+ # Flush remaining path
82
+ flush_path(STROKE_ONLY) if @path.length.positive?
83
+
84
+ @svg_out
85
+ end
86
+
87
+ private
88
+
89
+ def default_graphics_state
90
+ {
91
+ ctm: Matrix.new,
92
+ fill: "black",
93
+ stroke: nil,
94
+ stroke_width: 1,
95
+ line_cap: "butt",
96
+ line_join: "miter",
97
+ font: "Arial, sans-serif",
98
+ font_size: 12,
99
+ clip_stack: [],
100
+ dash: nil,
101
+ last_text_pos: nil,
102
+ pattern: nil,
103
+ pattern_dict: nil,
104
+ }
105
+ end
106
+
107
+ def clone_graphic_state(state)
108
+ {
109
+ ctm: Matrix.new(
110
+ a: state[:ctm].a, b: state[:ctm].b, c: state[:ctm].c,
111
+ d: state[:ctm].d, e: state[:ctm].e, f: state[:ctm].f
112
+ ),
113
+ fill: state[:fill],
114
+ stroke: state[:stroke],
115
+ stroke_width: state[:stroke_width],
116
+ line_cap: state[:line_cap],
117
+ line_join: state[:line_join],
118
+ font: state[:font],
119
+ font_size: state[:font_size],
120
+ clip_stack: state[:clip_stack].dup,
121
+ dash: state[:dash],
122
+ last_text_pos: state[:last_text_pos]&.dup,
123
+ pattern: state[:pattern],
124
+ pattern_dict: state[:pattern_dict]&.dup,
125
+ }
126
+ end
127
+
128
+ def update_path_transform_mode
129
+ need_group = !@g_state[:ctm].identity? || !@g_state[:clip_stack].empty?
130
+ @path.set_transform_mode(need_group, need_group ? nil : @g_state[:ctm])
131
+ end
132
+
133
+ def parse_procedure(tokens, start_index)
134
+ procedure = []
135
+ depth = 1
136
+ index = start_index
137
+
138
+ while index < tokens.length && depth.positive?
139
+ token = tokens[index]
140
+ if token.type == "brace" && token.value == "{"
141
+ depth += 1
142
+ elsif token.type == "brace" && token.value == "}"
143
+ depth -= 1
144
+ return { procedure: procedure, next_index: index + 1 } if depth.zero?
145
+ end
146
+ procedure << token if depth.positive?
147
+ index += 1
148
+ end
149
+
150
+ { procedure: procedure, next_index: index }
151
+ end
152
+
153
+ def parse_array(tokens, start_index)
154
+ array = []
155
+ index = start_index
156
+
157
+ while index < tokens.length
158
+ token = tokens[index]
159
+ if token.type == "bracket" && token.value == "]"
160
+ return { array: array,
161
+ next_index: index + 1 }
162
+ end
163
+
164
+ if token.type == "number"
165
+ array << token.value.to_f
166
+ elsif %w[string name].include?(token.type)
167
+ array << token.value
168
+ end
169
+ index += 1
170
+ end
171
+
172
+ { array: array, next_index: index }
173
+ end
174
+
175
+ def parse_dict(tokens, start_index)
176
+ dict = {}
177
+ index = start_index
178
+ current_key = nil
179
+
180
+ while index < tokens.length
181
+ token = tokens[index]
182
+
183
+ if token.type == "dict" && token.value == ">>"
184
+ return { dict: dict,
185
+ next_index: index + 1 }
186
+ end
187
+
188
+ if token.type == "name"
189
+ current_key = token.value
190
+ elsif current_key
191
+ if token.type == "number"
192
+ dict[current_key] = token.value.to_f
193
+ current_key = nil
194
+ elsif %w[string hexstring].include?(token.type)
195
+ dict[current_key] = token.value
196
+ current_key = nil
197
+ elsif token.type == "bracket" && token.value == "["
198
+ result = parse_array(tokens, index + 1)
199
+ dict[current_key] = result[:array]
200
+ index = result[:next_index] - 1
201
+ current_key = nil
202
+ elsif token.type == "dict" && token.value == "<<"
203
+ result = parse_dict(tokens, index + 1)
204
+ dict[current_key] = result[:dict]
205
+ index = result[:next_index] - 1
206
+ current_key = nil
207
+ elsif token.type == "brace" && token.value == "{"
208
+ result = parse_procedure(tokens, index + 1)
209
+ dict[current_key] = { type: "procedure", body: result[:procedure] }
210
+ index = result[:next_index] - 1
211
+ current_key = nil
212
+ end
213
+ end
214
+
215
+ index += 1
216
+ end
217
+
218
+ { dict: dict, next_index: index }
219
+ end
220
+
221
+ def execute_operator(op, tokens, current_index)
222
+ # Check if operator is defined in dictionary
223
+ dict_val = lookup_name(op)
224
+ if dict_val
225
+ if dict_val.is_a?(Hash) && dict_val[:type] == "procedure"
226
+ execute_procedure(tokens, dict_val[:body], current_index)
227
+ else
228
+ @stack << dict_val
229
+ end
230
+ return
231
+ end
232
+
233
+ # Execute built-in operators
234
+ case op
235
+ when "neg" then op_neg
236
+ when "add" then op_add
237
+ when "sub" then op_sub
238
+ when "mul" then op_mul
239
+ when "div" then op_div
240
+ when "exch" then op_exch
241
+ when "dict" then op_dict
242
+ when "begin" then op_begin
243
+ when "end" then op_end
244
+ when "def" then op_def
245
+ when "setdash" then op_setdash
246
+ when "newpath" then op_newpath
247
+ when "moveto" then op_moveto
248
+ when "rmoveto" then op_rmoveto
249
+ when "lineto" then op_lineto(tokens, current_index)
250
+ when "rlineto" then op_rlineto
251
+ when "curveto" then op_curveto
252
+ when "rcurveto" then op_rcurveto
253
+ when "closepath" then op_closepath
254
+ when "stroke" then op_stroke
255
+ when "fill", "eofill", "evenodd" then op_fill
256
+ when "setrgbcolor" then op_setrgbcolor
257
+ when "setgray" then op_setgray
258
+ when "setcmykcolor" then op_setcmykcolor
259
+ when "setlinewidth" then op_setlinewidth
260
+ when "setlinecap" then op_setlinecap
261
+ when "setlinejoin" then op_setlinejoin
262
+ when "translate" then op_translate
263
+ when "scale" then op_scale
264
+ when "rotate" then op_rotate
265
+ when "gsave" then op_gsave
266
+ when "grestore", "restore" then op_grestore
267
+ when "arc" then op_arc
268
+ when "clip" then op_clip
269
+ when "image", "imagemask" then op_image
270
+ when "findfont" then op_findfont
271
+ when "scalefont" then op_scalefont
272
+ when "setfont" then op_setfont
273
+ when "show" then op_show
274
+ when "showpage" then nil # No-op
275
+ when "shfill" then op_shfill
276
+ when "makepattern" then op_makepattern
277
+ when "setpattern" then op_setpattern
278
+ when "setcolorspace", "matrix" then nil # Not implemented
279
+ else
280
+ @svg_out[:element_shapes] << "<!-- Unhandled operator: #{op} -->"
281
+ end
282
+ end
283
+
284
+ def execute_procedure(tokens, proc_tokens, current_index)
285
+ tokens.insert(current_index + 1, *proc_tokens)
286
+ end
287
+
288
+ def lookup_name(name)
289
+ @dict_stack.reverse_each do |dict|
290
+ return dict[name] if dict.key?(name)
291
+ end
292
+ nil
293
+ end
294
+
295
+ def safe_pop_number(default = 0)
296
+ v = @stack.pop
297
+ return v if v.is_a?(Numeric)
298
+ return v.to_f if v.is_a?(String) && v.match?(/^-?\d+\.?\d*$/)
299
+
300
+ default
301
+ end
302
+
303
+ def num_fmt(n)
304
+ # Format number, removing unnecessary decimals
305
+ if n == n.to_i
306
+ n.to_i.to_s
307
+ else
308
+ format("%.3f", n).sub(/\.?0+$/, "")
309
+ end
310
+ end
311
+
312
+ def escape_xml(str)
313
+ str.to_s
314
+ .gsub("&", "&amp;")
315
+ .gsub("<", "&lt;")
316
+ .gsub(">", "&gt;")
317
+ .gsub('"', "&quot;")
318
+ .gsub("'", "&#39;")
319
+ end
320
+
321
+ def is_simple_line_ahead?(tokens, start_idx)
322
+ (start_idx...tokens.length).each do |j|
323
+ token = tokens[j]
324
+ next if %w[number name].include?(token.type)
325
+
326
+ next unless token.type == "operator"
327
+ return false if STATE_MODIFIERS.include?(token.value)
328
+ return true if PATH_TERMINATORS.include?(token.value)
329
+ return false if PATH_CONTINUATORS.include?(token.value)
330
+
331
+ return true
332
+ end
333
+ true
334
+ end
335
+
336
+ def flush_path(mode)
337
+ # rubocop:disable Style/ZeroLengthPredicate
338
+ return if @path.length.zero?
339
+ # rubocop:enable Style/ZeroLengthPredicate
340
+
341
+ d = @path.to_path
342
+ path_str = emit_svg_path(d, @g_state, mode)
343
+ @svg_out[:element_shapes] << path_str
344
+ @path = @path.reset
345
+ end
346
+
347
+ def emit_svg_path(d, g_state, mode, fill_id = nil)
348
+ need_group = !g_state[:ctm].identity? || !g_state[:clip_stack].empty?
349
+
350
+ decomp = g_state[:ctm].decompose
351
+ transform_str = ""
352
+ unless g_state[:ctm].identity?
353
+ parts = []
354
+ tx = decomp[:translate]
355
+ parts << "translate(#{num_fmt(tx[:x])} #{num_fmt(tx[:y])})" if tx[:x].abs > 1e-6 || tx[:y].abs > 1e-6
356
+ parts << "rotate(#{num_fmt(decomp[:rotate])})" if decomp[:rotate].abs > 1e-6
357
+ sc = decomp[:scale]
358
+ parts << "scale(#{num_fmt(sc[:x])} #{num_fmt(sc[:y])})" if (sc[:x] - 1).abs > 1e-6 || (sc[:y] - 1).abs > 1e-6
359
+ transform_str = parts.join(" ")
360
+ end
361
+
362
+ fill_color = mode[:fill] ? (g_state[:fill] || "black") : "none"
363
+ stroke_color = mode[:stroke] ? (g_state[:stroke] || "black") : "none"
364
+
365
+ path_attrs = ["d=\"#{d}\""]
366
+
367
+ path_attrs << if fill_id
368
+ "fill=\"url(##{fill_id})\""
369
+ else
370
+ "fill=\"#{fill_color}\""
371
+ end
372
+
373
+ if mode[:stroke] && stroke_color != "none"
374
+ path_attrs << "stroke=\"#{stroke_color}\""
375
+ if g_state[:stroke_width] && g_state[:stroke_width] != 1
376
+ path_attrs << "stroke-width=\"#{g_state[:stroke_width]}\""
377
+ end
378
+ path_attrs << "stroke-linecap=\"#{g_state[:line_cap]}\"" if g_state[:line_cap] && g_state[:line_cap] != "butt"
379
+ if g_state[:line_join] && g_state[:line_join] != "miter"
380
+ path_attrs << "stroke-linejoin=\"#{g_state[:line_join]}\""
381
+ end
382
+ path_attrs << "stroke-dasharray=\"#{g_state[:dash]}\"" if g_state[:dash] && !g_state[:dash].to_s.empty?
383
+ elsif mode[:fill] && fill_color != "none"
384
+ path_attrs << "stroke=\"none\""
385
+ end
386
+
387
+ path_attrs_str = path_attrs.join(" ")
388
+ clip_id = g_state[:clip_stack].empty? ? "" : " clip-path=\"url(#clip#{g_state[:clip_stack].length - 1})\""
389
+
390
+ if need_group
391
+ "<g transform=\"#{transform_str}\"#{clip_id}><path #{path_attrs_str} /></g>"
392
+ else
393
+ "<path #{path_attrs_str}#{clip_id} />"
394
+ end
395
+ end
396
+
397
+ # Operator implementations
398
+ def op_neg
399
+ v = @stack.pop
400
+ @stack << if v.is_a?(Numeric)
401
+ -v
402
+ elsif v.is_a?(String) && v.match?(/^-?\d+\.?\d*$/)
403
+ -v.to_f
404
+ else
405
+ 0
406
+ end
407
+ end
408
+
409
+ def op_add
410
+ b = safe_pop_number(0)
411
+ a = safe_pop_number(0)
412
+ @stack << (a + b)
413
+ end
414
+
415
+ def op_sub
416
+ b = safe_pop_number(0)
417
+ a = safe_pop_number(0)
418
+ @stack << (a - b)
419
+ end
420
+
421
+ def op_mul
422
+ b = safe_pop_number(1)
423
+ a = safe_pop_number(1)
424
+ @stack << (a * b)
425
+ end
426
+
427
+ def op_div
428
+ b = safe_pop_number(1)
429
+ a = safe_pop_number(0)
430
+ @stack << (b.zero? ? 0 : a / b)
431
+ end
432
+
433
+ def op_exch
434
+ b = @stack.pop
435
+ a = @stack.pop
436
+ @stack << b
437
+ @stack << a
438
+ end
439
+
440
+ def op_dict
441
+ safe_pop_number(0)
442
+ @stack << {}
443
+ end
444
+
445
+ def op_begin
446
+ d = @stack.pop
447
+ @dict_stack << if d.is_a?(Hash)
448
+ d
449
+ else
450
+ {}
451
+ end
452
+ end
453
+
454
+ def op_end
455
+ @dict_stack.pop if @dict_stack.length > 1
456
+ end
457
+
458
+ def op_def
459
+ value = @stack.pop
460
+ key = @stack.pop
461
+ @dict_stack.last[key.to_s] = value if key
462
+ end
463
+
464
+ def op_setdash
465
+ safe_pop_number(0)
466
+ arr = @stack.pop
467
+ @g_state[:dash] = if arr.is_a?(Array)
468
+ arr.map { |v| num_fmt(v.to_f) }.join(" ")
469
+ elsif arr.is_a?(Numeric)
470
+ num_fmt(arr.to_f)
471
+ end
472
+ end
473
+
474
+ def op_newpath
475
+ @path = @path.reset
476
+ end
477
+
478
+ def op_moveto
479
+ y = safe_pop_number(0)
480
+ x = safe_pop_number(0)
481
+ @path.move_to(x, y)
482
+ @current_x = x
483
+ @current_y = y
484
+ @g_state[:last_text_pos] = { x: x, y: y }
485
+ end
486
+
487
+ def op_rmoveto
488
+ dy = safe_pop_number(0)
489
+ dx = safe_pop_number(0)
490
+ @path.move_to_rel(dx, dy)
491
+ @current_x += dx
492
+ @current_y += dy
493
+ @g_state[:last_text_pos] = { x: @current_x, y: @current_y }
494
+ end
495
+
496
+ def op_lineto(tokens, current_index)
497
+ y = safe_pop_number(0)
498
+ x = safe_pop_number(0)
499
+ @path.line_to(x, y)
500
+ @current_x = x
501
+ @current_y = y
502
+
503
+ # Check if simple line
504
+ if @path.parts.length == 2 &&
505
+ @path.parts[0].start_with?("M ") &&
506
+ @path.parts[1].start_with?("L ") &&
507
+ is_simple_line_ahead?(tokens, current_index + 1)
508
+ flush_path(STROKE_ONLY)
509
+ @path = @path.reset
510
+ end
511
+ end
512
+
513
+ def op_rlineto
514
+ dy = safe_pop_number(0)
515
+ dx = safe_pop_number(0)
516
+ @path.line_to_rel(dx, dy)
517
+ @current_x += dx
518
+ @current_y += dy
519
+ end
520
+
521
+ def op_curveto
522
+ y = safe_pop_number(0)
523
+ x = safe_pop_number(0)
524
+ y2 = safe_pop_number(0)
525
+ x2 = safe_pop_number(0)
526
+ y1 = safe_pop_number(0)
527
+ x1 = safe_pop_number(0)
528
+ @path.curve_to(x1, y1, x2, y2, x, y)
529
+ @current_x = x
530
+ @current_y = y
531
+ end
532
+
533
+ def op_rcurveto
534
+ dy = safe_pop_number(0)
535
+ dx = safe_pop_number(0)
536
+ dy2 = safe_pop_number(0)
537
+ dx2 = safe_pop_number(0)
538
+ dy1 = safe_pop_number(0)
539
+ dx1 = safe_pop_number(0)
540
+ @path.curve_to_rel(dx1, dy1, dx2, dy2, dx, dy)
541
+ @current_x += dx
542
+ @current_y += dy
543
+ end
544
+
545
+ def op_closepath
546
+ return unless @path.length.positive? && !@path.parts.last&.end_with?("Z")
547
+
548
+ @path.close
549
+ end
550
+
551
+ def op_stroke
552
+ flush_path(STROKE_ONLY)
553
+ @path = @path.reset
554
+ end
555
+
556
+ def op_fill
557
+ flush_path(FILL_ONLY)
558
+ @path = @path.reset
559
+ end
560
+
561
+ def op_setrgbcolor
562
+ b = safe_pop_number(0)
563
+ g = safe_pop_number(0)
564
+ r = safe_pop_number(0)
565
+ rgb = Colors.color2rgb([r, g, b])
566
+ @g_state[:fill] = rgb
567
+ @g_state[:stroke] = rgb
568
+ @g_state[:pattern] = nil
569
+ @g_state[:pattern_dict] = nil
570
+ end
571
+
572
+ def op_setgray
573
+ v = safe_pop_number(0)
574
+ rgb = Colors.gray2rgb(v)
575
+ @g_state[:fill] = rgb
576
+ @g_state[:stroke] = rgb
577
+ @g_state[:pattern] = nil
578
+ @g_state[:pattern_dict] = nil
579
+ end
580
+
581
+ def op_setcmykcolor
582
+ k = safe_pop_number(0)
583
+ y = safe_pop_number(0)
584
+ m = safe_pop_number(0)
585
+ c = safe_pop_number(0)
586
+ rgb = Colors.cmyk2rgb([c, m, y, k])
587
+ @g_state[:fill] = rgb
588
+ @g_state[:stroke] = rgb
589
+ @g_state[:pattern] = nil
590
+ @g_state[:pattern_dict] = nil
591
+ end
592
+
593
+ def op_setlinewidth
594
+ @g_state[:stroke_width] = safe_pop_number(1)
595
+ end
596
+
597
+ def op_setlinecap
598
+ v = @stack.pop
599
+ if v.is_a?(Numeric)
600
+ @g_state[:line_cap] = if v.zero?
601
+ "butt"
602
+ else
603
+ v == 1 ? "round" : "square"
604
+ end
605
+ elsif v.is_a?(String)
606
+ @g_state[:line_cap] = v
607
+ end
608
+ end
609
+
610
+ def op_setlinejoin
611
+ v = @stack.pop
612
+ if v.is_a?(Numeric)
613
+ @g_state[:line_join] = if v.zero?
614
+ "miter"
615
+ elsif v == 1
616
+ "round"
617
+ elsif v == 2
618
+ "bevel"
619
+ else
620
+ v == 3 ? "arcs" : "miter"
621
+ end
622
+ elsif v.is_a?(String)
623
+ @g_state[:line_join] = v
624
+ end
625
+ end
626
+
627
+ def op_translate
628
+ ty = safe_pop_number(0)
629
+ tx = safe_pop_number(0)
630
+ @g_state[:ctm] = @g_state[:ctm].translate(tx, ty)
631
+ update_path_transform_mode
632
+ end
633
+
634
+ def op_scale
635
+ sy = safe_pop_number(1)
636
+ sx = safe_pop_number(1)
637
+ @g_state[:ctm] = @g_state[:ctm].scale(sx, sy)
638
+ update_path_transform_mode
639
+ end
640
+
641
+ def op_rotate
642
+ angle = safe_pop_number(0)
643
+ @g_state[:ctm] = @g_state[:ctm].rotate(angle)
644
+ update_path_transform_mode
645
+ end
646
+
647
+ def op_gsave
648
+ @g_stack << clone_graphic_state(@g_state)
649
+ end
650
+
651
+ def op_grestore
652
+ return if @g_stack.empty?
653
+
654
+ st = @g_stack.pop
655
+ return unless st
656
+
657
+ if st[:clip_stack].length > @g_state[:clip_stack].length
658
+ (@g_state[:clip_stack].length...st[:clip_stack].length).each do |j|
659
+ clip_path = st[:clip_stack][j]
660
+ @svg_out[:defs] << "<clipPath id=\"clip#{@id_counter}\"><path d=\"#{clip_path}\" /></clipPath>"
661
+ @id_counter += 1
662
+ end
663
+ end
664
+
665
+ @g_state = st
666
+ update_path_transform_mode
667
+ end
668
+
669
+ def op_arc
670
+ ang2 = safe_pop_number(0)
671
+ ang1 = safe_pop_number(0)
672
+ r = safe_pop_number(0)
673
+ y = safe_pop_number(0)
674
+ x = safe_pop_number(0)
675
+
676
+ need_group = !@g_state[:ctm].identity? || !@g_state[:clip_stack].empty?
677
+ if need_group
678
+ rx = r.abs
679
+ ry = r.abs
680
+ else
681
+ scale_x = Math.hypot(@g_state[:ctm].a, @g_state[:ctm].b) || 1
682
+ scale_y = Math.hypot(@g_state[:ctm].c, @g_state[:ctm].d) || 1
683
+ rx = (r * scale_x).abs
684
+ ry = (r * scale_y).abs
685
+ end
686
+
687
+ start_rad = ang1 * Math::PI / 180.0
688
+ start = { x: x + (r * Math.cos(start_rad)),
689
+ y: y + (r * Math.sin(start_rad)) }
690
+ end_rad = ang2 * Math::PI / 180.0
691
+ end_point = { x: x + (r * Math.cos(end_rad)),
692
+ y: y + (r * Math.sin(end_rad)) }
693
+
694
+ delta = (ang2 - ang1).abs
695
+ is_full_circle = (delta - 360).abs < 1e-6 || delta.abs < 1e-6
696
+
697
+ # Replace last moveTo if it's to the center
698
+ @path.parts.pop if @path.parts.last&.start_with?("M ")
699
+
700
+ @path.move_to(start[:x], start[:y])
701
+
702
+ if is_full_circle
703
+ mid_rad = (ang1 + 180) % 360
704
+ mid = { x: x + (r * Math.cos(mid_rad * Math::PI / 180.0)),
705
+ y: y + (r * Math.sin(mid_rad * Math::PI / 180.0)) }
706
+ @path.ellipse_to(rx, ry, 0, 1, 1, mid[:x], mid[:y])
707
+ @path.ellipse_to(rx, ry, 0, 1, 1, start[:x], start[:y])
708
+ @path.close
709
+ else
710
+ normalized_delta = (((ang2 - ang1) % 360) + 360) % 360
711
+ large_arc = normalized_delta > 180 ? 1 : 0
712
+ sweep = normalized_delta.positive? ? 1 : 0
713
+ @path.ellipse_to(rx, ry, 0, large_arc, sweep, end_point[:x],
714
+ end_point[:y])
715
+ end
716
+ @current_x = end_point[:x]
717
+ @current_y = end_point[:y]
718
+ end
719
+
720
+ def op_clip
721
+ return unless @path.length.positive?
722
+
723
+ clip_path = @path.to_path
724
+ clip_id = "clip#{@id_counter}"
725
+ @svg_out[:defs] << "<clipPath id=\"#{clip_id}\"><path d=\"#{clip_path}\" /></clipPath>"
726
+ @id_counter += 1
727
+ @g_state[:clip_stack] << clip_path
728
+ @path = @path.reset
729
+ end
730
+
731
+ def op_image
732
+ @svg_out[:element_shapes] << "<!-- image/imagemask not implemented -->\n" \
733
+ "<image transform=\"scale(1 -1)\" x=\"10\" y=\"-320\" " \
734
+ "width=\"50\" height=\"50\" href=\"#{DEFAULT_IMAGE}\" />"
735
+ end
736
+
737
+ def op_findfont
738
+ fname = @stack.pop
739
+ @stack << { font: fname.to_s }
740
+ end
741
+
742
+ def op_scalefont
743
+ size = safe_pop_number(0)
744
+ font_obj = @stack.pop
745
+ if font_obj.is_a?(Hash)
746
+ font_obj[:size] = size
747
+ @stack << font_obj
748
+ else
749
+ @stack << { font: font_obj.to_s, size: size }
750
+ end
751
+ end
752
+
753
+ def op_setfont
754
+ s_font = @stack.pop
755
+ if s_font.is_a?(Hash)
756
+ @g_state[:font] = s_font[:font] || @g_state[:font]
757
+ @g_state[:font_size] = s_font[:size] || @g_state[:font_size]
758
+ elsif s_font.is_a?(String)
759
+ @g_state[:font] = s_font
760
+ end
761
+ end
762
+
763
+ def op_show
764
+ s = @stack.pop.to_s
765
+ escaped = escape_xml(s)
766
+ if @g_state[:last_text_pos]
767
+ p = @g_state[:ctm].apply_point(@g_state[:last_text_pos][:x],
768
+ @g_state[:last_text_pos][:y])
769
+ @svg_out[:element_shapes] << "<text transform=\"scale(1 -1)\" " \
770
+ "x=\"#{num_fmt(p[:x])}\" y=\"#{num_fmt(-p[:y])}\" " \
771
+ "font-family=\"#{@g_state[:font]}\" " \
772
+ "font-size=\"#{num_fmt(@g_state[:font_size])}\" " \
773
+ "fill=\"#{@g_state[:fill] || 'black'}\" " \
774
+ "stroke=\"none\">#{escaped}</text>"
775
+ end
776
+ @path = @path.reset
777
+ @g_state[:last_text_pos] = nil
778
+ end
779
+
780
+ def op_shfill
781
+ shading = @stack.pop
782
+ return unless shading.is_a?(Hash)
783
+
784
+ grad_id = "grad#{@id_counter}"
785
+ @id_counter += 1
786
+
787
+ if shading["ShadingType"] == 2
788
+ # Linear gradient
789
+ coords = shading["Coords"]
790
+ c0 = Colors.color2rgb(shading.dig("Function", "C0") || [1, 1, 1])
791
+ c1 = Colors.color2rgb(shading.dig("Function", "C1") || [0, 0, 0])
792
+
793
+ x1, y1, x2, y2 = coords
794
+
795
+ @svg_out[:defs] << "<linearGradient id=\"#{grad_id}\" " \
796
+ "x1=\"#{num_fmt(x1)}%\" y1=\"#{num_fmt(y1)}%\" " \
797
+ "x2=\"#{num_fmt(x2)}%\" y2=\"#{num_fmt(y2)}%\">\n" \
798
+ "<stop offset=\"0\" stop-color=\"#{c0}\" />\n" \
799
+ "<stop offset=\"1\" stop-color=\"#{c1}\" />\n" \
800
+ "</linearGradient>"
801
+
802
+ min_x = [x1, x2].min
803
+ min_y = [y1, y2].min
804
+ width = (x2 - x1).abs
805
+ height = (y2 - y1).abs
806
+
807
+ d = "M #{min_x} #{min_y} L #{min_x + width} #{min_y} " \
808
+ "L #{min_x + width} #{min_y + height} L #{min_x} #{min_y + height} Z"
809
+ @svg_out[:element_shapes] << emit_svg_path(d, @g_state, FILL_ONLY,
810
+ grad_id)
811
+ @path = @path.reset
812
+ elsif shading["ShadingType"] == 3
813
+ # Radial gradient
814
+ coords = shading["Coords"]
815
+ c0 = Colors.color2rgb(shading.dig("Function", "C0") || [1, 1, 1])
816
+ c1 = Colors.color2rgb(shading.dig("Function", "C1") || [0, 0, 0])
817
+
818
+ _, _, _, cx, cy, r = coords
819
+
820
+ @svg_out[:defs] << "<radialGradient id=\"#{grad_id}\">\n" \
821
+ "<stop offset=\"0\" stop-color=\"#{c0}\" />\n" \
822
+ "<stop offset=\"1\" stop-color=\"#{c1}\" />\n" \
823
+ "</radialGradient>"
824
+
825
+ d = "M #{num_fmt(cx + r)} #{num_fmt(cy)} " \
826
+ "A #{num_fmt(r)} #{num_fmt(r)} 0 1 1 #{num_fmt(cx - r)} #{num_fmt(cy)} " \
827
+ "A #{num_fmt(r)} #{num_fmt(r)} 0 1 1 #{num_fmt(cx + r)} #{num_fmt(cy)} Z"
828
+
829
+ @svg_out[:element_shapes] << emit_svg_path(d, @g_state, FILL_ONLY,
830
+ grad_id)
831
+ @path = @path.reset
832
+ else
833
+ @svg_out[:element_shapes] << "<!-- shfill not fully implemented -->"
834
+ end
835
+ end
836
+
837
+ def op_makepattern
838
+ dict = @stack.pop
839
+ return unless dict.is_a?(Hash)
840
+
841
+ pattern_id = "pattern#{@id_counter}"
842
+ @id_counter += 1
843
+
844
+ bbox = dict["BBox"] || [0, 0, 20, 20]
845
+ x_step = dict["XStep"] || 20
846
+ y_step = dict["YStep"] || 20
847
+ if dict["PaintProc"].is_a?(Hash) && dict["PaintProc"][:body]
848
+ # Interpret the PaintProc procedure
849
+ paint_proc_tokens = dict["PaintProc"][:body]
850
+
851
+ # Create sub-interpreter for pattern
852
+ sub_interp = Interpreter.new
853
+ paint_proc_out = sub_interp.interpret(paint_proc_tokens)
854
+
855
+ # Extract path elements
856
+ paint_proc_path = paint_proc_out[:element_shapes]
857
+ .select { |s| s.start_with?("<path") }
858
+ .join("\n")
859
+
860
+ pattern_defs = "<pattern id=\"#{pattern_id}\" " \
861
+ "x=\"#{bbox[0]}\" y=\"#{bbox[1]}\" " \
862
+ "width=\"#{x_step}\" height=\"#{y_step}\" " \
863
+ "patternUnits=\"userSpaceOnUse\">\n" \
864
+ "#{paint_proc_path}\n" \
865
+ "</pattern>"
866
+
867
+ @svg_out[:defs] << pattern_defs
868
+ end
869
+
870
+ @stack << {
871
+ type: "pattern",
872
+ id: pattern_id,
873
+ dict: dict,
874
+ }
875
+ end
876
+
877
+ def op_setpattern
878
+ pattern = @stack.pop
879
+
880
+ if pattern.is_a?(Hash) && pattern[:type] == "pattern"
881
+ @g_state[:fill] = "url(##{pattern[:id]})"
882
+ @g_state[:pattern] = pattern[:id]
883
+ @g_state[:pattern_dict] = pattern[:dict]
884
+ else
885
+ @g_state[:fill] = "none"
886
+ @g_state[:pattern] = nil
887
+ @g_state[:pattern_dict] = nil
888
+ end
889
+ end
890
+ end
891
+ end