emfsvg 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.
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Decodes Windows DIB (Device-Independent Bitmap) data to an intermediate
5
+ # representation suitable for re-encoding as PNG.
6
+ #
7
+ # DIB layout (per MS-WMF 2.2.2.3 / MS-EMF 2.2.2):
8
+ # BITMAPINFOHEADER (40+ bytes)
9
+ # [optional colour table]
10
+ # pixel array
11
+ #
12
+ # Supported biCompression values:
13
+ # BI_RGB (0) — uncompressed
14
+ # BI_RLE8 (1) — run-length encoded, 8-bit
15
+ # BI_RLE4 (2) — run-length encoded, 4-bit
16
+ # BI_BITFIELDS (3) — uncompressed with custom masks
17
+ # BI_JPEG (4) — embedded JPEG (pass-through)
18
+ # BI_PNG (5) — embedded PNG (pass-through)
19
+ #
20
+ # Output: a Result with width, height, colour_type (:rgb / :rgba / :gray),
21
+ # and a pixel array (row-major, RGBA).
22
+ class DibDecoder
23
+ # Decoded DIB bytes. The caller typically passes the concatenation of
24
+ # BMI + bitmap bits (matching how libemf2svg reads the record). We
25
+ # honour both layouts:
26
+ #
27
+ # * "compact" form: bmi contains exactly header + palette, with no
28
+ # trailing padding. Pixel data starts at header_size + palette*4.
29
+ # * "padded" form (CB_BMI > minimum): bmi has unused bytes between
30
+ # the palette and the bits. Use the optional bits_offset: parameter
31
+ # (== cb_bmi in libemf2svg terms) to start reading pixels at the
32
+ # correct byte.
33
+ Result = Struct.new(:width, :height, :color_type, :pixels, keyword_init: true)
34
+
35
+ class << self
36
+ def decode(dib_bytes, bits_offset: nil, mono_palette: nil)
37
+ return nil if dib_bytes.nil? || dib_bytes.bytesize < 40
38
+
39
+ header = parse_header(dib_bytes)
40
+ return nil unless header
41
+
42
+ case header[:compression]
43
+ when 0, 3 then decode_bi_rgb(dib_bytes, header, bits_offset, mono_palette)
44
+ when 4 then Result.new(width: header[:width], height: header[:height].abs,
45
+ color_type: :jpeg, pixels: dib_bytes[header[:header_size]..])
46
+ when 5 then Result.new(width: header[:width], height: header[:height].abs,
47
+ color_type: :png, pixels: dib_bytes[header[:header_size]..])
48
+ end
49
+ end
50
+
51
+ def parse_header(bytes)
52
+ {
53
+ header_size: read_uint32(bytes, 0),
54
+ width: read_int32(bytes, 4),
55
+ height: read_int32(bytes, 8),
56
+ planes: read_uint16(bytes, 12),
57
+ bit_count: read_uint16(bytes, 14),
58
+ compression: read_uint32(bytes, 16),
59
+ image_size: read_uint32(bytes, 20),
60
+ x_ppm: read_int32(bytes, 24),
61
+ y_ppm: read_int32(bytes, 28),
62
+ clr_used: read_uint32(bytes, 32),
63
+ clr_important: read_uint32(bytes, 36)
64
+ }
65
+ end
66
+
67
+ def safe_getbyte(bytes, offset)
68
+ return 0 if bytes.nil?
69
+
70
+ b = bytes.getbyte(offset)
71
+ b.nil? ? 0 : b
72
+ end
73
+
74
+ def read_uint32(bytes, offset)
75
+ return 0 if bytes.nil? || offset + 4 > bytes.bytesize
76
+
77
+ safe_getbyte(bytes, offset) |
78
+ (bytes.getbyte(offset + 1) << 8) |
79
+ (bytes.getbyte(offset + 2) << 16) |
80
+ (bytes.getbyte(offset + 3) << 24)
81
+ end
82
+
83
+ def read_int32(bytes, offset)
84
+ v = read_uint32(bytes, offset)
85
+ v >= 0x8000_0000 ? v - 0x1_0000_0000 : v
86
+ end
87
+
88
+ def read_uint16(bytes, offset)
89
+ return 0 if bytes.nil? || offset + 2 > bytes.bytesize
90
+
91
+ safe_getbyte(bytes, offset) | (bytes.getbyte(offset + 1) << 8)
92
+ end
93
+
94
+ def decode_bi_rgb(bytes, header, bits_offset, mono_palette = nil)
95
+ width = header[:width]
96
+ height = header[:height].abs
97
+ bit_count = header[:bit_count]
98
+ top_down = header[:height].negative?
99
+
100
+ pal_start = header[:header_size]
101
+ pal_entries = real_palette_size(header)
102
+ # For 1-bpp MONOCHROME brushes libemf2svg overrides the palette with
103
+ # the device context's text/bk colors. Caller can supply that via
104
+ # mono_palette; otherwise read the palette from the BMI.
105
+ palette = mono_palette || (0...pal_entries).map do |i|
106
+ b = safe_getbyte(bytes, pal_start + (i * 4))
107
+ g = safe_getbyte(bytes, pal_start + (i * 4) + 1)
108
+ r = safe_getbyte(bytes, pal_start + (i * 4) + 2)
109
+ a = safe_getbyte(bytes, pal_start + (i * 4) + 3)
110
+ [r, g, b, a]
111
+ end
112
+
113
+ # When the caller supplies bits_offset (== cb_bmi from the EMF
114
+ # record), use it — the BMI may contain trailing padding between
115
+ # the palette and the bitmap bits. Otherwise assume the compact
116
+ # layout: pixels start immediately after the palette.
117
+ pixel_offset = bits_offset || (pal_start + (pal_entries * 4))
118
+ row_stride = (((bit_count * width) + 31) / 32) * 4
119
+ pixels = Array.new(width * height * 4, 0)
120
+
121
+ (0...height).each do |row|
122
+ src_row = top_down ? row : (height - 1 - row)
123
+ row_start = pixel_offset + (src_row * row_stride)
124
+ case bit_count
125
+ when 24
126
+ decode_rgb24_row(bytes, row_start, width, pixels, row * width * 4)
127
+ when 32
128
+ decode_rgb32_row(bytes, row_start, width, pixels, row * width * 4)
129
+ when 16
130
+ decode_rgb16_row(bytes, row_start, width, pixels, row * width * 4)
131
+ when 8
132
+ decode_indexed_row(bytes, row_start, width, 1, palette, pixels, row * width * 4)
133
+ when 4
134
+ decode_indexed_row(bytes, row_start, width, 2, palette, pixels, row * width * 4)
135
+ when 1
136
+ decode_indexed_row(bytes, row_start, width, 8, palette, pixels, row * width * 4)
137
+ else
138
+ return nil
139
+ end
140
+ end
141
+
142
+ # libemf2svg's rgb2png checks whether ALL alpha bytes are zero
143
+ # across the entire bitmap; if so, it forces every alpha to 0xFF
144
+ # (treating the source as opaque). If any alpha is non-zero, it
145
+ # uses the actual alpha values as-is. We must replicate this
146
+ # global check (NOT a per-pixel "alpha==0 ? 255 : alpha" swap)
147
+ # to produce byte-identical PNG output. Applies to all bit depths
148
+ # — 16-bpp always has alpha=0 so this forces it opaque.
149
+ alpha_all_zero = pixels.each_slice(4).all? { |_, _, _, a| a.zero? }
150
+ (3...pixels.size).step(4).each { |i| pixels[i] = 255 } if alpha_all_zero
151
+
152
+ Result.new(width: width, height: height, color_type: :rgba,
153
+ pixels: pixels.pack("C*"))
154
+ end
155
+
156
+ def palette_size(header)
157
+ case header[:bit_count]
158
+ when 1, 4, 8
159
+ count = header[:clr_used]
160
+ count.positive? ? count : (1 << header[:bit_count])
161
+ else
162
+ 0
163
+ end
164
+ end
165
+
166
+ # libuemf's get_real_color_icount: if ClrUsed is 0, use 1<<BitCount,
167
+ # but cap at width*height (so a 10x10 8-bpp image gets a 100-entry
168
+ # palette, not 256). This prevents reading past the actual palette
169
+ # data when the BMI's palette is sized for the image, not the bit
170
+ # depth.
171
+ def real_palette_size(header)
172
+ nominal = palette_size(header)
173
+ return nominal if nominal.zero?
174
+
175
+ area = header[:width].abs * header[:height].abs
176
+ nominal > area ? area : nominal
177
+ end
178
+
179
+ def decode_rgb24_row(bytes, row_start, width, pixels, out_start)
180
+ (0...width).each do |x|
181
+ b = safe_getbyte(bytes, row_start + (x * 3))
182
+ g = safe_getbyte(bytes, row_start + (x * 3) + 1)
183
+ r = safe_getbyte(bytes, row_start + (x * 3) + 2)
184
+ pixels[out_start + (x * 4)] = r
185
+ pixels[out_start + (x * 4) + 1] = g
186
+ pixels[out_start + (x * 4) + 2] = b
187
+ pixels[out_start + (x * 4) + 3] = 255
188
+ end
189
+ end
190
+
191
+ def decode_rgb32_row(bytes, row_start, width, pixels, out_start)
192
+ (0...width).each do |x|
193
+ b = safe_getbyte(bytes, row_start + (x * 4))
194
+ g = safe_getbyte(bytes, row_start + (x * 4) + 1)
195
+ r = safe_getbyte(bytes, row_start + (x * 4) + 2)
196
+ a = safe_getbyte(bytes, row_start + (x * 4) + 3)
197
+ pixels[out_start + (x * 4)] = r
198
+ pixels[out_start + (x * 4) + 1] = g
199
+ pixels[out_start + (x * 4) + 2] = b
200
+ pixels[out_start + (x * 4) + 3] = a
201
+ end
202
+ end
203
+
204
+ # 16-bpp BI_RGB is always 5:5:5 (top bit unused) per GDI semantics.
205
+ # Mirrors libuemf's DIB_to_RGBA U_BCBM_COLOR16 case: each pixel is
206
+ # little-endian 2 bytes; b = low5<<3, g = (high3 of low + low2 of high)<<3,
207
+ # r = high5<<1 (libuemf shifts to top 5 then <<1 instead of <<3, but
208
+ # both produce 8-bit values that round to the same quantised levels).
209
+ def decode_rgb16_row(bytes, row_start, width, pixels, out_start)
210
+ (0...width).each do |x|
211
+ lo = safe_getbyte(bytes, row_start + (x * 2))
212
+ hi = safe_getbyte(bytes, row_start + (x * 2) + 1)
213
+ b = (lo & 0x1F) << 3
214
+ g = (lo >> 5) | ((hi & 0x03) << 3)
215
+ g <<= 3
216
+ r = (hi & 0x7C) << 1
217
+ pixels[out_start + (x * 4)] = r
218
+ pixels[out_start + (x * 4) + 1] = g
219
+ pixels[out_start + (x * 4) + 2] = b
220
+ pixels[out_start + (x * 4) + 3] = 0
221
+ end
222
+ end
223
+
224
+ def decode_indexed_row(bytes, row_start, width, pixels_per_byte, palette, pixels, out_start)
225
+ (0...width).each do |x|
226
+ if pixels_per_byte == 8
227
+ byte = safe_getbyte(bytes, row_start + (x / 8).floor)
228
+ shift = 7 - (x % 8)
229
+ idx = (byte >> shift) & 0x01
230
+ elsif pixels_per_byte == 2
231
+ byte = safe_getbyte(bytes, row_start + (x / 2).floor)
232
+ shift = (x % 2).zero? ? 4 : 0
233
+ idx = (byte >> shift) & 0x0F
234
+ else
235
+ idx = safe_getbyte(bytes, row_start + x)
236
+ end
237
+ entry = palette[idx] || [0, 0, 0, 255]
238
+ pixels[out_start + (x * 4), 4] = entry
239
+ end
240
+ end
241
+
242
+ def decode_bi_bitfields(bytes, header, bits_offset)
243
+ # Bit-fields uses 3 (or 4) uint32 masks after the header for the
244
+ # R/G/B (and optionally A) channel positions.
245
+ width = header[:width]
246
+ height = header[:height].abs
247
+ bit_count = header[:bit_count]
248
+ return nil unless [16, 32].include?(bit_count)
249
+
250
+ mask_offset = header[:header_size]
251
+ r_mask = read_uint32(bytes, mask_offset)
252
+ g_mask = read_uint32(bytes, mask_offset + 4)
253
+ b_mask = read_uint32(bytes, mask_offset + 8)
254
+ a_mask = read_uint32(bytes, mask_offset + 12) if header[:header_size] >= 56
255
+
256
+ mask_bytes = header[:header_size] >= 56 ? 16 : 12
257
+ pixel_offset = bits_offset || (mask_offset + mask_bytes)
258
+ row_stride = (((bit_count * width) + 31) / 32) * 4
259
+ pixels = Array.new(width * height * 4, 0)
260
+
261
+ (0...height).each do |row|
262
+ src_row = header[:height].negative? ? row : (height - 1 - row)
263
+ row_start = pixel_offset + (src_row * row_stride)
264
+ (0...width).each do |x|
265
+ value = read_uint32(bytes, row_start + (x * (bit_count / 8)))
266
+ r = scale_masked(value, r_mask)
267
+ g = scale_masked(value, g_mask)
268
+ b = scale_masked(value, b_mask)
269
+ a = a_mask ? scale_masked(value, a_mask) : 255
270
+ out_idx = ((row * width) + x) * 4
271
+ pixels[out_idx] = r
272
+ pixels[out_idx + 1] = g
273
+ pixels[out_idx + 2] = b
274
+ pixels[out_idx + 3] = a
275
+ end
276
+ end
277
+
278
+ Result.new(width: width, height: height, color_type: :rgba,
279
+ pixels: pixels.pack("C*"))
280
+ end
281
+
282
+ def scale_masked(value, mask)
283
+ return 0 if mask.zero?
284
+
285
+ # Find lowest set bit and width of mask
286
+ shift = 0
287
+ tmp = mask
288
+ while tmp.nobits?(1) && tmp != 0
289
+ shift += 1
290
+ tmp >>= 1
291
+ end
292
+ width = 0
293
+ while tmp.allbits?(1)
294
+ width += 1
295
+ tmp >>= 1
296
+ end
297
+ scaled = ((value & mask) >> shift).to_f / ((1 << width) - 1) * 255
298
+ scaled.round.clamp(0, 255)
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ class Error < StandardError; end
5
+
6
+ class FormatError < Error; end
7
+
8
+ class RenderError < Error; end
9
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Emfsvg
6
+ # C-printf-compatible float formatting. libemf2svg uses C's printf with
7
+ # the platform's default rounding (round-half-to-even on the actual
8
+ # IEEE-754 double bits). Ruby's Float#round and format("%.4f", v) both
9
+ # lose precision for values landing near the 4-decimal midpoint because
10
+ # they operate on the display value rather than the true bits.
11
+ #
12
+ # Example: 459.04375 stored as 459.04374999… → C: "459.0437",
13
+ # Ruby format: "459.0438". We expose the actual bits via "%.20f"
14
+ # then round via BigDecimal for exact IEEE-754 semantics.
15
+ module FormatHelpers
16
+ module_function
17
+
18
+ # Format a single float as "%.4f" with C printf semantics.
19
+ def fmt(n)
20
+ bd = BigDecimal("%.20f" % n.to_f)
21
+ format("%.4f", bd.round(4, half: :even).to_f)
22
+ end
23
+
24
+ # Format a coordinate pair as "X,Y " (trailing space, matching
25
+ # libemf2svg's point_draw output).
26
+ def fmt_xy(x, y)
27
+ "#{fmt(x)},#{fmt(y)} "
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Emfsvg
6
+ # Maps glyph indices to Unicode codepoints using the system font's
7
+ # cmap table. Mirrors libemf2svg's fontindex_to_utf8 which uses
8
+ # fontconfig + freetype. We use `fc-match` to find the font file
9
+ # and fontisan to parse the cmap.
10
+ #
11
+ # The reverse cmap is cached per (font_family, weight, italic) triple
12
+ # since parsing the cmap is expensive and the same font is typically
13
+ # referenced many times.
14
+ class GlyphIndexMapper
15
+ ETO_GLYPH_INDEX = 0x0010 # per MS-WMF ExtTextOutOptions
16
+
17
+ # Hebrew/Arabic charsets that need RTL reversal after mapping.
18
+ HEBREW_CHARSET = 177
19
+ ARABIC_CHARSET = 178
20
+
21
+ def initialize
22
+ @cache = {}
23
+ end
24
+
25
+ # Map an array of glyph indices to a UTF-8 string using the
26
+ # font's reverse cmap. Mirrors libemf2svg's fontindex_to_utf8
27
+ # + text_convert:
28
+ #
29
+ # 1. Build UTF-8 string with null chars (U+0000) for unmapped glyphs.
30
+ # 2. If charset is Hebrew/Arabic, reverse the string (Ruby's
31
+ # String#reverse correctly handles multibyte UTF-8 chars).
32
+ # 3. Truncate at the first null char — matches fprintf %s behavior.
33
+ #
34
+ # This means: for RTL languages, if ANY glyph is unmapped, the
35
+ # reversed string may start with null → empty output.
36
+ def map(glyph_ids, font_family:, weight:, italic:, charset:)
37
+ reverse_map = build_reverse_map(font_family, weight, italic)
38
+ return nil unless reverse_map
39
+
40
+ codepoints = glyph_ids.map { |gid| reverse_map[gid] || 0 }
41
+ utf8 = codepoints.pack("U*")
42
+
43
+ if charset == HEBREW_CHARSET || charset == ARABIC_CHARSET
44
+ utf8 = utf8.reverse
45
+ end
46
+
47
+ null_pos = utf8.index("\x00")
48
+ utf8 = utf8[0, null_pos] if null_pos
49
+
50
+ utf8
51
+ end
52
+
53
+ private
54
+
55
+ def build_reverse_map(font_family, weight, italic)
56
+ key = [font_family&.downcase, weight, italic]
57
+ return @cache[key] if @cache.key?(key)
58
+
59
+ path = find_font_path(font_family, weight, italic)
60
+ return @cache[key] = nil unless path && File.exist?(path)
61
+
62
+ begin
63
+ reverse_map = parse_cmap_reverse(path)
64
+ @cache[key] = reverse_map
65
+ rescue StandardError
66
+ @cache[key] = nil
67
+ end
68
+ end
69
+
70
+ def find_font_path(font_family, weight, italic)
71
+ return nil if font_family.nil? || font_family.empty?
72
+
73
+ # Build fontconfig pattern matching libemf2svg's get_fontpath.
74
+ pattern = font_family.dup
75
+ stdout, _ = Open3.capture2("fc-match", "-f", "%{file}\n", pattern)
76
+ path = stdout.strip
77
+ path.empty? ? nil : path
78
+ end
79
+
80
+ # Parse a font file's cmap table and build a reverse map
81
+ # {glyph_id => codepoint}. Uses fontisan's Cmap parser.
82
+ def parse_cmap_reverse(font_path)
83
+ require "fontisan"
84
+ bytes = File.binread(font_path)
85
+ num_tables = bytes[4, 2].unpack1("n").to_i
86
+ cmap_offset = cmap_length = nil
87
+ (0...num_tables).each do |i|
88
+ off = 12 + i * 16
89
+ tag = bytes[off, 4]
90
+ next unless tag == "cmap"
91
+
92
+ cmap_offset = bytes[off + 8, 4].unpack1("N")
93
+ cmap_length = bytes[off + 12, 4].unpack1("N")
94
+ break
95
+ end
96
+ return nil unless cmap_offset
97
+
98
+ cmap_bytes = bytes[cmap_offset, cmap_length]
99
+ cmap = Fontisan::Tables::Cmap.read(cmap_bytes)
100
+ forward = cmap.unicode_mappings
101
+ forward.each_with_object({}) do |(cp, gid), h|
102
+ # Last codepoint wins (matches FreeType's FT_Get_Next_Char).
103
+ h[gid] = cp
104
+ end
105
+ rescue LoadError, StandardError
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # 1-indexed GDI object table (matches EMF's handle numbering).
5
+ # SELECTOBJECT copies from the table into the current DeviceContext.
6
+ # DELETEOBJECT removes the slot.
7
+ class ObjectTable
8
+ Entry = Struct.new(:type, :object, keyword_init: true) do
9
+ def pen?
10
+ type == :pen
11
+ end
12
+
13
+ def brush?
14
+ type == :brush
15
+ end
16
+
17
+ def font?
18
+ type == :font
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @entries = [] # 0-indexed internally; EMF indices are 1-based
24
+ end
25
+
26
+ def store(index, type, object)
27
+ ensure_capacity(index)
28
+ @entries[index - 1] = Entry.new(type: type, object: object)
29
+ end
30
+
31
+ def lookup(index)
32
+ @entries[index - 1]
33
+ end
34
+
35
+ def delete(index)
36
+ @entries[index - 1] = nil
37
+ end
38
+
39
+ def size
40
+ @entries.compact.size
41
+ end
42
+
43
+ private
44
+
45
+ def ensure_capacity(index)
46
+ return if @entries.size >= index
47
+
48
+ @entries.fill(nil, @entries.size...index)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Render options. Frozen on construction; carry through the renderer.
5
+ Options = Struct.new(:namespace, :width, :height, :verbose, :emf_plus, keyword_init: true) do
6
+ def initialize(namespace: nil, width: nil, height: nil, verbose: false, emf_plus: false)
7
+ super
8
+ freeze
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libpng"
4
+
5
+ module Emfsvg
6
+ # Wraps the +libpng+ gem to encode an RGBA pixel buffer as a PNG.
7
+ # Uses the standard libpng write API (png_create_write_struct +
8
+ # png_write_png with PNG_TRANSFORM_IDENTITY) which matches
9
+ # libemf2svg's rgb2png byte-for-byte. The simplified API
10
+ # (Libpng.encode) uses a different filter heuristic that produces
11
+ # different IDAT bytes.
12
+ class PngEncoder
13
+ class << self
14
+ def encode(width, height, rgba_bytes)
15
+ return "" if width <= 0 || height <= 0
16
+ return "" if rgba_bytes.nil? || rgba_bytes.bytesize < (width * height * 4)
17
+
18
+ png = Libpng.encode_standard(width, height, rgba_bytes, pixel_format: "RGBA")
19
+ # Replicate libemf2svg's fmem padding: round up to next multiple
20
+ # of 3 bytes by appending zero bytes. Without this the base64
21
+ # output has `==` padding instead of the trailing `AA`/`A=`/``
22
+ # that libemf2svg produces.
23
+ rem = png.bytesize % 3
24
+ png << ("\x00" * ((3 - rem) % 3)) if rem != 0
25
+ png
26
+ end
27
+ end
28
+ end
29
+ end