Read and write RTF files

You can read RTF files into attributed strings, and vice-versa, in a couple of ways. Notice that although NSAttributedString belongs to Foundation framework, the methods for dealing with RTF data are defined in AppKit framework, so you need the appropriate use statement.

First, the code for reading. Here we read the file as raw data, and create an attributed string from that.

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use framework "AppKit" -- needed for used rtf methods

-- classes, constants, and enums used
property NSData : a reference to current application's NSData
property NSAttributedString : a reference to current application's NSAttributedString
property NSDictionary : a reference to current application's NSDictionary
property NSString : a reference to current application's NSString
property NSRTFTextDocumentType : a reference to current application's NSRTFTextDocumentType

set posixPath to POSIX path of (choose file with prompt "Choose an RTF file" of type {"rtf"})
-- read file as RTF data
set theData to NSData's dataWithContentsOfFile:posixPath
-- create attributed string from the data
set {theStyledString, docAttributes} to NSAttributedString's alloc()'s initWithRTF:theData documentAttributes:(reference)
if theStyledString is missing value then error "Could not read RTF file"

The variable docAttributes contains extra information about the document that can be used to create a new file, but itā€™s optional.

Letā€™s change the string by inserting a line at the beginning:

-- make copy you can modify
set theStyledString to theStyledString's mutableCopy()
-- insert text at beginning
theStyledString's replaceCharactersInRange:{0, 0} withString:("Extra text" & linefeed)

To save the modified attributed string you need to create RTF data from it, and then write the data to a file as you would any other data:

--  first turn attributed string into RTF data
set theData to theStyledString's RTFFromRange:{0, theStyledString's |length|()} documentAttributes:docAttributes
-- build path for new file
set posixPath to NSString's stringWithString:posixPath
set newPath to (posixPath's stringByDeletingPathExtension()'s stringByAppendingString:"-copy")'s stringByAppendingPathExtension:(posixPath's pathExtension())
-- write the data to the file
theData's writeToFile:newPath atomically:true

If you are creating a file from scratch, you wonā€™t have the docAttributes value. You can create it like this:

set docAttributes to {DocumentType:NSRTFTextDocumentType}
5 Likes

Mark (@alldritt), many thanks for these how-to articles you have been posting. I find them very helpful. :+1:

2 Likes

Mark, thanks again for this great example.

It works fine as is, but when I try to a changing the text (attributed string) using RegEx it gets an error. What am I doing wrong?

### THIS WORKS ###
-- insert text at beginning
theAttString's replaceCharactersInRange:{0, 0} withString:("Extra text" & linefeed)

### THIS FAILS ###
--- RegEx Example of Change ---
set theAttString to (theAttString's stringByReplacingOccurrencesOfString:"Line (\\d+)" withString:"Bullet $1" options:(current application's NSRegularExpressionSearch) range:{0, theAttString's |length|()})

-->-[NSConcreteMutableAttributedString stringByReplacingOccurrencesOfString:withString:options:range:]: unrecognized selector sent to instance 0x618000a219a0

@alldritt and @ShaneStanley,

I made some progress. The RegEx change is working, but now I donā€™t know how to convert the nsPlainText back to RTF for the Clipboard. Can you please help with this?

Hereā€™s my complete test script, all based on scripts you guys have been so kind to share:

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use framework "AppKit"
use scripting additions

-- classes, constants, and enums used
property NSData : a reference to current application's NSData
property NSAttributedString : a reference to current application's NSAttributedString
property NSDictionary : a reference to current application's NSDictionary
property NSString : a reference to current application's NSString
property NSRTFTextDocumentType : a reference to current application's NSRTFTextDocumentType
property NSPasteboardTypeRTF : a reference to current application's NSPasteboardTypeRTF
property nsCurApp : a reference to current application


set reFind to "Line (\\d+)"
set reReplace to "Bullet $1"


-----------------------------
-- GET RTF FROM CLIPBOARD --
-----------------------------

set pb to current application's NSPasteboard's generalPasteboard() -- get pasteboard
set theData to pb's dataForType:(current application's NSPasteboardTypeRTF) -- get rtf data off pasteboard
if theData = missing value then error "No rtf data found on clipboard"

-- make into attributed string
--- set theAttString to current application's NSAttributedString's alloc()'s initWithRTF:theData documentAttributes:(missing value)

### IF You Want to Get RTF from File Instead ###
(*
set posixPath to POSIX path of (choose file with prompt "Choose an RTF file" of type {"rtf"})
-- read file as RTF data
set theData to NSData's dataWithContentsOfFile:posixPath
*)


-- create attributed string from the data
set {theAttString, docAttributes} to NSAttributedString's alloc()'s initWithRTF:theData documentAttributes:(reference)


(*
--- Decode Rich Text ---
  set nsRichText to current application's NSMutableAttributedString's alloc()'s initWithRTFD:nsRichTextEncoded documentAttributes:(missing value)
*)



-- make copy you can modify
set theAttString to theAttString's mutableCopy()

### THIS WORKS ###
-- insert text at beginning
theAttString's replaceCharactersInRange:{0, 0} withString:("Extra text" & linefeed)

### THIS FAILS ###
(*
--- RegEx Example of Change ---
set theAttString to (theAttString's stringByReplacingOccurrencesOfString:reFind withString:reReplace options:(current application's NSRegularExpressionSearch) range:{0, theAttString's |length|()})

-->-[NSConcreteMutableAttributedString stringByReplacingOccurrencesOfString:withString:options:range:]: unrecognized selector sent to instance 0x618000a219a0

*)
### THIS WORKS ###
--- GET PLAIN TEXT from Rich Text ---
set nsPlainText to theAttString's |string|()
set nsPlainText to (nsPlainText's stringByReplacingOccurrencesOfString:reFind withString:reReplace options:(current application's NSRegularExpressionSearch) range:{0, nsPlainText's |length|()})


### AFTER CHANGES, Re-CREATE the RTF DATA ###
## But HOW??? ##
--- ALL of the below get an error ---

----------------------------------
-- convert back to RTFD data
----------------------------------

(*
set nsRichTextEncoded to nsRichText's RTFDFromRange:{0, nsRichText's |length|()} documentAttributes:(missing value)

*)


set rtfData to theAttString's RTFFromRange:{0, nsPlainText's |length|()} documentAttributes:(missing value)

--  first turn attributed string into RTF data
--set rtfData to nsPlainText's RTFFromRange:{0, nsPlainText's |length|()} documentAttributes:docAttributes
--set rtfData to theAttString's RTFFromRange:{0, theAttString's |length|()} documentAttributes:{DocumentType:NSRTFTextDocumentType}



pb's clearContents()
pb's setData:rtfData forType:NSPasteboardTypeRTF

return

As youā€™ve found, you canā€™t call NSString methods on NSAttributedStrings. Converting a string back to RTF is also likely to be problematic. What you have to do is use the NSString for searching, and then do the replacing to the NSAttributedString. And the key to doing that is using ranges.

So in the simplest case, where you know there will only be one found instance and the replacement has no back references, you can do this:

set nsPlainText to theAttString's |string|()
set theRange to (nsPlainText's rangeOfString:reFind options:NSRegularExpressionSearch)
theAttString's replaceCharactersInRange:theRange withString:reReplace

If there might be multiple instances, you can do this:

set {theRegex, theError} to current application's NSRegularExpression's regularExpressionWithPattern:reFind options:0 |error|:(reference)
if theRegex = missing value then error theError's localizedDescription() as text
set nsPlainText to theAttString's |string|()
set theMatches to (theRegex's matchesInString:nsPlainText options:0 range:{0, nsPlainText's |length|()}) as list
set theMatches to reverse of theMatches -- so you work from back to front to keep ranges accurate
repeat with aMatch in theMatches
	(theAttString's replaceCharactersInRange:(aMatch's range()) withString:reReplace)
end repeat

But to cover all bases including capture groups, you need to go a step further:

set {theRegex, theError} to current application's NSRegularExpression's regularExpressionWithPattern:reFind options:0 |error|:(reference)
if theRegex = missing value then error theError's localizedDescription() as text
set nsPlainText to theAttString's |string|()
set theMatches to (theRegex's matchesInString:nsPlainText options:0 range:{0, nsPlainText's |length|()}) as list
set theMatches to reverse of theMatches -- so you work from back to front to keep ranges accurate
repeat with aMatch in theMatches
	set newString to (theRegex's replacementStringForResult:aMatch inString:nsPlainText |offset|:0 template:reReplace)
	(theAttString's replaceCharactersInRange:(aMatch's range()) withString:newString)
end repeat
2 Likes

Many thanks again, Shane. That was perfect! :+1:

Iā€™ll cleanup and test my script, and then post the final here.

Shane, thanks again for all your great help. Could not have done this without you, or without Markā€™s (@alldritt) great script.

Hereā€™s my ā€œfinalā€ script, which provides options for input/output to/from both Clipboard and File (set within the script).

I have no doubt that my script can be further optimized and improved. So, as always, I welcome any/all to feel free to post any comments, issues, suggestions, and/or improved script.

Mark, my apologies if you feel like this has hijacked your thread. Please feel free to move all posts concerning it to a new topic if you feel that is best.


property ptyScriptName : "Change Rich Text RTF Retain Format"
property ptyScriptVer : "1.3" --  ADD Options for Input/Output
property ptyScriptDate : "2018-04-29"
property ptyScriptAuthor : "JMichaelTX" -- heavy lifting by @ShaneStanley & Mark @alldritt
(*
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
PURPOSE:
  ā€¢ Read RTF Object from Clipboard OR File, Change text using RegEx (but retain format),
  
RETURNS:  Output to Clipboard OR File with RTF

REQUIRED:
  1.  macOS 10.11.6+
  2.  Mac Applications
      ā€¢ TextEdit (if file output is used)
      
TAGS:  @SW.KM @Lang.ASObjC @Lang.AS @CAT.Actions @CAT.Decode @Auth.Shane @CAT.RTF @Auth.JMichaelTX

REF:  The following were used in some way in the writing of this script.
  I wrote this script, and all errors are mine.  It was based on large part on:

  1.  2017-10-03, ShaneStanley, Late Night Software Ltd.
      How Do I base64 Decode and Encode Multiple Lines?
      http://forum.latenightsw.com/t/how-do-i-base64-decode-and-encode-multiple-lines/759/11
  
  2.  2018-03-22, alldritt, Late Night Software Ltd.
      Read and write RTF files
      http://forum.latenightsw.com/t/read-and-write-rtf-files/1200

  3.  2018-04-28, ShaneStanley, Late Night Software Ltd.
      Read and write RTF files
      http://forum.latenightsw.com/t/read-and-write-rtf-files/1200/5


~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*)
use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use framework "AppKit"
use scripting additions

property LF : linefeed

-- classes, constants, and enums used
property NSData : a reference to current application's NSData
property NSAttributedString : a reference to current application's NSAttributedString
property NSDictionary : a reference to current application's NSDictionary
property NSString : a reference to current application's NSString
property NSRTFTextDocumentType : a reference to current application's NSRTFTextDocumentType
property NSPasteboardTypeRTF : a reference to current application's NSPasteboardTypeRTF
property nsCurApp : a reference to current application
----------------------------------------------------------------------------

try --~~~~~~~~~~~~~~~~~~~~~~ TRY ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  
  ### SCRIPT USER INPUT DATA ###
  
  set rtfInputSource to "File" -- "Clipboard" OR "File"
  set rtfOutputDest to "File" -- "Clipboard" OR "File"
  
  --- Default Name if File Output is Chosen ---
  --  (Input File Name will be used if Input is File)
  set rtfOutputFileName to "RTF Output File.rtf"
  
  --- SET RegEx Find and Replace Patterns ---
  set reFind to "Line (\\d+)"
  set reReplace to "Bullet $1"
  
  ### end user input data ###
  
  if (rtfInputSource = "Clipboard") then
    -----------------------------
    -- GET RTF FROM CLIPBOARD --
    -----------------------------
    
    set pb to current application's NSPasteboard's generalPasteboard() -- get pasteboard
    set rtfNSData to pb's dataForType:(current application's NSPasteboardTypeRTF) -- get rtf data off pasteboard
    if rtfNSData = missing value then error "No rtf data found on clipboard"
    
  else
    -----------------------------
    -- GET RTF FROM FILE --
    -----------------------------
    
    set rtfInputFilePath to POSIX path of (choose file with prompt "Choose an RTF file" of type {"rtf"})
    --- Set Output Name to Input Name ---
    tell (info for rtfInputFilePath) to set rtfOutputFileName to its name
    
    -- READ FILE AS RTF DATA --
    set rtfNSData to NSData's dataWithContentsOfFile:rtfInputFilePath
  end if
  
  -- CREATE ATTRIBUTED STRING FROM THE DATA --
  set {rtfAttString, docAttributes} to NSAttributedString's alloc()'s initWithRTF:rtfNSData documentAttributes:(reference)
  
  -- MAKE COPY YOU CAN MODIFY --
  set rtfAttString to rtfAttString's mutableCopy()
  
  --- INSERT TEXT AT BEGINNING (example) ---
  ###    rtfAttString's replaceCharactersInRange:{0, 0} withString:("Extra text" & linefeed)
  
  --- GET PLAIN TEXT from Rich Text ---
  set nsPlainText to rtfAttString's |string|()
  
  --- CREATE NS REGEX OBJECT ---
  set {nsRegEx, theError} to current application's NSRegularExpression's regularExpressionWithPattern:reFind options:0 |error|:(reference)
  if nsRegEx = missing value then error theError's localizedDescription() as text
  
  --- GET LIST OF MATCHES IN PLAIN TEXT ---
  set nsReMatchList to (nsRegEx's matchesInString:nsPlainText options:0 range:{0, nsPlainText's |length|()}) as list
  set nsReMatchList to reverse of nsReMatchList -- so you work from back to front to keep ranges accurate
  
  --- UPDATE RTF ATTRIBUTED STRING FOR EACH MATCH ---
  set numMatches to count of nsReMatchList
  
  repeat with nsReMatch in nsReMatchList
    set nsChangedStr to (nsRegEx's replacementStringForResult:nsReMatch inString:nsPlainText |offset|:0 template:reReplace)
    (rtfAttString's replaceCharactersInRange:(nsReMatch's range()) withString:nsChangedStr)
  end repeat
  
  ----------------------------------
  -- CONVERT BACK TO RTF DATA --
  ----------------------------------
  
  set rtfData to rtfAttString's RTFFromRange:{0, nsPlainText's |length|()} documentAttributes:(docAttributes)
  
  if (rtfOutputDest = "Clipboard") then
    --- SET Clipboard (PasteBoard) ---
    
    pb's clearContents()
    pb's setData:rtfData forType:NSPasteboardTypeRTF
    
  else --- OUTPUT RESULTS TO RTF FILE ---
    
    set rtfOutputPath to POSIX path of (choose file name with prompt "Choose RTF Output File Name" default name rtfOutputFileName)
    rtfData's writeToFile:rtfOutputPath atomically:true
    
    tell application "TextEdit"
      activate
      open rtfOutputPath
    end tell
    
  end if
  
  --- RETURN Summary of Results ---
  
  if (rtfInputSource = "File") then set rtfInputSource to rtfInputSource & ": " & rtfInputFilePath
  if (rtfOutputDest = "File") then set rtfOutputDest to rtfOutputDest & ": " & rtfOutputPath
  
  set scriptResults to "Number of RegEx Matches Found & Changed: " & numMatches & LF & Ā¬
    "RegEx Find:    " & reFind & LF & Ā¬
    "RegEx Replace: " & reReplace & LF & Ā¬
    "Source: " & rtfInputSource & LF & Ā¬
    "Output: " & rtfOutputDest
  
  --~~~~~~~ END TRY ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
on error errMsg number errNum
  
  if errNum = -128 then ## User Canceled
    set errMsg to "[USER_CANCELED]"
  end if
  
  set scriptResults to "[ERROR]" & return & errMsg & return & return Ā¬
    & "SCRIPT: " & ptyScriptName & "   Ver: " & ptyScriptVer & return Ā¬
    & "Error Number: " & errNum
  
end try --~~~~~~~~~~~~~~~~~~~~ END TRY/ERROR ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

return scriptResults

--~~~~~~~~~~~~~~~~~~~ END OF MAIN SCRIPT ~~~~~~~~~~~~~~~~~~~~~~


Example source file

Test Source for RTF Script.rtf.zip (514 Bytes)

image

Iā€™m probably just being anal, but the mixing of current application as sometimes inline and sometimes in properties grates a bit. I suggest you compile, then choose Edit -> AppleScriptObjC -> Migrate to Properties.

1 Like

Thanks for pointing that out! Iā€™d never have thought to look when using a method belonging to a Foundation class!

The script commentā€™s a bit misleading, though ā€¦.

Itā€™s not common, but it does happen. My favorite example is NSArrayā€™s shuffledArray property ā€” itā€™s actually defined in GameplayKit.framework.

Fixed.

Thanks for pointing that out. It occurred due to merging of multiple scripts.

One question about the best practice of putting ASObjC enums etc in properties: Since script properties are visible only within the script itself, and not to handers in script libraries, I wonder whatā€™s the best way to handle this?

You basically need to define the properties in the scripts that use them, whether they be libraries or clients of libraries. So if youā€™re using an ASObjC property in both, you define it in both. Is that what youā€™re asking?

1 Like

Yes, thank you. Is there any downside, any performance hit, to defining a bunch of ASObjC properties in a script library where maybe they arenā€™t needed that much?

None that Iā€™m aware of. If anything, using properties can be a (tiny) performance boost.

Using this code, I was abele to read an rtf-file, make soms replacements of words and to paste in a mail. Thank you, very much!

Since, multiple words have to be replaced, I have made a function ā€˜Repackā€™. My function look like this:

on Replace(Tekst, Zoekwaarde, Vervangwaarde)
	set PlatteTekst to Tekst's |string|()
	--
	set {theRegex, theError} to current application's NSRegularExpression's regularExpressionWithPattern:Zoekwaarde options:0 |error|:(reference)
	if theRegex = missing value then error theError's localizedDescription() as text
	--
	set theMatches to (theRegex's matchesInString:PlatteTekst options:0 range:{0, PlatteTekst's |length|()}) as list
	set theMatches to reverse of theMatches -- so you work from back to front to keep ranges accurate
	repeat with aMatch in theMatches
		(Tekst's replaceCharactersInRange:(aMatch's range()) withString:Vervangwaarde)
	end repeat
end Replace

In the main code, I have:

	set cApp to current application
	set theFile to ((path to desktop folder as text) & "textB.rtf")
	set fileData to cApp's NSData's dataWithContentsOfFile:(POSIX path of theFile)
	set {attrstring, attributes} to cApp's NSMutableAttributedString's alloc()'s initWithRTF:fileData documentAttributes:(reference)
	tell cApp
		Replace(attrstring, "CONTRACTDATUM", ContractDatum)
		Replace(attrstring, "BWPLOMSCHRIJVING", BwplOmschrijving)
		Replace(attrstring, "BWHRNAAM", BwhrNaam)
	end tell

But, I always get the error: Itā€™s impossible to continue on ā€˜Replaceā€™. Can anyone help me with this error?
Thank in advance

Try removing the tell cApp.

Thank you, it worked. I would have sworn, that I tried it also this morning and that he didnā€™t do itā€¦odd

But, Iā€™m happyā€¦ Thank you!


Convert RTF data on the Clipboard to Plain Text.


I was needing to do this today and was able to cobble something together from this topic and some code I have from @ShaneStanley.

--------------------------------------------------------
# Auth: Christopher Stone <scriptmeister@thestoneforge.com>
# dCre: 2023/02/28 17:38
# dMod: 2023/02/28 17:38 
# Appl: AppleScriptObjC
# Task: Extract Plain Text from RTF Data on the Clipboard.
# Libs: None
# Osax: None
# Tags: @Applescript, @Script, @ASObjC, @Extract, @Plain, @Text, @RTF, @Clipboard
--------------------------------------------------------
use AppleScript version "2.4"
use framework "Foundation"
use framework "AppKit" -- Needed for used rtf methods
use scripting additions
--------------------------------------------------------
# Classes, Constants, and Enums
property NSData : a reference to current application's NSData
property NSAttributedString : a reference to current application's NSAttributedString
--------------------------------------------------------

# Get RTF Data from the Clipboard.
set clipboardRef to current application's NSPasteboard's generalPasteboard()
set rtfData to (clipboardRef's dataForType:"public.rtf")

# Create attributed string from the data.
set {theStyledString, docAttributes} to NSAttributedString's alloc()'s initWithRTF:rtfData documentAttributes:(reference)
if theStyledString is missing value then error "Could not read RTF from the Clipboard"

set plainText to theStyledString's |string|() as text

return plainText

--------------------------------------------------------

@ccstone

It can be done more easily like this:

use framework "Foundation"

set thePasteboard to current application's NSPasteboard's generalPasteboard()
set theArray to thePasteboard's readObjectsForClasses:{current application's class "NSString"} options:(missing value)
set theString to (theArray's componentsJoinedByString:return) as text

The last line is coercing multiple results you could have when copying several files in Finder, for example.

1 Like