123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- import { wrapText, parseTokens } from "../src/textWrapping";
- import type { FontString } from "../src/types";
- describe("Test wrapText", () => {
- // font is irrelevant as jsdom does not support FontFace API
- // `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
- // https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
- const font = "10px Cascadia, Segoe UI Emoji" as FontString;
- it("should wrap the text correctly when word length is exactly equal to max width", () => {
- const text = "Hello Excalidraw";
- // Length of "Excalidraw" is 100 and exacty equal to max width
- const res = wrapText(text, font, 100);
- expect(res).toEqual(`Hello\nExcalidraw`);
- });
- it("should return the text as is if max width is invalid", () => {
- const text = "Hello Excalidraw";
- expect(wrapText(text, font, NaN)).toEqual(text);
- expect(wrapText(text, font, -1)).toEqual(text);
- expect(wrapText(text, font, Infinity)).toEqual(text);
- });
- it("should show the text correctly when max width reached", () => {
- const text = "Hello😀";
- const maxWidth = 10;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("H\ne\nl\nl\no\n😀");
- });
- it("should not wrap number when wrapping line", () => {
- const text = "don't wrap this number 99,100.99";
- const maxWidth = 300;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("don't wrap this number\n99,100.99");
- });
- it("should trim all trailing whitespaces", () => {
- const text = "Hello ";
- const maxWidth = 50;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello");
- });
- it("should trim all but one trailing whitespaces", () => {
- const text = "Hello ";
- const maxWidth = 60;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello ");
- });
- it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
- const text = " Hello World";
- const maxWidth = 90;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(" Hello\nWorld");
- });
- it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
- const text = " Hello World ";
- const maxWidth = 90;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(" Hello\nWorld ");
- });
- it("should trim keep those whitespace that fit in the trailing line", () => {
- const text = "Hello Wo rl d ";
- const maxWidth = 100;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello Wo\nrl d ");
- });
- it("should support multiple (multi-codepoint) emojis", () => {
- const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
- const maxWidth = 1;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
- });
- it("should wrap the text correctly when text contains hyphen", () => {
- let text =
- "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
- const res = wrapText(text, font, 110);
- expect(res).toBe(
- `Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
- );
- text = "Hello thereusing-now";
- expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
- });
- it("should support wrapping nested lists", () => {
- const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
- const maxWidth = 100;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
- const maxWidth2 = 50;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
- });
- describe("When text is CJK", () => {
- it("should break each CJK character when width is very small", () => {
- // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
- const text = "안녕하세요こんにちは世界コンニチハ你好";
- const maxWidth = 10;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(
- "안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
- );
- });
- it("should break CJK text into longer segments when width is larger", () => {
- // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
- const text = "안녕하세요こんにちは世界コンニチハ你好";
- const maxWidth = 30;
- const res = wrapText(text, font, maxWidth);
- // measureText is mocked, so it's not precisely what would happen in prod
- expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
- });
- it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
- const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
- const maxWidth = 150;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
- const maxWidth2 = 50;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
- const maxWidth3 = 30;
- const res3 = wrapText(text, font, maxWidth3);
- expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
- });
- it("should break before and after a regular CJK character", () => {
- const text = "HelloたWorld";
- const maxWidth1 = 50;
- const res1 = wrapText(text, font, maxWidth1);
- expect(res1).toBe("Hello\nた\nWorld");
- const maxWidth2 = 60;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe("Helloた\nWorld");
- });
- it("should break before and after certain CJK symbols", () => {
- const text = "こんにちは〃世界";
- const maxWidth1 = 50;
- const res1 = wrapText(text, font, maxWidth1);
- expect(res1).toBe("こんにちは\n〃世界");
- const maxWidth2 = 60;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe("こんにちは〃\n世界");
- });
- it("should break after, not before for certain CJK pairs", () => {
- const text = "Hello た。";
- const maxWidth = 70;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello\nた。");
- });
- it("should break before, not after for certain CJK pairs", () => {
- const text = "Hello「たWorld」";
- const maxWidth = 60;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hello\n「た\nWorld」");
- });
- it("should break after, not before for certain CJK character pairs", () => {
- const text = "「Helloた」World";
- const maxWidth = 70;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("「Hello\nた」World");
- });
- it("should break Chinese sentences", () => {
- const text = `中国你好!这是一个测试。
- 我们来看看:人民币¥1234「很贵」
- (括号)、逗号,句号。空格 换行 全角符号…—`;
- const maxWidth1 = 80;
- const res1 = wrapText(text, font, maxWidth1);
- expect(res1).toBe(`中国你好!这是一\n个测试。
- 我们来看看:人民\n币¥1234「很\n贵」
- (括号)、逗号,\n句号。空格 换行\n全角符号…—`);
- const maxWidth2 = 50;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe(`中国你好!\n这是一个测\n试。
- 我们来看\n看:人民币\n¥1234\n「很贵」
- (括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
- });
- it("should break Japanese sentences", () => {
- const text = `日本こんにちは!これはテストです。
- 見てみましょう:円¥1234「高い」
- (括弧)、読点、句点。
- 空白 改行 全角記号…ー`;
- const maxWidth1 = 80;
- const res1 = wrapText(text, font, maxWidth1);
- expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
- 見てみましょ\nう:円¥1234\n「高い」
- (括弧)、読\n点、句点。
- 空白 改行\n全角記号…ー`);
- const maxWidth2 = 50;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
- 見てみ\nましょう:\n円\n¥1234\n「高い」
- (括\n弧)、読\n点、句点。
- 空白\n改行 全角\n記号…ー`);
- });
- it("should break Korean sentences", () => {
- const text = `한국 안녕하세요! 이것은 테스트입니다.
- 우리 보자: 원화₩1234「비싸다」
- (괄호), 쉼표, 마침표.
- 공백 줄바꿈 전각기호…—`;
- const maxWidth1 = 80;
- const res1 = wrapText(text, font, maxWidth1);
- expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
- 우리 보자: 원\n화₩1234「비\n싸다」
- (괄호), 쉼\n표, 마침표.
- 공백 줄바꿈 전\n각기호…—`);
- const maxWidth2 = 60;
- const res2 = wrapText(text, font, maxWidth2);
- expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
- 우리 보자:\n원화\n₩1234\n「비싸다」
- (괄호),\n쉼표, 마침\n표.
- 공백 줄바꿈\n전각기호…—`);
- });
- });
- describe("When text contains leading whitespaces", () => {
- const text = " \t Hello world";
- it("should preserve leading whitespaces", () => {
- const maxWidth = 120;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(" \t Hello\nworld");
- });
- it("should break and collapse leading whitespaces when line breaks", () => {
- const maxWidth = 60;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("\nHello\nworld");
- });
- it("should break and collapse leading whitespaces whe words break", () => {
- const maxWidth = 30;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("\nHel\nlo\nwor\nld");
- });
- });
- describe("When text contains trailing whitespaces", () => {
- it("shouldn't add new lines for trailing spaces", () => {
- const text = "Hello whats up ";
- const maxWidth = 190;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe(text);
- });
- it("should ignore trailing whitespaces when line breaks", () => {
- const text = "Hippopotomonstrosesquippedaliophobia ??????";
- const maxWidth = 400;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
- });
- it("should not ignore trailing whitespaces when word breaks", () => {
- const text = "Hippopotomonstrosesquippedaliophobia ??????";
- const maxWidth = 300;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
- });
- it("should ignore trailing whitespaces when word breaks and line breaks", () => {
- const text = "Hippopotomonstrosesquippedaliophobia ??????";
- const maxWidth = 180;
- const res = wrapText(text, font, maxWidth);
- expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
- });
- });
- describe("When text doesn't contain new lines", () => {
- const text = "Hello whats up";
- [
- {
- desc: "break all words when width of each word is less than container width",
- width: 70,
- res: `Hello\nwhats\nup`,
- },
- {
- desc: "break all characters when width of each character is less than container width",
- width: 15,
- res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
- },
- {
- desc: "break words as per the width",
- width: 130,
- res: `Hello whats\nup`,
- },
- {
- desc: "fit the container",
- width: 240,
- res: "Hello whats up",
- },
- {
- desc: "push the word if its equal to max width",
- width: 50,
- res: `Hello\nwhats\nup`,
- },
- ].forEach((data) => {
- it(`should ${data.desc}`, () => {
- const res = wrapText(text, font, data.width);
- expect(res).toEqual(data.res);
- });
- });
- });
- describe("When text contain new lines", () => {
- const text = `Hello\n whats up`;
- [
- {
- desc: "break all words when width of each word is less than container width",
- width: 70,
- res: `Hello\n whats\nup`,
- },
- {
- desc: "break all characters when width of each character is less than container width",
- width: 15,
- res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
- },
- {
- desc: "break words as per the width",
- width: 140,
- res: `Hello\n whats up`,
- },
- ].forEach((data) => {
- it(`should respect new lines and ${data.desc}`, () => {
- const res = wrapText(text, font, data.width);
- expect(res).toEqual(data.res);
- });
- });
- });
- describe("When text is long", () => {
- const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
- [
- {
- desc: "fit characters of long string as per container width",
- width: 160,
- res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
- },
- {
- desc: "fit characters of long string as per container width and break words as per the width",
- width: 120,
- res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
- },
- {
- desc: "fit the long text when container width is greater than text length and move the rest to next line",
- width: 590,
- res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
- },
- ].forEach((data) => {
- it(`should ${data.desc}`, () => {
- const res = wrapText(text, font, data.width);
- expect(res).toEqual(data.res);
- });
- });
- });
- describe("Test parseTokens", () => {
- it("should tokenize latin", () => {
- let text = "Excalidraw is a virtual collaborative whiteboard";
- expect(parseTokens(text)).toEqual([
- "Excalidraw",
- " ",
- "is",
- " ",
- "a",
- " ",
- "virtual",
- " ",
- "collaborative",
- " ",
- "whiteboard",
- ]);
- text =
- "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
- expect(parseTokens(text)).toEqual([
- "Wikipedia",
- " ",
- "is",
- " ",
- "hosted",
- " ",
- "by",
- " ",
- "Wikimedia-",
- " ",
- "Foundation,",
- " ",
- "a",
- " ",
- "non-",
- "profit",
- " ",
- "organization",
- " ",
- "that",
- " ",
- "also",
- " ",
- "hosts",
- " ",
- "a",
- " ",
- "range-",
- "of",
- " ",
- "other",
- " ",
- "projects",
- ]);
- });
- it("should not tokenize number", () => {
- const text = "99,100.99";
- const tokens = parseTokens(text);
- expect(tokens).toEqual(["99,100.99"]);
- });
- it("should tokenize joined emojis", () => {
- const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`;
- const tokens = parseTokens(text);
- expect(tokens).toEqual([
- "😬",
- "🌍",
- "🗺",
- "🔥",
- "☂️",
- "👩🏽🦰",
- "👨👩👧👦",
- "👩🏾🔬",
- "🏳️🌈",
- "🧔♀️",
- "🧑🤝🧑",
- "🙅🏽♂️",
- "✅",
- "0️⃣",
- "🇨🇿",
- "🦅",
- ]);
- });
- it("should tokenize emojis mixed with mixed text", () => {
- const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`;
- const tokens = parseTokens(text);
- expect(tokens).toEqual([
- "😬",
- "a",
- "🌍",
- "b",
- "🗺",
- "c",
- "🔥",
- "d",
- "☂️",
- "《",
- "👩🏽🦰",
- "》",
- "👨👩👧👦",
- "德",
- "👩🏾🔬",
- "こ",
- "🏳️🌈",
- "안",
- "🧔♀️",
- "g",
- "🧑🤝🧑",
- "h",
- "🙅🏽♂️",
- "e",
- "✅",
- "f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
- "🇨🇿",
- "10", // nice! do not break the number, as it's by default matched by \p{Emoji}
- "🦅",
- "#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
- ]);
- });
- it("should tokenize decomposed chars into their composed variants", () => {
- // each input character is in a decomposed form
- const text = "čでäぴέ다й한";
- expect(text.normalize("NFC").length).toEqual(8);
- expect(text).toEqual(text.normalize("NFD"));
- const tokens = parseTokens(text);
- expect(tokens.length).toEqual(8);
- expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
- });
- it("should tokenize artificial CJK", () => {
- const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
- // [
- // '《道', '德', '經》', '醫-',
- // '醫', 'こ', 'ん', 'に',
- // 'ち', 'は', '世', '界!',
- // '안', '녕', '하', '세',
- // '요', '세', '계;', '요』,',
- // '다.', '다...', '원/', '달',
- // '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
- // 'た…', '[Hello]', ' ', '\t',
- // ' ', 'World?', 'ニ', 'ュ',
- // 'ー', 'ヨ', 'ー', 'ク・',
- // '¥3700.55', 'す。', '090-', '1234-',
- // '5678', '¥1,000〜', '$5,000', '「素',
- // '晴', 'ら', 'し', 'い!」',
- // '〔重', '要〕', '#', '1:',
- // 'Taro', '君', '30%', 'は、',
- // '(た', 'な', 'ば', 'た)',
- // '〰', '¥110±', '¥570', 'で',
- // '20℃〜', '9:30〜', '10:00', '【一',
- // '番】'
- // ]
- const tokens = parseTokens(text);
- // Latin
- expect(tokens).toContain("[[1]]");
- expect(tokens).toContain("[Hello]");
- expect(tokens).toContain("World?");
- expect(tokens).toContain("Taro");
- // Chinese
- expect(tokens).toContain("《道");
- expect(tokens).toContain("德");
- expect(tokens).toContain("經》");
- expect(tokens).toContain("醫-");
- expect(tokens).toContain("醫");
- // Japanese
- expect(tokens).toContain("こ");
- expect(tokens).toContain("ん");
- expect(tokens).toContain("に");
- expect(tokens).toContain("ち");
- expect(tokens).toContain("は");
- expect(tokens).toContain("世");
- expect(tokens).toContain("ク・");
- expect(tokens).toContain("界!");
- expect(tokens).toContain("た…");
- expect(tokens).toContain("す。");
- expect(tokens).toContain("ュ");
- expect(tokens).toContain("「素");
- expect(tokens).toContain("晴");
- expect(tokens).toContain("ら");
- expect(tokens).toContain("し");
- expect(tokens).toContain("い!」");
- expect(tokens).toContain("君");
- expect(tokens).toContain("は、");
- expect(tokens).toContain("(た");
- expect(tokens).toContain("な");
- expect(tokens).toContain("ば");
- expect(tokens).toContain("た)");
- expect(tokens).toContain("で");
- expect(tokens).toContain("【一");
- expect(tokens).toContain("番】");
- // Check for Korean
- expect(tokens).toContain("안");
- expect(tokens).toContain("녕");
- expect(tokens).toContain("하");
- expect(tokens).toContain("세");
- expect(tokens).toContain("요");
- expect(tokens).toContain("세");
- expect(tokens).toContain("계;");
- expect(tokens).toContain("요』,");
- expect(tokens).toContain("다.");
- expect(tokens).toContain("다...");
- expect(tokens).toContain("원/");
- expect(tokens).toContain("달");
- expect(tokens).toContain("(((다)))");
- expect(tokens).toContain("〚({((한))>)〛");
- expect(tokens).toContain("(「た」)");
- // Numbers and units
- expect(tokens).toContain("¥3700.55");
- expect(tokens).toContain("090-");
- expect(tokens).toContain("1234-");
- expect(tokens).toContain("5678");
- expect(tokens).toContain("¥1,000〜");
- expect(tokens).toContain("$5,000");
- expect(tokens).toContain("1:");
- expect(tokens).toContain("30%");
- expect(tokens).toContain("¥110±");
- expect(tokens).toContain("20℃〜");
- expect(tokens).toContain("9:30〜");
- expect(tokens).toContain("10:00");
- // Punctuation and symbols
- expect(tokens).toContain(" ");
- expect(tokens).toContain("\t");
- expect(tokens).toContain(" ");
- expect(tokens).toContain("ニ");
- expect(tokens).toContain("ー");
- expect(tokens).toContain("ヨ");
- expect(tokens).toContain("〰");
- expect(tokens).toContain("#");
- });
- });
- });
|