ReCreateJS/txtjs

View on GitHub
src/CharacterText.ts

Summary

Maintainability
F
2 wks
Test Coverage
B
89%
import TextContainer from "./TextContainer";
import Align from "./Align";
import FontLoader from "./FontLoader";
import { ConstructObj, Style } from "./Interfaces";
import Font from "./Font";
import Character from "./Character";
import Line from "./Line";
import applyShapeEventListeners from "./utils/apply-shape-event-listeners";

const SPACE_CHAR_CODE = 32;

export default class CharacterText extends TextContainer {
  lineHeight: number = null;
  width = 100;
  height = 20;
  align: number = Align.TOP_LEFT;
  size = 12;
  minSize: number = null;
  maxTracking: number = null;
  tracking = 0;
  ligatures = false;
  fillColor = "#000";
  strokeColor: string = null;
  strokeWidth: number = null;
  singleLine = false;
  autoExpand = false;
  autoReduce = false;
  overset = false;
  oversetIndex: number = null;
  loaderId: number = null;
  debug = false;
  lines: Line[] = [];
  block: createjs.Container;
  missingGlyphs: any[] = null;
  renderCycle = true;
  measured = false;
  oversetPotential = false;

  constructor(props: ConstructObj = null) {
    super();

    if (props) {
      this.original = props;
      this.set(props);
      this.original.tracking = this.tracking;
    }
    this.loadFonts();
  }

  //layout text
  layout() {
    this.addAccessibility();

    this.overset = false;
    this.measured = false;
    this.oversetPotential = false;

    if (this.original.size) {
      this.size = this.original.size;
    }
    if (this.original.tracking) {
      this.tracking = this.original.tracking;
    }
    this.text = this.text.replace(/([\n][ \t]+)/g, "\n");

    if (this.singleLine === true) {
      this.text = this.text.split("\n").join("");
      this.text = this.text.split("\r").join("");
    }

    this.lines = [];
    this.missingGlyphs = null;
    this.removeAllChildren();

    if (this.text === "" || this.text === undefined) {
      this.render();
      this.complete();
      return;
    }

    this.block = new createjs.Container();
    this.addChild(this.block);

    if (this.debug == true) {
      this.addDebugLayout();
    }
    if (
      this.singleLine === true &&
      (this.autoExpand === true || this.autoReduce === true)
    ) {
      this.measure();
    }

    if (this.renderCycle === false) {
      this.removeAllChildren();
      this.complete();
      return;
    }

    if (this.characterLayout() === false) {
      this.removeAllChildren();
      return;
    }
    this.lineLayout();
    this.render();
    this.complete();
  }

  /**
   * Draw baseline, ascent, ascender, and descender lines
   */
  private addDebugLayout() {
    const font: Font = FontLoader.getFont(this.font);
    //outline
    let s = new createjs.Shape();
    s.graphics.beginStroke("#FF0000");
    s.graphics.setStrokeStyle(1.2);
    s.graphics.drawRect(0, 0, this.width, this.height);
    this.addChild(s);
    //baseline
    s = new createjs.Shape();
    s.graphics.beginFill("#000");
    s.graphics.drawRect(0, 0, this.width, 0.2);
    s.x = 0;
    s.y = 0;
    this.block.addChild(s);
    s = new createjs.Shape();
    s.graphics.beginFill("#F00");
    s.graphics.drawRect(0, 0, this.width, 0.2);
    s.x = 0;
    s.y = (-font["cap-height"] / font.units) * this.size;
    this.block.addChild(s);
    s = new createjs.Shape();
    s.graphics.beginFill("#0F0");
    s.graphics.drawRect(0, 0, this.width, 0.2);
    s.x = 0;
    s.y = (-font.ascent / font.units) * this.size;
    this.block.addChild(s);
    s = new createjs.Shape();
    s.graphics.beginFill("#00F");
    s.graphics.drawRect(0, 0, this.width, 0.2);
    s.x = 0;
    s.y = (-font.descent / font.units) * this.size;
    this.block.addChild(s);
  }

  measure(): boolean {
    this.measured = true;
    //Extract origin sizing from this.original to preserve
    //metrics. autoMeasure will change style properties
    //directly. Change this.original to re-render.

    let len = this.text.length;
    const defaultStyle = {
      size: this.original.size,
      font: this.original.font,
      tracking: this.original.tracking,
      characterCase: this.original.characterCase
    };
    let currentStyle: any;
    let charCode: number = null;
    let font: Font;
    const charMetrics = [];
    let largestFontSize = defaultStyle.size;
    for (let i = 0; i < len; i++) {
      charCode = this.text.charCodeAt(i);

      currentStyle = defaultStyle;
      if (
        this.original.style !== undefined &&
        this.original.style[i] !== undefined
      ) {
        currentStyle = this.original.style[i];
        // make sure style contains properties needed.
        if (currentStyle.size === undefined)
          currentStyle.size = defaultStyle.size;
        if (currentStyle.font === undefined)
          currentStyle.font = defaultStyle.font;
        if (currentStyle.tracking === undefined)
          currentStyle.tracking = defaultStyle.tracking;
      }
      if (currentStyle.size > largestFontSize) {
        largestFontSize = currentStyle.size;
      }
      font = FontLoader.fonts[currentStyle.font];

      charMetrics.push({
        char: this.text[i],
        size: currentStyle.size,
        charCode: charCode,
        font: currentStyle.font,
        offset: font.glyphs[charCode].offset,
        units: font.units,
        tracking: this.trackingOffset(
          currentStyle.tracking,
          currentStyle.size,
          font.units
        ),
        kerning: font.glyphs[charCode].getKerning(this.getCharCodeAt(i + 1), 1)
      });
    }

    //save space char using last known width/height
    const space: any = {
      char: " ",
      size: currentStyle.size,
      charCode: 32,
      font: currentStyle.font,
      offset: font.glyphs[32].offset,
      units: font.units,
      tracking: 0,
      kerning: 0
    };

    charMetrics[charMetrics.length - 1].tracking = 0;
    //charMetrics[ charMetrics.length-1 ].kerning=0;

    len = charMetrics.length;

    //measured without size
    let metricBaseWidth = 0;
    //measured at size
    let metricRealWidth = 0;
    //measured at size with tracking
    let metricRealWidthTracking = 0;

    let current = null;
    for (let i = 0; i < len; i++) {
      current = charMetrics[i];
      metricBaseWidth = metricBaseWidth + current.offset + current.kerning;
      metricRealWidth =
        metricRealWidth + (current.offset + current.kerning) * current.size;
      metricRealWidthTracking =
        metricRealWidthTracking +
        (current.offset + current.kerning + current.tracking) * current.size;
    }

    //size cases
    if (metricRealWidth > this.width) {
      if (this.autoReduce === true) {
        this.tracking = 0;
        this.size =
          (this.original.size * this.width) /
          (metricRealWidth + space.offset * space.size);
        if (this.minSize != null && this.size < this.minSize) {
          this.size = this.minSize;
          this.oversetPotential = true;
        }
        return true;
      }
      //tracking cases
    } else {
      let trackMetric = this.offsetTracking(
        (this.width - metricRealWidth) / len,
        current.size,
        current.units
      );
      if (trackMetric < 0) {
        trackMetric = 0;
      }
      //auto expand case
      if (trackMetric > this.original.tracking && this.autoExpand) {
        if (this.maxTracking != null && trackMetric > this.maxTracking) {
          this.tracking = this.maxTracking;
        } else {
          this.tracking = trackMetric;
        }
        this.size = this.original.size;
        return true;
      }
      //auto reduce tracking case
      if (trackMetric < this.original.tracking && this.autoReduce) {
        if (this.maxTracking != null && trackMetric > this.maxTracking) {
          this.tracking = this.maxTracking;
        } else {
          this.tracking = trackMetric;
        }
        this.size = this.original.size;
        return true;
      }
    }
    return true;
  }

  trackingOffset(tracking: number, size: number, units: number): number {
    return size * (2.5 / units + 1 / 900 + tracking / 990);
  }

  offsetTracking(offset: number, size: number, units: number): number {
    return Math.floor(((offset - 2.5 / units - 1 / 900) * 990) / size);
  }

  getWidth(): number {
    return this.width;
  }

  /**
   * Place characters in lines
   * adds Characters to lines. LineHeight IS a factor given lack of Words.
   */
  characterLayout(): boolean {
    //char layout
    const len = this.text.length;
    let char: Character;
    const defaultStyle: Style = {
      size: this.size,
      font: this.font,
      tracking: this.tracking,
      characterCase: this.characterCase,
      fillColor: this.fillColor,
      strokeColor: this.strokeColor,
      strokeWidth: this.strokeWidth
    };
    let currentStyle = defaultStyle;
    let hPosition = 0;
    let vPosition = 0;
    let lineY = 0;
    let firstLine = true;

    let currentLine: Line = new Line();

    this.lines.push(currentLine);
    this.block.addChild(currentLine);

    // loop over characters
    // place into lines
    for (let i = 0; i < len; i++) {
      if (this.style !== null && this.style[i] !== undefined) {
        currentStyle = this.style[i];
        // make sure style contains properties needed.
        if (currentStyle.size === undefined)
          currentStyle.size = defaultStyle.size;
        if (currentStyle.font === undefined)
          currentStyle.font = defaultStyle.font;
        if (currentStyle.tracking === undefined)
          currentStyle.tracking = defaultStyle.tracking;
        if (currentStyle.characterCase === undefined)
          currentStyle.characterCase = defaultStyle.characterCase;
        if (currentStyle.fillColor === undefined)
          currentStyle.fillColor = defaultStyle.fillColor;
        if (currentStyle.strokeColor === undefined)
          currentStyle.strokeColor = defaultStyle.strokeColor;
        if (currentStyle.strokeWidth === undefined)
          currentStyle.strokeWidth = defaultStyle.strokeWidth;
      }

      // newline
      // mark word as having newline
      // create new word
      // new line has no character
      if (this.text.charAt(i) == "\n" || this.text.charAt(i) == "\r") {
        //only if not last char
        if (i < len - 1) {
          if (firstLine === true) {
            vPosition = currentStyle.size;
            currentLine.measuredHeight = currentStyle.size;
            currentLine.measuredWidth = hPosition;
            lineY = 0;
            currentLine.y = 0;
          } else if (this.lineHeight != undefined) {
            vPosition = this.lineHeight;
            currentLine.measuredHeight = vPosition;
            currentLine.measuredWidth = hPosition;
            lineY = lineY + vPosition;
            currentLine.y = lineY;
          } else {
            vPosition = char.measuredHeight;
            currentLine.measuredHeight = vPosition;
            currentLine.measuredWidth = hPosition;
            lineY = lineY + vPosition;
            currentLine.y = lineY;
          }

          firstLine = false;
          currentLine = new Line();
          currentLine.measuredHeight = currentStyle.size;
          currentLine.measuredWidth = 0;
          this.lines.push(currentLine);
          this.block.addChild(currentLine);
          vPosition = 0;
          hPosition = 0;
        }

        if (this.text.charAt(i) == "\r" && this.text.charAt(i + 1) == "\n") {
          i++;
        }

        continue;
      }

      //runtime test for font
      if (FontLoader.isLoaded(currentStyle.font) === false) {
        FontLoader.load(this, [currentStyle.font]);
        return false;
      }

      // create character
      char = new Character(this.text.charAt(i), currentStyle, i);

      if (this.original.character) {
        applyShapeEventListeners(this.original.character, char);
      }

      if (char.missing) {
        if (this.missingGlyphs == null) {
          this.missingGlyphs = [];
        }
        this.missingGlyphs.push({
          position: i,
          character: this.text.charAt(i),
          font: currentStyle.font
        });
      }

      if (firstLine === true) {
        if (vPosition < char.size) {
          vPosition = char.size;
        }
      } else if (this.lineHeight != undefined && this.lineHeight > 0) {
        if (vPosition < this.lineHeight) {
          vPosition = this.lineHeight;
        }
      } else if (char.measuredHeight > vPosition) {
        vPosition = char.measuredHeight;
      }

      //swap character if ligature
      //ligatures removed if tracking or this.ligatures is false
      if (currentStyle.tracking == 0 && this.ligatures == true) {
        const ligTarget = this.text.substr(i, 4);
        i = i + this.ligatureSwap(char, ligTarget);
      }

      if (this.overset == true) {
        break;
      }

      const longerThanWidth = hPosition + char.measuredWidth > this.width;
      if (this.singleLine === false && longerThanWidth) {
        const lastchar: Character = currentLine.children[
          currentLine.children.length - 1
        ] as Character;

        currentLine.measuredWidth =
          hPosition -
          lastchar.trackingOffset() -
          lastchar._glyph.getKerning(this.getCharCodeAt(i), lastchar.size);

        if (lastchar.characterCode == SPACE_CHAR_CODE) {
          currentLine.measuredWidth -= lastchar.measuredWidth;
        }

        if (firstLine === true) {
          currentLine.measuredHeight = vPosition;
          currentLine.y = 0;
          lineY = 0;
        } else {
          currentLine.measuredHeight = vPosition;
          lineY = lineY + vPosition;
          currentLine.y = lineY;
        }
        firstLine = false;
        currentLine = new Line();
        currentLine.addChild(char);

        if (char.characterCode == SPACE_CHAR_CODE) {
          hPosition = 0;
        } else {
          hPosition =
            char.x +
            char._glyph.offset * char.size +
            char.characterCaseOffset +
            char.trackingOffset();
        }

        this.lines.push(currentLine);
        this.block.addChild(currentLine);
        vPosition = 0;

        //measured case
      } else if (
        this.measured == true &&
        this.singleLine === true &&
        longerThanWidth &&
        this.oversetPotential == true
      ) {
        this.oversetIndex = i;
        this.overset = true;

        //not measured
      } else if (
        this.measured == false &&
        this.singleLine === true &&
        longerThanWidth
      ) {
        this.oversetIndex = i;
        this.overset = true;
      } else {
        char.x = hPosition;
        // push character into word
        currentLine.addChild(char);
        hPosition =
          char.x +
          char._glyph.offset * char.size +
          char.characterCaseOffset +
          char.trackingOffset() +
          char._glyph.getKerning(this.getCharCodeAt(i + 1), char.size);
      }
    }

    //case of empty word at end.
    if (currentLine.children.length == 0) {
      currentLine = this.lines[this.lines.length - 1];
      hPosition = currentLine.measuredWidth;
      vPosition = currentLine.measuredHeight;
    }
    if (firstLine === true) {
      currentLine.measuredWidth = hPosition;
      currentLine.measuredHeight = vPosition;
      currentLine.y = 0;
    } else {
      currentLine.measuredWidth = hPosition;
      currentLine.measuredHeight = vPosition;
      if (vPosition == 0) {
        if (this.lineHeight) {
          vPosition = this.lineHeight;
        } else {
          vPosition = currentStyle.size;
        }
      }
      currentLine.y = lineY + vPosition;
    }
    return true;
  }

  lineLayout() {
    // loop over lines
    // place into text
    let measuredHeight = 0;
    let line;
    const a = Align;
    const fnt: Font = FontLoader.getFont(this.font);

    const len = this.lines.length;
    for (let i = 0; i < len; i++) {
      line = this.lines[i];

      //correct measuredWidth if last line character contains tracking
      if (line.lastCharacter()) {
        line.measuredWidth -= line.lastCharacter().trackingOffset();
      }

      if (this.original.line) {
        applyShapeEventListeners(this.original.line, line);
      }

      measuredHeight += line.measuredHeight;

      if (this.align === a.TOP_CENTER) {
        //move to center
        line.x = (this.width - line.measuredWidth) / 2;
      } else if (this.align === a.TOP_RIGHT) {
        //move to right
        line.x = this.width - line.measuredWidth;
      } else if (this.align === a.MIDDLE_CENTER) {
        //move to center
        line.x = (this.width - line.measuredWidth) / 2;
      } else if (this.align === a.MIDDLE_RIGHT) {
        //move to right
        line.x = this.width - line.measuredWidth;
      } else if (this.align === a.BOTTOM_CENTER) {
        //move to center
        line.x = (this.width - line.measuredWidth) / 2;
      } else if (this.align === a.BOTTOM_RIGHT) {
        //move to right
        line.x = this.width - line.measuredWidth;
      }
    }

    //TOP ALIGNED
    if (
      this.align === a.TOP_LEFT ||
      this.align === a.TOP_CENTER ||
      this.align === a.TOP_RIGHT
    ) {
      if (fnt.top == 0) {
        this.block.y = (this.lines[0].measuredHeight * fnt.ascent) / fnt.units;
      } else {
        this.block.y =
          (this.lines[0].measuredHeight * fnt.ascent) / fnt.units +
          (this.lines[0].measuredHeight * fnt.top) / fnt.units;
      }

      //MIDDLE ALIGNED
    } else if (
      this.align === a.MIDDLE_LEFT ||
      this.align === a.MIDDLE_CENTER ||
      this.align === a.MIDDLE_RIGHT
    ) {
      this.block.y =
        this.lines[0].measuredHeight +
        (this.height - measuredHeight) / 2 +
        (this.lines[0].measuredHeight * fnt.middle) / fnt.units;

      //BOTTOM ALIGNED
    } else if (
      this.align === a.BOTTOM_LEFT ||
      this.align === a.BOTTOM_CENTER ||
      this.align === a.BOTTOM_RIGHT
    ) {
      this.block.y =
        this.height -
        this.lines[this.lines.length - 1].y +
        (this.lines[0].measuredHeight * fnt.bottom) / fnt.units;
    }

    if (this.original.block) {
      applyShapeEventListeners(this.original.block, this.block);
    }
  }
}