Skip to content

Commit af350de

Browse files
ly-wang19claude
andcommitted
fix(line): truncate template title/altText on grapheme boundaries, not raw UTF-16
createConfirmTemplate/createButtonTemplate/createTemplateCarousel/createCarouselColumn/ createImageCarousel truncated title and altText with a raw `.slice(0, N)`, so an emoji straddling a LINE field limit (e.g. a 40-char button title) was cut in half, leaving a lone high surrogate that LINE renders as the replacement char or rejects. Route those fields through the file's existing grapheme-safe truncateTemplateText (already used for the text body) via a small truncateOptionalTemplateText wrapper. Byte-identical for all-BMP input; only straddling-emoji truncation changes. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 1069c60 commit af350de

2 files changed

Lines changed: 23 additions & 7 deletions

File tree

extensions/line/src/message-cards.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ describe("createButtonTemplate", () => {
4444
expect((template.template as { title: string }).title.length).toBe(40);
4545
});
4646

47+
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
48+
// 39 chars + an emoji land the truncation boundary inside the surrogate pair;
49+
// a raw code-unit slice would keep only the lone high surrogate.
50+
const template = createButtonTemplate(`${"x".repeat(39)}😀`, "Text", [messageAction("OK")]);
51+
const title = (template.template as { title: string }).title;
52+
53+
expect(title).toBe("x".repeat(39));
54+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(title)).toBe(false);
55+
});
56+
4757
it("truncates text to 60 chars when no thumbnail is provided", () => {
4858
const longText = "x".repeat(100);
4959
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);

extensions/line/src/template-messages.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ function truncateTemplateText(text: string, limit: number): string {
6565
return result;
6666
}
6767

68+
function truncateOptionalTemplateText(value: string | undefined, limit: number): string | undefined {
69+
return value === undefined ? undefined : truncateTemplateText(value, limit);
70+
}
71+
6872
function formatProductCarouselText(description: string, price?: string): string {
6973
if (!price) {
7074
return description;
@@ -86,13 +90,13 @@ export function createConfirmTemplate(
8690
): TemplateMessage {
8791
const template: ConfirmTemplate = {
8892
type: "confirm",
89-
text: text.slice(0, 240), // LINE limit
93+
text: truncateTemplateText(text, 240), // LINE limit
9094
actions: [confirmAction, cancelAction],
9195
};
9296

9397
return {
9498
type: "template",
95-
altText: altText?.slice(0, 400) ?? text.slice(0, 400),
99+
altText: truncateOptionalTemplateText(altText, 400) ?? truncateTemplateText(text, 400),
96100
template,
97101
};
98102
}
@@ -120,7 +124,7 @@ export function createButtonTemplate(
120124
});
121125
const template: ButtonsTemplate = {
122126
type: "buttons",
123-
title: title.slice(0, 40), // LINE limit
127+
title: truncateTemplateText(title, 40), // LINE limit
124128
text: truncateTemplateText(text, textLimit),
125129
actions: actions.slice(0, 4), // LINE limit: max 4 actions
126130
thumbnailImageUrl: options?.thumbnailImageUrl,
@@ -132,7 +136,9 @@ export function createButtonTemplate(
132136

133137
return {
134138
type: "template",
135-
altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400),
139+
altText:
140+
truncateOptionalTemplateText(options?.altText, 400) ??
141+
truncateTemplateText(`${title}: ${text}`, 400),
136142
template,
137143
};
138144
}
@@ -157,7 +163,7 @@ export function createTemplateCarousel(
157163

158164
return {
159165
type: "template",
160-
altText: options?.altText?.slice(0, 400) ?? "View carousel",
166+
altText: truncateOptionalTemplateText(options?.altText, 400) ?? "View carousel",
161167
template,
162168
};
163169
}
@@ -179,7 +185,7 @@ export function createCarouselColumn(params: {
179185
// the buttons template already applies above.
180186
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
181187
return {
182-
title: params.title?.slice(0, 40),
188+
title: truncateOptionalTemplateText(params.title, 40),
183189
text: truncateTemplateText(params.text, textLimit),
184190
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
185191
thumbnailImageUrl: params.thumbnailImageUrl,
@@ -202,7 +208,7 @@ export function createImageCarousel(
202208

203209
return {
204210
type: "template",
205-
altText: altText?.slice(0, 400) ?? "View images",
211+
altText: truncateOptionalTemplateText(altText, 400) ?? "View images",
206212
template,
207213
};
208214
}

0 commit comments

Comments
 (0)