Most legacy clinical data was produced by SAS sessions running WLATIN1 (Windows-1252); modern pipelines — Dataset-JSON, Parquet, R itself — are UTF-8. The two agree on every ASCII character and disagree everywhere else, so a migration has exactly three failure modes: mojibake (bytes interpreted as the wrong charset), truncation (byte-counted lengths that multibyte UTF-8 overflows), and unrepresentable characters (an ASCII target that cannot carry what UTF-8 can). This article shows how artoo handles each one.
1. Reading WLATIN1 data
read_xpt() detects the encoding: if every byte in the file validates as UTF-8 it reads as UTF-8, otherwise it assumes Windows-1252 — the right default for legacy clinical files. Formats whose spec pins UTF-8 (Dataset-JSON, Parquet) are read as UTF-8 and refuse loudly when the bytes disagree, rather than guessing:
Error in `read_parquet()`:
! Could not read '/tmp/RtmpNJ7pbJ/file1e8257749ccc.parquet' as
"parquet".
✖ entry 2 has wrong Encoding; marked as "UTF-8" but leading byte 0xDC followed
by invalid continuation byte (0x4E) at position 3
That refusal is the prompt to name the true source charset. encoding= is a transcode, not a label — the bytes are converted to UTF-8 and normalised (NFC), so what lands in R is ordinary text:
Any SAS or IANA spelling works — "wlatin1", "cp1252", "windows-1252" are the same charset; so are the OEM/DOS names ("pcoem850") a very old archive might declare. The rosetta is artoo_encodings(). Note that "latin1" (ISO-8859-1) is not WLATIN1: the two differ across the 0x80–0x9F range that holds the Euro sign and every smart quote.
2. Catching a mis-declared read
The classic silent corruption is reading WLATIN1 bytes as if they were UTF-8 (in SAS terms: a data set whose encoding attribute lies). Those bytes do not validate as UTF-8, and the invalid_encoding conformance dimension flags them before any writer runs:
bad <- cdisc_dm
bad$ETHNIC <- rawToChar(as.raw(c(0x4D, 0xDC, 0x4E))) # WLATIN1 bytes, raw
f <- check_spec(bad, sdtm_spec, "DM")
f[f$check == "invalid_encoding", c("variable", "severity", "message")]
variable severity
14 ETHNIC error
message
14 'ETHNIC' has 60 value(s) whose bytes are not valid UTF-8; re-read the source with its true encoding.
The fix is always upstream — re-read the source with its true encoding= — never a re-label.
3. Lengths are bytes, and UTF-8 needs more of them
A WLATIN1 character is always one byte; in UTF-8 the same character can take up to four. A spec length calibrated on WLATIN1 data (Ö = 1 byte) under-declares the UTF-8 form (Ö = 2 bytes). SAS handles this with the CVP engine and a guessed multiplier; artoo measures the actual bytes, never truncates, and tells you when the declared length had to give:
Warning in write_xpt(x, xpt): Widened 1 column past the declared spec length: "INVNAM (6 -> 8)".
ℹ Values need more bytes than the spec length; data was kept whole.
ℹ Update the spec length, or shorten the data, so the file matches its declared
metadata.
The length_overflow check reports the same fact at conformance time, so a migration can fix the spec (the durable answer) instead of relying on the writer’s defence.
4. Smart punctuation: the WLATIN1 characters ASCII can fold
Text that passed through a word processor carries typographic punctuation — curly quotes, en/em dashes, ellipses. In WLATIN1 these live in rows 8–9 of the code page (one byte each); in UTF-8 they are three bytes each; in US-ASCII they do not exist at all. They are also the one class of character with an exact ASCII equivalent, published by SAS as the NLS punctuation fold:
82 |
U+201A |
‚ |
single low-9 quotation mark |
, |
84 |
U+201E |
„ |
double low-9 quotation mark |
" |
85 |
U+2026 |
… |
horizontal ellipsis |
... |
8B |
U+2039 |
‹ |
single left-pointing angle quote |
< |
91 |
U+2018 |
‘ |
left single quotation mark |
' |
92 |
U+2019 |
’ |
right single quotation mark |
' |
93 |
U+201C |
“ |
left double quotation mark |
" |
94 |
U+201D |
” |
right double quotation mark |
" |
95 |
U+2022 |
• |
bullet |
* |
96 |
U+2013 |
– |
en dash |
- |
97 |
U+2014 |
— |
em dash |
- |
9B |
U+203A |
› |
single right-pointing angle quote |
> |
Every writer accepts on_invalid = "translit", which applies exactly this table and nothing else:
note <- data.frame(
USUBJID = "01-701-1015",
COMMENT = "Patient’s dose – “held”…"
)
ascii_xpt <- tempfile(fileext = ".xpt")
write_xpt(note, ascii_xpt, encoding = "US-ASCII", on_invalid = "translit")
Warning in write_xpt(note, ascii_xpt, encoding = "US-ASCII", on_invalid = "translit"): Transliterated smart punctuation to ASCII in 1 value for "US-ASCII".
ℹ The fold follows the SAS NLS punctuation table (quotes, dashes, ellipsis,
bullet).
[1] "Patient's dose - \"held\"..."
"translit" is deliberately narrower than "replace": punctuation folds because the ASCII form carries the same meaning; a character with no equivalent — a diacritic in a name — still aborts, because "Öztürk" silently becoming "Ozturk" (or worse, "?zt?rk") is data corruption, not conversion:
Error in `write_xpt()`:
! Cannot encode 1 value to "US-ASCII" even after punctuation folding.
✖ Offending value: "Öztürk".
ℹ Only smart punctuation has an ASCII fold; use `on_invalid = "fold"` to also
strip accents, or write to Dataset-JSON (UTF-8).
5. Accent stripping: the full fold, opted into
Sometimes stripping accents is the documented migration decision — the SAS BASECHAR() function exists for exactly this. artoo’s equivalent is on_invalid = "fold": the punctuation table above plus the ICU Latin-ASCII transliteration for the rest of the WLATIN1 range (À–ÿ to their base letters, Æ → AE, ß → ss, Þ → TH, × → *, «» → << >>, ½ → 1/2). The table is pinned inside artoo, so — unlike iconv //TRANSLIT, whose output differs between Linux and macOS — the fold is byte-identical on every platform:
names_df <- data.frame(INVNAM = c("Öztürk", "Straße", "Ærø"))
folded_xpt <- tempfile(fileext = ".xpt")
write_xpt(names_df, folded_xpt, encoding = "US-ASCII", on_invalid = "fold")
Warning in write_xpt(names_df, folded_xpt, encoding = "US-ASCII", on_invalid = "fold"): Folded 3 values to ASCII for "US-ASCII" (accents stripped).
ℹ The fold follows the SAS NLS punctuation table plus ICU Latin-ASCII; the
original characters are not recoverable from the output.
[1] "Ozturk" "Strasse" "AEro"
The warning is the audit trail: the fold is one-way, and the original characters are not recoverable from the output. A character neither authority maps to ASCII — the Euro sign, the trademark sign, µ, ° — still aborts, because artoo invents no mappings:
Error in `write_xpt()`:
! Cannot encode 1 value to "US-ASCII" even after ASCII folding.
✖ Offending value: "total €100".
ℹ The character has no standards-backed ASCII fold (ICU Latin-ASCII leaves it
unmapped); write to Dataset-JSON (UTF-8), or set `on_invalid`.
The escalation ladder, least to most lossy: "error" (refuse) → "translit" (fold punctuation only) → "fold" (also strip accents) → "replace" (? per character) → "ignore" (drop). Pick the earliest rung your migration plan allows.
6. The migration recipe
-
Read with the true source encoding. Let
read_xpt() detect, or pass encoding = "wlatin1" explicitly for formats that cannot carry the answer. Verify with check_spec() — zero invalid_encoding findings means the bytes arrived as characters.
-
Re-measure lengths in bytes. Run the
length_overflow dimension against the UTF-8 form and update the spec where multibyte characters grew.
-
Write UTF-8 by default (Dataset-JSON, Parquet, xpt v8 all carry it losslessly). For a US-ASCII submission XPT, write with
encoding = "US-ASCII", on_invalid = "translit" — punctuation folds, genuine data problems stay loud. Escalate to on_invalid = "fold" only when accent stripping is a documented step of the migration plan.