diegomura / react-pdf

📄 Create PDF files using React
https://react-pdf.org
MIT License
14.16k stars 1.11k forks source link

svg to react-pdf Svg JSX-Element #1234

Closed dennemark closed 3 years ago

dennemark commented 3 years ago

Hi, I know the svg feature is very new and I am excited to try it out! I was reading the announcment and read a few issues in here on svg support, however I could not find what I am looking for. The Announcment states:

As a future reference, just bear in mind that react-pdf does not take the same React SVG primitives but their own, so you will need to transform from one to the other. Should be as simple as cloning elements.

Now I have a svg (either as DOM or just as a string) and I want to convert it to react-pdf svg jsx-elements. However, I am currently not sure, how to approach this issue. Does someone have any hints, scripts or libraries to use?

(In my specific use case I have victory charts, get the svg element of the chart and want to export it to pdf preferably keeping it a svg)

nwsm commented 3 years ago

Here is some more info/work on doing this:

https://github.com/diegomura/react-pdf/issues/503 https://github.com/diegomura/react-pdf/issues/888

For SVGs specifically it might not be too much work since you're only looking at a few elements to convert.

dennemark commented 3 years ago

Thanks for those links! It seems they partly try the same as I did so far. (See sandbox next post)

Check the CreateSVG component an comment / uncomment the return values. return parse(svgpic2, {}) can create the svg and show it within the Document, but it is showing in downloaded pdf. It seams also that the svg should be a one liner. Otherwise PDFDownloadLink complains.

Edit: return svgToJsx2(parsed.children[0], 0); and return svgToJsx(parsed.children[0], 0); need style properties to be processed. will look into it.

(One thing that confuses me is the Text element, since we have it within SVG and within normal components. That means react-pdf/renderer exports two different Text elements if I am not mistaken? This seems pretty inconvenient! Currently I use for svg and

for normal text) > it seems to be the same element.

dennemark commented 3 years ago

Slowly getting there: https://codesandbox.io/s/staging-cdn-rz6t0?file=/src/index.js:0-6242

OliverLeighC commented 3 years ago

@dennemark We have been using something like this using the jsdom library, it's not fully there yet (having some problems with font being null on the Tspan component) but hopefully it's helpful

import {JSDOM} from 'jsdom'
import { Text, Svg, Rect, G, ClipPath, Defs, Path, Tspan } from '@react-pdf/renderer';

type ComponentType = React.ElementType

// https://dev.to/qausim/convert-html-inline-styles-to-a-style-object-for-react-components-2cbi
const formatStringToCamelCase = (str: string) => {
  const splitted = str.split("-");
  if (splitted.length === 1) return splitted[0];
  return (
    splitted[0] +
    splitted
      .slice(1)
      .map((word) => word[0].toUpperCase() + word.slice(1))
      .join("")
  );
};

export const getStyleObjectFromString = (str: string | null) => {
  const style: any = {};
  if (!str) return {};

  str.split(";").forEach((el) => {
    const [property, value] = el.split(":");
    if (!property) return;
    const formattedProperty = formatStringToCamelCase(property.trim());
    style[formattedProperty] = value.trim();
  });

  return style;
};

function parseIntAttributes(attr: string | null) {
  if (!attr) return null;
  if (attr.includes('px')) return attr;

  return Number(attr);
}

export function SVGToComponent(html: string) {
  if (!html || html === "") return null;

  const jsDom = new JSDOM(html)

  function renderNode(node: Element) {
    let Component: ComponentType;
    let componentProps = {};
    switch(node.tagName.toUpperCase()) {
      case "SVG": 
        Component = Svg
        componentProps = {
          height: parseIntAttributes(node.getAttribute('height')),
          width: parseIntAttributes(node.getAttribute('width')),
          viewBox: node.getAttribute('viewBox'),
          style: {
            fontSize: '12px'
          }
        }
        break;
      case "RECT": 
        Component = Rect
        componentProps = {
          x: parseIntAttributes(node.getAttribute('x')),
          y: parseIntAttributes(node.getAttribute('y')),
          fill: node.getAttribute('fill'),
          width: parseIntAttributes(node.getAttribute('width')),
          height: parseIntAttributes(node.getAttribute('height')),
          rx: parseIntAttributes(node.getAttribute('rx')),
          ry: parseIntAttributes(node.getAttribute('ry'))
        }
        break
      case "CLIPPATH": 
        Component = ClipPath
        break;
      case "DEFS":
        Component = Defs
        break;
      case "G":
        Component = G
        componentProps = {
          'data-z-index': node.getAttribute('data-z-index'),
          opacity: node.getAttribute('opacity'),
          transform: node.getAttribute('transform'),
          'clip-path': node.getAttribute('clip-path'),
          visibility: node.getAttribute('visibility')
        }
        break;
      case "TEXT":
        Component = Text
        componentProps = {
          x: parseIntAttributes(node.getAttribute('x')),
          'text-anchor': node.getAttribute('text-anchor'),
          'data-z-index': node.getAttribute('data-z-index'),
          style: getStyleObjectFromString(node.getAttribute('style')),
          y: parseIntAttributes(node.getAttribute('y'))
        }
        break;
      case "PATH":
        Component = Path
        componentProps = {
          'data-z-index': node.getAttribute('data-z-index'),
          d: node.getAttribute('d'),
          fill: node.getAttribute('fill'),
          opacity: node.getAttribute('opacity')
        }
        break;
      case "TSPAN":
        componentProps = {
          x: parseIntAttributes(node.getAttribute("x")),
          y: parseIntAttributes(node.getAttribute("y")),
          fill: node.getAttribute("fill"),
          stroke: node.getAttribute("stroke"),
          "stroke-width": node.getAttribute("stroke-width"),
          "stroke-linejoin": node.getAttribute("stroke-linejoin"),
          opacity: parseIntAttributes(node.getAttribute('opacity')),
          visibility: node.getAttribute('visibility'),
          fontWeight: node.getAttribute('fontWeight')
        }
        Component = Tspan
        break;
      case "DESC":
        return null;
      default:
        throw new Error(`unsupported type ${node.tagName}`)
    }

    if(node.children) {
      return (
        <Component {...componentProps}>
          {Array.from(node.children).map(renderNode)}
        </Component>
      )
    } 
    return (
      <Component {...componentProps} />
    )
  }

  return renderNode(jsDom.window.document.body.children[0])
}

and then you can use it like const chart = SVGToComponent(svg) and

 <View>
       {chart}
  </View>
dennemark commented 3 years ago

Thanks a lot! This really helped! I had a few similar elements in my code already and managed to clean it up. Since I used svg-parser instead of jsdom, my props are already parsed and I only need to do a few case exceptions for text and tspan. React-PDF svg converter won't have the same results as browser svg interpreters, i.e. rounded corners are missing. Also it seems relative positioning works a bit different - i.e. text will be displayed different for the svgtext in the following example, if you use it in a browser. But I implemented a rough relative positioning, since I needed it for my victory charts. I hope it works for most cases. Feel free to use it! :)

import React, {useMemo} from "react";
import { render } from "react-dom";
import {
  Document,
  Page,
  PDFDownloadLink,
  Text,
} from "@react-pdf/renderer";
import {parse} from "svg-parser";

const svgpic = `<svg xmlns="http://www.w3.org/2000/svg"
 width="467" height="462"><rect x="80" y="60" width="250" height="250" rx="20"
      style="fill:#ff0000; stroke:#000000;stroke-width:2px;" /><rect x="140" y="120" width="250" height="250" rx="40"
      style="fill:#0000ff; stroke:#000000; stroke-width:2px;
      fill-opacity:0.7;" /></svg>`;
const svgtext = `<svg height="30" width="200">
<text x="0" y="15" style="fill:red;">
I<tspan x="0" y="30">love</tspan>SVG!</text></svg>`;

//adapted from https://gist.github.com/goldhand/70de06a3bdbdb51565878ad1ee37e92b#gistcomment-3621492
const convertStylesStringToObject = (stringStyles) => {
  let styles =
    typeof stringStyles === "string" && stringStyles !== undefined
      ? stringStyles
          .replaceAll("&quot;", "'") /* font-family sometimes uses &quot; and we don't want that semicolon  */
          .split(";")
          .reduce((acc, style) => {
            const colonPosition = style.indexOf(":");

            if (colonPosition === -1) {
              return acc;
            }

            const camelCaseProperty = style
                .substr(0, colonPosition)
                .trim()
                .replace(/^-ms-/, "ms-")
                .replace(/-./g, (c) => c.substr(1).toUpperCase()),
              value = style.substr(colonPosition + 1).trim();
            // all supported svg styles of react-pdf
            let isSvgStyle = [
              "color",
              "dominantBaseline",
              "fill",
              "fillOpacity",
              "fillRule",
              "opacity",
              "stroke",
              "strokeWidth",
              "strokeOpacity",
              "strokeLinecap",
              "strokeDasharray",
              "transform",
              "textAnchor",
              "visibility"
            ].includes(camelCaseProperty);
            return isSvgStyle && value
              ? { ...acc, [camelCaseProperty]: value }
              : acc;
          }, {})
      : {};
  return styles;
};

const svgToJsx = (obj, index, parentX, parentY) => {
  //obj.type can be element or text, if its text, then its most probably raw text {type: text, value: "our text"}
  let name = obj.type === "element" ? obj.tagName : "rawText";
  //defs and clipPath are currently not supported by react-pdf, so lets skip them
  if(name !== "defs" && name !== "clipPath"){
    //lets give each element a unique key
    let props = { key: index + name };
    if (obj.properties !== undefined) {
      // text and tspan elements can create issues with some values being NaN, therefore we deal with them seperately
      // furthermore if parent elements provide x, y values we add them to the current x, y element,
      // since react-pdf does not handle relative positioning yet
      if(name === "text" || name === "tspan"){
        obj.properties.x = obj.properties.x ?? 0 + parentX ?? 0;// + obj.properties.dx ?? 0;
        obj.properties.y = obj.properties.y ?? 0 + parentY ?? 0;// + obj.properties.dy ?? 0;
        props = {x: obj.properties.x, y: obj.properties.y,  textAnchor: obj.properties["text-anchor"], ...props}
      }else{
        props = { ...obj.properties, ...props };
      }
      // add styling
      if (obj.properties.style !== undefined) {
        props.style = convertStylesStringToObject(obj.properties.style);
      }
    }
    let children = (obj.children !== undefined && obj.children.length > 0) ? obj.children.map((c, i) => svgToJsx(c, index+"-"+i, obj.properties.x ?? 0, obj.properties.y ?? 0)) : "";

    // uppercase to have correct elemment for react-pdf
    // remove line breaks from text
    // might mess up text that is supposed to have line breaks?
    // but handles issues where svg xml has line breaks:
    // <svg...><text>
    //    foo
    // instead of onliner <svg...><text>foo
    // other svg interpreters will still handle text often a bit different..
    name = name.toUpperCase();
    return obj.type === "text"
      ? obj.value.replace(/(\r\n|\n|\r)/gm,"")
      : React.createElement(
          name,
          props,
          children
        );
    }
};

const CreateSvg = ({svgXml}:{svgXml: string}) => {
  const svg = svgXml.replaceAll("px", "pt");
  const svgElement = useMemo(()=>{ 
    //parse svg xml with svg-parsers
    let parsed = parse(svg);
    //create jsx elements
    return svgToJsx(parsed.children[0], 0)
  },[svgXml]);
  return svgElement;
};

function App() {
return (
    <PDFDownloadLink
      document={
        <Document>
          <Page size="A4">
            <Text>Test</Text>
            <CreateSvg svgXml={svgpic} />
            <CreateSvg svgXml={svgtext} />
            <Text>Test</Text>
          </Page>
        </Document>
      }
      fileName="somename.pdf"
    >
      {({ blob, url, loading, error }) =>
        loading ? "Loading document..." : "Download now!"
      }
    </PDFDownloadLink>
  );
}

render(<App />, document.getElementById("root"));
OliverLeighC commented 3 years ago

@dennemark It looks like you are only adding the parentX/parentY for text/tspan elements, does relative positioning work for the other svg elements? I was seeing positioning issues with Rect on my chart

dennemark commented 3 years ago

@OliverLeighC yes so far it only works for text and tspan, but not correctly I assume. I had the case, that my tspan was missing a y-value and react-pdf expects it as absolute value. Maybe the case should rather be: if the parent has an x or y value, and the child is missing it, but using a dx / dy value, then add the parent y + dy, otherwise use the x and y value of the child itself. At least this is what I observed for svgs. I.e. these texts are all positioned differently, the difference is use of x/y and dx/dy for tspan. react-pdf needs to translate these into absolute x and y:

<svg width="500" height="500">
    <text x="10" y="10" width="90" height="60">
        <tspan dx="10" dy="10" width="20" height="10" fill="blue">text</tspan>
    </text>
    <text x="10" y="10" width="90" height="60" >
        <tspan x="10" dy="10" fill="red">text</tspan>
    </text>
  <text x="10" y="10" width="90" height="60" fill="green">
     <tspan x="10" y="10">text</tspan>
   </text>
</svg>

I am not so sure how the cases are for lines or rectangles. Do you have an example? Is your jsdom doing this translation correctly already?

OliverLeighC commented 3 years ago

I wanted to try with svg-parser, so I have this now

import React, { useMemo, createElement } from "react";
import { parse, TextNode, ElementNode, RootNode } from "svg-parser";

const supportedStyleProps = [
  "color",
  "dominantBaseline",
  "fill",
  "fillOpacity",
  "fillRule",
  "opacity",
  "stroke",
  "strokeWidth",
  "strokeOpacity",
  "strokeLinecap",
  "strokeDasharray",
  "transform",
  "textAnchor",
  "visibility"
]

function isElementNode(node: TextNode | ElementNode): node is ElementNode {
  return node.type === 'element'
}

function removeLineBreaks(text?: string | number | boolean) {
  if (typeof text === 'string') {

    return text.replace(/(\r\n|\n|\r)/gm, "")
  }

  return text;
}

// https://dev.to/qausim/convert-html-inline-styles-to-a-style-object-for-react-components-2cbi
const formatStringToCamelCase = (str: string) => {
  const splitted = str.split("-");
  if (splitted.length === 1) return splitted[0];
  return (
    splitted[0] +
    splitted
      .slice(1)
      .map((word) => word[0].toUpperCase() + word.slice(1))
      .join("")
  );
};

export const getStyleObjectFromString = (str: string | null) => {
  const style: any = {};
  if (!str) return {};

  str.split(";").forEach((el) => {
    const [property, value] = el.split(":");
    if (!property) return;
    if (property === "cursor") return;
    const formattedProperty = formatStringToCamelCase(property.trim());
    if (supportedStyleProps.includes(formattedProperty)) {
      style[formattedProperty] = value.trim();
    }
  });

  return style;
};

function handleRelativePositioning(currentPosition: string | number, parentPosition?: number) {
  let position = Number(currentPosition) ?? 0;
  if (parentPosition) {
    position += parentPosition
  }

  return position;
}

function getParentPosition(pos: number | string | undefined) {
  if (!pos) return 0;
  if (typeof pos === 'string') return Number(pos);
  return pos;
}

function svgToJSXWithRelPositioning(
  node: TextNode | ElementNode | string, parentX?: number, parentY?: number
): any {
  if (typeof node === 'string') {
    return removeLineBreaks(node);
  }
  if (!isElementNode(node)) {
    return removeLineBreaks(node.value);
  }
  const elementName = node.tagName;
  if (!elementName) {
    console.log('NO TAG NAME: ', node);
    return null;
  }
  let componentProps;
  if (node.tagName === 'desc' || node.tagName === 'defs') return null;

  if (node.properties !== undefined) {
    if (node.tagName === "text" || node.tagName === "tspan") {
      componentProps = {
        x: handleRelativePositioning(node.properties.x, parentX),
        y: handleRelativePositioning(node.properties.y, parentY),
        textAnchor: node.properties['text-anchor']
      }
    } else {
      componentProps = {
        x: handleRelativePositioning(node.properties.x, parentX),
        y: handleRelativePositioning(node.properties.y, parentY),
        ...node.properties
      }
    }

    if (node.properties.style) {
      componentProps = {
        ...componentProps,
        style: getStyleObjectFromString(node.properties.style as string)
      }
    }
  }
  let children;
  if (node.children && node.children.length > 0) {
    children = node.children.map(
      (childNode) => 
        svgToJSXWithRelPositioning(
          childNode, getParentPosition(node.properties?.x), getParentPosition(node.properties?.y)
        )
    )
  }
  return createElement(elementName.toUpperCase(), componentProps, children);
}

export const createSvgComponent = (svgXml: string) => {
  if (!svgXml || svgXml === "") return null;
  const svg = svgXml.replaceAll("px", "pt");
  const svgElement = useMemo(() => {
    const parsed: RootNode = parse(svg);
    return svgToJSXWithRelPositioning(parsed.children[0], 0);
  }, [svgXml]);
  return svgElement;
};

The text positioning is a lot better than before, and I'm adding the x/y positioning with parentX/parentY for both text/tspan and the other svg primitives (rect/path/etc) which helps with relative positioning of the chart elements. It seems like the transform rotations aren't behaving right yet, but I'm still looking into it.

Right now the text is rendering like this (so the text is overlapping and the kerning is off but it's in the right place) image

But the bars are way down at the bottom image

diegomura commented 3 years ago

For all those cases in which the SVG renderer of react-pdf does no behave well, it would be awesome if you can open small issues for each case, with a easy and reliable way of replicate them. Would be much simpler for me to fix them that way

dennemark commented 3 years ago

@diegomura I have opened another issue concerning rounded corners. I will continue with @OliverLeighC in here to figure out what is leading to issues within relative positioning, since we are still trying to figure out the differences between react-pdf and other svg interpreters. When have a clearer view, I can open an issue for you that is straightforward to read.

@OliverLeighC thanks for the clean code! do you have a svg for me, so I can reproduce the rect positioning? maybe you can create a simple one, that reproduces the issue. I currently don't know which element would inherit the x-y positioning to the rectangle. One thing I would like to try, or you can try, is the following code for relative positioning:

function handleRelativePositioning(node: ElementNode, parentX?: number, parentY?: number) {

  node.properties.x = node.properties.x ?? (parentX + node.properties.dx ?? 0);
  node.properties.y = node.properties.y ?? (parentY + node.properties.dy ?? 0);

  return node;
}

child x positions absoulte and ignores parent x. But if child x is missing, the element uses the parent value and adds dX if available. So I hope the code above works. However, I am not sure how it works if the parent element itself has relative absolute positioning, I am currently missing an svg example.

OliverLeighC commented 3 years ago

Here is a gist that has a simple svg (just 2 bars from the chart) and the full svg, as well as the converter code. https://gist.github.com/OliverLeighC/1ff2b801a45257af97aa3ed11022a319

I tried with the simple one and it rendered like this (screenshot below). I definitely think it's these transform attributes that aren't working because you can see in the gist that the bars are horizontal transform="translate(590,509) rotate(90) scale(-1,1) scale(1 1)"

image

I don't see any dx/dy properties on my svg but I'll give it a try and see.

dennemark commented 3 years ago

You are right, if I take out the transform part of the style attribute it is correct. To be honest, I do not know how to solve this one. Your other approach with jsdom worked? Could you have a look at its object and how it interprets the transform styling? What is wird is also, that only one rectangle is visible. Maybe it uses a radians angle and not degrees...

Here is my current code that works for me: https://gist.github.com/dennemark/5f0f3d7452d9334f9349172db6c40f74

Maybe we could now open at least one issue concerning the relative/absolute positioning. And another one for transform styling?

OliverLeighC commented 3 years ago

The jsdom approach had the same issue with the transformation. I opened a ticket yesterday for the transform issue it since it seems separate from the relative positioning issue.

dennemark commented 3 years ago

I hope the converter above works for most cases. As mentioned, there are still issues with rounded rectangles, relative text positioing and transform attribute, that are only partly solved by the converter. Discussions on these topics can be found in these issues:

https://github.com/diegomura/react-pdf/issues/1271 https://github.com/diegomura/react-pdf/issues/1247 https://github.com/diegomura/react-pdf/issues/1251

Maybe this issue can be closed until questions concerning this conversion arise?

Leviathan91 commented 10 months ago

@dennemark would you mind sharing your parser to convert the svg. into a string representation of the svg?

dennemark commented 10 months ago

@Leviathan91 the gist above is not suitable ? https://github.com/diegomura/react-pdf/issues/1234#issuecomment-825518671 It´s a long time ago that I touched the parser and I cannot find a version on my pc and also wouldn´t know anymore if i made some additional changes :/

Leviathan91 commented 9 months ago

@dennemark no I think the parser is fine, but I cannot figure out how to transform the svg into a string-representation of an svg. This is what I tried:

export async function wrapSvg(svgFilePath) {
  // Read SVG file content
  const svgFileContent = await fs.promises.readFile(svgFilePath, 'utf8');

  // Wrap SVG content with backticks
  const wrappedSvgContent = `\`${svgFileContent}\``;

  return wrappedSvgContent;
}

but somehow the parse function from svg-parser cannot work with that string representation and I am struggling to figure out why

Edit: I am ..not smart. It does not work because now the backticks are part of the string and therefore interpreted with it, which they should not. Easy fix: const wrappedSvgContent = '\n' + svgFileContent + '\n';

Thank you for your snippet, works great!