I need to calculate exact bounding box for every each character (glyph) in NSAttributedString (Core Text).
After putting together some code used to solve similar problems (Core Text selection, etc..), the result is quite good, but only few frames (red) are being calculated properly:
Most of the frames are misplaces either horizontally or vertically (by tiny bit). What is the cause of that? How can I perfect this code?:
-(void)recalculate{
// get characters from NSString
NSUInteger len = [_attributedString.string length];
UniChar *characters = (UniChar *)malloc(sizeof(UniChar)*len);
CFStringGetCharacters((__bridge CFStringRef)_attributedString.string, CFRangeMake(0, [_attributedString.string length]), characters);
// allocate glyphs and bounding box arrays for holding the result
// assuming that each character is only one glyph, which is wrong
CGGlyph *glyphs = (CGGlyph *)malloc(sizeof(CGGlyph)*len);
CTFontGetGlyphsForCharacters(_font, characters, glyphs, len);
// get bounding boxes for glyphs
CTFontGetBoundingRectsForGlyphs(_font, kCTFontDefaultOrientation, glyphs, _characterFrames, len);
free(characters); free(glyphs);
// Measure how mush specec will be needed for this attributed string
// So we can find minimun frame needed
CFRange fitRange;
CGSize s = CTFramesetterSuggestFrameSizeWithConstraints(_framesetter, rangeAll, NULL, CGSizeMake(W, MAXFLOAT), &fitRange);
_frameRect = CGRectMake(0, 0, s.width, s.height);
CGPathRef framePath = CGPathCreateWithRect(_frameRect, NULL);
_ctFrame = CTFramesetterCreateFrame(_framesetter, rangeAll, framePath, NULL);
CGPathRelease(framePath);
// Get the lines in our frame
NSArray* lines = (NSArray*)CTFrameGetLines(_ctFrame);
_lineCount = [lines count];
// Allocate memory to hold line frames information:
if (_lineOrigins != NULL)free(_lineOrigins);
_lineOrigins = malloc(sizeof(CGPoint) * _lineCount);
if (_lineFrames != NULL)free(_lineFrames);
_lineFrames = malloc(sizeof(CGRect) * _lineCount);
// Get the origin point of each of the lines
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), _lineOrigins);
// Solution borrowew from (but simplified):
// https://github.com/twitter/twui/blob/master/lib/Support/CoreText%2BAdditions.m
// Loop throught the lines
for(CFIndex i = 0; i < _lineCount; ++i) {
CTLineRef line = (__bridge CTLineRef)[lines objectAtIndex:i];
CFRange lineRange = CTLineGetStringRange(line);
CFIndex lineStartIndex = lineRange.location;
CFIndex lineEndIndex = lineStartIndex + lineRange.length;
CGPoint lineOrigin = _lineOrigins[i];
CGFloat ascent, descent, leading;
CGFloat lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
// If we have more than 1 line, we want to find the real height of the line by measuring the distance between the current line and previous line. If it's only 1 line, then we'll guess the line's height.
BOOL useRealHeight = i < _lineCount - 1;
CGFloat neighborLineY = i > 0 ? _lineOrigins[i - 1].y : (_lineCount - 1 > i ? _lineOrigins[i + 1].y : 0.0f);
CGFloat lineHeight = ceil(useRealHeight ? abs(neighborLineY - lineOrigin.y) : ascent + descent + leading);
_lineFrames[i].origin = lineOrigin;
_lineFrames[i].size = CGSizeMake(lineWidth, lineHeight);
for (int ic = lineStartIndex; ic < lineEndIndex; ic++) {
CGFloat startOffset = CTLineGetOffsetForStringIndex(line, ic, NULL);
_characterFrames[ic].origin = CGPointMake(startOffset, lineOrigin.y);
}
}
}
#pragma mark - Rendering Text:
-(void)renderInContext:(CGContextRef)context contextSize:(CGSize)size{
CGContextSaveGState(context);
// Draw Core Text attributes string:
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, CGRectGetHeight(_frameRect));
CGContextScaleCTM(context, 1.0, -1.0);
CTFrameDraw(_ctFrame, context);
// Draw line and letter frames:
CGContextSetStrokeColorWithColor(context, [UIColor colorWithRed:0.0 green:0.0 blue:1.0 alpha:0.5].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextBeginPath(context);
CGContextAddRects(context, _lineFrames, _lineCount);
CGContextClosePath(context);
CGContextStrokePath(context);
CGContextSetStrokeColorWithColor(context, [UIColor colorWithRed:1.0 green:0.0 blue:0.0 alpha:0.5].CGColor);
CGContextBeginPath(context);
CGContextAddRects(context, _characterFrames, _attributedString.string.length);
CGContextClosePath(context);
CGContextStrokePath(context);
CGContextRestoreGState(context);
}
You did an impressive amount of work in your question and were so close on your own. The problem you were having comes from this line of code where you position the bounding boxes for each frame:
_characterFrames[ic].origin = CGPointMake(startOffset, lineOrigin.y);
The problem with it is that you are overriding whatever offset the frame already had.
If you were to comment out that line you would see that all the frames were positioned more or less in the same place but you would also see that they are not positioned at the exact same place. Some are positioned more to the left or right and some more up or down. This means that the frames for the glyphs have a position of their own.
The solution to your problem is to take the current position of the frames into account when you move them into their correct place on the lines. You can either do it by adding to x and y separately:
_characterFrames[ic].origin.x += startOffset;
_characterFrames[ic].origin.y += lineOrigin.y;
or by offsetting the rectangle:
_characterFrames[ic] = CGRectOffset(_characterFrames[ic],
startOffset, lineOrigin.y);
Now the bounding boxes will have their correct positions:
and you should see that it works for some of the more extreme fonts out there
Swift 5, Xcode 11:
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: self.bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let string = "|優勝《ゆうしょう》の|懸《か》かった|試合《しあい》。|Test《テスト》.\nThe quick brown fox jumps over the lazy dog. 12354567890 ##-+"
let attributedString = Utility.sharedInstance.furigana(String: string)
let range = attributedString.mutableString.range(of: attributedString.string)
attributedString.addAttribute(.font, value: font, range: range)
let framesetter = attributedString.framesetter()
let textBounds = self.bounds.insetBy(dx: 20, dy: 20)
let frame = framesetter.createFrame(textBounds)
//Draw the frame text:
frame.draw(in: context)
let origins = frame.lineOrigins()
let lines = frame.lines()
context.setStrokeColor(UIColor.red.cgColor)
context.setLineWidth(0.7)
for i in 0 ..< origins.count {
let line = lines[i]
for run in line.glyphRuns() {
let font = run.font
let glyphPositions = run.glyphPositions()
let glyphs = run.glyphs()
let glyphsBoundingRects = font.boundingRects(of: glyphs)
//DRAW the bounding box for each glyph:
for k in 0 ..< glyphPositions.count {
let point = glyphPositions[k]
let gRect = glyphsBoundingRects [k]
var box = gRect
box.origin += point + origins[i] + textBounds.origin
context.stroke(box)
}// for k
}//for run
}//for i
}//func draw
Made with a CoreText Swift Wrapper.
Full Source: https://github.com/huse360/LetterFrame
Related
I have been searching for a quite while but couldn't find any answer to this, anyways, I am working on Google Maps for iOS using Obj C and have drawn routes (polyline) using multiple coordinates provided to me by the server in the form of an array. But the problem is that I want to show arrow heads on that line so that the direction can be seen on the map. Please help.
Here's a function which draws a nice little arrow line. It has some parameters you can tweak:
void TRDrawLineWithArrow(CGContextRef CXT, CGPoint FROMPOINT, CGPoint TOPOINT, CGFloat WIDTH, CGFloat ARROWSIZEMULTIPLE)
{
CGFloat rise = TOPOINT.y - FROMPOINT.y;
CGFloat run = TOPOINT.x - FROMPOINT.x;
// trig
CGFloat length = sqrt(rise*rise + run+run);
CGFloat angle = atan2(rise, run);
// the length of our arrowhead
CGFloat arrowLen = WIDTH*ARROWSIZEMULTIPLE;
// push graphics context
CGContextSaveGState(CXT);
// transform context according to line's origin and angle
CGContextTranslateCTM(CXT, FROMPOINT.x, FROMPOINT.y);
CGContextRotateCTM(CXT, angle);
// draw straight line
CGContextMoveToPoint(CXT, 0, -WIDTH/2.);
CGContextAddLineToPoint(CXT, 0, WIDTH/2.);
CGContextAddLineToPoint(CXT, length-arrowLen, WIDTH/2.);
// draw arrowhead
CGContextAddLineToPoint(CXT, length-arrowLen, (WIDTH*ARROWSIZEMULTIPLE)/2.);
CGContextAddLineToPoint(CXT, length, 0);
CGContextAddLineToPoint(CXT, length-arrowLen, -(WIDTH*ARROWSIZEMULTIPLE)/2.);
CGContextAddLineToPoint(CXT, length-arrowLen, -WIDTH/2.);
CGContextAddLineToPoint(CXT, 0, -WIDTH/2.);
// fill the path
CGContextFillPath(CXT);
// pop graphics context
CGContextRestoreGState(CXT);
}
You would call it from a UIView like this:
CGContextRef cxt = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(cxt, [UIColor blackColor].CGColor);
TRDrawLineWithArrow(cxt, CGPointMake(10,10), CGPointMake(300,100), 5, 3);
In my project, I do need to generate an image of a PDF page, generated with CoreGraphics.
I managed to create a context, with the size of the image I want (destinationSize: CGSize) but when I use the CGPDFPageGetDrawingTransform function, it only resize the page down, but it won't scale the context to make the page fill the destination rect.
Here is the extract of code I have right now in my project :
UIGraphicsBeginImageContextWithOptions(destinationSize, true, 0)
defer {
UIGraphicsEndImageContext()
}
// Invert y axis (CoreGraphics and UIKit axes are differents)
CGContextTranslateCTM( ctx, 0, destinationSize.height);
CGContextScaleCTM(ctx, 1, -1)
let transform = CGPDFPageGetDrawingTransform(pageRef, .CropBox, CGRect(origin: CGPointZero, size: destinationSize), 0, true)
CGContextConcatCTM(ctx, transform)
// TODO We need force the page to fill all the dest rect when it's bigger than the CropBox size
CGContextDrawPDFPage(ctx, pageRef)
I tried to scale my context by a scale factor with this, replacing the TODO :
let contextScale: CGFloat = (pageRect.width < expectedWidth) ? expectedWidth / pageRect.width : 1
CGContextScaleCTM(ctx, contextScale, contextScale)
but it created an incorrect offset of the drawing, and I'm kind of lost with CoreGraphics transformations.
What would be the correct way to rescale the context to make sure the pdf page draws to fill the context size ?
This is the solution I came up with.
For what I know, this is working for any pdf document page. (tested with many rotations, cropbox sizes and origins.
func transformationForPage(_ pageNumber: Int, targetSize: CGSize) -> CGAffineTransform {
let pageRef = getPage(pageNumber)
let rotation = getPageRotationInteger(pageNumber)
let cropbox = cropboxForPage(pageNumber)
var transform = pageRef!.getDrawingTransform(.cropBox, rect: CGRect(origin: CGPoint.zero, size: targetSize), rotate: 0, preserveAspectRatio: true)
// We change the context scale to fill completely the destination size
if cropbox.width < targetSize.width {
let contextScale = targetSize.width / cropbox.width
transform = transform.scaledBy(x: contextScale, y: contextScale)
transform.tx = -(cropbox.origin.x * transform.a + cropbox.origin.y * transform.b)
transform.ty = -(cropbox.origin.x * transform.c + cropbox.origin.y * transform.d)
// Rotation handling
if rotation == 180 || rotation == 270 {
transform.tx += targetSize.width
}
if rotation == 90 || rotation == 180 {
transform.ty += targetSize.height
}
}
return transform
}
I'm drawing bar graphs, and I have several stacked CGRects that are directly on top of each other (i.e. one rect's minY is the previous rect's maxY). However, there are still semi-transparent gaps between the rects. Is there any way to fix this? I've found that this also happens when drawing touching adjacent arcs.
Here's a screenshot of what I mean:
By zooming in, I've confirmed that this isn't just an optical illusion like one would find between adjacent red and blue rects. I would appreciate any input.
var upToNowSegmentTotal: CGFloat = 0
for k in 0..<data[i].bars[j].segments.count {
var segmentRect: CGRect = CGRect()
if barDirection == "vertical" {
let a: CGFloat = translateY(upToNowSegmentTotal)
let b: CGFloat = translateY(upToNowSegmentTotal + data[i].bars[j].segments[k].length)
upToNowSegmentTotal += data[i].bars[j].segments[k].length
var rectY: CGFloat
if a > b {
rectY = b
} else {
rectY = a
}
segmentRect = CGRect(
x: barWidthPosition,
y: rectY,
width: barWidthAbsolute,
height: abs(a - b)
)
}
}
Ignore the stuff about the width of the bars. Here's the translateY function. Basically, it translates coordinates from the graphing window into x/y stuff that's drawn. Remember that because the window/ graphing area does not change between drawn rects, the same y input will always produce the same result.
private func translateY(y: CGFloat) -> CGFloat {
if barsAreReversed {
return graphingArea.minY + graphingArea.height * (y - graphingWindow.startValue) / (graphingWindow.length)
} else {
return graphingArea.maxY - graphingArea.height * (y - graphingWindow.startValue) / (graphingWindow.length)
}
}
EDIT 2:
Here's a simplified version of my code that shows the problem:
override func drawRect(rect: CGRect) {
let rect1: CGRect = CGRect(
x: 0,
y: 0,
width: 40,
height: 33.7
)
let rect2: CGRect = CGRect(
x: 0,
y: rect1.height,
width: 40,
height: 33.5
)
let context: CGContextRef = UIGraphicsGetCurrentContext()
CGContextSetFillColorWithColor(context, UIColor(red: 1 / 255, green: 29 / 255, blue: 29 / 255, alpha: 1).CGColor)
CGContextAddRect(context, rect1)
CGContextFillRect(context, rect1)
CGContextSetFillColorWithColor(context, UIColor(red: 9 / 255, green: 47 / 255, blue: 46 / 255, alpha: 1).CGColor)
CGContextAddRect(context, rect2)
CGContextFillRect(context, rect2)
}
It produces this:
I suspect that in this particular case, the rects you are filling are not integral, i.e they might have origins/heights that are by default rendered with slightly transparent pixels (anti-aliasing). You could avoid this by properly rounding your Y-axis translation
private func translateY(y: CGFloat) -> CGFloat {
if barsAreReversed {
return round(graphingArea.minY + graphingArea.height * (y - graphingWindow.startValue) / (graphingWindow.length))
} else {
return round(graphingArea.maxY - graphingArea.height * (y - graphingWindow.startValue) / (graphingWindow.length))
}
}
With arcs and other shapes it is not as easy, however, you could try and get rid of it, by leaving a bit of overlap between shapes. Of course, as pointed out by matt, you could simply turn anti-aliasing off, in which case these transparent "half-pixels" will all be rendered as if they are actually fully-within the rect.
This is likely happening because the rectangle coordinates you are using to draw shapes are fractional values. As a result Core Graphics performs antialiasing at the edges of those rectangles when your coordinates land between pixel boundaries.
You could solve this by simply rounding the coordinates of the rectangles before drawing. You can use the CGRectIntegral function which performs this kind of rounding, for example:
CGContextFillRect(context, CGRectIntegral(rect1))
It's antialiasing. I can prevent this phenomenon by using your exact same code but drawing in a CGContext in which we have first called CGContextSetAllowsAntialiasing(context, false). Here it is without that call:
And here it is with that call:
But, as others have said, we can get the same result by changing your 33.7 and 33.5 to 40, so that we come down on pixel boundaries.
I want to make a nice graphic pie with 8 equal slices, that can be individually scaled or resized depending on an Int or something like this. This would look something like below just that all the slices should be equally cut:
I have tried this in Objective-C but it makes just one slice:
-(CAShapeLayer *)createPieSlice {
CAShapeLayer *slice = [CAShapeLayer layer];
slice.fillColor = [UIColor redColor].CGColor;
slice.strokeColor = [UIColor blackColor].CGColor;
slice.lineWidth = 3.0;
CGFloat angle = DEG2RAD(-60.0);
CGPoint center = CGPointMake(100.0, 100.0);
CGFloat radius = 100.0;
UIBezierPath *piePath = [UIBezierPath bezierPath];
[piePath moveToPoint:center];
[piePath addLineToPoint:CGPointMake(center.x + radius * cosf(angle), center.y + radius * sinf(angle))];
[piePath addArcWithCenter:center radius:radius startAngle:angle endAngle:DEG2RAD(60.0) clockwise:YES];
// [piePath addLineToPoint:center];
[piePath closePath]; // this will automatically add a straight line to the center
slice.path = piePath.CGPath;
return slice;
}
How can I achieve that graph in swift?
Break the problem into logical pieces.
You have wedges of different arc widths. All those radii need to add up to a full circle. I assume they represent fractions of something that adds up to 100%. Do you want a specific order? If so, map your fractions in the order you want, such that they all add up to 100%.
Then write code that starts at an angle of zero, and creates arcs that are the specified fraction of 2π. Each one would start at the end of the previous one. Assign a radius that's appropriate based on the data you need.
Now write code that creates closed path segments in a UIBezierPath.
EDIT
You've clarified, and told us that you always want 8 slices of the same width but with different radii.
So you need to write code that takes 8 input values and plots it as 8 arcs with different radius values.
Let's say your input value is an array of floats ranging from 0 to 1. At zero, the wedge is zero-sized. At 1.0, it's the largest circle size that will fit in your view (half the width of a square view.
So you would create an array of 8 floats:
var fractions = [0.5, 0.7, 0.3, 0.1, 1.0 .6, .2, .9]
The code to create a bezier curve with 8 arcs might look something like this:
let pi = 3.1415826
let largestRadius = myView.width/2
let piePath = UIBezierPath()
for (index, afloat) in fractions
{
let startAngle = Double(index) / fractions.count * 2 * pi
let endAngle = Double(index+1) / fractions.count * 2 * pi
let thisRadius = largestRadius * afloat
let center = CGPointMake( myView.width/2, myView.height/2)
piePath.moveToPoint(center)
piePath.addArcWithCenter(center,
radius: thisRadius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
piePath.lineToPoint(center)
piePath.closePath()
}
I think the code above would create 8 closed pie-slice paths, but I'm not positive. It might be necessary to add a lineToPoint call between the first moveToPoint call and the arc call.
Edit #2:
Since I am learning Swift, I decided to take this as an exercise and wrote a sample project that generates pie charts using a shape layer and a a custom path created from a UIBezierPath, as outlined above. You can find the sample project on github: PieCharts project on Github
I have managed to solve my problem using Core Graphics! Thanks #duncan-c for your interest.
EDIT:
I have dropped my first solution in the favour of #duncan-c's solution, that works better for my needs!
import UIKit
class Pie: UIView {
// In range of 0.0 to 1.0
var endArc:CGFloat = 0.0 {
didSet {
setNeedsDisplay()
}
}
var arcWidth:CGFloat = 5.0
var arcColor = UIColor()
var arcBackgroundColor = UIColor.clearColor()
var arcStrokeColor = UIColor()
var startFloat:CGFloat = 0.0
var radius:CGFloat = 0.0
var radiusSize: CGFloat = 0.0
override func drawRect(rect: CGRect) {
// Important constants for circle
let fullCircle = 2.0 * CGFloat(M_PI)
let start:CGFloat = startFloat * fullCircle
let end:CGFloat = endArc * fullCircle + start
// Find the centerpoint of the rect
var centerPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect))
// Set the radius
radius = (radiusSize - arcWidth) / 2.0
// Starting point for all drawing code is getting the context.
let context = UIGraphicsGetCurrentContext()
// Set colorspace
let colorspace = CGColorSpaceCreateDeviceRGB()
// Set line attributes
CGContextSetLineWidth(context, arcWidth)
// Draw the pie
CGContextSetStrokeColorWithColor(context, arcStrokeColor.CGColor)
CGContextSetFillColorWithColor(context, arcColor.CGColor)
CGContextMoveToPoint(context, centerPoint.x, centerPoint.y)
CGContextAddArc(context, centerPoint.x, centerPoint.y, radius, start, end, 0)
CGContextFillPath(context)
}
}
And then subclassd the UIView where I needed using:
#IBOutlet weak var graph: Pie!
override func awakeFromNib() {
super.awakeFromNib()
let backgroundTrackColor = UIColor.clearColor()
let strokeColor = UIColor(white: 0.15, alpha: 1.0)
graph.radiusSize = 50.0
graph.arcBackgroundColor = backgroundTrackColor
graph.arcStrokeColor = strokeColor
graph.arcColor = UIColor.redColor()
graph.startFloat = 0.750
graph.arcWidth = 5.0
graph.endArc = 0.125
}
I'm using CGLayers to implement a "painting" technique similar to Photoshop airbrush - and have run into something strange. When I use transparency and overpaint an area, the color never reaches full intensity (if the alpha value is below 0.5). My application uses a circular "airbrush" pattern with opacity fall off at the edges but I have reproduced the problem just using a semi-transparent white square. When the opacity level is less than 0.5, the overpainted area never reaches the pure white of the source layer. I probably wouldn't have noticed but I'm using the result of the painting as a mask, and not being able to get pure white causes problems. Any ideas what's going on here? Target iOS SDK 5.1.
Below is the resultant color after drawing the semi-transparent square many times over black background:
opacity color
------ -----
1.0 255
0.9 255
0.8 255
0.7 255
0.6 255
0.5 255
0.4 254
0.3 253
0.2 252
0.1 247
Simplified code that shows the issue:
- (void)drawRect:(CGRect)rect
{
CGContextRef viewContext = UIGraphicsGetCurrentContext();
// Create grey gradient to compare final blend color
CGRect lineRect = CGRectMake(20, 20, 1, 400);
float greyLevel = 1.0;
for(int i=0;i<728;i++)
{
CGContextSetRGBFillColor(viewContext, greyLevel, greyLevel, greyLevel, 1);
CGContextFillRect(viewContext, lineRect);
lineRect.origin.x += 1;
greyLevel -= 0.0001;
}
// Create semi-transparent white square
CGSize whiteSquareSize = CGSizeMake(40, 40);
CGLayerRef whiteSquareLayer = CGLayerCreateWithContext (viewContext, whiteSquareSize, NULL);
CGContextRef whiteSquareContext = CGLayerGetContext(whiteSquareLayer);
CGContextSetAlpha(whiteSquareContext, 1.0f); // just to make sure
CGContextSetRGBFillColor(whiteSquareContext, 1, 1, 1, 0.3); // ??? color never reaches pure white if alpha < 0.5
CGRect whiteSquareRect = CGRectMake(0, 0, whiteSquareSize.width, whiteSquareSize.height);
CGContextFillRect(whiteSquareContext, whiteSquareRect);
// "Paint" with layer a bazillion times
CGContextSetBlendMode(viewContext, kCGBlendModeNormal); // just to make sure
CGContextSetAlpha(viewContext, 1.0); // just to make sure
for(int strokeNum=0;strokeNum<100;strokeNum++)
{
CGPoint drawPoint = CGPointMake(0, 400);
for(int x=0;x<730;x++)
{
CGContextDrawLayerAtPoint(viewContext, drawPoint, whiteSquareLayer);
drawPoint.x++;
}
}
}