Implement correct path measurement, matrix calculation, getTextAnchorShift, startOffset.

Add method and spacing attributes to textPath.
Correct startOffset calculation.
Implement method="stretch".
This commit is contained in:
Mikael Sand
2017-06-22 17:02:30 +03:00
parent 0aeb68e904
commit bd84f09a96
7 changed files with 109 additions and 271 deletions

View File

@@ -1,186 +0,0 @@
/**
* Copyright (c) 2015-present, Horcrux.
* All rights reserved.
*
* This source code is licensed under the MIT-style license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.horcrux.svg;
import android.graphics.Matrix;
import android.graphics.PointF;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
public class BezierTransformer {
private ReadableArray mBezierCurves;
private PathShadowNode mPath;
private int mCurrentBezierIndex = 0;
private float mStartOffset = 0f;
private float mLastOffset = 0f;
private float mLastRecord = 0f;
private float mLastDistance = 0f;
private PointF mLastPoint = new PointF();
private PointF mP0 = new PointF();
private PointF mP1 = new PointF();
private PointF mP2 = new PointF();
private PointF mP3 = new PointF();
private boolean mReachedStart;
private boolean mReachedEnd;
BezierTransformer(PathShadowNode path, float startOffset) {
mBezierCurves = path.getBezierCurves();
mStartOffset = startOffset;
mPath = path;
}
private float calculateBezier(float t, float P0, float P1, float P2, float P3) {
return (1-t)*(1-t)*(1-t)*P0+3*(1-t)*(1-t)*t*P1+3*(1-t)*t*t*P2+t*t*t*P3;
}
private PointF pointAtOffset(float t) {
float x = calculateBezier(t, mP0.x, mP1.x, mP2.x, mP3.x);
float y = calculateBezier(t, mP0.y, mP1.y, mP2.y, mP3.y);
return new PointF(x, y);
}
private float calculateBezierPrime(float t, float P0, float P1, float P2, float P3) {
return -3*(1-t)*(1-t)*P0+(3*(1-t)*(1-t)*P1)-(6*t*(1-t)*P1)-(3*t*t*P2)+(6*t*(1-t)*P2)+3*t*t*P3;
}
private float angleAtOffset(float t) {
float dx = calculateBezierPrime(t, mP0.x, mP1.x, mP2.x, mP3.x);
float dy = calculateBezierPrime(t, mP0.y, mP1.y, mP2.y, mP3.y);
return (float)Math.atan2(dy, dx);
}
private float calculateDistance(PointF a, PointF b) {
return (float)Math.hypot(a.x - b.x, a.y - b.y);
}
private PointF getPointFromMap(ReadableMap map) {
return new PointF((float)map.getDouble("x"), (float)map.getDouble("y"));
}
// Simplistic routine to find the offset along Bezier that is
// `distance` away from `point`. `offset` is the offset used to
// generate `point`, and saves us the trouble of recalculating it
// This routine just walks forward until it finds a point at least
// `distance` away. Good optimizations here would reduce the number
// of guesses, but this is tricky since if we go too far out, the
// curve might loop back on leading to incorrect results. Tuning
// kStep is good start.
private float offsetAtDistance(float distance, PointF point, float offset) {
float kStep = 0.001f; // 0.0001 - 0.001 work well
float newDistance = 0;
float newOffset = offset + kStep;
while (newDistance <= distance && newOffset < 1.0) {
newOffset += kStep;
newDistance = calculateDistance(point, pointAtOffset(newOffset));
}
mLastDistance = newDistance;
return newOffset;
}
private void setControlPoints() {
ReadableArray bezier = mBezierCurves.getArray(mCurrentBezierIndex++);
if (bezier != null) {
// set start point
if (bezier.size() == 1) {
mLastPoint = mP0 = getPointFromMap(bezier.getMap(0));
setControlPoints();
} else if (bezier.size() == 3) {
mP1 = getPointFromMap(bezier.getMap(0));
mP2 = getPointFromMap(bezier.getMap(1));
mP3 = getPointFromMap(bezier.getMap(2));
}
}
}
public float getStartOffset() {
return mStartOffset;
}
public PathShadowNode getPath() {
return mPath;
}
public float getTotalDistance() {
float distance = 0;
while (!mReachedEnd) {
distance += 0.1f;
float offset = offsetAtDistance(distance - mLastRecord, mLastPoint, mLastOffset);
if (offset < 1) {
PointF glyphPoint = pointAtOffset(offset);
mLastOffset = offset;
mLastPoint = glyphPoint;
mLastRecord = distance;
} else if (mBezierCurves.size() == mCurrentBezierIndex) {
mReachedEnd = true;
} else {
mLastOffset = 0;
mLastPoint = mP0 = mP3;
mLastRecord += mLastDistance;
setControlPoints();
}
}
mCurrentBezierIndex = 0;
mLastOffset = 0f;
mLastRecord = 0f;
mLastDistance = 0f;
mLastPoint = new PointF();
mP0 = new PointF();
mP1 = new PointF();
mP2 = new PointF();
mP3 = new PointF();
mReachedEnd = false;
return distance;
}
public Matrix getTransformAtDistance(float distance) {
mReachedStart = distance >= 0;
if (mReachedEnd || !mReachedStart) {
return new Matrix();
}
float offset = offsetAtDistance(distance - mLastRecord, mLastPoint, mLastOffset);
if (offset < 1) {
PointF glyphPoint = pointAtOffset(offset);
mLastOffset = offset;
mLastPoint = glyphPoint;
mLastRecord = distance;
Matrix matrix = new Matrix();
matrix.setRotate((float)Math.toDegrees(angleAtOffset(offset)));
matrix.postTranslate(glyphPoint.x, glyphPoint.y);
return matrix;
} else if (mBezierCurves.size() == mCurrentBezierIndex) {
mReachedEnd = true;
return new Matrix();
} else {
mLastOffset = 0;
mLastPoint = mP0 = mP3;
mLastRecord += mLastDistance;
setControlPoints();
return getTransformAtDistance(distance);
}
}
public boolean hasReachedEnd() {
return mReachedEnd;
}
public boolean hasReachedStart() {
return mReachedStart;
}
}

View File

@@ -14,6 +14,7 @@ import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.PointF;
import android.graphics.RectF;
import android.graphics.Typeface;
@@ -24,14 +25,17 @@ import com.facebook.react.uimanager.annotations.ReactProp;
import javax.annotation.Nullable;
import static android.graphics.PathMeasure.POSITION_MATRIX_FLAG;
import static android.graphics.PathMeasure.TANGENT_MATRIX_FLAG;
/**
* Shadow node for virtual TSpan view
*/
public class TSpanShadowNode extends TextShadowNode {
private BezierTransformer mBezierTransformer;
private Path mCache;
private @Nullable String mContent;
private TextPathShadowNode textPath;
private static final String PROP_FONT_FAMILY = "fontFamily";
private static final String PROP_FONT_SIZE = "fontSize";
@@ -83,6 +87,21 @@ public class TSpanShadowNode extends TextShadowNode {
return path;
}
private float getTextAnchorShift(float width) {
float x = 0;
switch (getComputedTextAnchor()) {
case TEXT_ANCHOR_MIDDLE:
x = -width / 2;
break;
case TEXT_ANCHOR_END:
x = -width;
break;
}
return x;
}
private Path getLinePath(Canvas canvas, String line, Paint paint) {
ReadableMap font = applyTextPropertiesToPaint(paint);
int length = line.length();
@@ -92,29 +111,22 @@ public class TSpanShadowNode extends TextShadowNode {
return path;
}
PointF glyphPoint = getGlyphPointFromContext(0, 0);
PointF glyphDelta = getGlyphDeltaFromContext();
float textMeasure = 0;
float distance = 0;
float offset = 0;
PathShadowNode p;
Path bezierPath;
float distance = 0;
float renderMethodScaling = 1;
float textMeasure = paint.measureText(line);
float textAnchorShift = getTextAnchorShift(textMeasure);
if (mBezierTransformer != null) {
offset = mBezierTransformer.getStartOffset();
boolean debug = true;
if (debug) {
distance = mBezierTransformer.getTotalDistance();
textMeasure = paint.measureText(line);
p = mBezierTransformer.getPath();
bezierPath = p.getPath();
canvas.drawTextOnPath(
line,
bezierPath,
offset + glyphPoint.x + glyphDelta.x,
glyphDelta.y,
paint
);
PathMeasure pm = null;
if (textPath != null) {
pm = new PathMeasure(textPath.getPath(), false);
distance = pm.getLength();
offset = PropHelper.fromPercentageToFloat(textPath.getStartOffset(), distance, 0, mScale);
String spacing = textPath.getSpacing(); // spacing = "auto | exact"
String method = textPath.getMethod(); // method = "align | stretch"
if ("stretch".equals(method)) {
renderMethodScaling = distance / textMeasure;
}
}
@@ -122,6 +134,8 @@ public class TSpanShadowNode extends TextShadowNode {
float width;
Matrix matrix;
String current;
PointF glyphPoint;
PointF glyphDelta;
String previous = "";
float glyphPosition = 0;
char[] chars = line.toCharArray();
@@ -133,8 +147,8 @@ public class TSpanShadowNode extends TextShadowNode {
paint.getTextWidths(line, widths);
for (int index = 0; index < length; index++) {
width = widths[index] * renderMethodScaling;
current = String.valueOf(chars[index]);
width = widths[index];
glyph = new Path();
if (isKerningValueSet) {
@@ -149,32 +163,42 @@ public class TSpanShadowNode extends TextShadowNode {
previous = current;
}
glyphPoint = getGlyphPointFromContext(glyphPosition, width);
glyphPoint = getGlyphPointFromContext(textAnchorShift + glyphPosition, width);
glyphDelta = getGlyphDeltaFromContext();
glyphPosition += width;
matrix = new Matrix();
if (mBezierTransformer != null) {
if (textPath != null) {
float halfway = width / 2;
float start = offset + glyphPoint.x + glyphDelta.x;
float midpoint = start + halfway;
matrix = mBezierTransformer.getTransformAtDistance(
offset + glyphPoint.x + glyphDelta.x + halfway
);
if (textPathHasReachedEnd()) {
break;
} else if (!textPathHasReachedStart()) {
if (midpoint > distance ) {
if (start <= distance) {
// Seems to cut off too early, see e.g. toap3, this shows the last "p"
midpoint = start;
halfway = 0;
} else {
break;
}
} else if (midpoint < 0) {
continue;
}
pm.getMatrix(midpoint, matrix, POSITION_MATRIX_FLAG | TANGENT_MATRIX_FLAG);
matrix.preTranslate(-halfway, glyphDelta.y);
matrix.preScale(renderMethodScaling, 1);
matrix.postTranslate(0, glyphPoint.y);
} else {
matrix = new Matrix();
matrix.setTranslate(glyphPoint.x + glyphDelta.x, glyphPoint.y + glyphDelta.y);
matrix.setTranslate(
glyphPoint.x + glyphDelta.x + textAnchorShift,
glyphPoint.y + glyphDelta.y
);
}
paint.getTextPath(current, 0, 1, 0, 0, glyph);
glyphDelta = getGlyphDeltaFromContext();
glyph.transform(matrix);
glyphPosition += width;
path.addPath(glyph);
}
@@ -218,8 +242,7 @@ public class TSpanShadowNode extends TextShadowNode {
while (parent != null) {
if (parent.getClass() == TextPathShadowNode.class) {
TextPathShadowNode textPath = (TextPathShadowNode)parent;
mBezierTransformer = textPath.getBezierTransformer();
textPath = (TextPathShadowNode)parent;
break;
} else if (!(parent instanceof TextShadowNode)) {
break;
@@ -228,12 +251,4 @@ public class TSpanShadowNode extends TextShadowNode {
parent = parent.getParent();
}
}
private boolean textPathHasReachedEnd() {
return mBezierTransformer.hasReachedEnd();
}
private boolean textPathHasReachedStart() {
return mBezierTransformer.hasReachedStart();
}
}

View File

@@ -23,6 +23,8 @@ import javax.annotation.Nullable;
public class TextPathShadowNode extends TextShadowNode {
private String mHref;
private String mMethod;
private String mSpacing;
private @Nullable String mStartOffset;
@ReactProp(name = "href")
@@ -37,12 +39,36 @@ public class TextPathShadowNode extends TextShadowNode {
markUpdated();
}
@ReactProp(name = "method")
public void setMethod(@Nullable String method) {
mMethod = method;
markUpdated();
}
@ReactProp(name = "spacing")
public void setSpacing(@Nullable String spacing) {
mSpacing = spacing;
markUpdated();
}
public String getMethod() {
return mMethod;
}
public String getSpacing() {
return mSpacing;
}
public String getStartOffset() {
return mStartOffset;
}
@Override
public void draw(Canvas canvas, Paint paint, float opacity) {
drawGroup(canvas, paint, opacity);
}
public BezierTransformer getBezierTransformer() {
public Path getPath() {
SvgViewShadowNode svg = getSvgShadowNode();
VirtualNode template = svg.getDefinedTemplate(mHref);
@@ -52,7 +78,7 @@ public class TextPathShadowNode extends TextShadowNode {
}
PathShadowNode path = (PathShadowNode)template;
return new BezierTransformer(path, relativeOnWidth(mStartOffset));
return path.getPath();
}
@Override

View File

@@ -33,10 +33,10 @@ import com.facebook.react.uimanager.annotations.ReactProp;
public class TextShadowNode extends GroupShadowNode {
private static final int TEXT_ANCHOR_AUTO = 0;
private static final int TEXT_ANCHOR_START = 1;
private static final int TEXT_ANCHOR_MIDDLE = 2;
private static final int TEXT_ANCHOR_END = 3;
static final int TEXT_ANCHOR_AUTO = 0;
static final int TEXT_ANCHOR_START = 1;
static final int TEXT_ANCHOR_MIDDLE = 2;
static final int TEXT_ANCHOR_END = 3;
private int mTextAnchor = TEXT_ANCHOR_AUTO;
private @Nullable ReadableArray mDeltaX;
@@ -86,8 +86,6 @@ public class TextShadowNode extends GroupShadowNode {
setupGlyphContext();
clip(canvas, paint);
Path path = getGroupPath(canvas, paint);
Matrix matrix = getAlignMatrix(path);
canvas.concat(matrix);
drawGroup(canvas, paint, opacity);
releaseCachedPath();
}
@@ -97,9 +95,6 @@ public class TextShadowNode extends GroupShadowNode {
protected Path getPath(Canvas canvas, Paint paint) {
setupGlyphContext();
Path groupPath = getGroupPath(canvas, paint);
Matrix matrix = getAlignMatrix(groupPath);
groupPath.transform(matrix);
releaseCachedPath();
return groupPath;
}
@@ -108,20 +103,21 @@ public class TextShadowNode extends GroupShadowNode {
return mTextAnchor;
}
private int getComputedTextAnchor() {
int getComputedTextAnchor() {
int anchor = mTextAnchor;
ReactShadowNode shadowNode = this;
while (shadowNode.getChildCount() > 0 &&
anchor == TEXT_ANCHOR_AUTO) {
shadowNode = shadowNode.getChildAt(0);
while (shadowNode instanceof GroupShadowNode) {
if (shadowNode instanceof TextShadowNode) {
anchor = ((TextShadowNode) shadowNode).getTextAnchor();
} else {
break;
if (anchor != TEXT_ANCHOR_AUTO) {
break;
}
}
shadowNode = shadowNode.getParent();
}
return anchor;
}
@@ -147,25 +143,4 @@ public class TextShadowNode extends GroupShadowNode {
protected void pushGlyphContext() {
getTextRoot().getGlyphContext().pushContext(mFont, mDeltaX, mDeltaY, mPositionX, mPositionY);
}
private Matrix getAlignMatrix(Path path) {
RectF box = new RectF();
path.computeBounds(box, true);
float width = box.width();
float x = 0;
switch (getComputedTextAnchor()) {
case TEXT_ANCHOR_MIDDLE:
x = -width / 2;
break;
case TEXT_ANCHOR_END:
x = -width;
break;
}
Matrix matrix = new Matrix();
matrix.setTranslate(x, 0);
return matrix;
}
}

View File

@@ -16,6 +16,8 @@ export default class extends Shape {
...pathProps,
...fontProps,
href: PropTypes.string.isRequired,
method: PropTypes.oneOf(['align', 'stretch']),
spacing: PropTypes.oneOf(['auto', 'exact']),
startOffset: numberProp
};

View File

@@ -114,6 +114,8 @@ const TextAttributes = {
const TextPathAttributes = {
href: true,
method: true,
spacing: true,
startOffset: true,
...RenderableAttributes
};

View File

@@ -91,6 +91,8 @@ export default function(props, container) {
y,
dx,
dy,
method,
spacing,
textAnchor,
startOffset
} = props;
@@ -127,6 +129,8 @@ export default function(props, container) {
content,
deltaX,
deltaY,
method,
spacing,
startOffset: (startOffset || 0).toString(),
positionX: _.isNil(x) ? null : x.toString(),
positionY: _.isNil(y) ? null : y.toString()