diff --git a/src/render.js b/src/render.js index f24d905..6f36b03 100644 --- a/src/render.js +++ b/src/render.js @@ -250,7 +250,10 @@ const truncate = (s) => { // instead of falling through to the sentence rule below. const line = s.slice(0, TEXT_CAP).lastIndexOf('\n'); if (line >= TEXT_CAP * SENTENCE_FLOOR) return `${s.slice(0, line)} ... +${num(s.length - line)} chars`; - let cut = TEXT_CAP; + // A character past U+FFFF (an emoji, a rare CJK ideograph) is two UTF-16 + // units. A cut between them printed half of one, which a terminal shows as + // the replacement character, so the cut steps back to before it. + let cut = /[\uD800-\uDBFF]/.test(s[TEXT_CAP - 1]) ? TEXT_CAP - 1 : TEXT_CAP; for (const m of s.slice(0, TEXT_CAP).matchAll(SENTENCE_END)) { const end = (m.index ?? 0) + m[0].length; if (end >= TEXT_CAP * SENTENCE_FLOOR) cut = end; diff --git a/tests/distill.test.js b/tests/distill.test.js index b66ea2f..d365fda 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -592,6 +592,15 @@ test('a truncated block ends on a sentence, so what is shown can be trusted', () assert.ok(unbroken.length > TEXT_CAP, `an early sentence end must not shrink the view:\n${unbroken}`); }); +test('a cut at the cap never splits an emoji in half', () => { + // An emoji is two UTF-16 units. With its first unit the last one inside + // the cap, the view printed half of it, which a terminal shows as U+FFFD. + const text = `${'x'.repeat(TEXT_CAP - 1)}\u{1F600} and the rest of the post`; + const line = render({ url: '', title: '', blocks: [{ n: 1, type: 'text', text }] }, { budget: 60 }).text; + assert.ok(line.isWellFormed(), `a lone surrogate reached the view: ${JSON.stringify(line.slice(TEXT_CAP - 2, TEXT_CAP + 20))}`); + assert.match(line, new RegExp(`x{${TEXT_CAP - 1}} \\.\\.\\. \\+${text.length - TEXT_CAP + 1} chars`)); +}); + test('a highlighted command comes out runnable', () => { // Every token of this command is its own element on the page. Space-joining // them gave `aws s3 cp s3 : // bucket / -- recursive`, which is not a command