Skip to main content

document_svg/
ir.rs

1//! Output-independent page model used by all input converters.
2//!
3//! Coordinates are expressed in points with the origin at the page's top-left.
4//! [`Page::nodes`] is paint-ordered: later nodes are drawn above earlier nodes.
5//! Matrix values use SVG's `[a, b, c, d, e, f]` affine convention.
6
7use serde::Serialize;
8
9pub type Matrix = [f64; 6];
10pub const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
11
12#[derive(Clone, Debug, PartialEq, Serialize)]
13pub struct OuterShadow {
14    pub color: String,
15    pub opacity: f64,
16    pub blur_radius: f64,
17    pub distance: f64,
18    pub direction_degrees: f64,
19}
20
21#[derive(Clone, Debug, PartialEq, Serialize)]
22pub struct GlowEffect {
23    pub color: String,
24    pub opacity: f64,
25    pub radius: f64,
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum ImageColorEffect {
31    Duotone {
32        dark: String,
33        light: String,
34    },
35    Grayscale,
36    Luminance {
37        brightness: f64,
38        contrast: f64,
39    },
40    ColorChange {
41        from: String,
42        to: String,
43        to_opacity: f64,
44    },
45}
46
47#[derive(Clone, Debug, Default, PartialEq, Serialize)]
48pub struct SourceMeta {
49    pub kind: String,
50    pub source_id: String,
51    pub semantic_role: String,
52    pub alt_text: String,
53    pub blend_mode: String,
54    pub mask_id: String,
55    pub image_rendering: String,
56    pub isolation: bool,
57    pub alpha_is_shape: bool,
58    pub shape_rendering: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub outer_shadow: Option<OuterShadow>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub glow: Option<GlowEffect>,
63    #[serde(skip_serializing_if = "Vec::is_empty")]
64    pub image_effects: Vec<ImageColorEffect>,
65}
66
67#[derive(Clone, Debug, Default, PartialEq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum Paint {
70    #[default]
71    None,
72    Solid {
73        color: String,
74        opacity: f64,
75    },
76    LinearGradient(Box<LinearGradient>),
77    RadialGradient(Box<RadialGradient>),
78    PatternRef {
79        id: String,
80        opacity: f64,
81    },
82}
83
84impl Paint {
85    #[must_use]
86    pub fn solid(color: impl Into<String>) -> Self {
87        Self::Solid {
88            color: color.into(),
89            opacity: 1.0,
90        }
91    }
92}
93
94#[derive(Clone, Debug, PartialEq, Serialize)]
95pub struct GradientStop {
96    pub offset: f64,
97    pub color: String,
98    pub opacity: f64,
99}
100
101#[derive(Clone, Debug, PartialEq, Serialize)]
102pub struct LinearGradient {
103    pub x1: f64,
104    pub y1: f64,
105    pub x2: f64,
106    pub y2: f64,
107    pub stops: Vec<GradientStop>,
108}
109
110#[derive(Clone, Debug, PartialEq, Serialize)]
111pub struct RadialGradient {
112    pub fx: f64,
113    pub fy: f64,
114    pub fr: f64,
115    pub cx: f64,
116    pub cy: f64,
117    pub radius: f64,
118    pub transform: Matrix,
119    pub stops: Vec<GradientStop>,
120}
121
122#[derive(Clone, Debug, Default, PartialEq, Serialize)]
123pub struct Stroke {
124    pub paint: Paint,
125    pub width: f64,
126    pub line_cap: LineCap,
127    pub line_join: LineJoin,
128    pub miter_limit: f64,
129    pub dash_array: Vec<f64>,
130    pub dash_offset: f64,
131}
132
133#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "snake_case")]
135pub enum LineCap {
136    #[default]
137    Butt,
138    Round,
139    Square,
140}
141
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "snake_case")]
144pub enum LineJoin {
145    #[default]
146    Miter,
147    Round,
148    Bevel,
149}
150
151#[derive(Clone, Debug, PartialEq, Serialize)]
152pub struct TextRun {
153    pub text: String,
154    pub font_family: String,
155    pub font_size: f64,
156    pub bold: bool,
157    pub italic: bool,
158    pub fill: Paint,
159    pub baseline_shift: f64,
160    /// Optional glyph origins in the text node's local coordinate space.
161    ///
162    /// PDF text frequently carries exact per-glyph advances that cannot be
163    /// reproduced by a substitute font. When this has one entry per Unicode
164    /// scalar in `text`, the SVG writer emits positioned child tspans.
165    pub glyph_x_offsets: Vec<f64>,
166    /// Optional expected advance in the text node's local coordinate space.
167    ///
168    /// PDF fonts frequently use widths that differ materially from the
169    /// browser fallback font. SVG `textLength` keeps the editable text aligned
170    /// to the source document without forcing all text to outlines.
171    pub target_advance: Option<f64>,
172}
173
174impl Default for TextRun {
175    fn default() -> Self {
176        Self {
177            text: String::new(),
178            font_family: "Arial, 'Hiragino Sans', 'Yu Gothic', sans-serif".into(),
179            font_size: 12.0,
180            bold: false,
181            italic: false,
182            fill: Paint::solid("#000000"),
183            baseline_shift: 0.0,
184            glyph_x_offsets: Vec::new(),
185            target_advance: None,
186        }
187    }
188}
189
190#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
191#[serde(rename_all = "snake_case")]
192pub enum TextAnchor {
193    #[default]
194    Start,
195    Middle,
196    End,
197}
198
199#[derive(Clone, Debug, PartialEq, Serialize)]
200#[serde(tag = "kind", rename_all = "snake_case")]
201pub enum Node {
202    Path {
203        id: String,
204        d: String,
205        fill_rule: String,
206        fill: Paint,
207        stroke: Stroke,
208        transform: Matrix,
209        clip_id: Option<String>,
210        meta: SourceMeta,
211    },
212    Text {
213        id: String,
214        x: f64,
215        y: f64,
216        runs: Vec<TextRun>,
217        anchor: TextAnchor,
218        transform: Matrix,
219        opacity: f64,
220        stroke: Stroke,
221        clip_id: Option<String>,
222        meta: SourceMeta,
223    },
224    Image {
225        id: String,
226        href: String,
227        x: f64,
228        y: f64,
229        width: f64,
230        height: f64,
231        transform: Matrix,
232        opacity: f64,
233        clip_id: Option<String>,
234        meta: SourceMeta,
235    },
236    Group {
237        id: String,
238        nodes: Vec<Node>,
239        transform: Matrix,
240        opacity: f64,
241        clip_id: Option<String>,
242        meta: SourceMeta,
243    },
244}
245
246#[derive(Clone, Debug, PartialEq, Serialize)]
247pub struct ClipPath {
248    pub id: String,
249    pub d: String,
250    pub transform: Matrix,
251    pub fill_rule: String,
252    pub parent_id: Option<String>,
253    pub additional_paths: Vec<ClipMember>,
254}
255
256#[derive(Clone, Debug, PartialEq, Serialize)]
257pub struct ClipMember {
258    pub d: String,
259    pub transform: Matrix,
260    pub fill_rule: String,
261}
262
263#[derive(Clone, Debug, PartialEq, Serialize)]
264pub struct MaskDefinition {
265    pub id: String,
266    pub mask_type: String,
267    pub nodes: Vec<Node>,
268    pub transfer_values: Vec<f64>,
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize)]
272pub struct TilingPatternDefinition {
273    pub id: String,
274    pub x: f64,
275    pub y: f64,
276    pub width: f64,
277    pub height: f64,
278    pub transform: Matrix,
279    pub nodes: Vec<Node>,
280}
281
282#[derive(Clone, Debug, PartialEq, Serialize)]
283pub struct Page {
284    pub number: usize,
285    pub width: f64,
286    pub height: f64,
287    pub source_format: String,
288    pub title: String,
289    pub description: String,
290    /// The source document this page was drawn from, kept verbatim so a
291    /// reverse conversion can restore it instead of packaging a picture.
292    ///
293    /// draw.io writes the same thing into its own SVG exports, as the `content`
294    /// attribute on the root element, and reads it back when the SVG is opened
295    /// as a diagram.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub embedded_source: Option<String>,
298    pub nodes: Vec<Node>,
299    pub clips: Vec<ClipPath>,
300    pub masks: Vec<MaskDefinition>,
301    pub patterns: Vec<TilingPatternDefinition>,
302    pub warnings: Vec<String>,
303}
304
305impl Page {
306    #[must_use]
307    pub fn new(number: usize, width: f64, height: f64, source_format: &str) -> Self {
308        Self {
309            number,
310            width,
311            height,
312            source_format: source_format.into(),
313            title: String::new(),
314            description: String::new(),
315            embedded_source: None,
316            nodes: Vec::new(),
317            clips: Vec::new(),
318            masks: Vec::new(),
319            patterns: Vec::new(),
320            warnings: Vec::new(),
321        }
322    }
323
324    pub fn warn(&mut self, warning: impl Into<String>) {
325        let warning = warning.into();
326        if !self.warnings.contains(&warning) {
327            self.warnings.push(warning);
328        }
329    }
330}
331
332#[must_use]
333pub fn compose(left: Matrix, right: Matrix) -> Matrix {
334    let [la, lb, lc, ld, le, lf] = left;
335    let [ra, rb, rc, rd, re, rf] = right;
336    [
337        la * ra + lc * rb,
338        lb * ra + ld * rb,
339        la * rc + lc * rd,
340        lb * rc + ld * rd,
341        la * re + lc * rf + le,
342        lb * re + ld * rf + lf,
343    ]
344}
345
346#[must_use]
347pub fn transform_point(matrix: Matrix, x: f64, y: f64) -> (f64, f64) {
348    let [a, b, c, d, e, f] = matrix;
349    (a * x + c * y + e, b * x + d * y + f)
350}