Skip to main content

rust_docx/
emf.rs

1//! EMF (Enhanced Metafile) to SVG conversion for the vector path-drawing
2//! record subset: window/viewport mapping, paths, solid brushes/pens,
3//! positioned Unicode text, solid raster-operation fills, saved device
4//! contexts, fill modes, and clipping. Records outside the
5//! supported set abort the conversion so the caller can fall back to the
6//! unsupported-image placeholder instead of rendering something wrong.
7
8use std::fmt::Write as _;
9
10fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
11    data.get(offset..offset + 4)
12        .map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
13}
14
15fn read_i32(data: &[u8], offset: usize) -> Option<i32> {
16    read_u32(data, offset).map(|value| value as i32)
17}
18
19fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
20    data.get(offset..offset + 2)
21        .map(|bytes| i16::from_le_bytes([bytes[0], bytes[1]]))
22}
23
24fn escape_svg(value: &str) -> String {
25    value
26        .replace('&', "&amp;")
27        .replace('<', "&lt;")
28        .replace('>', "&gt;")
29        .replace('"', "&quot;")
30}
31
32fn colorref_to_css(color: u32) -> String {
33    let red = color & 0xff;
34    let green = (color >> 8) & 0xff;
35    let blue = (color >> 16) & 0xff;
36    format!("#{red:02x}{green:02x}{blue:02x}")
37}
38
39#[derive(Clone, Debug, PartialEq)]
40enum DeviceObject {
41    SolidBrush(u32),
42    NullBrush,
43    SolidPen { color: u32, width_logical: f64 },
44    NullPen,
45    Font(LogicalFont),
46}
47
48#[derive(Clone, Debug, PartialEq)]
49struct LogicalFont {
50    family: String,
51    height: f64,
52    weight: u32,
53    italic: bool,
54    underline: bool,
55    strikeout: bool,
56}
57
58#[derive(Clone)]
59struct SavedState {
60    mapping: Mapping,
61    brush: Option<u32>,
62    pen: Option<(u32, f64)>,
63    font: Option<LogicalFont>,
64    text_color: u32,
65    background_color: u32,
66    background_mode: u32,
67    text_align: u32,
68    fill_rule: &'static str,
69    current_point: (f64, f64),
70    clip: Option<usize>,
71}
72
73#[derive(Clone, Copy, Debug)]
74struct Mapping {
75    window_org: (f64, f64),
76    window_ext: (f64, f64),
77    viewport_org: (f64, f64),
78    viewport_ext: (f64, f64),
79}
80
81impl Mapping {
82    fn identity() -> Self {
83        Mapping {
84            window_org: (0.0, 0.0),
85            window_ext: (1.0, 1.0),
86            viewport_org: (0.0, 0.0),
87            viewport_ext: (1.0, 1.0),
88        }
89    }
90
91    fn scale_x(&self) -> f64 {
92        if self.window_ext.0 == 0.0 {
93            1.0
94        } else {
95            self.viewport_ext.0 / self.window_ext.0
96        }
97    }
98
99    fn scale_y(&self) -> f64 {
100        if self.window_ext.1 == 0.0 {
101            1.0
102        } else {
103            self.viewport_ext.1 / self.window_ext.1
104        }
105    }
106
107    fn map(&self, x: f64, y: f64) -> (f64, f64) {
108        (
109            (x - self.window_org.0) * self.scale_x() + self.viewport_org.0,
110            (y - self.window_org.1) * self.scale_y() + self.viewport_org.1,
111        )
112    }
113}
114
115struct Coordinate(f64);
116
117impl std::fmt::Display for Coordinate {
118    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        let scaled = (self.0 * 100.0).round();
120        let rounded = scaled / 100.0;
121        if rounded == rounded.trunc() {
122            write!(formatter, "{}", rounded as i64)
123        } else if scaled.abs() <= (1u64 << 40) as f64 {
124            // Scaled integers are exact here, and distinct cents map to distinct f64 values.
125            let value = scaled as i64;
126            let absolute = value.unsigned_abs();
127            if value < 0 {
128                formatter.write_str("-")?;
129            }
130            write!(formatter, "{}.", absolute / 100)?;
131            let fraction = absolute % 100;
132            if fraction < 10 {
133                write!(formatter, "0{fraction}")
134            } else if fraction % 10 == 0 {
135                write!(formatter, "{}", fraction / 10)
136            } else {
137                write!(formatter, "{fraction}")
138            }
139        } else {
140            write!(formatter, "{rounded}")
141        }
142    }
143}
144
145fn fmt(value: f64) -> Coordinate {
146    Coordinate(value)
147}
148
149const EMR_HEADER: u32 = 1;
150const EMR_POLYGON16: u32 = 86;
151const EMR_POLYLINE16: u32 = 87;
152const EMR_POLYBEZIER16: u32 = 85;
153const EMR_POLYBEZIERTO16: u32 = 88;
154const EMR_POLYLINETO16: u32 = 89;
155const EMR_POLYPOLYLINE16: u32 = 90;
156const EMR_POLYPOLYGON16: u32 = 91;
157const EMR_SETWINDOWEXTEX: u32 = 9;
158const EMR_SETWINDOWORGEX: u32 = 10;
159const EMR_SETVIEWPORTEXTEX: u32 = 11;
160const EMR_SETVIEWPORTORGEX: u32 = 12;
161const EMR_SETBRUSHORGEX: u32 = 13;
162const EMR_EOF: u32 = 14;
163const EMR_SETMAPMODE: u32 = 17;
164const EMR_SETBKMODE: u32 = 18;
165const EMR_SETPOLYFILLMODE: u32 = 19;
166const EMR_SETROP2: u32 = 20;
167const EMR_SETSTRETCHBLTMODE: u32 = 21;
168const EMR_SETTEXTALIGN: u32 = 22;
169const EMR_SETTEXTCOLOR: u32 = 24;
170const EMR_SETBKCOLOR: u32 = 25;
171const EMR_MOVETOEX: u32 = 27;
172const EMR_SETMETARGN: u32 = 28;
173const EMR_SETMITERLIMIT: u32 = 58;
174const EMR_LINETO: u32 = 54;
175const EMR_SELECTOBJECT: u32 = 37;
176const EMR_CREATEPEN: u32 = 38;
177const EMR_CREATEBRUSHINDIRECT: u32 = 39;
178const EMR_DELETEOBJECT: u32 = 40;
179const EMR_BEGINPATH: u32 = 59;
180const EMR_ENDPATH: u32 = 60;
181const EMR_CLOSEFIGURE: u32 = 61;
182const EMR_FILLPATH: u32 = 62;
183const EMR_STROKEANDFILLPATH: u32 = 63;
184const EMR_STROKEPATH: u32 = 64;
185const EMR_SELECTCLIPPATH: u32 = 67;
186const EMR_COMMENT: u32 = 70;
187const EMR_SAVEDC: u32 = 33;
188const EMR_RESTOREDC: u32 = 34;
189const EMR_INTERSECTCLIPRECT: u32 = 30;
190const EMR_BITBLT: u32 = 76;
191const EMR_EXTCREATEFONTINDIRECTW: u32 = 82;
192const EMR_EXTTEXTOUTW: u32 = 84;
193const EMR_EXTSELECTCLIPRGN: u32 = 75;
194const EMR_EXTCREATEPEN: u32 = 95;
195const EMR_SETICMMODE: u32 = 98;
196
197const STOCK_OBJECT_FLAG: u32 = 0x8000_0000;
198
199struct Converter<'a> {
200    data: &'a [u8],
201    mapping: Mapping,
202    objects: std::collections::HashMap<u32, DeviceObject>,
203    current_brush: Option<u32>,
204    current_pen: Option<(u32, f64)>,
205    current_font: Option<LogicalFont>,
206    text_color: u32,
207    background_color: u32,
208    background_mode: u32,
209    text_align: u32,
210    saved_states: Vec<SavedState>,
211    fill_rule: &'static str,
212    in_path: bool,
213    path_data: String,
214    pending_path: Option<String>,
215    current_point: (f64, f64),
216    elements: Vec<String>,
217    clip_defs: Vec<String>,
218    active_clip_id: Option<usize>,
219    bounds: (i32, i32, i32, i32),
220}
221
222impl<'a> Converter<'a> {
223    fn save_state(&self) -> SavedState {
224        SavedState {
225            mapping: self.mapping,
226            brush: self.current_brush,
227            pen: self.current_pen,
228            font: self.current_font.clone(),
229            text_color: self.text_color,
230            background_color: self.background_color,
231            background_mode: self.background_mode,
232            text_align: self.text_align,
233            fill_rule: self.fill_rule,
234            current_point: self.current_point,
235            clip: self.active_clip_id,
236        }
237    }
238
239    fn restore_state(&mut self, state: SavedState) {
240        self.mapping = state.mapping;
241        self.current_brush = state.brush;
242        self.current_pen = state.pen;
243        self.current_font = state.font;
244        self.text_color = state.text_color;
245        self.background_color = state.background_color;
246        self.background_mode = state.background_mode;
247        self.text_align = state.text_align;
248        self.fill_rule = state.fill_rule;
249        self.current_point = state.current_point;
250        self.active_clip_id = state.clip;
251    }
252
253    fn mapped_rect(&self, x: f64, y: f64, width: f64, height: f64) -> String {
254        let first = self.map_point(x, y);
255        let last = self.map_point(x + width, y + height);
256        format!(
257            "x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"",
258            fmt(first.0.min(last.0)),
259            fmt(first.1.min(last.1)),
260            fmt((last.0 - first.0).abs()),
261            fmt((last.1 - first.1).abs())
262        )
263    }
264
265    fn intersect_clip_rect(&mut self, rect: String) {
266        let id = self.clip_defs.len();
267        let previous = self
268            .active_clip_id
269            .map(|id| format!(" clip-path=\"url(#c{id})\""))
270            .unwrap_or_default();
271        self.clip_defs.push(format!(
272            "<clipPath id=\"c{id}\"><rect {rect}{previous}/></clipPath>"
273        ));
274        self.active_clip_id = Some(id);
275    }
276
277    fn emit_text(&mut self, record: &[u8]) -> Option<()> {
278        if record.len() < 76 || self.in_path {
279            return None;
280        }
281        let font = self.current_font.clone()?;
282        let count = read_u32(record, 44)? as usize;
283        let string_offset = read_u32(record, 48)? as usize;
284        let options = read_u32(record, 52)?;
285        let dx_offset = read_u32(record, 72)? as usize;
286        if count > 0 && string_offset < 76 {
287            return None;
288        }
289        if options & !(2 | 4 | 0x1000 | 0x2000) != 0 || self.text_align & 1 != 0 {
290            return None;
291        }
292        let encoded =
293            record.get(string_offset..string_offset.checked_add(count.checked_mul(2)?)?)?;
294        let units: Vec<u16> = encoded
295            .chunks_exact(2)
296            .map(|v| u16::from_le_bytes([v[0], v[1]]))
297            .collect();
298        let text = String::from_utf16(&units).ok()?;
299        if text.is_empty() {
300            return Some(());
301        }
302        let x = read_i32(record, 36)? as f64;
303        let y = read_i32(record, 40)? as f64;
304        let mut advances = Vec::new();
305        if dx_offset != 0 {
306            let stride = if options & 0x2000 != 0 { 8 } else { 4 };
307            if dx_offset < 76 || dx_offset.checked_add(count.checked_mul(stride)?)? > record.len() {
308                return None;
309            }
310            for index in 0..count {
311                let offset = dx_offset + index * stride;
312                advances.push((
313                    read_i32(record, offset)? as f64,
314                    if stride == 8 {
315                        read_i32(record, offset + 4)? as f64
316                    } else {
317                        0.0
318                    },
319                ));
320            }
321        }
322        let old_clip = self.active_clip_id;
323        let rectangle = self.mapped_rect(
324            read_i32(record, 56)? as f64,
325            read_i32(record, 60)? as f64,
326            (read_i32(record, 64)? as f64) - (read_i32(record, 56)? as f64),
327            (read_i32(record, 68)? as f64) - (read_i32(record, 60)? as f64),
328        );
329        if options & 4 != 0 {
330            self.intersect_clip_rect(rectangle.clone());
331        }
332        if options & 2 != 0 {
333            self.push_element(format!(
334                "<rect {rectangle} fill=\"{}\"/>",
335                colorref_to_css(self.background_color)
336            ));
337        } else if self.background_mode == 2 {
338            let rect = self.mapped_rect(
339                read_i32(record, 8)? as f64,
340                read_i32(record, 12)? as f64,
341                (read_i32(record, 16)? as f64) - (read_i32(record, 8)? as f64) + 1.0,
342                (read_i32(record, 20)? as f64) - (read_i32(record, 12)? as f64) + 1.0,
343            );
344            self.push_element(format!(
345                "<rect {rect} fill=\"{}\"/>",
346                colorref_to_css(self.background_color)
347            ));
348        }
349        let horizontal = self.text_align & 6;
350        let width: f64 = advances.iter().map(|v| v.0).sum();
351        let shift = if horizontal == 6 {
352            width / 2.0
353        } else if horizontal == 2 {
354            width
355        } else {
356            0.0
357        };
358        let coordinate_capacity = if advances.is_empty() { 16 } else { count.saturating_mul(6) };
359        let mut xs = String::with_capacity(coordinate_capacity);
360        let mut ys = String::with_capacity(coordinate_capacity);
361        let mut position = (x - shift, y);
362        let mut unit_index = 0;
363        if advances.is_empty() {
364            let point = self.map_point(x, y);
365            let _ = write!(xs, "{}", fmt(point.0));
366            let _ = write!(ys, "{}", fmt(point.1));
367        } else {
368            for character in text.chars() {
369                let point = self.map_point(position.0, position.1);
370                if !xs.is_empty() {
371                    xs.push(' ');
372                    ys.push(' ');
373                }
374                let _ = write!(xs, "{}", fmt(point.0));
375                let _ = write!(ys, "{}", fmt(point.1));
376                for _ in 0..character.len_utf16() {
377                    position.0 += advances[unit_index].0;
378                    position.1 += advances[unit_index].1;
379                    unit_index += 1;
380                }
381            }
382        }
383        let anchor = if advances.is_empty() && horizontal == 6 {
384            "middle"
385        } else if advances.is_empty() && horizontal == 2 {
386            "end"
387        } else {
388            "start"
389        };
390        let baseline = match self.text_align & 24 {
391            24 => "alphabetic",
392            8 => "text-after-edge",
393            _ => "text-before-edge",
394        };
395        let decoration = [
396            if font.underline { "underline" } else { "" },
397            if font.strikeout { "line-through" } else { "" },
398        ]
399        .join(" ");
400        self.push_element(format!(
401            "<text xml:space=\"preserve\" x=\"{}\" y=\"{}\" font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" font-style=\"{}\" text-decoration=\"{}\" text-anchor=\"{anchor}\" dominant-baseline=\"{baseline}\" fill=\"{}\">{}</text>",
402            xs,ys,escape_svg(&font.family),fmt(font.height*self.mapping.scale_y().abs()),font.weight,
403            if font.italic { "italic" } else { "normal" },decoration.trim(),colorref_to_css(self.text_color),escape_svg(&text)));
404        self.active_clip_id = old_clip;
405        Some(())
406    }
407    fn map_point(&self, x: f64, y: f64) -> (f64, f64) {
408        self.mapping.map(x, y)
409    }
410
411    fn push_element(&mut self, element: String) {
412        if let Some(clip_id) = self.active_clip_id {
413            self.elements
414                .push(format!("<g clip-path=\"url(#c{clip_id})\">{element}</g>"));
415        } else {
416            self.elements.push(element);
417        }
418    }
419
420    fn fill_attr(&self) -> String {
421        match self.current_brush {
422            Some(color) => format!("fill=\"{}\"", colorref_to_css(color)),
423            None => "fill=\"none\"".to_string(),
424        }
425    }
426
427    fn stroke_attr(&self) -> String {
428        match self.current_pen {
429            Some((color, width_logical)) => {
430                let width_px = (width_logical * self.mapping.scale_x()).abs().max(0.25);
431                format!(
432                    "stroke=\"{}\" stroke-width=\"{}\"",
433                    colorref_to_css(color),
434                    fmt(width_px)
435                )
436            }
437            None => "stroke=\"none\"".to_string(),
438        }
439    }
440
441    fn emit_path(&mut self, d: String, fill: bool, stroke: bool) {
442        if d.is_empty() {
443            return;
444        }
445        let fill_attr = if fill {
446            format!("{} fill-rule=\"{}\"", self.fill_attr(), self.fill_rule)
447        } else {
448            "fill=\"none\"".to_string()
449        };
450        let stroke_attr = if stroke {
451            self.stroke_attr()
452        } else {
453            "stroke=\"none\"".to_string()
454        };
455        self.push_element(format!("<path d=\"{d}\" {fill_attr} {stroke_attr}/>"));
456    }
457
458    fn read_points16(&self, offset: usize, record_end: usize) -> Option<Vec<(f64, f64)>> {
459        let count = read_u32(self.data, offset)? as usize;
460        let mut points = Vec::with_capacity(count);
461        let mut cursor = offset + 4;
462        for _ in 0..count {
463            if cursor + 4 > record_end {
464                return None;
465            }
466            let x = read_i16(self.data, cursor)? as f64;
467            let y = read_i16(self.data, cursor + 2)? as f64;
468            points.push(self.map_point(x, y));
469            cursor += 4;
470        }
471        Some(points)
472    }
473
474    fn polyline_to_d(points: &[(f64, f64)], close: bool) -> String {
475        let mut d = String::new();
476        for (index, (x, y)) in points.iter().enumerate() {
477            if index == 0 {
478                write!(d, "M{} {}", fmt(*x), fmt(*y)).expect("writing into a string cannot fail");
479            } else {
480                write!(d, "L{} {}", fmt(*x), fmt(*y)).expect("writing into a string cannot fail");
481            }
482        }
483        if close && !d.is_empty() {
484            d.push('Z');
485        }
486        d
487    }
488
489    fn bezier_to_d(points: &[(f64, f64)], starts_with_move: bool) -> Option<String> {
490        let mut d = String::new();
491        let control_points = if starts_with_move {
492            let Some(((x, y), rest)) = points.split_first() else {
493                return None;
494            };
495            write!(d, "M{} {}", fmt(*x), fmt(*y)).expect("writing into a string cannot fail");
496            rest
497        } else {
498            points
499        };
500        if control_points.len() % 3 != 0 {
501            return None;
502        }
503        for triple in control_points.chunks(3) {
504            write!(d,
505                "C{} {} {} {} {} {}",
506                fmt(triple[0].0),
507                fmt(triple[0].1),
508                fmt(triple[1].0),
509                fmt(triple[1].1),
510                fmt(triple[2].0),
511                fmt(triple[2].1)
512            ).expect("writing into a string cannot fail");
513        }
514        Some(d)
515    }
516}
517
518/// Converts an EMF byte stream to an SVG document when every record falls in
519/// the supported vector subset; returns `None` otherwise so callers keep the
520/// placeholder fallback.
521pub fn emf_to_svg(data: &[u8]) -> Option<String> {
522    if data.len() < 88 || read_u32(data, 0)? != EMR_HEADER {
523        return None;
524    }
525    let bounds = (
526        read_i32(data, 8)?,
527        read_i32(data, 12)?,
528        read_i32(data, 16)?,
529        read_i32(data, 20)?,
530    );
531    if bounds.2 <= bounds.0 || bounds.3 <= bounds.1 {
532        return None;
533    }
534
535    let mut converter = Converter {
536        data,
537        mapping: Mapping::identity(),
538        objects: std::collections::HashMap::new(),
539        current_brush: Some(0x00ff_ffff),
540        current_pen: Some((0, 1.0)),
541        current_font: None,
542        text_color: 0,
543        background_color: 0x00ff_ffff,
544        background_mode: 2,
545        text_align: 0,
546        saved_states: Vec::new(),
547        fill_rule: "evenodd",
548        in_path: false,
549        path_data: String::new(),
550        pending_path: None,
551        current_point: (0.0, 0.0),
552        elements: Vec::new(),
553        clip_defs: Vec::new(),
554        active_clip_id: None,
555        bounds,
556    };
557
558    let header_size = read_u32(data, 4)? as usize;
559    let mut offset = header_size;
560    while offset + 8 <= data.len() {
561        let record_type = read_u32(data, offset)?;
562        let record_size = read_u32(data, offset + 4)? as usize;
563        if record_size < 8 || offset + record_size > data.len() {
564            return None;
565        }
566        let record_end = offset + record_size;
567        let body = offset + 8;
568
569        match record_type {
570            EMR_EOF => break,
571            EMR_HEADER => return None,
572            EMR_SETMAPMODE
573            | EMR_SETSTRETCHBLTMODE
574            | EMR_SETBRUSHORGEX
575            | EMR_SETMETARGN
576            | EMR_SETMITERLIMIT
577            | EMR_SETICMMODE
578            | EMR_COMMENT => {}
579            EMR_SAVEDC => converter.saved_states.push(converter.save_state()),
580            EMR_RESTOREDC => {
581                let level = read_i32(data, body)?;
582                let index = if level < 0 {
583                    converter
584                        .saved_states
585                        .len()
586                        .checked_sub(level.unsigned_abs() as usize)?
587                } else {
588                    (level as usize).checked_sub(1)?
589                };
590                let state = converter.saved_states.get(index)?.clone();
591                converter.saved_states.truncate(index);
592                converter.restore_state(state);
593            }
594            EMR_SETROP2 => {
595                if read_u32(data, body)? != 13 {
596                    return None;
597                }
598            }
599            EMR_SETBKMODE => converter.background_mode = read_u32(data, body)?,
600            EMR_SETTEXTALIGN => converter.text_align = read_u32(data, body)?,
601            EMR_SETTEXTCOLOR => converter.text_color = read_u32(data, body)?,
602            EMR_SETBKCOLOR => converter.background_color = read_u32(data, body)?,
603            EMR_INTERSECTCLIPRECT => {
604                if record_size < 24 {
605                    return None;
606                }
607                let left = read_i32(data, body)? as f64;
608                let top = read_i32(data, body + 4)? as f64;
609                let rect = converter.mapped_rect(
610                    left,
611                    top,
612                    read_i32(data, body + 8)? as f64 - left,
613                    read_i32(data, body + 12)? as f64 - top,
614                );
615                converter.intersect_clip_rect(rect);
616            }
617            EMR_BITBLT => {
618                if record_size < 100 {
619                    return None;
620                }
621                let color = match read_u32(data, offset + 40)? {
622                    0x00f00021 => converter.current_brush?,
623                    0x00000042 => 0,
624                    0x00ff0062 => 0x00ffffff,
625                    _ => return None,
626                };
627                let rect = converter.mapped_rect(
628                    read_i32(data, offset + 24)? as f64,
629                    read_i32(data, offset + 28)? as f64,
630                    read_i32(data, offset + 32)? as f64,
631                    read_i32(data, offset + 36)? as f64,
632                );
633                converter.push_element(format!(
634                    "<rect {rect} fill=\"{}\"/>",
635                    colorref_to_css(color)
636                ));
637            }
638            EMR_EXTCREATEFONTINDIRECTW => {
639                if record_size < 104 {
640                    return None;
641                }
642                let height = read_i32(data, offset + 12)?;
643                if height >= 0
644                    || read_i32(data, offset + 16)? != 0
645                    || read_i32(data, offset + 20)? != 0
646                    || read_i32(data, offset + 24)? != 0
647                {
648                    return None;
649                }
650                let units: Vec<u16> = data[offset + 40..offset + 104]
651                    .chunks_exact(2)
652                    .map(|v| u16::from_le_bytes([v[0], v[1]]))
653                    .take_while(|v| *v != 0)
654                    .collect();
655                let font = LogicalFont {
656                    family: String::from_utf16(&units).ok()?,
657                    height: -(height as f64),
658                    weight: read_u32(data, offset + 28)?.clamp(1, 1000),
659                    italic: data[offset + 32] != 0,
660                    underline: data[offset + 33] != 0,
661                    strikeout: data[offset + 34] != 0,
662                };
663                converter
664                    .objects
665                    .insert(read_u32(data, body)?, DeviceObject::Font(font));
666            }
667            EMR_EXTTEXTOUTW => converter.emit_text(&data[offset..record_end])?,
668            EMR_SETWINDOWEXTEX => {
669                converter.mapping.window_ext = (
670                    read_i32(data, body)? as f64,
671                    read_i32(data, body + 4)? as f64,
672                );
673            }
674            EMR_SETWINDOWORGEX => {
675                converter.mapping.window_org = (
676                    read_i32(data, body)? as f64,
677                    read_i32(data, body + 4)? as f64,
678                );
679            }
680            EMR_SETVIEWPORTEXTEX => {
681                converter.mapping.viewport_ext = (
682                    read_i32(data, body)? as f64,
683                    read_i32(data, body + 4)? as f64,
684                );
685            }
686            EMR_SETVIEWPORTORGEX => {
687                converter.mapping.viewport_org = (
688                    read_i32(data, body)? as f64,
689                    read_i32(data, body + 4)? as f64,
690                );
691            }
692            EMR_SETPOLYFILLMODE => {
693                converter.fill_rule = if read_u32(data, body)? == 2 {
694                    "nonzero"
695                } else {
696                    "evenodd"
697                };
698            }
699            EMR_CREATEBRUSHINDIRECT => {
700                let handle = read_u32(data, body)?;
701                let style = read_u32(data, body + 4)?;
702                let color = read_u32(data, body + 8)?;
703                let object = if style == 1 {
704                    DeviceObject::NullBrush
705                } else {
706                    DeviceObject::SolidBrush(color)
707                };
708                converter.objects.insert(handle, object);
709            }
710            EMR_CREATEPEN => {
711                let handle = read_u32(data, body)?;
712                let style = read_u32(data, body + 4)?;
713                let width = read_i32(data, body + 8)? as f64;
714                let color = read_u32(data, body + 16)?;
715                let object = if style & 0xff == 5 {
716                    DeviceObject::NullPen
717                } else {
718                    DeviceObject::SolidPen {
719                        color,
720                        width_logical: width.max(1.0),
721                    }
722                };
723                converter.objects.insert(handle, object);
724            }
725            EMR_EXTCREATEPEN => {
726                let handle = read_u32(data, body)?;
727                let pen_style = read_u32(data, body + 20)?;
728                let width = read_u32(data, body + 24)? as f64;
729                let color = read_u32(data, body + 32)?;
730                let object = if pen_style & 0xff == 5 {
731                    DeviceObject::NullPen
732                } else {
733                    DeviceObject::SolidPen {
734                        color,
735                        width_logical: width.max(1.0),
736                    }
737                };
738                converter.objects.insert(handle, object);
739            }
740            EMR_SELECTOBJECT => {
741                let handle = read_u32(data, body)?;
742                if handle & STOCK_OBJECT_FLAG != 0 {
743                    match handle & !STOCK_OBJECT_FLAG {
744                        0 => converter.current_brush = Some(0x00ff_ffff),
745                        1 => converter.current_brush = Some(0x00c0_c0c0),
746                        2 => converter.current_brush = Some(0x0080_8080),
747                        3 => converter.current_brush = Some(0x0040_4040),
748                        4 => converter.current_brush = Some(0x0000_0000),
749                        5 => converter.current_brush = None,
750                        6 => converter.current_pen = Some((0x00ff_ffff, 1.0)),
751                        7 => converter.current_pen = Some((0x0000_0000, 1.0)),
752                        8 => converter.current_pen = None,
753                        _ => {}
754                    }
755                } else {
756                    match converter.objects.get(&handle) {
757                        Some(DeviceObject::SolidBrush(color)) => {
758                            converter.current_brush = Some(*color);
759                        }
760                        Some(DeviceObject::NullBrush) => converter.current_brush = None,
761                        Some(DeviceObject::SolidPen {
762                            color,
763                            width_logical,
764                        }) => converter.current_pen = Some((*color, *width_logical)),
765                        Some(DeviceObject::NullPen) => converter.current_pen = None,
766                        Some(DeviceObject::Font(font)) => {
767                            converter.current_font = Some(font.clone())
768                        }
769                        None => {}
770                    }
771                }
772            }
773            EMR_DELETEOBJECT => {
774                let handle = read_u32(data, body)?;
775                converter.objects.remove(&handle);
776            }
777            EMR_BEGINPATH => {
778                converter.in_path = true;
779                converter.path_data.clear();
780            }
781            EMR_ENDPATH => {
782                converter.in_path = false;
783                converter.pending_path = Some(std::mem::take(&mut converter.path_data));
784            }
785            EMR_CLOSEFIGURE => {
786                if converter.in_path {
787                    converter.path_data.push('Z');
788                }
789            }
790            EMR_MOVETOEX => {
791                let point = converter.map_point(
792                    read_i32(data, body)? as f64,
793                    read_i32(data, body + 4)? as f64,
794                );
795                converter.current_point = point;
796                if converter.in_path {
797                    write!(converter.path_data, "M{} {}", fmt(point.0), fmt(point.1)).expect("writing into a string cannot fail");
798                }
799            }
800            EMR_LINETO => {
801                let start = converter.current_point;
802                let point = converter.map_point(
803                    read_i32(data, body)? as f64,
804                    read_i32(data, body + 4)? as f64,
805                );
806                converter.current_point = point;
807                if converter.in_path {
808                    write!(converter.path_data, "L{} {}", fmt(point.0), fmt(point.1)).expect("writing into a string cannot fail");
809                } else {
810                    converter.emit_path(
811                        format!(
812                            "M{} {}L{} {}",
813                            fmt(start.0),
814                            fmt(start.1),
815                            fmt(point.0),
816                            fmt(point.1)
817                        ),
818                        false,
819                        true,
820                    );
821                }
822            }
823            EMR_POLYLINETO16 | EMR_POLYBEZIERTO16 => {
824                let points = converter.read_points16(body + 16, record_end)?;
825                if points.is_empty() {
826                    offset = record_end;
827                    continue;
828                }
829                if !converter.in_path {
830                    return None;
831                }
832                if record_type == EMR_POLYLINETO16 {
833                    for (x, y) in &points {
834                        write!(converter.path_data, "L{} {}", fmt(*x), fmt(*y)).expect("writing into a string cannot fail");
835                    }
836                } else {
837                    let segment = Converter::bezier_to_d(&points, false)?;
838                    converter.path_data.push_str(&segment);
839                }
840                converter.current_point = *points.last().unwrap();
841            }
842            EMR_POLYLINE16 | EMR_POLYGON16 | EMR_POLYBEZIER16 => {
843                let points = converter.read_points16(body + 16, record_end)?;
844                if points.is_empty() {
845                    offset = record_end;
846                    continue;
847                }
848                let d = if record_type == EMR_POLYBEZIER16 {
849                    Converter::bezier_to_d(&points, true)?
850                } else {
851                    Converter::polyline_to_d(&points, record_type == EMR_POLYGON16)
852                };
853                if converter.in_path {
854                    converter.path_data.push_str(&d);
855                } else if record_type == EMR_POLYGON16 {
856                    converter.emit_path(d, true, converter.current_pen.is_some());
857                } else {
858                    converter.emit_path(d, false, true);
859                }
860            }
861            EMR_POLYPOLYLINE16 | EMR_POLYPOLYGON16 => {
862                let poly_count = read_u32(data, body + 16)? as usize;
863                let total_points = read_u32(data, body + 20)? as usize;
864                let counts_offset = body + 24;
865                let points_offset = counts_offset + poly_count * 4;
866                let mut d = String::new();
867                let mut cursor = points_offset;
868                let mut consumed = 0usize;
869                for poly_index in 0..poly_count {
870                    let count = read_u32(data, counts_offset + poly_index * 4)? as usize;
871                    let mut points = Vec::with_capacity(count);
872                    for _ in 0..count {
873                        if cursor + 4 > record_end {
874                            return None;
875                        }
876                        let x = read_i16(data, cursor)? as f64;
877                        let y = read_i16(data, cursor + 2)? as f64;
878                        points.push(converter.map_point(x, y));
879                        cursor += 4;
880                    }
881                    consumed += count;
882                    d.push_str(&Converter::polyline_to_d(
883                        &points,
884                        record_type == EMR_POLYPOLYGON16,
885                    ));
886                }
887                if consumed != total_points {
888                    return None;
889                }
890                if converter.in_path {
891                    converter.path_data.push_str(&d);
892                } else if record_type == EMR_POLYPOLYGON16 {
893                    converter.emit_path(d, true, converter.current_pen.is_some());
894                } else {
895                    converter.emit_path(d, false, true);
896                }
897            }
898            EMR_FILLPATH | EMR_STROKEPATH | EMR_STROKEANDFILLPATH => {
899                let d = converter.pending_path.clone().unwrap_or_default();
900                converter.emit_path(
901                    d,
902                    record_type != EMR_STROKEPATH,
903                    record_type != EMR_FILLPATH,
904                );
905            }
906            EMR_SELECTCLIPPATH => {
907                // All combine modes are approximated as replace; logos use
908                // RGN_COPY in practice.
909                let Some(d) = converter.pending_path.clone() else {
910                    return None;
911                };
912                let clip_id = converter.clip_defs.len();
913                converter.clip_defs.push(format!(
914                    "<clipPath id=\"c{clip_id}\" clip-rule=\"{}\"><path d=\"{d}\"/></clipPath>",
915                    converter.fill_rule
916                ));
917                converter.active_clip_id = Some(clip_id);
918            }
919            EMR_EXTSELECTCLIPRGN => {
920                let mode = read_u32(data, body + 4)?;
921                // RGN_COPY with no region data resets the clip; anything more
922                // elaborate than a reset or a full-bounds region bails.
923                let region_data_size = read_u32(data, body)? as usize;
924                if mode == 5 && region_data_size == 0 {
925                    converter.active_clip_id = None;
926                } else if mode == 5 {
927                    let rect_count = read_u32(data, body + 8 + 8).unwrap_or(0);
928                    if rect_count <= 1 {
929                        converter.active_clip_id = None;
930                    } else {
931                        return None;
932                    }
933                } else {
934                    return None;
935                }
936            }
937            _ => return None,
938        }
939
940        offset = record_end;
941    }
942
943    let (left, top, right, bottom) = converter.bounds;
944    let width = right - left;
945    let height = bottom - top;
946    let defs = if converter.clip_defs.is_empty() {
947        String::new()
948    } else {
949        format!("<defs>{}</defs>", converter.clip_defs.join(""))
950    };
951    Some(format!(
952        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {width} {height}\" width=\"{width}\" height=\"{height}\" preserveAspectRatio=\"xMidYMid meet\">{defs}{}</svg>",
953        converter.elements.join("")
954    ))
955}
956
957#[cfg(test)]
958mod coordinate_output_tests {
959    #[test]
960    fn coordinates_preserve_rounding_and_saturated_integer_output() {
961        for (value, expected) in [
962            (0.0, "0"), (-0.0, "0"), (0.004, "0"), (-0.004, "0"),
963            (0.005, "0.01"), (-0.005, "-0.01"), (123.456, "123.46"),
964            (-123.456, "-123.46"), (1234567890123.0, "1234567890123"),
965            (f64::INFINITY, "9223372036854775807"),
966            (f64::NEG_INFINITY, "-9223372036854775808"), (f64::NAN, "NaN"),
967        ] {
968            assert_eq!(super::fmt(value).to_string(), expected);
969        }
970    }
971}
972
973#[cfg(test)]
974mod bounded_coordinate_tests {
975    #[test]
976    fn bounded_decimal_paths_match_standard_float_display() {
977        let mut state = 17u64;
978        for index in 0..1000000 {
979            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
980            let integer = ((state >> 16) % (1u64 << 41)) as i64 - (1i64 << 40);
981            let value = match index % 4 {
982                0 => integer as f64 / 100.0,
983                1 => f64::from_bits(state),
984                2 => (integer % 10000000) as f64 / 123.0,
985                _ => f64::from_bits((integer as f64 / 100.0).to_bits().wrapping_add(index % 3)),
986            };
987            let rounded = (value * 100.0).round() / 100.0;
988            let expected = if rounded == rounded.trunc() {
989                format!("{}", rounded as i64)
990            } else {
991                format!("{rounded}")
992            };
993            assert_eq!(super::fmt(value).to_string(), expected);
994        }
995    }
996}
997
998#[cfg(test)]
999mod positioned_text_tests {
1000    use super::*;
1001
1002    fn render(advances: &[(i32, i32)], align: u32) -> String {
1003        let units: Vec<u16> = "A😀B".encode_utf16().collect();
1004        let text_end = 76 + units.len() * 2;
1005        let mut record = vec![0u8; text_end + advances.len() * 8];
1006        for (offset, value) in [
1007            (36, 100u32), (40, 200), (44, units.len() as u32),
1008            (48, 76), (52, if advances.is_empty() { 0 } else { 0x2000 }),
1009            (72, if advances.is_empty() { 0 } else { text_end as u32 }),
1010        ] {
1011            record[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1012        }
1013        for (index, unit) in units.iter().enumerate() {
1014            record[76 + index * 2..78 + index * 2].copy_from_slice(&unit.to_le_bytes());
1015        }
1016        for (index, (x, y)) in advances.iter().enumerate() {
1017            let offset = text_end + index * 8;
1018            record[offset..offset + 4].copy_from_slice(&x.to_le_bytes());
1019            record[offset + 4..offset + 8].copy_from_slice(&y.to_le_bytes());
1020        }
1021        let mut converter = Converter {
1022            data: &[],
1023            mapping: Mapping { window_org: (0.0, 0.0), window_ext: (2.0, 4.0),
1024                               viewport_org: (0.0, 0.0), viewport_ext: (3.0, -2.0) },
1025            objects: std::collections::HashMap::new(), current_brush: None, current_pen: None,
1026            current_font: Some(LogicalFont { family: "serif".into(), height: 10.0, weight: 400,
1027                                            italic: false, underline: false, strikeout: false }),
1028            text_color: 0, background_color: 0xffffff, background_mode: 1, text_align: align,
1029            saved_states: Vec::new(), fill_rule: "nonzero", in_path: false,
1030            path_data: String::new(), pending_path: None, current_point: (0.0, 0.0),
1031            elements: Vec::new(), clip_defs: Vec::new(), active_clip_id: None, bounds: (0, 0, 1000, 1000),
1032        };
1033        assert_eq!(converter.emit_text(&record), Some(()));
1034        assert_eq!(converter.elements.len(), 1);
1035        converter.elements.pop().unwrap()
1036    }
1037
1038    #[test]
1039    fn paired_advances_preserve_surrogate_positions_and_alignment() {
1040        assert_eq!(
1041            render(&[(4, 8), (5, -4), (3, 4), (-2, 0)], 6 | 24),
1042            "<text xml:space=\"preserve\" x=\"142.5 148.5 160.5\" y=\"-100 -104 -104\" font-family=\"serif\" font-size=\"5\" font-weight=\"400\" font-style=\"normal\" text-decoration=\"\" text-anchor=\"start\" dominant-baseline=\"alphabetic\" fill=\"#000000\">A😀B</text>"
1043        );
1044    }
1045
1046    #[test]
1047    fn unpositioned_text_retains_one_anchor_point() {
1048        assert_eq!(
1049            render(&[], 2 | 24),
1050            "<text xml:space=\"preserve\" x=\"150\" y=\"-100\" font-family=\"serif\" font-size=\"5\" font-weight=\"400\" font-style=\"normal\" text-decoration=\"\" text-anchor=\"end\" dominant-baseline=\"alphabetic\" fill=\"#000000\">A😀B</text>"
1051        );
1052    }
1053}