emfsvg 0.1.3 → 0.1.4

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +98 -14
  3. data/lib/emfsvg/device_context.rb +9 -6
  4. data/lib/emfsvg/dib_decoder.rb +12 -8
  5. data/lib/emfsvg/drawing_hint.rb +88 -0
  6. data/lib/emfsvg/error.rb +11 -0
  7. data/lib/emfsvg/format_helpers.rb +46 -2
  8. data/lib/emfsvg/glyph_index_mapper.rb +5 -6
  9. data/lib/emfsvg/object_table.rb +7 -15
  10. data/lib/emfsvg/record_access.rb +38 -0
  11. data/lib/emfsvg/renderer.rb +114 -55
  12. data/lib/emfsvg/svg/clip_path_registry.rb +287 -4
  13. data/lib/emfsvg/svg/color.rb +32 -9
  14. data/lib/emfsvg/svg/document.rb +7 -0
  15. data/lib/emfsvg/svg/element.rb +3 -3
  16. data/lib/emfsvg/svg/elements/clip_path.rb +89 -24
  17. data/lib/emfsvg/svg/elements/defs.rb +85 -4
  18. data/lib/emfsvg/svg/elements/group.rb +30 -4
  19. data/lib/emfsvg/svg/elements/path.rb +239 -3
  20. data/lib/emfsvg/svg/elements/rect.rb +5 -2
  21. data/lib/emfsvg/svg/elements/stylable.rb +41 -11
  22. data/lib/emfsvg/svg/elements/text.rb +101 -16
  23. data/lib/emfsvg/svg/paint.rb +8 -2
  24. data/lib/emfsvg/svg/parser.rb +8 -1
  25. data/lib/emfsvg/svg/path_data/command.rb +1 -1
  26. data/lib/emfsvg/svg/path_data.rb +47 -36
  27. data/lib/emfsvg/svg/stroke.rb +78 -12
  28. data/lib/emfsvg/svg_builder.rb +38 -9
  29. data/lib/emfsvg/translation/context.rb +262 -10
  30. data/lib/emfsvg/translation/decimal_detector.rb +26 -8
  31. data/lib/emfsvg/translation/emf_renderer.rb +16 -12
  32. data/lib/emfsvg/translation/handler_registry.rb +16 -6
  33. data/lib/emfsvg/translation/handlers/circular_arc.rb +103 -0
  34. data/lib/emfsvg/translation/handlers/defs_handler.rb +33 -4
  35. data/lib/emfsvg/translation/handlers/ellipse_handler.rb +2 -2
  36. data/lib/emfsvg/translation/handlers/elliptical_arc.rb +125 -0
  37. data/lib/emfsvg/translation/handlers/group_handler.rb +7 -0
  38. data/lib/emfsvg/translation/handlers/image_handler.rb +53 -8
  39. data/lib/emfsvg/translation/handlers/line_handler.rb +2 -2
  40. data/lib/emfsvg/translation/handlers/mapped_arc.rb +41 -0
  41. data/lib/emfsvg/translation/handlers/mapped_circular_arc.rb +55 -0
  42. data/lib/emfsvg/translation/handlers/mapped_pie.rb +30 -0
  43. data/lib/emfsvg/translation/handlers/path_flattener.rb +70 -49
  44. data/lib/emfsvg/translation/handlers/path_handler.rb +169 -14
  45. data/lib/emfsvg/translation/handlers/polyline_handler.rb +2 -2
  46. data/lib/emfsvg/translation/handlers/rect_handler.rb +21 -5
  47. data/lib/emfsvg/translation/handlers/shared_gdi.rb +75 -12
  48. data/lib/emfsvg/translation/handlers/text_handler.rb +92 -29
  49. data/lib/emfsvg/translation/handlers.rb +5 -0
  50. data/lib/emfsvg/translation/header_builder.rb +20 -0
  51. data/lib/emfsvg/translation/local_origin.rb +40 -0
  52. data/lib/emfsvg/translation/record_emitter.rb +8 -5
  53. data/lib/emfsvg/translation/scaler/fixed.rb +8 -2
  54. data/lib/emfsvg/translation.rb +1 -0
  55. data/lib/emfsvg/version.rb +1 -1
  56. data/lib/emfsvg/visitors/emr_visitor.rb +197 -129
  57. data/lib/emfsvg.rb +49 -2
  58. metadata +24 -10
@@ -6,21 +6,51 @@ module Emfsvg
6
6
  # Common presentation attribute parsing shared by every shape.
7
7
  # Module function — call as `Stylable.parse_style(node)` from
8
8
  # an Element subclass's `.from_node` factory.
9
+ #
10
+ # Paint and Stroke are immutable value objects, and a drawing repeats
11
+ # a handful of distinct fill/stroke combinations across thousands of
12
+ # elements, so one instance per distinct attribute set is shared
13
+ # instead of parsing and allocating the same objects for every
14
+ # element.
9
15
  module Stylable
16
+ # Distinct attribute sets remembered; past this, parsing falls back
17
+ # to building a fresh pair, so a hostile document cannot grow it.
18
+ PAINT_CACHE_LIMIT = 1024
19
+ PAINT_CACHE_LOCK = Mutex.new
20
+ private_constant :PAINT_CACHE_LOCK, :PAINT_CACHE_LIMIT
21
+
22
+ @paint_cache = {}
23
+
10
24
  module_function
11
25
 
12
26
  def parse_style(node)
13
- {
14
- fill: Svg::Paint.from_fill(node["fill"]),
15
- stroke: Svg::Stroke.parse(
16
- stroke: node["stroke"],
17
- width: node["stroke-width"],
18
- dash_array: node["stroke-dasharray"],
19
- line_cap: node["stroke-linecap"],
20
- line_join: node["stroke-linejoin"]
21
- ),
22
- clip_path: node["clip-path"]
23
- }
27
+ fill, stroke = paints(
28
+ node["fill"], node["stroke"], node["stroke-width"],
29
+ node["stroke-dasharray"], node["stroke-linecap"], node["stroke-linejoin"],
30
+ node["stroke-miterlimit"]
31
+ )
32
+ { fill: fill, stroke: stroke, clip_path: node["clip-path"] }
33
+ end
34
+
35
+ def paints(fill, stroke, width, dash_array, line_cap, line_join, miter_limit = nil)
36
+ key = [fill, stroke, width, dash_array, line_cap, line_join, miter_limit]
37
+ @paint_cache.fetch(key) do
38
+ fill_paint = Svg::Paint.from_fill(fill)
39
+ pair = [
40
+ fill_paint,
41
+ Svg::Stroke.parse(stroke: stroke, width: width, dash_array: dash_array,
42
+ line_cap: line_cap, line_join: line_join,
43
+ miter_limit: miter_limit, fill: fill_paint)
44
+ ].freeze
45
+ remember(key.freeze, pair)
46
+ end
47
+ end
48
+
49
+ def remember(key, pair)
50
+ PAINT_CACHE_LOCK.synchronize do
51
+ @paint_cache[key] = pair if @paint_cache.size < PAINT_CACHE_LIMIT
52
+ end
53
+ pair
24
54
  end
25
55
  end
26
56
  end
@@ -12,48 +12,133 @@ module Emfsvg
12
12
  ELEMENT_NAME = "text"
13
13
  Element.register("text", self)
14
14
 
15
- attr_reader :x, :y, :content, :font_family, :font_size, :font_weight,
16
- :font_style, :text_anchor, :fill, :stroke, :clip_path,
17
- :transform_matrix
15
+ # Recovered LOGFONT rotation and vertical alignment, separate from SVG affine transforms.
16
+ Rotation = Struct.new(:angle, :vertical_alignment, :escapement, :native_height, :native_y, keyword_init: true)
18
17
 
19
- def initialize(x:, y:, content:, font_family:, font_size:, font_weight:,
20
- font_style:, text_anchor:, fill:, stroke:, clip_path: nil,
21
- transform_matrix: Emf::Model::Geometry::Matrix.identity)
18
+ # The font attributes of a <text> element.
19
+ Font = Struct.new(:family, :point_size, :weight, :style, :rotation, keyword_init: true)
20
+
21
+ attr_reader :x, :y, :content, :font, :text_anchor, :fill, :stroke,
22
+ :clip_path, :transform_matrix, :empty_cdata_content, :canonical_text_rotation
23
+
24
+ def initialize(x:, y:, content:, font:, text_anchor:, fill:, stroke:, clip_path: nil,
25
+ transform_matrix: Emf::Model::Geometry::Matrix.identity,
26
+ empty_cdata_content: false, canonical_text_rotation: nil)
22
27
  @x = x
23
28
  @y = y
24
29
  @content = content
25
- @font_family = font_family
26
- @font_size = font_size
27
- @font_weight = font_weight
28
- @font_style = font_style
30
+ @font = font
29
31
  @text_anchor = text_anchor
30
32
  @fill = fill
31
33
  @stroke = stroke
32
34
  @clip_path = clip_path
33
35
  @transform_matrix = transform_matrix
36
+ @empty_cdata_content = empty_cdata_content
37
+ @canonical_text_rotation = canonical_text_rotation
34
38
  end
35
39
 
40
+ def font_family = font.family
41
+
42
+ def font_size = font.point_size
43
+
44
+ def font_weight = font.weight
45
+
46
+ def font_style = font.style
47
+
36
48
  def transformed?
37
49
  !transform_matrix.identity?
38
50
  end
39
51
 
40
52
  def self.from_node(node)
53
+ rotation = canonical_rotation(node)
41
54
  new(
42
55
  x: AttributeParser.float(node["x"]),
43
56
  y: AttributeParser.float(node["y"]),
44
57
  content: node.text,
45
- font_family: node["font-family"],
46
- font_size: AttributeParser.float(node["font-size"], default: 12.0),
47
- font_weight: parse_weight(node["font-weight"]),
48
- font_style: node["font-style"],
58
+ empty_cdata_content: node.children.any? do |child|
59
+ child.is_a?(Leptris::XML::CDATA) && child.content.empty?
60
+ end,
61
+ font: Font.new(
62
+ family: node["font-family"],
63
+ point_size: AttributeParser.float(node["font-size"], default: 12.0),
64
+ weight: parse_weight(node["font-weight"]),
65
+ style: node["font-style"],
66
+ rotation: rotation&.angle
67
+ ),
49
68
  text_anchor: node["text-anchor"] || "start",
50
- transform_matrix: Svg::TransformParser.parse(node["transform"]),
69
+ canonical_text_rotation: rotation,
70
+ transform_matrix: Svg::TransformParser.parse(rotation ? nil : node["transform"]),
51
71
  **Stylable.parse_style(node)
52
72
  )
53
73
  end
54
74
 
75
+ # Canonical LOGFONT output independently rounds size, pivot and offset.
76
+ # Intersect their four-decimal source intervals instead of inferring an
77
+ # arbitrary affine transform. TA_TOP prints the pivot as the displayed y.
78
+ def self.canonical_rotation(node)
79
+ return if node["dx"] || node["dy"]
80
+
81
+ decimal = "(-?\\d+\\.\\d{4})"
82
+ pattern = /\Arotate\((0|-?[1-9]\d*), #{decimal}, #{decimal}\) translate\(0, #{decimal}\)\z/
83
+ match = node["transform"]&.match(pattern)
84
+ return unless match
85
+
86
+ angle = match[1].to_i
87
+ return unless angle.between?(-359, 359)
88
+
89
+ x, pivot, offset = match.captures.drop(1).map(&:to_r)
90
+ return unless x == AttributeParser.float(node["x"]).to_s.to_r
91
+
92
+ y = AttributeParser.float(node["y"]).to_s.to_r
93
+ height = AttributeParser.float(node["font-size"], default: 12.0).to_s.to_r
94
+ half_unit = Rational(1, 20_000)
95
+ low = [(height - half_unit) * Rational(9, 10), offset - half_unit].max
96
+ high = [(height + half_unit) * Rational(9, 10), offset + half_unit].min
97
+ return unless low < high
98
+
99
+ alignment = if pivot == y
100
+ height.zero? ? :baseline : :top
101
+ elsif [low, pivot - y - (2 * half_unit)].max < [high, pivot - y + (2 * half_unit)].min
102
+ :baseline
103
+ end
104
+ return unless alignment
105
+
106
+ native_height, native_y = recover_baseline(height, y, pivot, offset) if alignment == :baseline
107
+ Rotation.new(angle: angle, vertical_alignment: alignment, escapement: angle.zero? ? 1 : -angle * 10,
108
+ native_height: native_height, native_y: native_y)
109
+ end
110
+
111
+ # A four-decimal native grid cannot always reproduce independently rounded
112
+ # height, baseline and pivot. Choose an interior point of all four source
113
+ # intervals; the handler uses a finer native mapping only for this case.
114
+ def self.recover_baseline(height, y, pivot, offset)
115
+ # Match the mapped IEEE-754 values and C rounding used by the renderer.
116
+ mapped_height = (height * 10_000).round * 0.0001
117
+ mapped_y = (y * 10_000).round * 0.0001
118
+ return if FormatHelpers.fmt(mapped_y + (mapped_height * 0.9)) == FormatHelpers.fmt(pivot)
119
+
120
+ half = Rational(1, 20_000)
121
+ ratio = Rational(9, 10)
122
+ low = [height - half, (offset - half) / ratio, (pivot - y - (2 * half)) / ratio].max
123
+ high = [height + half, (offset + half) / ratio, (pivot - y + (2 * half)) / ratio].min
124
+ return unless low < high
125
+
126
+ native_height = ((low + high) / 2 * 1_000_000).round / 1_000_000.0
127
+ low_y = [y - half, pivot - half - (native_height.to_r * ratio)].max
128
+ high_y = [y + half, pivot + half - (native_height.to_r * ratio)].min
129
+ return unless low_y < high_y
130
+
131
+ native_y = ((low_y + high_y) / 2 * 1_000_000).round / 1_000_000.0
132
+ actual = [native_height, native_y, native_height * 0.9, native_y + (native_height * 0.9)]
133
+ expected = [height, y, offset, pivot].map { |value| FormatHelpers.fmt(value) }
134
+ return unless actual.map { |value| FormatHelpers.fmt(value) } == expected
135
+
136
+ [native_height, native_y]
137
+ end
138
+
55
139
  def self.parse_weight(value)
56
- return 400 if value.nil? || value == "normal"
140
+ return 0 if value.nil?
141
+ return 400 if value == "normal"
57
142
 
58
143
  value == "bold" ? 700 : value.to_i
59
144
  end
@@ -10,19 +10,25 @@ module Emfsvg
10
10
  class Paint
11
11
  SOLID = 0
12
12
  NULL = 1
13
+ PATTERN = 3
13
14
 
14
- def initialize(color:, style: SOLID)
15
+ def initialize(color:, style: SOLID, reference: nil)
15
16
  @style = style
16
17
  @color = color
18
+ @reference = reference
17
19
  end
18
20
 
19
- attr_reader :style, :color
21
+ attr_reader :style, :color, :reference
20
22
 
21
23
  def null?
22
24
  style == NULL || color.null?
23
25
  end
24
26
 
25
27
  def self.from_fill(value)
28
+ if (match = /\A(?:#(img-[1-9]\d*-ref)|url\(#(img-[1-9]\d*-ref)\))\z/.match(value.to_s))
29
+ return new(style: PATTERN, color: Color.parse("black"), reference: match[1] || match[2])
30
+ end
31
+
26
32
  color = Color.parse(value)
27
33
  new(style: color.null? ? NULL : SOLID, color: color)
28
34
  end
@@ -22,14 +22,21 @@ module Emfsvg
22
22
  root = doc.root
23
23
  raise ParseError, "not an SVG document: missing root element" unless root
24
24
 
25
+ source = string.b
25
26
  Document.new(
26
27
  width: parse_dimension(root["width"]),
27
28
  height: parse_dimension(root["height"]),
28
29
  view_box: parse_view_box(root["viewBox"]),
29
- root_element: Element.from_node(root)
30
+ root_element: Element.from_node(root) { |node| source_tag(source, node) }
30
31
  )
31
32
  end
32
33
 
34
+ # Node serialization normalizes spacing; the original start tag carries
35
+ # the canonical renderer's distinction between Polyline and LineTo.
36
+ def source_tag(string, node)
37
+ string.match(/\G<(?:path|rect)\b(?:[^"'>]|"[^"]*"|'[^']*')*>/m, node.byte_offset)&.to_s
38
+ end
39
+
33
40
  def parse_dimension(value)
34
41
  return 0.0 if value.nil? || value.empty?
35
42
 
@@ -7,7 +7,7 @@ module Emfsvg
7
7
  # `letter` preserves case (absolute uppercase vs relative lowercase).
8
8
  Command = Struct.new(:letter, :args, keyword_init: true) do
9
9
  def absolute?
10
- letter == letter.upcase
10
+ ARG_COUNTS.key?(letter) || (!LETTER_ARG_COUNTS.key?(letter) && letter == letter.upcase)
11
11
  end
12
12
  end
13
13
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "strscan"
4
+
3
5
  module Emfsvg
4
6
  module Svg
5
7
  # Namespace for the SVG path-data parser. Pure parser, no EMF
@@ -14,6 +16,20 @@ module Emfsvg
14
16
  "Q" => 4, "T" => 2, "A" => 7, "Z" => 0
15
17
  }.freeze
16
18
 
19
+ # ARG_COUNTS keyed by the letter as written, so the parser does not
20
+ # upcase a fresh String per token to look it up.
21
+ LETTER_ARG_COUNTS = ARG_COUNTS.each_with_object({}) do |(letter, count), counts|
22
+ counts[letter] = count
23
+ counts[letter.downcase] = count
24
+ end.freeze
25
+
26
+ # Frozen command-letter Strings keyed by the Symbol the Scanner
27
+ # emits, shared by every Command rather than allocated per token.
28
+ LETTERS = LETTER_ARG_COUNTS.keys.to_h { |letter| [letter.to_sym, letter.freeze] }.freeze
29
+
30
+ # Shared by every zero-argument command (Z/z).
31
+ NO_ARGS = [].freeze
32
+
17
33
  # Stateful parser walks the string once, emitting Commands as
18
34
  # enough args accumulate for the current letter. Implicit repeats:
19
35
  # subsequent arg groups after M/m are treated as L/l.
@@ -26,24 +42,24 @@ module Emfsvg
26
42
  commands = []
27
43
  scanner = Scanner.new(string)
28
44
  current_letter = nil
29
- args = []
45
+ args = nil
30
46
 
31
47
  while (token = scanner.next_token)
32
48
  case token
33
49
  when Symbol # command letter
34
50
  flush_command(commands, current_letter, args) if current_letter && args.any?
35
- current_letter = token.to_s
51
+ current_letter = LETTERS.fetch(token) { token.to_s }
36
52
  args = []
37
53
  # Zero-arg commands (Z/z) emit immediately.
38
- if ARG_COUNTS.fetch(current_letter.upcase, 1).zero?
39
- emit_command(commands, current_letter, [])
54
+ if LETTER_ARG_COUNTS.fetch(current_letter, 1).zero?
55
+ emit_command(commands, current_letter, NO_ARGS)
40
56
  current_letter = nil
41
57
  end
42
58
  when Numeric
43
59
  raise FormatError, "path data begins with a number: #{string.inspect}" if current_letter.nil?
44
60
 
45
61
  args << token
46
- if args.size == ARG_COUNTS.fetch(current_letter.upcase)
62
+ if args.size == (LETTER_ARG_COUNTS[current_letter] || ARG_COUNTS.fetch(current_letter.upcase))
47
63
  emit_command(commands, current_letter, args)
48
64
  args = []
49
65
  current_letter = implicit_repeat_letter(current_letter)
@@ -56,9 +72,9 @@ module Emfsvg
56
72
  end
57
73
 
58
74
  def emit_command(commands, letter, args)
59
- raise FormatError, "unknown path command: #{letter}" unless ARG_COUNTS.key?(letter.upcase)
75
+ raise FormatError, "unknown path command: #{letter}" unless LETTER_ARG_COUNTS.key?(letter)
60
76
 
61
- commands << Command.new(letter: letter, args: args.dup)
77
+ commands << Command.new(letter: letter, args: args)
62
78
  end
63
79
 
64
80
  def flush_command(_commands, letter, args)
@@ -86,48 +102,43 @@ module Emfsvg
86
102
  # as Symbols (e.g. :M, :z) and numbers as Floats.
87
103
  class Scanner
88
104
  NUMBER_RE = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
105
+ SEPARATORS_RE = /[ \t\n\r,]+/
106
+
107
+ # The Symbol for each command-letter byte, so a letter is read
108
+ # without allocating a String for it.
109
+ LETTER_SYMBOLS = (("A".."Z").to_a + ("a".."z").to_a).to_h { |letter| [letter.ord, letter.to_sym] }.freeze
110
+ private_constant :LETTER_SYMBOLS
89
111
 
90
112
  def initialize(string)
91
113
  @string = string
92
- @pos = 0
114
+ @scanner = StringScanner.new(string)
93
115
  end
94
116
 
95
117
  def next_token
96
- skip_separators
97
- return nil if @pos >= @string.length
98
-
99
- char = @string[@pos]
100
- if command_letter?(char)
101
- @pos += 1
102
- char.to_sym
103
- else
104
- read_number
105
- end
106
- end
118
+ @scanner.skip(SEPARATORS_RE)
119
+ return nil if @scanner.eos?
107
120
 
108
- private
121
+ letter = LETTER_SYMBOLS[@string.getbyte(@scanner.pos)]
122
+ return read_number unless letter
109
123
 
110
- def skip_separators
111
- @pos += 1 while @pos < @string.length && separator?(@string[@pos])
124
+ @scanner.pos += 1
125
+ letter
112
126
  end
113
127
 
114
- def separator?(char)
115
- case char
116
- when " ", "\t", "\n", "\r", "," then true
117
- else false
118
- end
119
- end
120
-
121
- def command_letter?(char)
122
- ("A".."Z").cover?(char) || ("a".."z").cover?(char)
123
- end
128
+ private
124
129
 
130
+ # A number is normally right at the cursor. Anything else is
131
+ # skipped up to the next number, as the scan has always done.
125
132
  def read_number
126
- match = @string.match(NUMBER_RE, @pos)
127
- raise FormatError, "expected number at position #{@pos} in path data" unless match
133
+ number = @scanner.scan(NUMBER_RE)
134
+ return number.to_f if number
135
+
136
+ position = @scanner.charpos
137
+ unless @scanner.skip_until(NUMBER_RE)
138
+ raise FormatError, "expected number at position #{position} in path data"
139
+ end
128
140
 
129
- @pos = match.end(0)
130
- match.to_s.to_f
141
+ @scanner.matched.to_f
131
142
  end
132
143
  end
133
144
  end
@@ -3,7 +3,7 @@
3
3
  module Emfsvg
4
4
  module Svg
5
5
  # Parsed SVG stroke style: color, width, dash pattern, line cap,
6
- # line join. Maps directly to the EMF LOGPEN / EXTLOGPEN32 fields.
6
+ # line join, and miter limit.
7
7
  #
8
8
  # Pen style bits (per MS-WMF 2.1.1.8 / emr_visitor.rb mirror):
9
9
  #
@@ -21,61 +21,123 @@ module Emfsvg
21
21
  END_CAP_BITS = { "butt" => 0x0200, "round" => 0x0000, "square" => 0x0100 }.freeze
22
22
  LINE_JOIN_BITS = { "miter" => 0x2000, "round" => 0x0000, "bevel" => 0x1000 }.freeze
23
23
  NULL_PEN_WIDTH_SENTINEL = "1px"
24
+ COSMETIC_DASH_PATTERNS = {
25
+ 1 => [5, 5], 2 => [1, 1], 3 => [5, 5, 1, 5], 4 => [5, 5, 1, 1, 1, 5]
26
+ }.freeze
27
+ private_constant :COSMETIC_DASH_PATTERNS
24
28
 
25
- attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :raw_width,
26
- :line_cap_explicit, :line_join_explicit
29
+ attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :miter_limit, :raw_width,
30
+ :line_cap_explicit, :line_join_explicit, :null_pen_fallback, :fallback_brush, :canonical_cosmetic_dash
27
31
 
28
32
  def initialize(paint:, width: 1.0, dash_array: nil, line_cap: "round",
29
- line_join: "round", raw_width: nil,
30
- line_cap_explicit: false, line_join_explicit: false)
33
+ line_join: "round", miter_limit: 10.0, raw_width: nil,
34
+ line_cap_explicit: false, line_join_explicit: false,
35
+ null_pen_fallback: true, fallback_brush: nil)
31
36
  @paint = paint
32
37
  @width = width.to_f
33
38
  @dash_array = dash_array
34
39
  @line_cap = line_cap
35
40
  @line_join = line_join
41
+ @miter_limit = miter_limit.to_f
36
42
  @raw_width = raw_width
37
43
  @line_cap_explicit = line_cap_explicit
38
44
  @line_join_explicit = line_join_explicit
45
+ @null_pen_fallback = null_pen_fallback
46
+ @fallback_brush = fallback_brush
39
47
  end
40
48
 
41
49
  def null_pen_sentinel?
42
50
  raw_width == NULL_PEN_WIDTH_SENTINEL &&
51
+ null_pen_fallback &&
43
52
  !line_cap_explicit &&
44
53
  !line_join_explicit
45
54
  end
46
55
 
56
+ # Recover only the exact unfilled Polyline spelling emitted for these
57
+ # legacy cosmetic pens. Ordinary SVG shares the parsed paint cache.
58
+ def with_canonical_cosmetic_dash(source_tag)
59
+ return self unless %w[1.0000 1px].include?(raw_width) && !paint.null? && dash_array
60
+
61
+ number = "-?\\d+\\.\\d{4}"
62
+ point = "#{number},#{number}"
63
+ data = source_tag&.[](/\A<path d="(M #{point}(?: L #{point})+ )"/, 1)
64
+ color = source_tag&.[](/ stroke="(#[0-9A-F]{6})"/, 1)
65
+ return self unless data && color
66
+
67
+ lengths = dash_array.map { |length| format("%.4f", length) }.join(",")
68
+ expected = %(<path d="#{data}" stroke="#{color}" stroke-width="#{raw_width}" )
69
+ expected << %(stroke-dasharray="#{lengths}" stroke-linecap="round" stroke-linejoin="round" fill="none" />)
70
+ return self unless source_tag == expected
71
+
72
+ COSMETIC_DASH_PATTERNS.each do |style, pattern|
73
+ stored_width = dash_array.first / pattern.first
74
+ next unless stored_width >= 1 && stored_width == stored_width.to_i
75
+ next unless dash_array == pattern.map { |length| length * stored_width }
76
+
77
+ return dup.tap { |copy| copy.canonical_cosmetic_dash = [stored_width, style].freeze }
78
+ end
79
+ self
80
+ end
81
+
82
+ protected
83
+
84
+ attr_writer :canonical_cosmetic_dash
85
+
86
+ public
87
+
47
88
  PS_GEOMETRIC = 0x00010000
48
89
 
90
+ # Cosmetic widths are device pixels, independent of logical coordinates.
91
+ def cosmetic_pixel_width?
92
+ raw_width == "1px" && !paint.null? && !null_pen_sentinel?
93
+ end
94
+
49
95
  # Pen style bitmask (full 32 bits). Includes:
50
96
  # * dash style (bits 0-7)
51
97
  # * end-cap (bits 8-11)
52
98
  # * line-join (bits 12-15)
53
- # * pen type GEOMETRIC (bit 16) — set when width > 1, so
54
- # emfsvg's EMF→SVG renderer uses the actual pen.width
55
- # instead of hardcoding 1.
99
+ # * pen type GEOMETRIC (bit 16) — set when width > 1 or an
100
+ # explicit unitless width of 1 must survive SVG round trips.
56
101
  def pen_style
102
+ return canonical_cosmetic_dash[1] if canonical_cosmetic_dash
103
+
104
+ @pen_style ||= compute_pen_style
105
+ end
106
+
107
+ def compute_pen_style
57
108
  base = if paint.null? || null_pen_sentinel?
58
109
  5 # PS_NULL
59
110
  else
60
111
  dash_style
61
112
  end
62
- type_bit = width > 1.0 && !paint.null? ? PS_GEOMETRIC : 0
113
+ type_bit = (width > 1.0 || explicit_unitless_one_width?) && !paint.null? ? PS_GEOMETRIC : 0
63
114
  base | type_bit | END_CAP_BITS.fetch(line_cap, 0x0000) | LINE_JOIN_BITS.fetch(line_join, 0x0000)
64
115
  end
116
+ private :compute_pen_style
65
117
 
66
- def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:)
118
+ def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:, miter_limit: nil, fill: nil)
67
119
  new(
68
120
  paint: Paint.from_stroke(stroke),
69
121
  width: parse_width(width),
70
122
  dash_array: parse_dash_array(dash_array),
71
123
  line_cap: line_cap || "round",
72
124
  line_join: line_join || "round",
125
+ miter_limit: parse_miter_limit(miter_limit),
73
126
  raw_width: width,
74
127
  line_cap_explicit: !line_cap.nil?,
75
- line_join_explicit: !line_join.nil?
128
+ line_join_explicit: !line_join.nil?,
129
+ null_pen_fallback: fill.nil? || !fill.null?
76
130
  )
77
131
  end
78
132
 
133
+ def self.parse_miter_limit(value)
134
+ return 10.0 if value.nil? || value.strip.empty?
135
+
136
+ Float(value)
137
+ rescue ArgumentError
138
+ 10.0
139
+ end
140
+
79
141
  def self.parse_width(value)
80
142
  return 1.0 if value.nil? || value.strip.empty?
81
143
 
@@ -96,11 +158,15 @@ module Emfsvg
96
158
  first < (width * 2) ? 2 : 1 # PS_DOT=2, PS_DASH=1
97
159
  end
98
160
 
161
+ def explicit_unitless_one_width?
162
+ raw_width.to_s.match?(/\A\s*1(?:\.0+)?\s*\z/)
163
+ end
164
+
99
165
  def self.parse_dash_array(value)
100
166
  return nil if value.nil? || value.strip == "none" || value.strip.empty?
101
167
 
102
168
  parts = value.split(/[\s,]+/).map(&:to_f)
103
- parts.empty? ? nil : parts
169
+ parts.empty? ? nil : parts.freeze
104
170
  end
105
171
  end
106
172
  end
@@ -6,7 +6,8 @@ module Emfsvg
6
6
  # - self-closing elements use `/>` (no leading space)
7
7
  # - callers control newlines explicitly via `rawln` / `newline`
8
8
  class SvgBuilder
9
- def initialize
9
+ def initialize(max_output_bytes: Renderer::MAX_OUTPUT_BYTES)
10
+ @max_output_bytes = max_output_bytes
10
11
  @out = (+"").force_encoding("ASCII-8BIT")
11
12
  end
12
13
 
@@ -22,24 +23,25 @@ module Emfsvg
22
23
  end
23
24
 
24
25
  def text(content)
25
- @out << escape_text(content)
26
+ append(escape_text(content))
26
27
  self
27
28
  end
28
29
 
29
30
  # Raw append, no newline.
30
31
  def raw(string)
31
- @out << string
32
+ append(string)
32
33
  self
33
34
  end
34
35
 
35
36
  # Raw append with trailing newline.
36
37
  def rawln(string)
37
- @out << string << "\n"
38
+ append(string)
39
+ append("\n")
38
40
  self
39
41
  end
40
42
 
41
43
  def newline
42
- @out << "\n"
44
+ append("\n")
43
45
  self
44
46
  end
45
47
 
@@ -50,20 +52,46 @@ module Emfsvg
50
52
  private
51
53
 
52
54
  def open_tag(name, attrs, single:)
53
- @out << "<" << name
55
+ append("<")
56
+ append(name)
54
57
  attrs.each do |k, v|
55
58
  next if v.nil?
56
59
 
57
- @out << " " << k.to_s << '="' << escape_attr(v.to_s) << '"'
60
+ append(" ")
61
+ append(k.to_s)
62
+ append('="')
63
+ append(escape_attr(v.to_s))
64
+ append('"')
58
65
  end
59
- @out << (single ? "/>" : ">")
66
+ append(single ? "/>" : ">")
60
67
  end
61
68
 
62
69
  def close_tag(name)
63
- @out << "</" << name << ">"
70
+ append("</")
71
+ append(name)
72
+ append(">")
73
+ end
74
+
75
+ def append(string)
76
+ check_budget(string.bytesize)
77
+ @out << string
78
+ end
79
+
80
+ def check_budget(additional_bytes)
81
+ actual = @out.bytesize + additional_bytes
82
+ return if actual <= @max_output_bytes
83
+
84
+ raise ResourceLimitError.new(resource: :output_bytes, limit: @max_output_bytes, actual: actual)
85
+ end
86
+
87
+ def check_escaped_budget(value, attribute: false)
88
+ additional_bytes = value.bytesize + (value.count("&") * 4) + (value.count("<>") * 3)
89
+ additional_bytes += value.count('"') * 5 if attribute
90
+ check_budget(additional_bytes)
64
91
  end
65
92
 
66
93
  def escape_attr(value)
94
+ check_escaped_budget(value, attribute: true)
67
95
  value.gsub("&", "&amp;")
68
96
  .gsub("<", "&lt;")
69
97
  .gsub(">", "&gt;")
@@ -71,6 +99,7 @@ module Emfsvg
71
99
  end
72
100
 
73
101
  def escape_text(value)
102
+ check_escaped_budget(value)
74
103
  value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
75
104
  end
76
105
  end