diff --git a/README.md b/README.md
index e2ffd8ad..fc24f799 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,32 @@ Visit: https://react.nodegui.org for docs.
- https://blog.logrocket.com/electron-alternatives-exploring-nodegui-and-react-nodegui/ - Electron alternatives: Exploring NodeGUI and React NodeGUI by [Siegfried Grimbeek](https://blog.logrocket.com/author/siegfriedgrimbeek/).
+## SVG
+
+React NodeGUI can render inline SVG trees by serializing SVG-like React components and rendering the result with Qt's SVG widget.
+
+```jsx
+import React from "react";
+import { Renderer, Svg, Rect, Circle, Path, Window } from "@nodegui/react-nodegui";
+
+const App = () => (
+
+
+
+);
+
+Renderer.render();
+```
+
+For complete SVG documents, pass `src`, `buffer`, or `content` to `Svg`.
+
+Most inline SVG primitives can be used as lowercase JSX tags or via the exported helpers like `Rect` and `Circle`.
+Use `SvgText` for SVG text nodes.
+
**Talks/Podcasts**
- [NodeGui and React NodeGui at KarmaJS Nov 2019 meetup: https://www.youtube.com/watch?v=8jH5gaEEDv4](https://www.youtube.com/watch?v=8jH5gaEEDv4)
@@ -76,6 +102,10 @@ Please read: https://github.com/nodegui/.github/blob/master/CONTRIBUTING.md
`npm run build`
+## Verification
+
+`npm run verify:svg`
+
## Using custom Qt
`QT_INSTALL_DIR=/path/to/qt npm install`
diff --git a/package.json b/package.json
index b7abff71..d739e104 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"scripts": {
"build": "tsc",
"dev": "tsc && qode ./dist/demo.js",
- "docs": "typedoc && node ./website/docs/scripts/fixdocs.js"
+ "docs": "typedoc && node ./website/docs/scripts/fixdocs.js",
+ "verify:svg": "npm run build && qode ./dist/development/svg-acceptance.js"
},
"dependencies": {
"@nodegui/nodegui": "^0.57.1",
diff --git a/src/components/Svg/RNSvg.ts b/src/components/Svg/RNSvg.ts
new file mode 100644
index 00000000..8f42815b
--- /dev/null
+++ b/src/components/Svg/RNSvg.ts
@@ -0,0 +1,658 @@
+import {
+ QSvgWidget,
+ QWidgetSignals,
+} from "@nodegui/nodegui";
+import { RNComponent, RNProps, RNWidget } from "../config";
+import { ViewProps, setViewProps } from "../View/RNView";
+
+type SvgPrimitive = string | number | boolean;
+type SvgStyle = string | Record;
+type SvgPropValue =
+ | SvgPrimitive
+ | SvgStyle
+ | null
+ | undefined
+ | Record;
+
+export interface SvgProps extends ViewProps {
+ src?: string;
+ buffer?: Buffer;
+ content?: string;
+ children?: unknown;
+ width?: SvgPrimitive;
+ height?: SvgPrimitive;
+ viewBox?: string;
+ preserveAspectRatio?: string;
+ [attribute: string]: SvgPropValue | unknown;
+}
+
+export interface SvgElementProps extends RNProps {
+ children?: unknown;
+ style?: SvgStyle;
+ id?: string;
+ className?: string;
+ fill?: SvgPrimitive;
+ stroke?: SvgPrimitive;
+ strokeWidth?: SvgPrimitive;
+ opacity?: SvgPrimitive;
+ transform?: string;
+ [attribute: string]: SvgPropValue | unknown;
+}
+
+export interface SvgRectProps extends SvgElementProps {
+ x?: SvgPrimitive;
+ y?: SvgPrimitive;
+ width?: SvgPrimitive;
+ height?: SvgPrimitive;
+ rx?: SvgPrimitive;
+ ry?: SvgPrimitive;
+}
+
+export interface SvgCircleProps extends SvgElementProps {
+ cx?: SvgPrimitive;
+ cy?: SvgPrimitive;
+ r?: SvgPrimitive;
+}
+
+export interface SvgEllipseProps extends SvgElementProps {
+ cx?: SvgPrimitive;
+ cy?: SvgPrimitive;
+ rx?: SvgPrimitive;
+ ry?: SvgPrimitive;
+}
+
+export interface SvgLineProps extends SvgElementProps {
+ x1?: SvgPrimitive;
+ y1?: SvgPrimitive;
+ x2?: SvgPrimitive;
+ y2?: SvgPrimitive;
+}
+
+export interface SvgPolygonProps extends SvgElementProps {
+ points?: string;
+}
+
+export interface SvgPolylineProps extends SvgElementProps {
+ points?: string;
+}
+
+export interface SvgPathProps extends SvgElementProps {
+ d?: string;
+}
+
+export interface SvgTextProps extends SvgElementProps {
+ x?: SvgPrimitive;
+ y?: SvgPrimitive;
+ dx?: SvgPrimitive;
+ dy?: SvgPrimitive;
+ textAnchor?: string;
+ fontFamily?: string;
+ fontSize?: SvgPrimitive;
+ fontWeight?: SvgPrimitive;
+}
+
+type DangerousHtml = {
+ __html?: string;
+};
+
+export type SvgParent = RNSvg | RNSvgElement;
+
+export interface SvgRenderable {
+ setSvgParent(parent: SvgParent | null): void;
+ toSvgString(): string;
+}
+
+const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
+
+const WIDGET_PROP_NAMES = new Set([
+ "visible",
+ "styleSheet",
+ "geometry",
+ "id",
+ "mouseTracking",
+ "enabled",
+ "windowOpacity",
+ "windowTitle",
+ "windowState",
+ "cursor",
+ "windowIcon",
+ "minSize",
+ "maxSize",
+ "size",
+ "pos",
+ "on",
+ "attributes",
+ "windowFlags",
+]);
+
+const IGNORED_SVG_PROPS = new Set([
+ "children",
+ "key",
+ "ref",
+ "__self",
+ "__source",
+ "src",
+ "buffer",
+ "content",
+ "dangerouslySetInnerHTML",
+ "visible",
+ "styleSheet",
+ "geometry",
+ "mouseTracking",
+ "enabled",
+ "windowOpacity",
+ "windowTitle",
+ "windowState",
+ "cursor",
+ "windowIcon",
+ "minSize",
+ "maxSize",
+ "size",
+ "pos",
+ "on",
+ "attributes",
+ "windowFlags",
+]);
+
+const SVG_ATTRIBUTE_NAMES: Record = {
+ acceptCharset: "accept-charset",
+ accentHeight: "accent-height",
+ alignmentBaseline: "alignment-baseline",
+ arabicForm: "arabic-form",
+ baselineShift: "baseline-shift",
+ capHeight: "cap-height",
+ className: "class",
+ clipPath: "clip-path",
+ clipRule: "clip-rule",
+ colorInterpolation: "color-interpolation",
+ colorInterpolationFilters: "color-interpolation-filters",
+ dominantBaseline: "dominant-baseline",
+ enableBackground: "enable-background",
+ fillOpacity: "fill-opacity",
+ fillRule: "fill-rule",
+ floodColor: "flood-color",
+ floodOpacity: "flood-opacity",
+ fontFamily: "font-family",
+ fontSize: "font-size",
+ fontSizeAdjust: "font-size-adjust",
+ fontStretch: "font-stretch",
+ fontStyle: "font-style",
+ fontVariant: "font-variant",
+ fontWeight: "font-weight",
+ glyphName: "glyph-name",
+ horizAdvX: "horiz-adv-x",
+ horizOriginX: "horiz-origin-x",
+ imageRendering: "image-rendering",
+ letterSpacing: "letter-spacing",
+ lightingColor: "lighting-color",
+ markerEnd: "marker-end",
+ markerMid: "marker-mid",
+ markerStart: "marker-start",
+ overlinePosition: "overline-position",
+ overlineThickness: "overline-thickness",
+ paintOrder: "paint-order",
+ pointerEvents: "pointer-events",
+ shapeRendering: "shape-rendering",
+ stopColor: "stop-color",
+ stopOpacity: "stop-opacity",
+ strikethroughPosition: "strikethrough-position",
+ strikethroughThickness: "strikethrough-thickness",
+ strokeDasharray: "stroke-dasharray",
+ strokeDashoffset: "stroke-dashoffset",
+ strokeLinecap: "stroke-linecap",
+ strokeLinejoin: "stroke-linejoin",
+ strokeMiterlimit: "stroke-miterlimit",
+ strokeOpacity: "stroke-opacity",
+ strokeWidth: "stroke-width",
+ textAnchor: "text-anchor",
+ textDecoration: "text-decoration",
+ textRendering: "text-rendering",
+ underlinePosition: "underline-position",
+ underlineThickness: "underline-thickness",
+ unicodeBidi: "unicode-bidi",
+ unicodeRange: "unicode-range",
+ vectorEffect: "vector-effect",
+ vertAdvY: "vert-adv-y",
+ vertOriginX: "vert-origin-x",
+ vertOriginY: "vert-origin-y",
+ wordSpacing: "word-spacing",
+ writingMode: "writing-mode",
+ xHeight: "x-height",
+ xlinkActuate: "xlink:actuate",
+ xlinkArcrole: "xlink:arcrole",
+ xlinkHref: "xlink:href",
+ xlinkRole: "xlink:role",
+ xlinkShow: "xlink:show",
+ xlinkTitle: "xlink:title",
+ xlinkType: "xlink:type",
+ xmlBase: "xml:base",
+ xmlLang: "xml:lang",
+ xmlSpace: "xml:space",
+};
+
+const CASE_SENSITIVE_SVG_ATTRIBUTES = new Set([
+ "attributeName",
+ "baseFrequency",
+ "calcMode",
+ "clipPathUnits",
+ "diffuseConstant",
+ "edgeMode",
+ "filterUnits",
+ "gradientTransform",
+ "gradientUnits",
+ "kernelMatrix",
+ "kernelUnitLength",
+ "keyPoints",
+ "keySplines",
+ "keyTimes",
+ "lengthAdjust",
+ "limitingConeAngle",
+ "markerHeight",
+ "markerUnits",
+ "markerWidth",
+ "maskContentUnits",
+ "maskUnits",
+ "numOctaves",
+ "pathLength",
+ "patternContentUnits",
+ "patternTransform",
+ "patternUnits",
+ "pointsAtX",
+ "pointsAtY",
+ "pointsAtZ",
+ "preserveAlpha",
+ "preserveAspectRatio",
+ "primitiveUnits",
+ "refX",
+ "refY",
+ "repeatCount",
+ "repeatDur",
+ "requiredExtensions",
+ "requiredFeatures",
+ "specularConstant",
+ "specularExponent",
+ "spreadMethod",
+ "startOffset",
+ "stdDeviation",
+ "surfaceScale",
+ "systemLanguage",
+ "tableValues",
+ "targetX",
+ "targetY",
+ "viewBox",
+ "viewTarget",
+]);
+
+/**
+ * @ignore
+ */
+export class RNSvg extends QSvgWidget implements RNWidget {
+ static tagName = "svg";
+ private props: SvgProps = {};
+ private svgChildren: SvgRenderable[] = [];
+
+ setProps(newProps: SvgProps, oldProps: SvgProps): void {
+ this.props = newProps;
+
+ setViewProps(this, getWidgetProps(newProps), getWidgetProps(oldProps));
+ this.renderSvg();
+ }
+
+ appendInitialChild(child: any): void {
+ this.appendChild(child);
+ }
+
+ appendChild(child: any): void {
+ if (!isSvgRenderable(child)) {
+ return;
+ }
+
+ child.setSvgParent(this);
+ this.svgChildren = withoutSvgChild(this.svgChildren, child);
+ this.svgChildren.push(child);
+ this.renderSvg();
+ }
+
+ insertBefore(child: any, beforeChild: any): void {
+ if (!isSvgRenderable(child)) {
+ return;
+ }
+
+ child.setSvgParent(this);
+ const nextChildren = withoutSvgChild(this.svgChildren, child);
+ const childIndex = nextChildren.indexOf(beforeChild);
+
+ if (childIndex === -1) {
+ nextChildren.push(child);
+ } else {
+ nextChildren.splice(childIndex, 0, child);
+ }
+
+ this.svgChildren = nextChildren;
+ this.renderSvg();
+ }
+
+ removeChild(child: any): void {
+ const childIndex = this.svgChildren.indexOf(child);
+
+ if (childIndex === -1) {
+ return;
+ }
+
+ child.setSvgParent(null);
+ this.svgChildren.splice(childIndex, 1);
+ this.renderSvg();
+ }
+
+ requestRender(): void {
+ this.renderSvg();
+ }
+
+ toSvgString(): string {
+ return serializeElement("svg", this.props, this.svgChildren);
+ }
+
+ private renderSvg(): void {
+ if (this.props.buffer instanceof Buffer) {
+ this.load(this.props.buffer);
+ return;
+ }
+
+ if (typeof this.props.src === "string" && this.props.src) {
+ this.load(this.props.src);
+ return;
+ }
+
+ const svg = typeof this.props.content === "string"
+ ? this.props.content
+ : this.toSvgString();
+
+ this.load(Buffer.from(svg));
+ }
+}
+
+/**
+ * @ignore
+ */
+export class RNSvgElement implements RNComponent, SvgRenderable {
+ private props: SvgElementProps = {};
+ private svgChildren: SvgRenderable[] = [];
+ private svgParent: SvgParent | null = null;
+
+ constructor(private readonly svgTagName: string) {
+ }
+
+ setProps(newProps: SvgElementProps, _oldProps: SvgElementProps): void {
+ this.props = newProps;
+ this.requestRender();
+ }
+
+ appendInitialChild(child: any): void {
+ this.appendChild(child);
+ }
+
+ appendChild(child: any): void {
+ if (!isSvgRenderable(child)) {
+ return;
+ }
+
+ child.setSvgParent(this);
+ this.svgChildren = withoutSvgChild(this.svgChildren, child);
+ this.svgChildren.push(child);
+ this.requestRender();
+ }
+
+ insertBefore(child: any, beforeChild: any): void {
+ if (!isSvgRenderable(child)) {
+ return;
+ }
+
+ child.setSvgParent(this);
+ const nextChildren = withoutSvgChild(this.svgChildren, child);
+ const childIndex = nextChildren.indexOf(beforeChild);
+
+ if (childIndex === -1) {
+ nextChildren.push(child);
+ } else {
+ nextChildren.splice(childIndex, 0, child);
+ }
+
+ this.svgChildren = nextChildren;
+ this.requestRender();
+ }
+
+ removeChild(child: any): void {
+ const childIndex = this.svgChildren.indexOf(child);
+
+ if (childIndex === -1) {
+ return;
+ }
+
+ child.setSvgParent(null);
+ this.svgChildren.splice(childIndex, 1);
+ this.requestRender();
+ }
+
+ setSvgParent(parent: SvgParent | null): void {
+ this.svgParent = parent;
+ }
+
+ requestRender(): void {
+ if (this.svgParent) {
+ this.svgParent.requestRender();
+ }
+ }
+
+ toSvgString(): string {
+ return serializeElement(this.svgTagName, this.props, this.svgChildren);
+ }
+}
+
+export class RNSvgTextNode implements RNComponent, SvgRenderable {
+ private svgParent: SvgParent | null = null;
+
+ constructor(private text: string) {
+ }
+
+ setProps(newProps: RNProps, _oldProps: RNProps): void {
+ this.text = String((newProps as { text?: string }).text ?? "");
+ this.requestRender();
+ }
+
+ setText(text: string): void {
+ this.text = text;
+ this.requestRender();
+ }
+
+ appendInitialChild(): void {
+ throw new Error("SVG text nodes cannot have children");
+ }
+
+ appendChild(): void {
+ throw new Error("SVG text nodes cannot have children");
+ }
+
+ insertBefore(): void {
+ throw new Error("SVG text nodes cannot have children");
+ }
+
+ removeChild(): void {
+ throw new Error("SVG text nodes cannot have children");
+ }
+
+ setSvgParent(parent: SvgParent | null): void {
+ this.svgParent = parent;
+ }
+
+ requestRender(): void {
+ if (this.svgParent) {
+ this.svgParent.requestRender();
+ }
+ }
+
+ toSvgString(): string {
+ return escapeText(this.text);
+ }
+}
+
+export function createSvgElement(svgTagName: string, props: SvgElementProps): RNSvgElement {
+ const element = new RNSvgElement(svgTagName);
+ element.setProps(props, {});
+ return element;
+}
+
+export function createSvgTextNode(text: string): RNSvgTextNode {
+ return new RNSvgTextNode(text);
+}
+
+function getWidgetProps(props: SvgProps): ViewProps {
+ return Object.keys(props || {}).reduce((widgetProps, key) => {
+ if (WIDGET_PROP_NAMES.has(key)) {
+ (widgetProps as Record)[key] = props[key];
+ }
+
+ return widgetProps;
+ }, {} as ViewProps);
+}
+
+function isSvgRenderable(value: unknown): value is SvgRenderable {
+ return (
+ value instanceof RNSvgElement
+ || value instanceof RNSvgTextNode
+ );
+}
+
+function withoutSvgChild(
+ children: SvgRenderable[],
+ child: SvgRenderable
+): SvgRenderable[] {
+ return children.filter((existingChild) => existingChild !== child);
+}
+
+function serializeElement(
+ tagName: string,
+ props: SvgProps | SvgElementProps,
+ children: SvgRenderable[]
+): string {
+ const attributes = serializeAttributes(tagName, props);
+ const innerSvg = serializeInnerSvg(props, children);
+
+ if (innerSvg) {
+ return `<${tagName}${attributes}>${innerSvg}${tagName}>`;
+ }
+
+ return `<${tagName}${attributes}/>`;
+}
+
+function serializeInnerSvg(
+ props: SvgProps | SvgElementProps,
+ children: SvgRenderable[]
+): string {
+ const dangerousHtml = props.dangerouslySetInnerHTML as DangerousHtml | undefined;
+
+ if (typeof dangerousHtml?.__html === "string") {
+ return dangerousHtml.__html;
+ }
+
+ return [
+ serializeTextChildren(props.children),
+ ...children.map(child => child.toSvgString()),
+ ].join("");
+}
+
+function serializeTextChildren(children: unknown): string {
+ if (typeof children === "string" || typeof children === "number") {
+ return escapeText(children);
+ }
+
+ if (Array.isArray(children)) {
+ const hasOnlyPrimitiveChildren = children.every(
+ (child) => typeof child === "string" || typeof child === "number"
+ );
+
+ if (!hasOnlyPrimitiveChildren) {
+ return "";
+ }
+
+ return children
+ .filter(child => typeof child === "string" || typeof child === "number")
+ .map(child => escapeText(child as string | number))
+ .join("");
+ }
+
+ return "";
+}
+
+function serializeAttributes(tagName: string, props: SvgProps | SvgElementProps): string {
+ const attributes = Object.keys(props || {}).reduce((result, key) => {
+ if (IGNORED_SVG_PROPS.has(key) || key.startsWith("on")) {
+ return result;
+ }
+
+ const value = (props as Record)[key];
+
+ if (value === null || value === undefined || value === false) {
+ return result;
+ }
+
+ const attributeName = getSvgAttributeName(key);
+ const attributeValue = key === "style" && typeof value === "object"
+ ? serializeStyle(value as Record)
+ : String(value);
+
+ if (!attributeValue) {
+ return result;
+ }
+
+ result.push(value === true ? attributeName : `${attributeName}="${escapeAttribute(attributeValue)}"`);
+ return result;
+ }, [] as string[]);
+
+ if (tagName === "svg" && !("xmlns" in (props || {}))) {
+ attributes.unshift(`xmlns="${SVG_NAMESPACE}"`);
+ }
+
+ return attributes.length > 0 ? ` ${attributes.join(" ")}` : "";
+}
+
+function getSvgAttributeName(propName: string): string {
+ if (SVG_ATTRIBUTE_NAMES[propName]) {
+ return SVG_ATTRIBUTE_NAMES[propName];
+ }
+
+ if (
+ CASE_SENSITIVE_SVG_ATTRIBUTES.has(propName)
+ || propName.startsWith("data-")
+ || propName.startsWith("aria-")
+ ) {
+ return propName;
+ }
+
+ return propName.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`);
+}
+
+function serializeStyle(style: Record): string {
+ return Object.keys(style)
+ .reduce((result, key) => {
+ const value = style[key];
+
+ if (value === null || value === undefined || value === false) {
+ return result;
+ }
+
+ result.push(`${getSvgAttributeName(key)}:${String(value)}`);
+ return result;
+ }, [] as string[])
+ .join(";");
+}
+
+function escapeText(value: string | number): string {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+function escapeAttribute(value: string): string {
+ return escapeText(value).replace(/"/g, """);
+}
diff --git a/src/components/Svg/index.ts b/src/components/Svg/index.ts
new file mode 100644
index 00000000..eb08068e
--- /dev/null
+++ b/src/components/Svg/index.ts
@@ -0,0 +1,196 @@
+import { Fiber } from "react-reconciler";
+import { AppContainer } from "../../reconciler";
+import { ComponentConfig, RNComponent, RNProps, registerComponent } from "../config";
+import {
+ RNSvg,
+ RNSvgElement,
+ createSvgTextNode,
+ SvgCircleProps,
+ SvgElementProps,
+ SvgEllipseProps,
+ SvgLineProps,
+ SvgPathProps,
+ SvgPolygonProps,
+ SvgPolylineProps,
+ SvgProps,
+ SvgRectProps,
+ SvgTextProps,
+ createSvgElement,
+} from "./RNSvg";
+
+class SvgConfig extends ComponentConfig {
+ tagName = RNSvg.tagName;
+
+ getContext(parentContext: any) {
+ return {
+ ...parentContext,
+ isInSvgTree: true,
+ };
+ }
+
+ shouldSetTextContent(nextProps: SvgProps): boolean {
+ return hasTextChildren(nextProps);
+ }
+
+ createInstance(
+ newProps: SvgProps,
+ rootInstance: AppContainer,
+ context: any,
+ workInProgress: Fiber
+ ): RNSvg {
+ const widget = new RNSvg();
+ widget.setProps(newProps, {});
+ return widget;
+ }
+
+ commitMount(
+ instance: RNSvg,
+ newProps: SvgProps,
+ internalInstanceHandle: any
+ ): void {
+ if (newProps.visible !== false) {
+ instance.show();
+ }
+ }
+
+ commitUpdate(
+ instance: RNSvg,
+ updatePayload: any,
+ oldProps: SvgProps,
+ newProps: SvgProps,
+ finishedWork: Fiber
+ ): void {
+ instance.setProps(newProps, oldProps);
+ }
+}
+
+class SvgElementConfig extends ComponentConfig {
+ constructor(
+ readonly tagName: string,
+ private readonly svgTagName = tagName
+ ) {
+ super();
+ }
+
+ getContext(parentContext: any) {
+ return {
+ ...parentContext,
+ isInSvgTree: true,
+ };
+ }
+
+ shouldSetTextContent(nextProps: SvgElementProps): boolean {
+ return hasTextChildren(nextProps);
+ }
+
+ createInstance = (
+ newProps: SvgElementProps,
+ rootInstance: AppContainer,
+ context: any,
+ workInProgress: Fiber
+ ): RNSvgElement => {
+ return createSvgElement(this.svgTagName, newProps);
+ };
+
+ commitUpdate(
+ instance: RNComponent,
+ updatePayload: any,
+ oldProps: RNProps,
+ newProps: RNProps,
+ finishedWork: Fiber
+ ): void {
+ instance.setProps(newProps, oldProps);
+ }
+}
+
+function hasTextChildren(props: RNProps): boolean {
+ const children = (props as { children?: unknown }).children;
+ return (
+ typeof children === "string"
+ || typeof children === "number"
+ || (
+ Array.isArray(children)
+ && children.every(
+ (child) => typeof child === "string" || typeof child === "number"
+ )
+ )
+ );
+}
+
+function registerSvgElement(
+ tagName: string,
+ svgTagName = tagName
+) {
+ registeredSvgTags.add(tagName);
+ return registerComponent(new SvgElementConfig(tagName, svgTagName));
+}
+
+const registeredSvgTags = new Set();
+
+export const Svg = registerComponent(new SvgConfig());
+export const G = registerSvgElement("g");
+export const Group = G;
+export const Rect = registerSvgElement("rect");
+export const Circle = registerSvgElement("circle");
+export const Ellipse = registerSvgElement("ellipse");
+export const Line = registerSvgElement("line");
+export const Polygon = registerSvgElement("polygon");
+export const Polyline = registerSvgElement("polyline");
+export const Path = registerSvgElement("path");
+export const SvgText = registerSvgElement("svgText", "text");
+
+const svgElementTags = [
+ "a",
+ "animate",
+ "animateMotion",
+ "animateTransform",
+ "clipPath",
+ "defs",
+ "desc",
+ "feBlend",
+ "feColorMatrix",
+ "feComponentTransfer",
+ "feComposite",
+ "feConvolveMatrix",
+ "feDiffuseLighting",
+ "feDisplacementMap",
+ "feDistantLight",
+ "feDropShadow",
+ "feFlood",
+ "feFuncA",
+ "feFuncB",
+ "feFuncG",
+ "feFuncR",
+ "feGaussianBlur",
+ "feImage",
+ "feMerge",
+ "feMergeNode",
+ "feMorphology",
+ "feOffset",
+ "fePointLight",
+ "feSpecularLighting",
+ "feSpotLight",
+ "feTile",
+ "feTurbulence",
+ "filter",
+ "foreignObject",
+ "linearGradient",
+ "marker",
+ "mask",
+ "metadata",
+ "pattern",
+ "radialGradient",
+ "stop",
+ "style",
+ "switch",
+ "symbol",
+ "title",
+ "tspan",
+ "use",
+];
+
+for (const tagName of svgElementTags) {
+ if (!registeredSvgTags.has(tagName)) {
+ registerSvgElement(tagName);
+ }
+}
diff --git a/src/development/svg-acceptance.tsx b/src/development/svg-acceptance.tsx
new file mode 100644
index 00000000..6251fb7d
--- /dev/null
+++ b/src/development/svg-acceptance.tsx
@@ -0,0 +1,371 @@
+import fs from "fs";
+import os from "os";
+import path from "path";
+import React from "react";
+import { Renderer } from "../renderer";
+import { appContainer } from "../reconciler";
+import {
+ Circle,
+ Path,
+ Rect,
+ Svg,
+ SvgText,
+ Text,
+ Window,
+} from "../index";
+import { RNSvg, createSvgElement } from "../components/Svg/RNSvg";
+
+type Resolve = () => void;
+type Reject = (reason?: unknown) => void;
+
+const failures: string[] = [];
+const warnings: string[] = [];
+const originalWarn = console.warn;
+
+console.warn = (...args: unknown[]) => {
+ warnings.push(args.map(String).join(" "));
+ originalWarn(...args);
+};
+
+function closeAllWindows() {
+ for (const widget of Array.from(appContainer)) {
+ if ((widget as any).close) {
+ (widget as any).close();
+ }
+ appContainer.delete(widget);
+ }
+}
+
+function assert(condition: unknown, message: string) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function finishTest(
+ name: string,
+ error: unknown,
+ resolve: Resolve,
+ reject: Reject
+) {
+ closeAllWindows();
+ if (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ failures.push(`${name}: ${message}`);
+ reject(error);
+ return;
+ }
+ console.log(`${name}: PASS`);
+ resolve();
+}
+
+function runRenderTest(
+ name: string,
+ element: React.ReactElement,
+ verify?: () => void,
+ timeoutMs = 5000
+) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const timer = setTimeout(() => {
+ if (!settled) {
+ settled = true;
+ finishTest(name, new Error("Timed out"), resolve, reject);
+ }
+ }, timeoutMs);
+
+ Renderer.render(element, {
+ onRender: () => {
+ if (settled) {
+ return;
+ }
+ setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ try {
+ verify && verify();
+ settled = true;
+ clearTimeout(timer);
+ finishTest(name, null, resolve, reject);
+ } catch (error) {
+ settled = true;
+ clearTimeout(timer);
+ finishTest(name, error, resolve, reject);
+ }
+ }, 0);
+ },
+ });
+ });
+}
+
+async function verifyRootImport() {
+ await runRenderTest(
+ "root-import",
+
+ ok
+
+ );
+}
+
+async function verifyReadmeShapes() {
+ const svgRef = React.createRef();
+ await runRenderTest(
+ "readme-helper-shapes",
+
+
+ ,
+ () => {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes("();
+ await runRenderTest(
+ "lowercase-rect",
+
+
+ ,
+ () => {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes('fill="#00aa00"'), "Lowercase rect was not serialized");
+ }
+ );
+}
+
+async function verifySvgText() {
+ const svgRef = React.createRef();
+ await runRenderTest(
+ "svg-text-helper",
+
+
+ ,
+ () => {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes(">ok"), "SvgText content missing");
+ }
+ );
+}
+
+async function verifySvgContent() {
+ const content =
+ "";
+ await runRenderTest(
+ "content-prop",
+
+
+
+ );
+}
+
+async function verifySvgBuffer() {
+ const buffer = Buffer.from(
+ ""
+ );
+ await runRenderTest(
+ "buffer-prop",
+
+
+
+ );
+}
+
+async function verifySvgSrc() {
+ const svgPath = path.join(os.tmpdir(), "react-nodegui-svg-source.svg");
+ fs.writeFileSync(
+ svgPath,
+ ""
+ );
+ try {
+ await runRenderTest(
+ "src-prop",
+
+
+
+ );
+ } finally {
+ fs.rmSync(svgPath, { force: true });
+ }
+}
+
+async function verifyAdjacentText() {
+ const svgRef = React.createRef();
+ await runRenderTest(
+ "adjacent-text",
+
+
+ ,
+ () => {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes(">hello world"), "Adjacent text did not serialize");
+ }
+ );
+}
+
+async function verifyMixedTspan() {
+ const svgRef = React.createRef();
+ await runRenderTest(
+ "mixed-tspan",
+
+
+ ,
+ () => {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes("hello "), "Mixed tspan leading text missing");
+ assert(
+ svg.includes('there'),
+ "Mixed tspan child missing"
+ );
+ assert(svg.includes(" friend"), "Mixed tspan trailing text missing");
+ }
+ );
+}
+
+async function verifyMixedTextUpdate() {
+ return new Promise((resolve, reject) => {
+ const svgRef = React.createRef();
+ let initialRender = true;
+ let timeout = setTimeout(() => {
+ finishTest("mixed-text-update", new Error("Timed out"), resolve, reject);
+ }, 5000);
+
+ class App extends React.Component<{}, { leading: string }> {
+ state = { leading: "hello " };
+
+ componentDidMount() {
+ setTimeout(() => this.setState({ leading: "goodbye " }), 0);
+ }
+
+ componentDidUpdate() {
+ try {
+ const svg = svgRef.current!.toSvgString();
+ assert(svg.includes("goodbye "), "Updated leading text missing");
+ assert(!svg.includes("hello "), "Stale leading text still present");
+ assert(
+ svg.includes('there'),
+ "Updated mixed tspan child missing"
+ );
+ clearTimeout(timeout);
+ finishTest("mixed-text-update", null, resolve, reject);
+ } catch (error) {
+ clearTimeout(timeout);
+ finishTest("mixed-text-update", error, resolve, reject);
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+ }
+
+ Renderer.render(, {
+ onRender: () => {
+ if (initialRender) {
+ initialRender = false;
+ return;
+ }
+ },
+ });
+ });
+}
+
+function verifyReorderMove() {
+ const root = new RNSvg();
+ root.setProps({ width: 100, height: 40, viewBox: "0 0 100 40" }, {});
+ const a = createSvgElement("rect", { id: "a" });
+ const b = createSvgElement("rect", { id: "b" });
+ root.appendChild(a);
+ root.appendChild(b);
+ root.insertBefore(b, a);
+
+ const svg = root.toSvgString();
+ const countA = svg.split('id="a"').length - 1;
+ const countB = svg.split('id="b"').length - 1;
+
+ assert(countA === 1, "Reorder duplicated child a");
+ assert(countB === 1, "Reorder duplicated child b");
+ assert(
+ svg.indexOf('id="b"') < svg.indexOf('id="a"'),
+ "Reorder did not move child before sibling"
+ );
+ console.log("reorder-move: PASS");
+}
+
+async function main() {
+ try {
+ await verifyRootImport();
+ await verifyReadmeShapes();
+ await verifyLowercaseRect();
+ await verifySvgText();
+ await verifySvgContent();
+ await verifySvgBuffer();
+ await verifySvgSrc();
+ await verifyAdjacentText();
+ await verifyMixedTspan();
+ await verifyMixedTextUpdate();
+ verifyReorderMove();
+
+ assert(warnings.length === 0, `Unexpected warnings: ${warnings.join(" | ")}`);
+ console.log("svg-acceptance: PASS");
+ process.exit(0);
+ } catch (error) {
+ const message = error instanceof Error ? error.stack || error.message : String(error);
+ console.error("svg-acceptance: FAIL");
+ console.error(message);
+ if (failures.length > 0) {
+ console.error(failures.join("\n"));
+ }
+ process.exit(1);
+ } finally {
+ closeAllWindows();
+ }
+}
+
+void main();
diff --git a/src/index.ts b/src/index.ts
index 78e18b80..6f06f199 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,19 @@ export { Window } from "./components/Window";
export { Text } from "./components/Text";
export { Image } from "./components/Image";
export { AnimatedImage } from "./components/AnimatedImage";
+export {
+ Svg,
+ G,
+ Group,
+ Rect,
+ Circle,
+ Ellipse,
+ Line,
+ Polygon,
+ Polyline,
+ Path,
+ SvgText,
+} from "./components/Svg";
export { Button } from "./components/Button";
export { CheckBox } from "./components/CheckBox";
export { LineEdit } from "./components/LineEdit";
diff --git a/src/reconciler/index.ts b/src/reconciler/index.ts
index ec5013af..26815f3c 100644
--- a/src/reconciler/index.ts
+++ b/src/reconciler/index.ts
@@ -6,6 +6,7 @@ import {
RNProps,
RNComponent
} from "../components/config";
+import { createSvgTextNode, RNSvgTextNode } from "../components/Svg/RNSvg";
export type AppContainer = Set>;
export const appContainer: AppContainer = new Set>();
@@ -48,6 +49,9 @@ const HostConfig: Reconciler.HostConfig<
context,
workInProgress
) {
+ if (context && context.isInSvgTree) {
+ return createSvgTextNode(String(newText));
+ }
// throw new Error(`Can't create text without for text: ${newText}`);
console.warn(
"createTextInstance called in reconciler when platform doesnt have host level text. "
@@ -171,6 +175,10 @@ const HostConfig: Reconciler.HostConfig<
}
},
commitTextUpdate: (textInstance, oldText, newText) => {
+ if (textInstance instanceof RNSvgTextNode) {
+ textInstance.setText(String(newText));
+ return;
+ }
//noop since we manage all text using Text component
console.warn(
"commitTextUpdate called when platform doesnt have host level text"