fabricjs / fabric.js

Javascript Canvas Library, SVG-to-Canvas (& canvas-to-SVG) Parser
http://fabricjs.com
Other
29.02k stars 3.51k forks source link

Export text on path to SVG #6958

Open qaywsx22 opened 3 years ago

qaywsx22 commented 3 years ago

Hello, an export a text on path to SVG is not working. Test case: https://jsfiddle.net/meugen22/ojatr2cL/5/

asturur commented 3 years ago

The only implementation of svg textpath that works across firefox and chrome is the hardest one to replicate.

stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

hoomanaskari commented 2 years ago

Okay, I have made some progress on this one, while it works, there are some things I am having trouble getting right. pathSide right now only works in FF, so I don't know what to do with it. Also the size of the SVG, I have no idea how to accurately calculate it. Either way, here is my code:

My guess was that for fabric.Text we would not need tspan element in the SVG export. After getting rid of those, it was a simple solution. I am still not sure about the side effects of the following approach.

fabric.Text.prototype.toSVG = function (reviver) {
  if (this.type === 'text') {
    var noShadow = true,
      textDecoration = this.getSvgTextDecoration(this);

    var pathString = '';

    this.path?.path.forEach(p => {
      pathString += p.join(' ');
      pathString += ', ';
    });

    return this._createBaseSVGMarkup(
      this.path?.path ?
        [
          '<defs>',
          '<path id="textOnPathId" fill="none" stroke="none" d="',
          pathString,
          '" />',
          '</defs>',
          '\t\t<text xml:space="preserve" ',
          (this.fontFamily ? 'font-family="' + this.fontFamily.replace(/"/g, '\'') + '" ' : ''),
          (this.fontSize ? 'font-size="' + this.fontSize + '" ' : ''),
          (this.fontStyle ? 'font-style="' + this.fontStyle + '" ' : ''),
          (this.fontWeight ? 'font-weight="' + this.fontWeight + '" ' : ''),
          (textDecoration ? 'text-decoration="' + textDecoration + '" ' : ''),
          'style="', this.getSvgStyles(noShadow), '"', this.addPaintOrder(), ' >',
          '<textPath side="',
          this.pathSide,
          '" href="#textOnPathId">',
          this.text,
          '</textPath>',
          '</text>\n'
        ]
      :
        [
          '\t\t<text xml:space="preserve" ',
          (this.fontFamily ? 'font-family="' + this.fontFamily.replace(/"/g, '\'') + '" ' : ''),
          (this.fontSize ? 'font-size="' + this.fontSize + '" ' : ''),
          (this.fontStyle ? 'font-style="' + this.fontStyle + '" ' : ''),
          (this.fontWeight ? 'font-weight="' + this.fontWeight + '" ' : ''),
          (textDecoration ? 'text-decoration="' + textDecoration + '" ' : ''),
          'style="', this.getSvgStyles(noShadow), '"', this.addPaintOrder(), ' >',
          this.text,
          '</text>\n'
        ],
      { reviver: reviver, noStyle: true, withShadow: true }
    );
  } else {
    return this._createBaseSVGMarkup(
      this._toSVG(),
      { reviver: reviver, noStyle: true, withShadow: true }
    );
  }
};
asturur commented 2 years ago

great! textOnPathId needs to be mixed with this.__uid in order to don't clash with multiple text on path on the same svg.

Also there is a common part of text code that we can reuse, right?

If you manage to get a single color, single path example working, open a PR. I m sure for styles and tSpan will be more complicated

khajaamin commented 2 years ago

facing same issue here https://web-platform-n8kzvg.stackblitz.io

you can check using developer tool console with below command - Please add text and then run in console

canvas.loadFromJSON(canvas.toJSON(['p1', 'p0', 'p2', 'line','canvas']))

This command should add same components again

ramonortigosa commented 1 year ago

Hello! Is there any improvement on this?

nagendra-y commented 1 year ago

https://jsfiddle.net/3btoymjd/

Does this help? Used hoomanaskari's approach and some transformations. Need to think of a bounding rect or something to make it generalise

mudassirali007 commented 1 year ago

Added more parameters. side="right" will work on Firefox.

fabric.Text.prototype.toSVG = function (reviver) {
    if (this.path) {
        var noShadow = true,
            textDecoration = this.getSvgTextDecoration(this);

        var pathString = this.path?.path
            .map(function (segment) {
                return segment.join(" ");
            })
            .join(" ");
        const id = Math.random().toString(36).substr(2, 9);

        return this._createBaseSVGMarkup(
            this.path?.path
                ? [
                    "<defs>",
                    '<path id="textOnPath' + id + '" fill="none" stroke="none" d="',
                    pathString,
                    '" />',
                    "</defs>\n",
                    '<text xml:space="preserve" ',
                    this.fontFamily
                        ? 'font-family="' + this.fontFamily.replace(/"/g, "'") + '" '
                        : "",
                    this.fontSize ? 'font-size="' + this.fontSize + '" ' : "",
                    this.fontStyle ? 'font-style="' + this.fontStyle + '" ' : "",
                    this.fontWeight ? 'font-weight="' + this.fontWeight + '" ' : "",
                    textDecoration ? 'text-decoration="' + textDecoration + '" ' : "",
                    'style="',
                    this.getSvgStyles(noShadow),
                    '"',
                    this.addPaintOrder(),
                    " >\n",
                    '<textPath side="' + this.pathSide + '" ',
                    'startOffset="' + this.pathStartOffset + '" ',
                    'href="#textOnPath' + id + '" ',
                    'xlink:href="#textOnPath' + id + '"> ',
                    this.text,
                    "</textPath>\n",
                    "</text>\n",
                ]
                : [
                    '\t\t<text xml:space="preserve" ',
                    this.fontFamily
                        ? 'font-family="' + this.fontFamily.replace(/"/g, "'") + '" '
                        : "",
                    this.fontSize ? 'font-size="' + this.fontSize + '" ' : "",
                    this.fontStyle ? 'font-style="' + this.fontStyle + '" ' : "",
                    this.fontWeight ? 'font-weight="' + this.fontWeight + '" ' : "",
                    textDecoration ? 'text-decoration="' + textDecoration + '" ' : "",
                    'style="',
                    this.getSvgStyles(noShadow),
                    '"',
                    this.addPaintOrder(),
                    " >",
                    this.text,
                    "</text>\n",
                ],
            { reviver: reviver, noStyle: true, withShadow: true }
        );
    } else {
        return this._createBaseSVGMarkup(this._toSVG(), {
            reviver: reviver,
            noStyle: true,
            withShadow: true,
        });
    }
};
herrstrietzel commented 1 year ago

Based on hoomanaskari's and mudassirali007's approaches - You might also add a function to reverse path direction.

This way we get a workaround for the unsupported "side" attribute (at least in chromium).
Besides, I added some conditions to translate fabric.js baseline alignment options to svg's dominant-basline values.

fabric.Text.prototype.toSVG = function (reviver) {
  let fontFamily = this.fontFamily.replace(/"/g, "'");
  let fontSize = this.fontSize;
  let fontStyle = this.fontStyle;
  let fontWeight = this.fontWeight;
  let fill = this.fill;

  if (this.path) {
    let path = this.path;
    let fillPath = path.fill ? path.fill : "none";
    let strokePath = path.stroke ? path.stroke : "none";
    let strokeWidth = path.strokeWidth ? path.strokeWidth : 0;

    // get path length
    let pathData = this.path.path;
    let pathInfo = fabric.util.getPathSegmentsInfo(pathData);
    let pathLength = pathInfo[pathInfo.length - 1].length;

    // reverse pathdata to emulate side="right"
    if (this.pathSide === "right") {
      // clone pathdata for reversing
      pathData = JSON.parse(JSON.stringify(pathData));
      pathData = reversePathData(pathData);
    }
    // get pathdata d string
    let d = pathData.flat().join(" ");

    let id = Math.random().toString(36).substr(2, 9);
    let dominantbaseline = "auto";
    let pathStartOffset = this.pathStartOffset;
    let dy = 0;

    // translate fabric.js baseline offsets to svg dominant baseline values
    if (this.pathAlign === "center") {
      dominantbaseline = "middle";
    } else if (this.pathAlign === "baseline") {
      dominantbaseline = "auto";
    } else if (this.pathAlign === "ascender") {
      dominantbaseline = "hanging";
    } else if (this.pathAlign === "descender") {
      dominantbaseline = "auto";
      dy = (fontSize / 100) * -22;
    }

    let textAnchor = "start";
    if (this.textAlign == "center") {
      textAnchor = "middle";
      pathStartOffset += pathLength / 2;
    }

    if (this.textAlign == "right") {
      textAnchor = "end";
      pathStartOffset += pathLength;
    }

    // append texpath to defs or as rendered element
    let textPathEl;
    if (
      (fillPath && fillPath !== "none") ||
      (!strokePath && strokePath !== "none")
    ) {
      textPathEl = `<path id="textOnPath${id}" fill="${fillPath}" stroke="${strokePath}" stroke-width="${strokeWidth}" d="${d}" />`;
    } else {
      textPathEl = `<defs>
        <path id="textOnPath${id}" d="${d}" />
      </defs>`;
    }

    return this._createBaseSVGMarkup(
      this.path?.path
        ? [
            textPathEl,
            `<text 
              font-family="${fontFamily.replace(/"/g, "'")}" 
              fill="${fill}"
              font-size="${fontSize}" 
              font-style="${fontStyle}" 
              font-weight="${fontWeight}"
              >
                <textPath text-anchor="${textAnchor}" 
                dominant-baseline="${dominantbaseline}" 
                startOffset="${pathStartOffset}" 
                href="#textOnPath${id}" 
                xlink:href="#textOnPath${id}"> 
                <tspan dy="${dy}">${this.text}</tspan>
                </textPath>
              </text>`
          ]
        : [
            `<text 
            xml:space="preserve" 
            font-family="${fontFamily}" 
            font-size="${fontSize}" 
            font-style="${fontStyle}" 
            font-weight="${fontWeight}" 
            > 
            ${this.addPaintOrder()}
            ${this.text}
            </text>`
          ],
      { reviver: reviver, noStyle: true, withShadow: true }
    );
  } else {
    return this._createBaseSVGMarkup(this._toSVG(), {
      reviver: reviver,
      noStyle: true,
      withShadow: true
    });
  }
};

/**
 * Reverse pathdata
 */
function reversePathData(pathData) {
  // start compiling new path data
  let pathDataNew = [];

  // helper to rearrange control points for all command types
  const reverseControlPoints = (values) => {
    let controlPoints = [];
    let endPoint = [];
    for (let p = 0; p < values.length; p += 2) {
      controlPoints.push([values[p], values[p + 1]]);
    }
    endPoint = controlPoints.pop();
    controlPoints.reverse();
    return [controlPoints, endPoint];
  };

  let closed =
    pathData[pathData.length - 1][0].toLowerCase() === "z" ? true : false;
  if (closed) {
    // add lineto closing space between Z and M
    pathData = addClosePathLineto(pathData);
    // remove Z closepath
    pathData.pop();
  }

  // define last point as new M if path isn't closed
  let valuesLast = pathData[pathData.length - 1];
  let valuesLastL = valuesLast.length;
  let M = closed
    ? pathData[0]
    : ["M", valuesLast[valuesLastL - 2], valuesLast[valuesLastL - 1]];
  // starting M stays the same – unless the path is not closed
  pathDataNew.push(M);

  // reverse path data command order for processing
  pathData.reverse();
  for (let i = 1; i < pathData.length; i++) {
    let com = pathData[i];
    let values = com.slice(1);
    let comPrev = pathData[i - 1];
    let typePrev = comPrev[0];
    let valuesPrev = comPrev.slice(1);
    // get reversed control points and new end coordinates
    let [controlPointsPrev, endPointsPrev] = reverseControlPoints(valuesPrev);
    let [controlPoints, endPoints] = reverseControlPoints(values);

    // create new path data
    let newValues = [];
    newValues = controlPointsPrev.flat().concat(endPoints);
    pathDataNew.push([typePrev, ...newValues]);
  }

  // add previously removed Z close path
  if (closed) {
    pathDataNew.push(["z"]);
  }
  return pathDataNew;
}

/**
 * Add closing lineto:
 * needed for path reversing or adding points
 */
function addClosePathLineto(pathData) {
  let pathDataL = pathData.length;
  let closed = pathData[pathDataL - 1][0] === "Z";
  let M = pathData[0];
  let [x0, y0] = [M[1], M[2]];
  let lastCom = closed ? pathData[pathDataL - 2] : pathData[pathDataL - 1];
  let lastComL = lastCom.length;
  let [xE, yE] = [lastCom[lastComL - 2], lastCom[lastComL - 1]];
  if (closed && (x0 !== xE || y0 !== yE)) {
    pathData.pop();
    pathData.push(["L", x0, y0], ["Z"]);
  }
  return path;
}

See codepen example

Clemweb commented 1 year ago

@herrstrietzel pretty good! but I don't know why if I change the path the svg is weird (not at the good place). For example with this : M 75 0 A 10 10 0 0 1 75 -150 A 1 1 0 0 1 75 0 Z Have you a idea ?

celioFagundes commented 6 months ago

@Clemweb have you found a solution to this? facing the same problem, any different path get's posicioned wrong on the export

herrstrietzel commented 6 months ago

@herrstrietzel, @Clemweb: sorry for the late response.
See new codepen example Not sure, but I added a cropping/repositioning function so off-viewBox paths get aligned to x/y=0 origin point