//
// DTHTMLWriter.m
// DTCoreText
//
// Created by Oliver Drobnik on 23.12.12.
// Copyright (c) 2012 Drobnik.com. All rights reserved.
//
#import "DTHTMLWriter.h"
#import "DTCoreText.h"
#import "DTVersion.h"
#import "NSDictionary+DTCoreText.h"
@implementation DTHTMLWriter
{
NSAttributedString *_attributedString;
NSString *_HTMLString;
CGFloat _textScale;
BOOL _useAppleConvertedSpace;
NSMutableDictionary *_styleLookup;
}
- (id)initWithAttributedString:(NSAttributedString *)attributedString
{
self = [super init];
if (self)
{
_attributedString = attributedString;
_useAppleConvertedSpace = YES;
// default is to leave px sizes as is
_textScale = 1.0f;
}
return self;
}
#pragma mark - Generating HTML
- (NSMutableArray *)_styleArrayForElement:(NSString *)elementName
{
// get array of styles for element
NSMutableArray *_styleArray = [_styleLookup objectForKey:elementName];
if (!_styleArray)
{
// first time we see this element
_styleArray = [[NSMutableArray alloc] init];
[_styleLookup setObject:_styleArray forKey:elementName];
}
return _styleArray;
}
// checks the style against previous styles and returns the style class for this
- (NSString *)_styleClassForElement:(NSString *)elementName style:(NSString *)style
{
// get array of styles for element
NSMutableArray *_styleArray = [self _styleArrayForElement:elementName];
NSInteger index = [_styleArray indexOfObject:style];
if (index==NSNotFound)
{
// need to add this style
[_styleArray addObject:style];
index = [_styleArray count];
}
else
{
index++;
}
return [NSString stringWithFormat:@"%@%d", [elementName substringToIndex:1],(int)index];
}
- (NSString *)_tagRepresentationForListStyle:(DTCSSListStyle *)listStyle closingTag:(BOOL)closingTag listPadding:(CGFloat)listPadding inlineStyles:(BOOL)inlineStyles
{
BOOL isOrdered = NO;
NSString *typeString = nil;
switch (listStyle.type)
{
case DTCSSListStyleTypeInherit:
case DTCSSListStyleTypeDisc:
{
typeString = @"disc";
isOrdered = NO;
break;
}
case DTCSSListStyleTypeCircle:
{
typeString = @"circle";
isOrdered = NO;
break;
}
case DTCSSListStyleTypeSquare:
{
typeString = @"square";
isOrdered = NO;
break;
}
case DTCSSListStyleTypePlus:
{
typeString = @"plus";
isOrdered = NO;
break;
}
case DTCSSListStyleTypeUnderscore:
{
typeString = @"underscore";
isOrdered = NO;
break;
}
case DTCSSListStyleTypeImage:
{
typeString = @"image";
isOrdered = NO;
break;
}
case DTCSSListStyleTypeDecimal:
{
typeString = @"decimal";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeDecimalLeadingZero:
{
typeString = @"decimal-leading-zero";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeUpperAlpha:
{
typeString = @"upper-alpha";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeUpperLatin:
{
typeString = @"upper-latin";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeLowerAlpha:
{
typeString = @"lower-alpha";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeLowerLatin:
{
typeString = @"lower-latin";
isOrdered = YES;
break;
}
case DTCSSListStyleTypeNone:
{
typeString = @"none";
break;
}
case DTCSSListStyleTypeInvalid:
{
break;
}
}
if (closingTag)
{
if (isOrdered)
{
return @"";
}
else
{
return @"";
}
}
else
{
if (listStyle.position == DTCSSListStylePositionInside)
{
typeString = [typeString stringByAppendingString:@" inside"];
}
else if (listStyle.position == DTCSSListStylePositionOutside)
{
typeString = [typeString stringByAppendingString:@" outside"];
}
NSString *blockElement;
if (isOrdered)
{
blockElement = @"ol";
}
else
{
blockElement = @"ul";
}
NSString *listStyleString = [NSString stringWithFormat:@"list-style:'%@';", typeString];
if (listPadding>0)
{
listStyleString = [listStyleString stringByAppendingFormat:@"-webkit-padding-start:%.0fpx;padding-left:%.0fpx;", listPadding, listPadding];
}
NSString *className = [self _styleClassForElement:blockElement style:listStyleString];
if (inlineStyles)
{
return [NSString stringWithFormat:@"<%@ style=\"%@\">", blockElement, listStyleString];
}
else
{
return [NSString stringWithFormat:@"<%@ class=\"%@\">", blockElement, className];
}
}
}
- (void)_buildOutput
{
[self _buildOutputAsHTMLFragment:NO];
}
- (void)_buildOutputAsHTMLFragment:(BOOL)fragment
{
// reusable styles
_styleLookup = [[NSMutableDictionary alloc] init];
NSString *plainString = [_attributedString string];
// divide the string into it's blocks (we assume that these are the P)
NSArray *paragraphs = [plainString componentsSeparatedByString:@"\n"];
NSMutableString *retString = [NSMutableString string];
NSInteger location = 0;
NSArray *previousListStyles = nil;
for (NSUInteger i=0; i<[paragraphs count]; i++)
{
NSString *oneParagraph = [paragraphs objectAtIndex:i];
NSRange paragraphRange = NSMakeRange(location, [oneParagraph length]);
// skip empty paragraph at the end
if (i==[paragraphs count]-1)
{
if (!paragraphRange.length)
{
continue;
}
}
__block BOOL needsToRemovePrefix = NO;
BOOL fontIsBlockLevel = NO;
// check if font is same in the entire paragraph
NSRange fontEffectiveRange;
CTFontRef paragraphFont = (__bridge CTFontRef)[_attributedString attribute:(id)kCTFontAttributeName atIndex:paragraphRange.location longestEffectiveRange:&fontEffectiveRange inRange:paragraphRange];
if (NSEqualRanges(paragraphRange, fontEffectiveRange))
{
fontIsBlockLevel = YES;
}
// next paragraph start
location = location + paragraphRange.length + 1;
NSDictionary *paraAttributes = [_attributedString attributesAtIndex:paragraphRange.location effectiveRange:NULL];
// lets see if we have a list style
NSArray *currentListStyles = [paraAttributes objectForKey:DTTextListsAttribute];
DTCSSListStyle *effectiveListStyle = [currentListStyles lastObject];
// retrieve the paragraph style
DTCoreTextParagraphStyle *paragraphStyle = [paraAttributes paragraphStyle];
NSString *paraStyleString = nil;
if (paragraphStyle && !effectiveListStyle)
{
if (_textScale!=1.0f)
{
paragraphStyle.minimumLineHeight = round(paragraphStyle.minimumLineHeight / _textScale);
paragraphStyle.maximumLineHeight = round(paragraphStyle.maximumLineHeight / _textScale);
paragraphStyle.paragraphSpacing = round(paragraphStyle.paragraphSpacing/ _textScale);
paragraphStyle.paragraphSpacingBefore = round(paragraphStyle.paragraphSpacingBefore / _textScale);
paragraphStyle.firstLineHeadIndent = round(paragraphStyle.firstLineHeadIndent / _textScale);
paragraphStyle.headIndent = round(paragraphStyle.headIndent / _textScale);
paragraphStyle.tailIndent = round(paragraphStyle.tailIndent / _textScale);
}
paraStyleString = [paragraphStyle cssStyleRepresentation];
}
if (!paraStyleString)
{
paraStyleString = @"";
}
if (fontIsBlockLevel)
{
if (paragraphFont)
{
DTCoreTextFontDescriptor *desc = [DTCoreTextFontDescriptor fontDescriptorForCTFont:paragraphFont];
if (_textScale!=1.0f)
{
desc.pointSize /= _textScale;
}
NSString *paraFontStyle = [desc cssStyleRepresentation];
if (paraFontStyle)
{
paraStyleString = [paraStyleString stringByAppendingString:paraFontStyle];
}
}
}
NSString *blockElement;
// close until we are at current or nil
if ([previousListStyles count]>[currentListStyles count])
{
NSMutableArray *closingStyles = [previousListStyles mutableCopy];
do
{
DTCSSListStyle *closingStyle = [closingStyles lastObject];
if (closingStyle == effectiveListStyle)
{
break;
}
// end of a list block
[retString appendString:[self _tagRepresentationForListStyle:closingStyle closingTag:YES listPadding:0 inlineStyles:fragment]];
[retString appendString:@"\n"];
[closingStyles removeLastObject];
previousListStyles = closingStyles;
}
while ([closingStyles count]);
}
if (effectiveListStyle)
{
// next text needs to have list prefix removed
needsToRemovePrefix = YES;
// get lists that need to be opened here
NSArray *listsToOpen = nil;
if (!previousListStyles)
{
listsToOpen = currentListStyles;
}
else
{
NSMutableArray *tmpArray = [NSMutableArray array];
for (DTCSSListStyle *oneList in currentListStyles)
{
NSRange listRange = [_attributedString rangeOfTextList:oneList atIndex:paragraphRange.location];
if (listRange.location == paragraphRange.location)
{
// lists starts here
[tmpArray addObject:oneList];
}
}
if ([tmpArray count])
{
listsToOpen = [tmpArray copy];
}
}
[listsToOpen enumerateObjectsUsingBlock:^(DTCSSListStyle *oneList, NSUInteger idx, BOOL *stop) {
// only padding can be reconstructed so far
CGFloat listPadding = (paragraphStyle.headIndent - paragraphStyle.firstLineHeadIndent) / _textScale;
// beginning of a list block
[retString appendString:[self _tagRepresentationForListStyle:oneList closingTag:NO listPadding:listPadding inlineStyles:fragment]];
[retString appendString:@"\n"];
// all but the effective list need an extra LI
if (oneList != effectiveListStyle)
{
[retString appendString:@"
"];
}
}];
blockElement = @"li";
}
else
{
blockElement = @"p";
}
NSNumber *headerLevel = [paraAttributes objectForKey:DTHeaderLevelAttribute];
if (headerLevel)
{
blockElement = [NSString stringWithFormat:@"h%d", (int)[headerLevel integerValue]];
}
if ([paragraphs lastObject] == oneParagraph)
{
// last paragraph in string
if (![plainString hasSuffix:@"\n"])
{
// not a whole paragraph, so we don't put it in P
blockElement = @"span";
}
}
// find which custom attributes are for the entire paragraph
NSDictionary *HTMLAttributes = [_attributedString HTMLAttributesAtIndex:paragraphRange.location];
NSMutableDictionary *paragraphLevelHTMLAttributes = [NSMutableDictionary dictionary];
[HTMLAttributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) {
// check if range is longer than current paragraph
NSRange attributeEffectiveRange = [_attributedString rangeOfHTMLAttribute:key atIndex:paragraphRange.location];
if (NSIntersectionRange(attributeEffectiveRange, paragraphRange).length == paragraphRange.length)
{
[paragraphLevelHTMLAttributes setObject:value forKey:key];
}
}];
// Add dir="auto" if the writing direction is unknown
if (paragraphStyle)
{
switch (paragraphStyle.baseWritingDirection)
{
case kCTWritingDirectionNatural:
{
[paragraphLevelHTMLAttributes setObject:@"auto" forKey:@"dir"];
break;
}
case kCTWritingDirectionRightToLeft:
{
[paragraphLevelHTMLAttributes setObject:@"rtl" forKey:@"dir"];
break;
}
case kCTWritingDirectionLeftToRight:
{
// this is default, so we omit it
break;
}
}
}
// start paragraph start tag
[retString appendFormat:@"<%@", blockElement];
// do we have style info?
if ([paraStyleString length])
{
if (fragment)
{
// stays style for fragment mode
[paragraphLevelHTMLAttributes setObject:paraStyleString forKey:@"style"];
}
else
{
// compress style for document mode
NSString *className = [self _styleClassForElement:blockElement style:paraStyleString];
NSString *existingClasses = [paragraphLevelHTMLAttributes objectForKey:@"class"];
if (existingClasses)
{
NSMutableArray *individualClasses = [[existingClasses componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
// insert compressed class at index 0
[individualClasses insertObject:className atIndex:0];
// rejoin
className = [individualClasses componentsJoinedByString:@" "];
}
[paragraphLevelHTMLAttributes setObject:className forKey:@"class"];
}
}
// add paragraph level attributes
[paragraphLevelHTMLAttributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) {
[retString appendFormat:@" %@=\"%@\"", key, value];
}];
// end paragraph start tag
[retString appendString:@">"];
// add the attributed string ranges in this paragraph to the paragraph container
__block NSRange currentLinkRange = {NSNotFound, 0};
__block NSMutableDictionary *linkLevelHTMLAttributes = nil;
// ----- SPAN enumeration
[_attributedString enumerateAttributesInRange:paragraphRange options:0 usingBlock:^(NSDictionary *attributes, NSRange spanRange, BOOL *stopEnumerateAttributes) {
NSURL *spanURL = [attributes objectForKey:DTLinkAttribute];
NSString *spanAnchorName = [attributes objectForKey:DTAnchorAttribute];
BOOL isFirstPartOfHyperlink = NO;
BOOL isLastPartOfHyperlink = NO;
if ((spanURL || spanAnchorName) && (currentLinkRange.location == NSNotFound))
{
if (spanURL)
{
currentLinkRange = [_attributedString rangeOfLinkAtIndex:spanRange.location URL:NULL];
}
else if (spanAnchorName)
{
currentLinkRange = [_attributedString rangeOfAnchorNamed:spanAnchorName];
}
isFirstPartOfHyperlink = YES;
// build the attributes for the A tag
linkLevelHTMLAttributes = [NSMutableDictionary dictionary];
if (spanURL)
{
[linkLevelHTMLAttributes setObject:[spanURL relativeString] forKey:@"href"];
}
// add anchor name if present
if (spanAnchorName)
{
[linkLevelHTMLAttributes setObject:spanAnchorName forKey:@"name"];
}
// find which custom attributes are for the link
NSDictionary *localHTMLAttributes = [_attributedString HTMLAttributesAtIndex:currentLinkRange.location];
[localHTMLAttributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stopEnumerateKeysAndObjects) {
// check if range is longer than current paragraph
NSRange attributeEffectiveRange = [_attributedString rangeOfHTMLAttribute:key atIndex:currentLinkRange.location];
if (NSEqualRanges(attributeEffectiveRange, currentLinkRange))
{
[linkLevelHTMLAttributes setObject:value forKey:key];
}
}];
}
// check if previous link is over yet
if (NSMaxRange(spanRange) >= NSMaxRange(currentLinkRange))
{
isLastPartOfHyperlink = YES;
}
NSString *plainSubString =[plainString substringWithRange:spanRange];
if (effectiveListStyle && needsToRemovePrefix)
{
NSRange prefixRange = [_attributedString rangeOfFieldAtIndex:spanRange.location];
if (prefixRange.location != NSNotFound)
{
if (NSMaxRange(prefixRange) persistableAttachment = (id)attachment;
NSString *HTMLString = [persistableAttachment stringByEncodingAsHTML];
if (HTMLString)
{
[retString appendString:HTMLString];
}
}
if (isLastPartOfHyperlink)
{
currentLinkRange = NSMakeRange(NSNotFound, 0);
}
return;
}
NSString *fontStyle = nil;
if (!fontIsBlockLevel)
{
DTCoreTextFontDescriptor *fontDescriptor = [attributes fontDescriptor];
if (fontDescriptor)
{
if (_textScale!=1.0f)
{
fontDescriptor.pointSize /= _textScale;
}
fontStyle = [fontDescriptor cssStyleRepresentation];
}
}
if (!fontStyle)
{
fontStyle = @"";
}
CGFloat kerning = [attributes kerning] / _textScale;
if (kerning)
{
fontStyle = [fontStyle stringByAppendingFormat:@"letter-spacing:%.0fpx;", kerning];
}
DTColor *textColor = [attributes foregroundColor];
if (textColor)
{
fontStyle = [fontStyle stringByAppendingFormat:@"color:#%@;", DTHexStringFromDTColor(textColor)];
}
DTColor *backgroundColor = [attributes backgroundColor];
if (backgroundColor)
{
fontStyle = [fontStyle stringByAppendingFormat:@"background-color:#%@;", DTHexStringFromDTColor(backgroundColor)];
}
NSNumber *underline = [attributes objectForKey:(id)kCTUnderlineStyleAttributeName];
if (underline)
{
fontStyle = [fontStyle stringByAppendingString:@"text-decoration:underline;"];
}
else
{
// there can be no underline and strike-through at the same time
NSNumber *strikout = [attributes objectForKey:DTStrikeOutAttribute];
if ([strikout boolValue])
{
fontStyle = [fontStyle stringByAppendingString:@"text-decoration:line-through;"];
}
}
NSNumber *superscript = [attributes objectForKey:(id)kCTSuperscriptAttributeName];
if (superscript)
{
NSInteger style = [superscript integerValue];
switch (style)
{
case 1:
{
fontStyle = [fontStyle stringByAppendingString:@"vertical-align:super;"];
break;
}
case -1:
{
fontStyle = [fontStyle stringByAppendingString:@"vertical-align:sub;"];
break;
}
default:
{
// all other are baseline because we don't support anything else for text
fontStyle = [fontStyle stringByAppendingString:@"vertical-align:baseline;"];
break;
}
}
}
NSString *spanTagName = @"span";
__block BOOL needsSpanTag = NO;
// find which custom attributes are only for this span
NSDictionary *localHTMLAttributes = [attributes objectForKey:DTCustomAttributesAttribute];
NSMutableDictionary *spanLevelHTMLAttributes = [NSMutableDictionary dictionary];
[localHTMLAttributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) {
// check if there is already an identical paragraph attribute
id valueForParagraph = [paragraphLevelHTMLAttributes objectForKey:key];
if (valueForParagraph)
{
if (fragment)
{
if ([valueForParagraph isEqual:value])
{
return;
}
}
else
{
// need to check components
NSArray *paragraphClassComponents = [valueForParagraph componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([paragraphClassComponents containsObject:value])
{
return;
}
}
}
NSRange attributeEffectiveRange = [_attributedString rangeOfHTMLAttribute:key atIndex:spanRange.location];
if (currentLinkRange.location==NSNotFound || !NSEqualRanges(attributeEffectiveRange, currentLinkRange))
{
[spanLevelHTMLAttributes setObject:value forKey:key];
needsSpanTag = YES;
}
}];
if ([fontStyle length])
{
needsSpanTag = YES;
if (fragment)
{
// stays style for fragment mode
[spanLevelHTMLAttributes setObject:fontStyle forKey:@"style"];
}
else
{
// compress style for document mode
NSString *className = [self _styleClassForElement:spanTagName style:fontStyle];
NSString *existingClasses = [spanLevelHTMLAttributes objectForKey:@"class"];
if (existingClasses)
{
NSMutableArray *individualClasses = [[existingClasses componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
// insert compressed class at index 0
[individualClasses insertObject:className atIndex:0];
// rejoin
className = [individualClasses componentsJoinedByString:@" "];
}
[spanLevelHTMLAttributes setObject:className forKey:@"class"];
}
}
if (isFirstPartOfHyperlink)
{
// start link start tag
[retString appendString:@""];
}
if (needsSpanTag)
{
// start span start tag
[retString appendFormat:@"<%@", spanTagName];
// add span level attributes
[spanLevelHTMLAttributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) {
[retString appendFormat:@" %@=\"%@\"", key, value];
}];
// end span start tag
[retString appendString:@">"];
}
// add string in span
[retString appendString:subString];
if (needsSpanTag)
{
// span end tag
[retString appendFormat:@"%@>", spanTagName];
}
if (isLastPartOfHyperlink)
{
[retString appendFormat:@""];
currentLinkRange = NSMakeRange(NSNotFound, 0);
}
}]; // end of SPAN loop
if ([blockElement isEqualToString:@"li"])
{
BOOL shouldCloseLI = YES;
NSUInteger nextParagraphStart = NSMaxRange([plainString paragraphRangeForRange:paragraphRange]);
if (nextParagraphStart < [plainString length])
{
NSArray *nextListStyles = [_attributedString attribute:DTTextListsAttribute atIndex:nextParagraphStart effectiveRange:NULL];
// LI are only closed if there is not a deeper list level following
if (nextListStyles && ([nextListStyles indexOfObjectIdenticalTo:effectiveListStyle]!=NSNotFound) && [nextListStyles count] > [currentListStyles count])
{
// deeper list following
shouldCloseLI = NO;
}
}
if (shouldCloseLI)
{
[retString appendString:@""];
}
}
else
{
// other blocks are always closed
[retString appendFormat:@"%@>\n", blockElement];
}
previousListStyles = [currentListStyles copy];
} // end of P loop
// close list if still open
if ([previousListStyles count])
{
NSMutableArray *closingStyles = [previousListStyles mutableCopy];
do
{
DTCSSListStyle *closingStyle = [closingStyles lastObject];
// end of a list block
[retString appendString:[self _tagRepresentationForListStyle:closingStyle closingTag:YES listPadding:0 inlineStyles:fragment]];
[retString appendString:@"\n"];
if ([closingStyles count]>1)
{
[retString appendString:@""];
}
[closingStyles removeLastObject];
}
while ([closingStyles count]);
}
NSMutableString *output = [NSMutableString string];
BOOL hasTab = ([retString rangeOfString:@"\t"].location != NSNotFound);
if (!fragment)
{
// append style block before text
NSMutableString *styleBlock = [NSMutableString string];
NSArray *keys = [[_styleLookup allKeys] sortedArrayUsingSelector:@selector(compare:)];
for (NSString *oneKey in keys)
{
NSArray *styleArray = [_styleLookup objectForKey:oneKey];
[styleArray enumerateObjectsUsingBlock:^(NSString *style, NSUInteger idx, BOOL *stop) {
NSString *className = [NSString stringWithFormat:@"%@%d", [oneKey substringToIndex:1], (int)idx+1];
[styleBlock appendFormat:@"%@.%@ {%@}\n", oneKey, className, style];
}];
}
if (hasTab)
{
[styleBlock appendString:@"span.Apple-tab-span {white-space:pre;}"];
}
[output appendFormat:@"\n\n\n\n\n\n\n\n\n", styleBlock];
}
if (hasTab)
{
NSRange range = NSMakeRange(0, [retString length]);
if (fragment)
{
[retString replaceOccurrencesOfString:@"\t" withString:@"\t" options:0 range:range];
}
else
{
[retString replaceOccurrencesOfString:@"\t" withString:@"\t" options:0 range:range];
}
}
if (_useAppleConvertedSpace)
{
NSString *convertedSpaces = [retString stringByAddingAppleConvertedSpace];
[output appendString:convertedSpaces];
}
else
{
[output appendString:retString];
}
if (!fragment)
{
[output appendString:@"\n\n"];
}
_HTMLString = output;
}
#pragma mark - Public
- (NSString *)HTMLString
{
if (!_HTMLString)
{
[self _buildOutput];
}
return _HTMLString;
}
- (NSString *)HTMLFragment
{
if (!_HTMLString)
{
[self _buildOutputAsHTMLFragment:true];
}
return _HTMLString;
}
#pragma mark - Properties
@synthesize attributedString = _attributedString;
@synthesize textScale = _textScale;
@synthesize useAppleConvertedSpace = _useAppleConvertedSpace;
@end