Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Iterating format % placeholders one by one

I've got an NSAttributedString that looks like

"Somestring bold %@ template %f %d blah blah blah"

I want to be able to replace the format template parts just like in [NSString stringWithFormat:...] but keeping the styling, so that the replaced strings match the styling around them (all bold in my example above).

Is there a way to iterate through each format % placeholder one by one so I can use a list of arguments to fill the string?

I don't want to build my own % implementation because I know theres a million and one different formats.

Or is there an easier solution that I'm overlooking?

Edit: I'll explain a bit of the complete solution I'm solving:

To make it possible for my team to attribute localized strings I've already got a way to write

"key"="test //italic %@// **bold** __underline %d__";

If a specifier is between attributed tags I want that part to be attributed. Currently I can create an attributed string as seen above, the next part is to take care of the specifiers left over.

I'm doing it in the order of Parse attributes -> Apply arguments..., I could easily solve it the other way but I don't want format arguments to mess with the attributes

like image 343
SomeGuy Avatar asked Nov 25 '14 07:11

SomeGuy


3 Answers

Big thanks to @Rick for pointing me in the right direction.

His post recommends first going over the arguments and pre-escaping any string or character objects before applying the format strings. This brought me back to another problem I was having earlier, in trying to iterate an argument list of different types (NSString, int etc), like NSLog does. I think I found that it is impossible (or at least really difficult) to do this, and the reason NSLog can is that it knows what types to expect through the format specifiers (%@, %i etc).

I realize I can actually get the same effect not by escaping the arguments, but by escaping the format string itself.

Example:

format: "test //italic %@//"
args: "text // more text"

Steps:

  1. First replace all instances of // with //-TAG-//
  2. Apply the arguments
  3. Determine where styling applies between //-TAG-//

Obviously //-TAG-// can still be written in the arguments to mess up the styling, however depending on what you use as a replacement, the chances of this happening are essentially zero.

like image 185
SomeGuy Avatar answered Nov 02 '22 15:11

SomeGuy


I'm doing it in the order of Parse attributes -> Apply arguments..., I could easily solve it the other way but I don't want format arguments to mess with the attributes

Why not simply add an escape-character? From what I understand you run the risk of the example you provided getting messed up if the first string contains a double slash?

"key"="test //italic %@// **bold** __underline %d__";

if %@ is text // more text that would screw up the formatting.

If so, then simply parse every vararg of the type NSString and char to make sure that they don't contain any of the characters you reserved for your attributes. If they do, add some escape char before which you remove upon parsing the attributes.

The above example would look like this after applying the arguments:

"key"="test //italic text \/\/ more text// **bold** __underline 34__";

After which you parse the attributes, same way as before but you ignore characters preceded by \ and make sure to remove the \.

It's a bit of effort but I bet it's a lot less than implementing your own printf-style parser.

like image 36
Rick Avatar answered Nov 02 '22 14:11

Rick


Here is working code:

#import <Foundation/Foundation.h>

@interface NSAttributedString (AttributedFormat)
- (instancetype)initWithFormat:(NSAttributedString *)attrFormat, ...;
- (instancetype)initWithFormat:(NSAttributedString *)attrFormat arguments:(va_list)arguments;
@end

@implementation NSAttributedString (AttributedFormat)

- (instancetype)initWithFormat:(NSAttributedString *)attrFormat, ... {
    va_list args;
    va_start(args, attrFormat);
    self = [self initWithFormat:attrFormat arguments:args];
    va_end(args);
    return self;
}

- (instancetype)initWithFormat:(NSAttributedString *)attrFormat arguments:(va_list)arguments {
    NSRegularExpression *regex;
    regex = [[NSRegularExpression alloc] initWithPattern: @"(%.*?[@%dDuUxXoOfeEgGccsSpaAF])"
                                                 options: 0
                                                   error: nil];
    NSString *format = attrFormat.string;
    format = [regex stringByReplacingMatchesInString: format
                                             options: 0
                                               range: NSMakeRange(0, format.length)
                                        withTemplate: @"\0$1\0"];
    NSString *result = [[NSString alloc] initWithFormat:format arguments:arguments];
    NSMutableArray *f_comps = [format componentsSeparatedByString:@"\0"].mutableCopy;
    NSMutableArray *r_comps = [result componentsSeparatedByString:@"\0"].mutableCopy;

    NSMutableAttributedString *output = [[NSMutableAttributedString alloc] init];
    __block int consumed_length = 0;

    [attrFormat enumerateAttributesInRange:NSMakeRange(0, attrFormat.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) {
        NSMutableString *substr = [NSMutableString string];
        while(f_comps.count > 0 && NSMaxRange(range) >= consumed_length + [(NSString *)f_comps[0] length]){
            NSString *f_str = f_comps[0];
            NSString *r_str = r_comps[0];
            [substr appendString:r_str];
            [f_comps removeObjectAtIndex:0];
            [r_comps removeObjectAtIndex:0];
            consumed_length += f_str.length;
        }

        NSUInteger idx = NSMaxRange(range) - consumed_length;
        if(f_comps.count > 0 && idx > 0) {
            NSString *f_str = f_comps[0];

            NSString *leading = [f_str substringToIndex:idx];
            [substr appendString:leading];

            NSString *trailing = [f_str substringFromIndex:idx];
            [f_comps replaceObjectAtIndex:0 withObject:trailing];
            [r_comps replaceObjectAtIndex:0 withObject:trailing];
            consumed_length += idx;
        }
        [output appendAttributedString:[[NSAttributedString alloc] initWithString:substr attributes:attrs]];
    }];

    return [self initWithAttributedString:output];
}
@end

Usage example:

NSMutableAttributedString *fmt = [[NSMutableAttributedString alloc] initWithString:@"test: "];
[fmt appendAttributedString: [[NSAttributedString alloc] initWithString: @"Some%%string"
                                                             attributes: @{
                                                                           NSFontAttributeName: [UIFont systemFontOfSize:17]
                                                                           }]];
[fmt appendAttributedString: [[NSAttributedString alloc] initWithString: @"bold %@ template %.3f %d"
                                                             attributes: @{
                                                                           NSFontAttributeName: [UIFont boldSystemFontOfSize:20],
                                                                           NSForegroundColorAttributeName: [UIColor redColor]
                                                                           }]];
[fmt appendAttributedString: [[NSAttributedString alloc] initWithString: @"%@ blah blah blah"
                                                             attributes: @{
                                                                           NSFontAttributeName: [UIFont systemFontOfSize:16],
                                                                           NSForegroundColorAttributeName: [UIColor blueColor]
                                                                           }]];

NSAttributedString *result = [[NSAttributedString alloc] initWithFormat:fmt, @"[foo]", 1.23, 56, @"[[bar]]"];

Result:

screenshot

Maybe this still have some bugs, but it should work in most cases.


(%.*?[@%dDuUxXoOfeEgGccsSpaAF])

This regex matches "Format Specifiers". specifier begin with % and end with listed characters. and may have some modifiers between them. It's not perfect, for example this illegal format "%__s" should be ignored but my regex matches this whole string. but as long as the specifier is legal one, it should works.

My code matches it, and insert delimiters around the specifiers:

I'm %s.
I'm <delimiter>%s<delimiter>.

I use \0 as a delimiter.

I'm \0%s\0.

then interpolate it.

I'm \0rintaro\0.

then split the format and the result with the delimiter:

f_comps: ["I'm ", "%s", "."]
r_comps: ["I'm ", "rintaro", "."]

Here, total string length of f_comps is exact the same as original attributed format. Then, iterate the attributes with enumerateAttributesInRange, we can apply the attributes to the results.

I'm sorry but it's too hard to explain the jobs inside enumerateAttributesInRange, with my poor English skills :)

like image 1
rintaro Avatar answered Nov 02 '22 15:11

rintaro