niklasvh / html2canvas

Screenshots with JavaScript
https://html2canvas.hertzen.com/
MIT License
30.47k stars 4.8k forks source link

SVG elements not present in output #3175

Open sporritt opened 4 months ago

sporritt commented 4 months ago

Bug reports:

Over the years people have reported an issue with the connectors from JsPlumb not appearing in the output from html2canvas but I've never had an opportunity to look at the problem until recently. JsPlumb renders each connection as a single SVG element.

So, if you have this:

image

and you export it via html2canvas, you get this:

image

There are no errors reported in the logs. To satisfy myself that this is not simply an issue with the SVG elements created by JsPlumb I rendered a page with an SVG element inside of it and tried to export:

<svg style="position:absolute;left:432px;top:69px" x="432" y="69" width="214.5" height="323" pointer-events="none" class="connectorClass jtk-connector">
  <path d="M 213 0 C 99 150 99 150 5 320 " transform="translate(0,1.5)" pointer-events="visibleStroke" fill="none" stroke="#89bcde" stroke-width="1"></path>
  <path class="jtk-overlay" d="M5,320 L14.214691049574217,313.6683754959018 L8.014761192686514,314.54801733760354 L5.463515026465964,308.8292724578817 Z" stroke="#89bcde" fill="#89bcde" transform="translate(0,1.5)" pointer-events="visibleStroke"></path>
  </svg>

This is what was rendered - the JsPlumb stuff and the stand alone element:

image

but again no SVG output from htmlcanvas. Tracing the code through I see that SVG elements are processed by the SVGElementContainer class:

https://github.com/niklasvh/html2canvas/blob/master/src/dom/replaced-elements/svg-element-container.ts

The code is given img and uses XMLSerializerto generate a data URL that will subsequently be loaded into an Image:

image

img has an extensive set of styles on it and these are serialized into the output (excuse the size of this but the style attribute is the key here):

<svg xmlns="http://www.w3.org/2000/svg" x="432" y="69" width="214.5px" height="323px" pointer-events="none" class="connectorClass jtk-connector" style="position: absolute; inset: 69px -596.5px -342px 432px; transition: all 0s ease 0s; -webkit-writing-mode: horizontal-tb; -webkit-user-modify: read-only; -webkit-user-drag: auto; -webkit-text-stroke: 0px rgb(0, 0, 0); -webkit-text-security: none; -webkit-text-orientation: vertical-right; -webkit-text-fill-color: rgb(0, 0, 0); -webkit-text-decorations-in-effect: none; -webkit-text-combine: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0.18); -webkit-rtl-ordering: logical; -webkit-print-color-adjust: economy; -webkit-mask-box-image-width: initial; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: initial; -webkit-mask-box-image-repeat: initial; -webkit-mask-box-image-outset: initial; -webkit-locale: auto; -webkit-line-break: auto; -webkit-font-smoothing: auto; -webkit-box-pack: start; -webkit-box-orient: horizontal; -webkit-box-ordinal-group: 1; -webkit-box-flex: 0; -webkit-box-direction: normal; -webkit-box-decoration-break: slice; -webkit-box-align: stretch; border-spacing: 0px; -webkit-border-image: none; zoom: 1; z-index: 12; y: 69px; x: 432px; writing-mode: horizontal-tb; word-spacing: 0px; word-break: normal; will-change: auto; width: 214.5px; widows: 2; white-space: normal; visibility: visible; view-transition-name: none; view-timeline: none; vertical-align: baseline; vector-effect: none; user-select: auto; unicode-bidi: normal; translate: none; transform-style: flat; transform-origin: 107.25px 161.5px; transform: none; touch-action: auto; timeline-scope: none; text-underline-position: auto; text-transform: none; text-spacing-trim: normal; text-size-adjust: auto; text-shadow: none; text-rendering: auto; text-overflow: clip; text-indent: 0px; text-emphasis: none rgb(0, 0, 0); text-emphasis-position: over; text-decoration: none solid rgb(0, 0, 0); text-decoration-skip-ink: auto; text-anchor: start; text-align-last: auto; text-align: start; table-layout: auto; tab-size: 8; stroke-width: 1px; stroke-opacity: 1; stroke-miterlimit: 4; stroke-linejoin: miter; stroke-linecap: butt; stroke-dashoffset: 0px; stroke-dasharray: none; stroke: none; stop-opacity: 1; stop-color: rgb(0, 0, 0); speak: normal; shape-rendering: auto; shape-outside: none; shape-margin: 0px; shape-image-threshold: 0; scrollbar-width: auto; scrollbar-gutter: auto; scrollbar-color: auto; scroll-timeline: none; scroll-padding-inline: auto; scroll-padding-block: auto; scroll-margin-inline: 0px; scroll-margin-block: 0px; scroll-behavior: auto; scale: none; ry: auto; rx: auto; ruby-position: over; gap: normal; rotate: none; resize: none; r: 0px; pointer-events: none; perspective-origin: 107.25px 161.5px; perspective: none; paint-order: normal; padding: 0px; padding-inline: 0px; padding-block: 0px; overscroll-behavior-inline: auto; overscroll-behavior-block: auto; overlay: none; overflow: visible; overflow-wrap: normal; overflow-clip-margin: content-box; overflow-anchor: auto; outline: rgb(0, 0, 0) none 0px; outline-offset: 0px; orphans: 2; order: 0; opacity: 1; offset: normal; object-view-box: none; object-position: 50% 50%; object-fit: fill; mix-blend-mode: normal; min-width: 0px; min-inline-size: 0px; min-height: 0px; min-block-size: 0px; max-width: none; max-inline-size: none; max-height: none; max-block-size: none; math-style: normal; math-shift: normal; math-depth: 0; mask-type: luminance; mask: none; marker: none; margin: 0px; margin-inline: 0px; margin-block: 0px; list-style: outside none disc; line-height: normal; line-break: auto; lighting-color: rgb(255, 255, 255); letter-spacing: normal; place-self: auto; place-items: normal; place-content: normal; isolation: auto; inset-inline: 432px -596.5px; inset-block: 69px -342px; inline-size: 214.5px; initial-letter: normal; image-rendering: auto; image-orientation: from-image; hyphens: manual; hyphenate-limit-chars: auto; hyphenate-character: auto; height: 323px; grid: none; grid-area: auto; font-weight: 400; font-variant: normal; font-synthesis: weight style small-caps; font-style: normal; font-stretch: 100%; font-size: 16px; font-palette: normal; font-optical-sizing: auto; font-kerning: auto; font-family: Times; flood-opacity: 1; flood-color: rgb(0, 0, 0); float: none; flex-flow: row; flex: 0 1 auto; filter: none; fill-rule: nonzero; fill-opacity: 1; fill: rgb(0, 0, 0); field-sizing: fixed; empty-cells: show; dominant-baseline: auto; display: block; direction: ltr; cy: 0px; cx: 0px; cursor: grab; container: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; contain-intrinsic-block-size: none; columns: auto; column-span: none; column-rule: 0px rgb(0, 0, 0); color-rendering: auto; color-interpolation-filters: linearrgb; color-interpolation: srgb; color: rgb(0, 0, 0); clip-rule: nonzero; clip-path: none; clip: auto; clear: none; caret-color: rgb(0, 0, 0); caption-side: top; buffered-rendering: auto; break-inside: auto; break-before: auto; break-after: auto; box-sizing: content-box; box-shadow: none; border-width: 0px; border-style: none; border-radius: 0px; border-color: rgb(0, 0, 0); border-start-start-radius: 0px; border-start-end-radius: 0px; border-inline-start: 0px none rgb(0, 0, 0); border-inline-end: 0px none rgb(0, 0, 0); border-image: none 100% / 1 / 0 stretch; border-end-start-radius: 0px; border-end-end-radius: 0px; border-collapse: separate; border-block-start: 0px none rgb(0, 0, 0); border-block-end: 0px none rgb(0, 0, 0); block-size: 323px; baseline-source: auto; baseline-shift: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; backface-visibility: visible; backdrop-filter: none; appearance: none; app-region: none; animation: 0s ease 0s 1 normal none running none; animation-composition: replace; alignment-baseline: auto; accent-color: auto;"> ..child elements.. </svg>

What I have found is that the presence of these style attributes in the serialized SVG cause the image to fail to load.

If I change the serializer code and blow away all the styles before serializing:

constructor(context: Context, img: SVGSVGElement) {
        super(context, img);
        const s = new XMLSerializer();
        const bounds = parseBounds(context, img);
        img.setAttribute('width', `${bounds.width}px`);
        img.setAttribute('height', `${bounds.height}px`);

        img.style = '';     // <--- clear out the styles

        this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`;
        this.intrinsicWidth = img.width.baseVal.value;
        this.intrinsicHeight = img.height.baseVal.value;

        this.context.cache.addImage(this.svg);
    }

then I get the SVG elements in the output:

image

I'd be interested to hear your thoughts on this. My feeling is that the style attribute in the serialized XML is ignored by the Image class and it's perfectly safe to do this. But I am brand new to this codebase and don't know it at all so maybe there are important considerations I'm overlooking.

Specifications:

sporritt commented 4 months ago

..I've put a codepen here demonstrating the difference between setting the src of an image with a style attribute on the SVG and without:

https://codepen.io/sporritt/pen/rNgVrGY

sporritt commented 4 months ago

and I've discovered from that codepen that in fact it seems to be just the presence of the position:absolute rule in the style that causes the issue. So this also works for me to get output:

constructor(context: Context, img: SVGSVGElement) {
        super(context, img);
        const s = new XMLSerializer();
        const bounds = parseBounds(context, img);
        img.setAttribute('width', `${bounds.width}px`);
        img.setAttribute('height', `${bounds.height}px`);

        img.style.position = '';

        this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`;
        this.intrinsicWidth = img.width.baseVal.value;
        this.intrinsicHeight = img.height.baseVal.value;

        this.context.cache.addImage(this.svg);
    }
sporritt commented 4 months ago

A couple more bits of information.

JsPlumb adds these SVG elements with position:absolute and left/top values in their style:

<svg style="position:absolute;left:362px;top:252px" width="834.5" height="104.97673811564006" class="connectorClass jtk-connector"><path d="M 5 0 C 429 40 429 40 833 100 " transform="translate(0,1.5)" pointer-events="visibleStroke" fill="none" stroke="#89bcde" stroke-width="1"></path></svg>

I wondered whether I could remove that position:absolute and rely on a rule in the stylesheet instead:

.jtk-connector {
  position:absolute;
}

this of course works for JsPlumb rendering, but it still doesn't result in output from html2canvas - because the img element still has position:absolute set on its style.

BowonLee commented 2 months ago

I was in a similar situation, The cause of the problem is that SVG tags are drawn asynchronously after HTML rendering. html2canvas duplicates the HTML and changes the duplicated items to a canvas. At this time, the svg tag is not drawn immediately after duplication. So if you want to add svg tags to html canvas you need to duplicate the drawn result and add it to html dynamically. After duplicating, I hid the original and added the duplicate in its place.

sporritt commented 2 months ago

Do you have any code you could share to illustrate the process?

sporritt commented 2 months ago

Incidentally for anyone else who comes across this issue, we ended up releasing a fork of html2canvas:

npm install --save @jsplumb/html2canvas

it wasn't my preference but we couldn't get any traction with the library author.

There's a post on our site about this here: https://jsplumbtoolkit.com/blog/2024/05/17/jsplumb-and-html2canvas

BowonLee commented 2 months ago

Um, sorry I was trying to write a verification code, I think I was wrong about this. I can't attach it because it's my previous company's code, but I think it was a complex problem.

Maki-Db commented 1 month ago

For optimal SVG support try to switch to https://github.com/bubkoo/html-to-image