internal/write-clipboard-rtf-windows.md

Putting RTF on the Windows clipboard

We need to put RTF on the clipboard for reprex(venue = "rtf"), a.k.a. reprex_rtf(). This appears to happen automagically on macOS, i.e. pbcopy detects the RTF file header and automatically writes RTF to the appropriate pasteboard. Alas, this doesn’t “just work” for us on Windows, because clipr calls utils::writeClipboard() and R does not even register the RTF format (although it could).

We have to shell out for this, which is a bit gross, but clipr shells out to, e.g., pbcopy and reprex already shells out in order to call highlight. This whole RTF feature is already a bit hacky, so I’m not fussed about adding another system() call.

At first, you think you can use the Set-Clipboard PowerShell cmdlet, but you can’t because it does not support writing rich text. Interesting observations:

We will have to exert ourselves a bit to write RTF to the Windows clipboard. We use PowerShell to access the .NET framework and, specifically, to work with classes and methods from the System.Windows.Forms namespace. It feels like the “right” way to do this would be to write a PowerShell script, but there are a couple of downsides. First, it’s not conventional to ship and execute a PowerShell script from within an R package. Second – the real dealkiller – is the need to deal with the PowerShell execution policy. Therefore we actually construct a rather ugly one-liner and execute it via system() or similar.

Generate some RTF

I'm turning this into a static .md, so that this file of notes doesn't need to be .Rmd, rendered on Windows, but could be edited from any OS. I'll store live code chunks elsewhere.

If you have the highlight command line utility installed and on the path and the dev version of reprex, this will work:

library(reprex)
reprex_rtf({x <- rnorm(10); mean(x)}, outfile = here::here("rnorm"))
#> Non-interactive session, setting `html_preview = FALSE`.
#> Preparing reprex as .R file:
#>   * C:/Users/jenny/Desktop/reprex/rnorm_reprex.R
#> Rendering reprex...
#> Writing reprex file:
#>   * C:/Users/jenny/Desktop/reprex/rnorm_reprex.rtf

Otherwise this code writes a minimal RTF file to work with:

# using literal string feature from R 4.0
minimal_rtf <- r"({\rtf1\ansi\deff0 {\fonttbl {\f0 Times New Roman;}} \f0\fs60 Hello, World!})"
writeLines(minimal_rtf, here::here("minimal.rtf"))

Hopefully we’ve got at least one RTF file now:

list.files(here::here(), pattern = "[.]rtf$")
#> [1] "minimal.rtf"      "rnorm_reprex.rtf"

The Solution

After much struggle, I finally reached this simple-but-hacky solution to write the contents of an RTF file onto the RTF part of the Windows clipboard.

write_clipboard_rtf <- function(path) {
  stopifnot(.Platform$OS.type == "windows")
  cmd <- glue::glue('
    powershell -Command "\\
    Add-Type -AssemblyName System.Windows.Forms | Out-Null;\\
    [Windows.Forms.Clipboard]::SetText(
    (Get-Content {path}),\\
    [Windows.Forms.TextDataFormat]::Rtf
    )"')
  res <- system(cmd)
  if (res > 0) {
    stop("Failed to put RTF on the Windows clipboard", call. = FALSE)
  }
  invisible(res)
}

The function above is essentially what we use in reprex.

Let’s create a function to expose the RTF part of the Windows clipboard:

read_clipboard_rtf <- function() {
  stopifnot(.Platform$OS.type == "windows")
  system(
    'powershell -Command "Get-Clipboard -TextFormatType Rtf"',
    # `intern = TRUE` is a concession to knitr and making sure we see what
    # `Get-Clipboard` returns in our rendered result
    # it would not be necessary if you execute this interactively
    intern = TRUE)
}

Finally, let’s show that we can put our RTF onto the Windows clipboard.

write_clipboard_rtf(here::here("rnorm_reprex.rtf"))
read_clipboard_rtf()
#> [1] "{\\rtf1\\ansi \\deff1{\\fonttbl{\\f1\\fmodern\\fprq1\\fcharset0 Courier Regular;}}{\\colortbl;\\red00\\green00\\blue00;\\red160\\green160\\blue192;\\red208\\green224\\blue128;\\red208\\green224\\blue128;\\red96\\green96\\blue128;\\red96\\green96\\blue128;\\red128\\green128\\blue128;\\red160\\green160\\blue192;\\red208\\green224\\blue128;\\red96\\green96\\blue128;\\red204\\green204\\blue204;\\red207\\green165\\blue219;\\red128\\green144\\blue240;\\red224\\green224\\blue255;\\red159\\green191\\blue175;} \\paperw11905\\paperh16837\\margl1134\\margr1134\\margt1134\\margb1134\\sectd\\plain\\f1\\fs100 \\pard \\cbpat1{{\\cf2{x}} {\\cf11{<-}} {\\cf2{}}{\\cf15{rnorm}}{\\cf2{}}{\\cf11{(}}{\\cf2{}}{\\cf4{{1}{0}}}{\\cf2{}}{\\cf11{);}} {\\cf2{}}{\\cf15{mean}}{\\cf2{}}{\\cf11{(}}{\\cf2{x}}{\\cf11{)}}}\\par\\pard \\cbpat1{{\\cf2{}}{\\cf5{#> [{1}] {0}.{6}{4}{2}{7}{7}{8}}}{\\cf2{}}}}"

write_clipboard_rtf(here::here("minimal.rtf"))
read_clipboard_rtf()
#> [1] "{\\rtf1\\ansi\\deff0 {\\fonttbl {\\f0 Times New Roman;}} \\f0\\fs60 Hello, World!}"

Clean up.

unlink(list.files(here::here(), pattern = "rnorm_reprex", full.names = TRUE))
unlink(here::here("minimal.rtf"))

The Struggle

I never want to re-learn all of this again from scratch, so here are some notes for the future.

A working script and Add-Type

My first goal was to get a working PowerShell script, even though I knew I’d eventually need a one-liner. I started with a much more complicated script (see later), but eventually got to this one. I save this as write_clipboard_rtf_alfa.ps1.

Add-Type -AssemblyName System.Windows.Forms
$rtf = Get-Content -Path alfa_reprex.rtf
[Windows.Forms.Clipboard]::SetText($rtf, [System.Windows.Forms.TextDataFormat]::Rtf)

(If I were really writing such a script, of course I wouldn’t hard-wire the RTF filename like this. I’d take a path or perhaps the RTF string via standard input or some such. But this script is just a stepping stone here.)

Create an RTF file that we want to load onto the clipboard.

reprex::reprex_rtf(sample(LETTERS, 5), outfile = here::here("internal/alfa"))
#> Non-interactive session, setting `html_preview = FALSE`.
#> Preparing reprex as .R file:
#>   * C:/Users/jenny/Desktop/reprex/internal/alfa_reprex.R
#> Rendering reprex...
#> Writing reprex file:
#>   * C:/Users/jenny/Desktop/reprex/internal/alfa_reprex.rtf

In an interactive PowerShell instance, we could then execute the script like so (at least, once you’ve set the session execution policy to allow this):

.\write_clipboard_rtf_alfa.ps1

Invoke this script from R and inspect the RTF clipboard.

system(r"(powershell -Command ".\write_clipboard_rtf_alfa.ps1")")
#> [1] 0
read_clipboard_rtf()
#> [1] "{\\rtf1\\ansi \\deff1{\\fonttbl{\\f1\\fmodern\\fprq1\\fcharset0 Courier Regular;}}{\\colortbl;\\red00\\green00\\blue00;\\red160\\green160\\blue192;\\red208\\green224\\blue128;\\red208\\green224\\blue128;\\red96\\green96\\blue128;\\red96\\green96\\blue128;\\red128\\green128\\blue128;\\red160\\green160\\blue192;\\red208\\green224\\blue128;\\red96\\green96\\blue128;\\red204\\green204\\blue204;\\red207\\green165\\blue219;\\red128\\green144\\blue240;\\red224\\green224\\blue255;\\red159\\green191\\blue175;} \\paperw11905\\paperh16837\\margl1134\\margr1134\\margt1134\\margb1134\\sectd\\plain\\f1\\fs100 \\pard \\cbpat1{{\\cf2{}}{\\cf15{sample}}{\\cf2{}}{\\cf11{(}}{\\cf2{LETTERS}}{\\cf11{,}} {\\cf2{}}{\\cf4{{5}}}{\\cf2{}}{\\cf11{)}}}\\par\\pard \\cbpat1{{\\cf2{}}{\\cf5{#> [{1}] \"Z\" \"D\" \"F\" \"L\" \"W\"}}{\\cf2{}}}}"

IT’S WORKING!

Here’s how that looks in one-liner form, i.e. as something you could execute in PowerShell or cmd.exe or from R with system():

powershell -Command "Add-Type -AssemblyName System.Windows.Forms | Out-Null;[Windows.Forms.Clipboard]::SetText((Get-Content alfa_reprex.rtf),[Windows.Forms.TextDataFormat]::Rtf)"

If you execute write_clipboard_rtf_alfa.ps1 from an interactive PowerShell instance, this first line is actually unnecessary:

Add-Type -AssemblyName System.Windows.Forms

But AFAICT, most other ways of executing the script require this line to work. I learned this the hard way. For example, this line is needed if you want to execute this in an interactive cmd.exe shell:

powershell -File write_clipboard_rtf_alfa.ps1

I believe Add-Type -AssemblyName System.Windows.Forms is required for any of the ways we might eventually execute this from R.

I learned about this line and other tricks for smushing such a script into a one-liner by asking this question on Stack Overflow. You’ll also notice at that time, I was working with a much more convoluted approach to writing RTF to the clipboard (more below).

Clean up.

unlink(list.files(here::here("internal"), pattern = "alfa_reprex", full.names = TRUE))

A more complicated and worse solution

The first working script that I wrote was actually much more complicated and demonstrably worse. It was heavily influenced by:

My script started like this, which I save to write_clipboard_rtf_beta.ps1:

Add-Type -AssemblyName "System.Windows.Forms"
$data = New-Object Windows.Forms.DataObject
$rtf = Get-Content -Path beta_reprex.rtf
$data.SetData([Windows.Forms.DataFormats]::Rtf, $rtf)
[Windows.Forms.Clipboard]::SetDataObject($data)

Here I instantiate a new DataObject, add the RTF to it, then set the clipboard to this object. In addition to being more convoluted, this had a huge functional problem: the RTF disappears from the clipboard as soon as the PowerShell exits. During interactive development, you don’t notice this, but it means the approach basically doesn’t work from R. This discussion about a different tool contains a good description of why information placed on the clipboard by a certain app might disappear once that app is closed. Finally, I later figured out you can address this by passing an additional boolean $true to the Clipboard.SetDataObject method. I even overlooked this in some of the examples I used as inspiration. Luckily using the simpler approach of writing rich text to the clipboard with the SetText method also solved the persistence problem at the same time.

The persistence problem was very hard to figure out. To prove to myself that I was, indeed, writing the RTF to the clipboard, I inserted this at the end the script:

Get-Clipboard -TextFormatType Rtf

And I could see even from R that the RTF was being stored on the clipboard. Briefly.

Once I figured out the persistence problem, I added some sleep to keep the PowerShell alive for a while, with this line:

Start-Sleep -Seconds 60

Then I had to direct R not to wait for the command to finish like so:

system(..., wait = FALSE)

Then you had one minute after calling reprex_rtf() to paste the RTF somewhere. Luckily I was able to do much better than this initial "solution".

What PowerShell are we talking about?

Windows PowerShell ships with Windows and is not the same thing as PowerShell Core. Pragmatically, they are very similar, but if things don’t seem to work as documented, consider that you are reading the docs for the wrong one. When call powershell from R, we are using Windows PowerShell. I guess that might not be true if the user has explicitly installed PowerShell Core? On my Windows 10 VM, I have Windows PowerShell 5.1. If my solution doesn’t work for people on different versions of Windows, consider that this may be due to their having a different version of Windows PowerShell.

Path quoting

On Windows, spaces are allowed in file paths. Even if we could think that we are safe when using a temporary directory, it is not the case because spaces can be present in username which is used in (almost) all user-writable absolute file paths we could use. R has a quoting function shQuote() which on Windows has no special support for powershell shell but only for cmd. Using this function will add double quotes around the file path. This will work most of the time but not with Get Content -Raw {path} unfortunately. Some solutions that seems to work:

  1. Adding single quotes, with glue::single_quote() for example. Using shQuote(<path>, type = "sh") would add single quotes, but would add double quote if any single quotes are in the path. However, this is unlikely.
  2. Escaping space in the path for powershell using path <- gsub("\\s", "` ", path). This will target only space and not other special char in path, but again this is less likely to have some, unless some username with special chars.

We'll use the second one as it is more specific to the issue encountered (see https://github.com/tidyverse/reprex/issues/409) and less likely to have other unintended side effect.

Thought and link dump

Clearing browser tabs and Untitled14, etc.

How R writes to the Windows clipboard

It happens here:

src/library/utils/src/windows/util.c#L272-L338

You could imagine registering the rich text format, but that seems exceedingly unlikely. More likely: an R package that uses OS-level APIs on macOS and Windows to read/write the clipboard.

Other languages

Proper clipboard access in Python comes from Python for Windows (pywin32) Extensions

Yori is a CMD replacement shell that supports backquotes, job control, and improves tab completion, file matching, aliases, command history, and more. It includes a handful of native Win32 tools that implement commonly needed tasks which can be used with any shell.

Good C example from the Yori source:

https://github.com/malxau/yori/blob/4f20ad8f01a67385013670f6075f0260eab41f5b/lib/clip.c

PowerShell

How to get help for PowerShell commands:

Get-Help Get-Service
help Get-Service
help Get-Service -Full
help Get-Service -Detailed

From https://www.tutorialspoint.com/how-to-use-powershell-help-commands

Stack Overflow threads (or similar)

https://stackoverflow.com/questions/51977190/how-to-copy-rich-text-format-to-clipboard-with-python

https://superuser.com/questions/1080239/run-powershell-command-from-cmd

.NET

The ClipBoard class:

The DataFormats class provides static, predefined Clipboard format names:

The Clipboard.SetText method:

The TextDataFormat Enum:

The Clipboard.SetDataObject method:

Handy syntax and patterns

Sweeping the cutting room floor.

(args <- c("-File", "write_rtf_clipboard.ps1"))
system2("powershell.exe", args, wait = FALSE)

PS <- "Get-Clipboard -TextFormatType Rtf"
(args <- c("-Command", shQuote(PS)))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

PS <- r"(Set-Clipboard -Value "clipboard stuff")"
(args <- c("-Command", shQuote(PS)))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

PS <- "Get-Clipboard"
(args <- c("-Command", shQuote(PS)))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

system("powershell.exe -File write_rtf_clipboard.ps1", wait = FALSE)
system('powershell.exe -Command "Get-Clipboard -TextFormatType Rtf"')

shell("Get-Location", shell = "powershell")

# inline the script
script_parts <- c(
  "[Windows.Forms.Clipboard]::SetDataObject(",
  "[Windows.Forms.DataObject]::new(",
  "[Windows.Forms.DataFormats]::Rtf",
  ",",
  "(Get-Content -Raw ",
  "minimal.rtf",
  ")))"
)
PS <- paste(
  "Add-Type -AssemblyName System.Windows.Forms | Out-Null",
  paste0(script_parts, collapse = ""),
  "Get-Clipboard -TextFormatType Rtf",
  "Start-Sleep -Seconds 30",
  sep = ";"
)
system2("powershell.exe", c("-Command", PS), wait = FALSE)
args <- c("-Command", "Get-Clipboard -TextFormatType Rtf")
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

command <- 'Set-Clipboard -Value "abc"'
args <- c("-Command", shQuote(command))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

command <- 'Set-Clipboard -Value "abc"; Get-Clipboard'
args <- c("-Command", shQuote(command))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

command <- "Get-Location"
(args <- c("-Command", shQuote(command)))
system2("powershell.exe", args, stdout = TRUE, stderr = TRUE)

system2("powershell.exe", "Get-TimeZone", stdout = TRUE, stderr = TRUE)


jennybc/reprex documentation built on Jan. 12, 2024, 9:33 p.m.