Files
cappuccino/AppKit/CPTextView/_CPRTFProducer.j
T
David Richardson 2b77b98163 AppKit: Add RTF list and Unicode support, and preserve rich content during internal copy-paste
The RTF producer and parser lacked support for text lists, failed to encode
non-ASCII characters properly (\uN), and treated first-line indents (\fi) as
absolute values rather than offsets relative to left indents (\li).
Additionally, paste operations ingested application-generated RTF rather than
the native internal archive, causing image attachments and tables to be stripped
during copy-paste workflows within the application.

* AppKit/CPTextView/_CPRTFProducer.j:
  - Implement RTF serialization for text lists using list tables, override tables,
    \levelmarker, \levelprepend, \lsN\ilvlK tags, and \listtext marker blocks.
  - Encode non-ASCII characters via \uc0\uN syntax and compute \fi indents
    relative to \li.
  - Omit image attachment characters from text runs and adjust run starting indices
    to accommodate list markers.

* AppKit/CPTextView/_CPRTFParser.j:
  - Implement pre-pass parsing for list and override tables.
  - Assign shared text lists to items using \ls and \ilvl, extracting marker formats
    from \levelmarker or standard number/level definitions.
  - Parse \uN sequences (skipping fallback characters) and evaluate relative \fi offsets.

* AppKit/CPTextView/CPTextView.j:
  - Configure paste handling to prioritize internal archives when clipboard RTF
    originates from the application, ensuring image attachments and tables survive internal copy-paste.

* AppKit/CPTextView/_CPTableTextAttachment.j:
  - Implement keyed archiving and decoding for table headers, rows, widths,
    and configuration settings, reconstructing cell instances upon unarchiving.

* Tests/AppKit/CPRTFTest.j:
  - Add comprehensive unit tests covering Unicode encoding, relative first-line indents,
    TextEdit and Word list interoperability, and nested list round-trip fidelity.

* Tests/AppKit/CPTextAttachmentTest.j:
  - Add test coverage for table attachment archiving.

* Tests/Manual/TextEditor/WORKLIST.md:
  - Mark intra-application copy and paste testing as completed.
  - Log external rich text exchange limitations as open items.

Intra-application copy and paste now fully preserves images, tables, and lists,
while RTF serialization supports Unicode and structured hierarchical lists.
2026-10-24 14:37:57 -06:00

1046 lines
32 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* _CPRTFProducer.j
*
* Serialize CPAttributedString to a RTF String
*
* Copyright (C) 2014 Daniel Boehringer
* This file is based on the RTFProducer from GNUStep
* (which I co-authored with Fred Kiefer in 1999)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import <Foundation/CPAttributedString.j>
@import "CPParagraphStyle.j"
@import "CPColor.j"
@import "CPGraphics.j"
@import "CPFontManager.j"
@import "_CPTableTextAttachment.j"
@import "CPTextList.j"
@global CPForegroundColorAttributeName
@global CPBackgroundColorAttributeName
@global CPUnderlineStyleAttributeName
@global CPSuperscriptAttributeName
@global CPBaselineOffsetAttributeName
@global CPAttachmentAttributeName
@global CPLigatureAttributeName
@global CPKernAttributeName
@global CPLeftTextAlignment
@global CPRightTextAlignment
@global CPCenterTextAlignment
@global CPJustifiedTextAlignment
@global CPNaturalTextAlignment
@global CPLeftTabStopType
@global CPRightTabStopType
@global CPCenterTabStopType
@global CPDecimalTabStopType
var PAPERSIZE = @"PaperSize",
LEFTMARGIN = @"LeftMargin",
RIGHTMARGIN = @"RightMargin",
TOPMARGIN = @"TopMargin",
BUTTOMMARGIN = @"ButtomMargin";
function _points2twips(a) { return (a) * 20.0; }
/*
Escapes text for RTF. Backslashes and braces are escaped, and every character outside ASCII
is written as a Unicode control word, \uN, with no fallback characters (\uc0), as Cocoa
writes it. Values above 32767 are written as negative numbers, as RTF requires.
*/
var _CPRTFEscapeUnicode = function(aString)
{
return aString.replace(/[^\x00-\x7f]/g, function(aCharacter)
{
var code = aCharacter.charCodeAt(0);
return "\\uc0\\u" + (code > 32767 ? code - 65536 : code) + " ";
});
};
/*
Returns aNumber as two hexadecimal digits, for the \'hh escapes of a list level's text.
*/
var _CPRTFHexByte = function(aNumber)
{
return ("0" + (aNumber & 0xff).toString(16)).slice(-2);
};
/*
The RTF number format (\levelnfc) of each marker format name. Bullets are 23. Hexadecimal and
octal have no RTF number format, so other readers show them as decimal numbers; Cocoa reads
the marker format itself from \levelmarker.
*/
var _CPRTFLevelNumberFormats = {
"decimal": 0,
"upper-roman": 1,
"lower-roman": 2,
"upper-alpha": 3,
"upper-latin": 3,
"lower-alpha": 4,
"lower-latin": 4
};
@implementation _CPRTFProducer : CPObject
{
CPAttributedString text;
CPMutableDictionary fontDict;
CPMutableDictionary colorDict;
CPDictionary docDict;
CPMutableArray attachments;
CPFont currentFont;
CPColor fgColor;
CPColor bgColor;
CPColor ulColor;
// The outermost text lists in the text, each written as one RTF list, and for each, the text
// list of every level, as first met.
CPArray textLists;
CPArray textListLevels;
}
// MARK: -
// MARK: Class methods
+ (CPString)produceRTF:(CPAttributedString)aText documentAttributes:(CPDictionary)dict
{
var mynew = [self new];
return [mynew RTFDStringFromAttributedString:aText documentAttributes:dict];
}
// MARK: -
// MARK: init methods
- (id)init
{
if (self = [super init])
{
colorDict = [CPMutableDictionary new];
fontDict = [CPMutableDictionary new];
fgColor = [CPColor blackColor];
bgColor= [CPColor whiteColor];
textLists = [];
textListLevels = [];
}
return self;
}
- (CPString)fontTable
{
if (![fontDict count])
return @"";
var fontlistString = "",
fontEnum,
currFont,
keyArray;
keyArray = [fontDict allKeys];
keyArray = [keyArray sortedArrayUsingSelector:@selector(compare:)];
fontEnum = [keyArray objectEnumerator];
while ((currFont = [fontEnum nextObject]) != nil)
{
var fontFamily,
detail;
if ([currFont isEqualToString:@"Symbol"])
fontFamily = @"tech";
else if ([currFont isEqualToString:@"Helvetica"])
fontFamily = @"swiss";
else if ([currFont isEqualToString:@"Arial"])
fontFamily = @"swiss";
else if ([currFont isEqualToString:@"Courier"])
fontFamily = @"modern";
else if ([currFont isEqualToString:@"Times"])
fontFamily = @"roman";
else fontFamily = @"nil";
detail = [CPString stringWithFormat:@"%@\\f%@ %@;", [fontDict objectForKey:currFont], fontFamily, currFont];
fontlistString += detail;
}
return [CPString stringWithFormat:@"{\\fonttbl%@}\n", fontlistString];
}
- (CPString)colorTable
{
if (![colorDict count])
return @"";
var result,
count = [colorDict count],
list = [CPMutableArray arrayWithCapacity:count],
keyEnum = [colorDict keyEnumerator],
next,
i;
while ((next = [keyEnum nextObject]) != nil)
{
var cn = [colorDict objectForKey:next];
[list insertObject:[CPColor colorWithCSSString:next] atIndex:[cn intValue]-1];
}
result = [CPString stringWithString:@"{\\colortbl;"];
for (i = 0; i < count; i++)
{
var color = [[list objectAtIndex:i]
colorUsingColorSpaceName:CPCalibratedRGBColorSpace];
result += [CPString stringWithFormat:@"\\red%d\\green%d\\blue%d;",
([color redComponent] * 255),
([color greenComponent] * 255),
([color blueComponent] * 255)];
}
result += @"}\n";
return result;
}
- (CPString)documentAttributes
{
if (!docDict)
return @"";
var result,
detail,
val,
num;
result = [CPString string];
val = [docDict objectForKey:PAPERSIZE];
if (val)
{
detail = [CPString stringWithFormat:@"\\paperw%d \\paperh%d",
_points2twips(val.width),
_points2twips(val.height)];
result += detail;
}
num = [docDict objectForKey:LEFTMARGIN];
if (num)
{
var f = [num floatValue];
detail = [CPString stringWithFormat:@"\\margl%d", _points2twips(f)];
result += detail;
}
num = [docDict objectForKey:RIGHTMARGIN];
if (num)
{
var f = [num floatValue];
detail = [CPString stringWithFormat:@"\\margr%d", _points2twips(f)];
result += detail;
}
num = [docDict objectForKey:TOPMARGIN];
if (num)
{
var f = [num floatValue];
detail = [CPString stringWithFormat:@"\\margt%d", _points2twips(f)];
result += detail;
}
num = [docDict objectForKey:BUTTOMMARGIN];
if (num)
{
var f = [num floatValue];
detail = [CPString stringWithFormat:@"\\margb%d", _points2twips(f)];
result += detail;
}
return result;
}
- (CPString)headerString
{
var result;
result = [CPString stringWithString:@"{\\rtf1\\ansi"];
result += [self fontTable];
result += [self colorTable];
result += [self listTables];
result += [self documentAttributes];
return result;
}
- (CPString)trailerString
{
return @"}";
}
- (CPString)fontToken:(CPString) fontName
{
var fCount = [fontDict objectForKey:fontName];
if (fCount == nil)
{
var count = [fontDict count];
fCount = [CPString stringWithFormat:@"\\f%d", count];
[fontDict setObject:fCount forKey:fontName];
}
return fCount;
}
- (int)numberForColor:(CPColor)color
{
var num = [colorDict objectForKey:[color cssString]];
if (!num)
[colorDict setObject:num = [CPNumber numberWithInt:[colorDict count] + 1]
forKey:[color cssString]];
return [num intValue];
}
- (CPString)paragraphStyle:(CPParagraphStyle)paraStyle
{
var headerString = [CPString stringWithString:@"\\pard"],
twips;
if (paraStyle == nil)
return headerString;
switch ([paraStyle alignment])
{
case CPRightTextAlignment:
headerString += @"\\qr";
break;
case CPCenterTextAlignment:
headerString += @"\\qc";
break;
case CPLeftTextAlignment:
headerString += @"\\ql";
break;
case CPJustifiedTextAlignment:
headerString += @"\\qj";
break;
default:
headerString += @"\\ql";
break;
}
// In RTF the first line indent is an offset from the left indent.
var twips = _points2twips([paraStyle firstLineHeadIndent] - [paraStyle headIndent]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\fi%d", twips];
twips = _points2twips([paraStyle headIndent]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\li%d", twips];
twips = _points2twips([paraStyle tailIndent]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\ri%d", twips];
twips = _points2twips([paraStyle paragraphSpacing]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\sa%d", twips];
twips = _points2twips([paraStyle minimumLineHeight]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\sl%d", twips];
twips = _points2twips([paraStyle maximumLineHeight]);
if (twips != 0.0)
headerString += [CPString stringWithFormat:@"\\sl-%d", twips];
var enumerator,
tab;
enumerator = [[paraStyle tabStops] objectEnumerator];
while ((tab = [enumerator nextObject]))
{
var tabType = [tab respondsToSelector:@selector(tabStopType)] ? [tab tabStopType] : nil;
if (tabType === nil && [tab respondsToSelector:@selector(alignment)])
tabType = [tab alignment];
switch (tabType)
{
case CPLeftTabStopType:
case CPLeftTextAlignment:
break;
case CPRightTabStopType:
case CPRightTextAlignment:
headerString += @"\\tqr";
break;
case CPCenterTabStopType:
case CPCenterTextAlignment:
headerString += @"\\tqc";
break;
case CPDecimalTabStopType:
headerString += @"\\tqdec";
break;
}
headerString += [CPString stringWithFormat:@"\\tx%d",_points2twips([tab location])];
}
// A list item names its RTF list and its level.
var lists = [paraStyle textLists];
if ([lists count])
headerString += [CPString stringWithFormat:@"\\ls%d\\ilvl%d", [self listNumberForTextLists:lists], [lists count] - 1];
return headerString;
}
// MARK: -
// MARK: Text lists
/*
Returns the number of the RTF list for a paragraph's text lists, and records the list of each
level. Each outermost text list becomes one RTF list, with one level for each nesting depth.
*/
- (int)listNumberForTextLists:(CPArray)someLists
{
var index = [textLists indexOfObjectIdenticalTo:someLists[0]];
if (index === CPNotFound)
{
index = textLists.length;
textLists.push(someLists[0]);
textListLevels.push([]);
}
var levels = textListLevels[index];
for (var level = 0; level < someLists.length; level++)
if (!levels[level])
levels[level] = someLists[level];
return index + 1;
}
/*
Returns the list table and the list override table for the text lists in the text. Each RTF
list is a hybrid list with one level for each nesting depth, and one override, \lsN, that
paragraphs refer to.
*/
- (CPString)listTables
{
if (!textLists.length)
return @"";
var listTable = @"{\\*\\listtable",
overrideTable = @"{\\*\\listoverridetable";
for (var i = 0; i < textLists.length; i++)
{
var listNumber = i + 1,
levels = textListLevels[i];
listTable += "{\\list\\listtemplateid" + listNumber + "\\listhybrid";
for (var level = 0; level < levels.length; level++)
listTable += [self listLevelString:levels atLevel:level templateID:listNumber * 100 + level + 1];
listTable += "{\\listname ;}\\listid" + listNumber + "}";
overrideTable += "{\\listoverride\\listid" + listNumber + "\\listoverridecount0\\ls" + listNumber + "}";
}
return listTable + "}\n" + overrideTable + "}\n";
}
/*
Returns the RTF definition of one list level. Cocoa reads the marker format from the
\levelmarker destination and the prepending of the enclosing marker from \levelprepend. Other
readers use the number format (\levelnfc), the starting number (\levelstartat) and the
level's text (\leveltext), in which \'0N stands for the number of level N.
*/
- (CPString)listLevelString:(CPArray)someLevels atLevel:(int)aLevel templateID:(int)aTemplateID
{
var list = someLevels[aLevel],
format = [list markerFormat],
match = format.match(/\{([a-z-]+)\}/),
name = match ? match[1] : "",
numberFormat = [list isOrdered] ? (_CPRTFLevelNumberFormats[name] || 0) : 23,
parts = [self levelTextParts:someLevels atLevel:aLevel],
levelText = "",
levelNumbers = "",
length = 0,
indent = 720 * (aLevel + 1);
for (var i = 0; i < parts.length; i++)
{
var part = parts[i];
if (typeof part === "number")
{
length++;
levelText += "\\'" + _CPRTFHexByte(part);
levelNumbers += "\\'" + _CPRTFHexByte(length);
}
else
{
length += part.length;
levelText += _CPRTFEscapeUnicode(part.replace(/[\\{};]/g, function(aCharacter)
{
return "\\'" + _CPRTFHexByte(aCharacter.charCodeAt(0));
}));
}
}
return "{\\listlevel\\levelnfc" + numberFormat + "\\levelnfcn" + numberFormat +
"\\leveljc0\\leveljcn0\\levelfollow0\\levelstartat" + [list startingItemNumber] +
"\\levelspace360\\levelindent0{\\*\\levelmarker " + _CPRTFEscapeUnicode(format.replace(/([\\{}])/g, "\\$1")) + "}" +
(([list listOptions] & CPTextListPrependEnclosingMarker) ? "\\levelprepend" : "") +
"{\\leveltext\\leveltemplateid" + aTemplateID + "\\'" + _CPRTFHexByte(length) + levelText + ";}" +
"{\\levelnumbers" + levelNumbers + ";}\\fi-720\\li" + indent + "\\lin" + indent + " }";
}
/*
Returns the parts of a list level's text: strings, and the level numbers whose item numbers
take their place. A bullet level is its bullet. A numbered level is the text around its number,
after the enclosing level's text when the list prepends the enclosing marker.
*/
- (CPArray)levelTextParts:(CPArray)someLevels atLevel:(int)aLevel
{
var list = someLevels[aLevel],
format = [list markerFormat];
if (![list isOrdered])
return [[list markerForItemNumber:1]];
var match = format.match(/\{[a-z-]+\}/),
parts = [];
if (aLevel > 0 && ([list listOptions] & CPTextListPrependEnclosingMarker))
parts = [self levelTextParts:someLevels atLevel:aLevel - 1];
if (match.index > 0)
parts.push(format.substring(0, match.index));
parts.push(aLevel);
if (match.index + match[0].length < format.length)
parts.push(format.substring(match.index + match[0].length));
return parts;
}
- (CPString)runStringForString:(CPString) substring
attributes:(CPDictionary) attributes
paragraphStart:(BOOL) first
{
return [self runStringForString:substring attributes:attributes paragraphStart:first listMarker:NO];
}
/*
As -runStringForString:attributes:paragraphStart:. When isListMarker is YES, the run is the
marker at the start of a list item, and is written in a \listtext group, as Cocoa writes it.
Readers that understand lists number the items themselves; others show the marker text.
*/
- (CPString)runStringForString:(CPString) substring
attributes:(CPDictionary) attributes
paragraphStart:(BOOL) first
listMarker:(BOOL) isListMarker
{
var unwrap = function(obj) {
if (!obj) return null;
if ((typeof obj.respondsToSelector === "function" && ([obj respondsToSelector:@selector(headers)] || [obj respondsToSelector:@selector(rows)])) ||
obj.headers || obj._headers || obj.rows || obj._rows) {
return obj;
}
var unwrapped = null;
if (typeof obj.respondsToSelector === "function") {
if ([obj respondsToSelector:@selector(attachmentCell)]) {
unwrapped = [obj attachmentCell];
} else if ([obj respondsToSelector:@selector(content)]) {
unwrapped = [obj content];
} else if ([obj respondsToSelector:@selector(view)]) {
unwrapped = [obj view];
}
}
if (!unwrapped) {
unwrapped = obj._attachmentCell || obj._content || obj._view || obj.attachmentCell || obj.content || obj.view;
}
return unwrapped ? unwrapped : obj;
};
var tableAttachment = null;
if (typeof CPAttachmentAttributeName !== "undefined") {
tableAttachment = [attributes objectForKey:CPAttachmentAttributeName];
}
if (!tableAttachment) {
tableAttachment = [attributes objectForKey:@"CPAttachmentAttributeName"];
}
if (!tableAttachment) {
tableAttachment = [attributes objectForKey:@"TableAttachmentAttribute"];
}
if (!tableAttachment) {
tableAttachment = [attributes objectForKey:@"_CPAttachmentView"];
}
if (!tableAttachment && typeof _CPAttachmentView !== "undefined") {
tableAttachment = [attributes objectForKey:_CPAttachmentView];
}
tableAttachment = unwrap(tableAttachment);
if (!tableAttachment && (substring === "\uFFFC" || substring === ""))
{
var keys = [attributes allKeys],
count = [keys count];
for (var i = 0; i < count; i++)
{
var key = [keys objectAtIndex:i],
val = unwrap([attributes objectForKey:key]);
if (val && (
(typeof val.respondsToSelector === "function" && [val respondsToSelector:@selector(headers)]) ||
val._headers ||
val.headers ||
(typeof _CPTableTextAttachment !== "undefined" && [val isKindOfClass:[_CPTableTextAttachment class]])
)) {
tableAttachment = val;
break;
}
}
}
if (tableAttachment)
{
var headers = null,
rows = null;
// Try to fetch from the active live view of the attachment first to capture user edits
var activeView = null;
if (typeof tableAttachment.respondsToSelector === "function" && [tableAttachment respondsToSelector:@selector(view)]) {
activeView = [tableAttachment view];
}
if (!activeView) {
activeView = tableAttachment._view || tableAttachment.view;
}
if (activeView) {
if (typeof activeView.respondsToSelector === "function") {
if ([activeView respondsToSelector:@selector(headers)]) {
headers = [activeView headers];
}
if ([activeView respondsToSelector:@selector(rows)]) {
rows = [activeView rows];
}
}
if (!headers) {
headers = activeView._headers || activeView.headers;
}
if (!rows) {
rows = activeView._rows || activeView.rows;
}
}
// Fall back to the attachment's parsed properties if the view is nil or lacks the properties
if (!headers || !rows) {
if (typeof tableAttachment.respondsToSelector === "function") {
if ([tableAttachment respondsToSelector:@selector(headers)]) {
headers = [tableAttachment headers];
}
if ([tableAttachment respondsToSelector:@selector(rows)]) {
rows = [tableAttachment rows];
}
}
if (!headers) {
headers = tableAttachment._headers || tableAttachment.headers;
}
if (!rows) {
rows = tableAttachment._rows || tableAttachment.rows;
}
}
var getCount = function(arr) {
if (!arr) return 0;
if (typeof arr.count === "function") return [arr count];
return arr.length;
};
var getObjectAtIndex = function(arr, idx) {
if (!arr) return null;
if (typeof arr.objectAtIndex === "function") return [arr objectAtIndex:idx];
return arr[idx];
};
var numCols = getCount(headers);
if (numCols == 0 && getCount(rows) > 0) {
numCols = getCount(getObjectAtIndex(rows, 0));
}
if (numCols > 0)
{
var totalWidthTwips = 10000;
var colWidthTwips = Math.floor(totalWidthTwips / numCols);
var cellBoundaries = [];
var currentBoundary = 0;
for (var i = 0; i < numCols; i++)
{
currentBoundary += colWidthTwips;
cellBoundaries.push(currentBoundary);
}
var tableRTF = "";
var writeRow = function(rowData, isHeaderRow) {
var rowRTF = "\\trowd\\trgaph115\\trleft0";
for (var c = 0; c < numCols; c++) {
rowRTF += "\\clbrdrt\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10\\clbrdrl\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10";
rowRTF += "\\cellx" + cellBoundaries[c];
}
for (var c = 0; c < numCols; c++) {
var cellText = "";
if (c < getCount(rowData)) {
cellText = getObjectAtIndex(rowData, c);
}
if (cellText === null || cellText === undefined) {
cellText = "";
}
// Safely extract text representation from CPAttributedString / CPTextStorage if present
if (cellText && typeof cellText === "object") {
if (typeof cellText.string === "function") {
cellText = [cellText string];
} else if (cellText._string !== undefined) {
cellText = cellText._string;
} else if (cellText.string !== undefined) {
cellText = cellText.string;
}
}
cellText = String(cellText);
cellText = cellText.replace(/\\/g, '\\\\');
cellText = cellText.replace(/{/g, '\\{');
cellText = cellText.replace(/}/g, '\\}');
cellText = cellText.replace(/\n/g, '\\line ');
if (isHeaderRow) {
rowRTF += "{\\intbl\\b " + cellText + "\\b0\\cell}";
} else {
rowRTF += "{\\intbl " + cellText + "\\cell}";
}
}
rowRTF += "\\row\n";
return rowRTF;
};
if (getCount(headers) > 0) {
tableRTF += writeRow(headers, YES);
}
var rowCount = getCount(rows);
for (var r = 0; r < rowCount; r++) {
tableRTF += writeRow(getObjectAtIndex(rows, r), NO);
}
return tableRTF;
}
}
var result = "",
headerString = "",
trailerString = "",
attribEnum,
currAttrib;
if (first)
{
var paraStyle = [attributes objectForKey:CPParagraphStyleAttributeName];
headerString += [self paragraphStyle:paraStyle];
}
attribEnum = [attributes keyEnumerator];
while ((currAttrib = [attribEnum nextObject]) != nil)
{
if ([currAttrib isEqualToString:CPFontAttributeName])
{
var font,
fontName,
traits;
font = [attributes objectForKey:CPFontAttributeName];
fontName = [font familyName];
traits = [[CPFontManager sharedFontManager] traitsOfFont:font];
if (currentFont == nil || ![fontName isEqualToString:[currentFont familyName]])
headerString += [self fontToken:fontName];
if (currentFont == nil || [font size] != [currentFont size])
{
var points = [font size] * 2,
pString;
pString = [CPString stringWithFormat:@"\\fs%d", points];
headerString += pString;
}
if (traits & CPItalicFontMask)
{
headerString += @"\\i";
trailerString += @"\\i0";
}
if (traits & CPBoldFontMask)
{
headerString += @"\\b";
trailerString += @"\\b0";
}
if (first)
currentFont = font;
}
else if ([currAttrib isEqualToString:CPForegroundColorAttributeName])
{
var color = [attributes objectForKey:CPForegroundColorAttributeName];
if (![color isEqual:fgColor])
{
headerString += [CPString stringWithFormat:@"\\cf%d", [self numberForColor:color]];
trailerString += @"\\cf0";
}
}
else if ([currAttrib isEqualToString:CPBackgroundColorAttributeName])
{
var color = [attributes objectForKey:CPBackgroundColorAttributeName];
if (![color isEqual:bgColor])
{
headerString += [CPString stringWithFormat:@"\\cb%d", [self numberForColor:color]];
trailerString += @"\\cb0";
}
}
else if ([currAttrib isEqualToString:CPUnderlineStyleAttributeName])
{
headerString += @"\\ul";
trailerString += @"\\ulnone "; // trailing space important!
}
else if ([currAttrib isEqualToString:CPSuperscriptAttributeName])
{
var value = [attributes objectForKey:CPSuperscriptAttributeName],
ivalue = [value intValue];
if (ivalue > 0)
{
headerString += @"\\super";
trailerString += @"\\nosupersub "; // trailing space important!
}
else if (ivalue < 0)
{
headerString += @"\\sub";
trailerString += @"\\nosupersub "; // trailing space important!
}
}
else if ([currAttrib isEqualToString:CPBaselineOffsetAttributeName])
{
var value = [attributes objectForKey:CPBaselineOffsetAttributeName],
fvalue = [value floatValue],
svalue = Math.round(fvalue * 2.0); // Convert standard points to RTF half-points
if (svalue > 0)
{
headerString += [CPString stringWithFormat:@"\\up%d", svalue];
trailerString += @"\\up0";
}
else if (svalue < 0)
{
// Correct negative formatting using safe positive boundary
headerString += [CPString stringWithFormat:@"\\dn%d", Math.abs(svalue)];
trailerString += @"\\dn0";
}
}
else if ([currAttrib isEqualToString:CPAttachmentAttributeName])
{
}
else if ([currAttrib isEqualToString:CPLigatureAttributeName])
{
}
else if ([currAttrib isEqualToString:CPKernAttributeName])
{
}
}
// An image attachment is not written yet, so its attachment character is left out.
substring = substring.replace(/\uFFFC/g, '');
substring = substring.replace(/\\/g, '\\\\');
substring = substring.replace(/\n/g, '\\par\n');
substring = substring.replace(/\t/g, '\\tab ');
substring = substring.replace(/{/g, '\\{');
substring = substring.replace(/}/g, '\\}');
substring = _CPRTFEscapeUnicode(substring);
if (isListMarker)
substring = "{\\listtext " + substring + "}";
if (!first)
{
var braces;
if ([headerString length])
braces = [CPString stringWithFormat:@"{%@ %@}", headerString, substring];
else
braces = substring;
result += braces;
}
else
{
var nobraces;
if ([headerString length])
nobraces = [CPString stringWithFormat:@"%@ %@", headerString, substring];
else
nobraces = substring;
result += nobraces;
}
return result + trailerString;
}
- (CPString)bodyString
{
var string = [text string],
result = "",
loc = 0,
length = [string length],
currRange = CPMakeRange(loc, 0),
completeRange = CPMakeRange(0, length),
paragraphStart = YES;
while (CPMaxRange(currRange) < CPMaxRange(completeRange))
{
var attributes,
substring,
runString;
var runStart = CPMaxRange(currRange);
attributes = [text attributesAtIndex:runStart
longestEffectiveRange:currRange
inRange:completeRange];
// The longest effective range can reach back before the run's start, after a list marker
// that shares its attributes was written as a run of its own.
if (currRange.location < runStart)
currRange = CPMakeRange(runStart, CPMaxRange(currRange) - runStart);
// A list item's marker, a tab, the marker and a tab, is written as a run of its own.
var markerLength = paragraphStart ? [self listMarkerLengthAtIndex:currRange.location attributes:attributes] : 0,
isListMarker = markerLength > 0 && markerLength <= currRange.length;
if (isListMarker)
currRange.length = markerLength;
substring = [string substringWithRange:currRange];
runString = [self runStringForString:substring
attributes:attributes
paragraphStart:paragraphStart
listMarker:isListMarker];
result += runString;
if (substring.length > 0 && substring.charAt(substring.length - 1) === '\n')
paragraphStart = YES;
else
paragraphStart = NO;
}
return result;
}
/*
Returns the length of the marker at the start of a list item, a tab, the marker and a tab, or
0 when the paragraph is not a list item or does not start with a marker.
*/
- (unsigned)listMarkerLengthAtIndex:(unsigned)anIndex attributes:(CPDictionary)attributes
{
var string = [text string];
if (![[[attributes objectForKey:CPParagraphStyleAttributeName] textLists] count] || string.charAt(anIndex) !== "\t")
return 0;
for (var i = anIndex + 1; i < string.length; i++)
{
var character = string.charAt(i);
if (character === "\t")
return i - anIndex + 1;
if (character === "\n")
break;
}
return 0;
}
- (CPString)RTFDStringFromAttributedString:(CPAttributedString)aText
documentAttributes:(CPDictionary)dict
{
var output = [CPString string],
headerString,
trailerString,
bodyString;
text = aText;
docDict = dict;
bodyString = [self bodyString];
trailerString = [self trailerString];
headerString = [self headerString];
output += headerString;
output += bodyString;
output += trailerString;
return output;
}
@end