Remove spaces from newlines between CJK characters#7350
Remove spaces from newlines between CJK characters#7350isuffix wants to merge 4 commits intotypst:mainfrom
Conversation
|
Thank you for implementing this! As for space-collapsing-cjk-strong ( Besides, the cjk-unbreak package has accumulated a few test cases in https://github.com/KZNS/cjk-unbreak/blob/2ea9b0ce3654ab537116499f63aa4077165192bc/test.typ. They might be relevant. Update on 2025-12-17: The cjk-spacer package published recently is also relevant. |
crates/typst-syntax/src/lexer.rs
Outdated
| pub fn is_cjk(c: char) -> bool { | ||
| matches!( | ||
| c.script(), | ||
| Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul |
There was a problem hiding this comment.
I suggest we also include punctuation marks, for the following use case.
Typst source:
我就站住,
豫备她来讨钱。
“你回来了?”
她先这样问。
Result:
我就站住,豫备她来讨钱。“你回来了?”她先这样问。
The list of CJK punctuation marks can be found in clreq and jlreq. (I don't know if K should be included here.)
This might be more complicated than it seems to be. For example, the following three characters are widely used in Chinese documents, but their categories are different. See regex.pdf for further comparisons.
-
U+3001
、顿号 (secondary comma) matches\p{Script_Extensions=Han}. -
U+FF0C
,逗号 (regular full-width comma) matches\p{Script_Extensions=Common}. -
U+201C
“上双引号 (left double quotation mark) matches\p{Script_Extensions=Common}, and it is also used in Latin documents.
They all match \p{General_Category=Punctuation} and \p{Script=Common}.
There was a problem hiding this comment.
For Korean, they should be included, along with its half-width counter parts of the Latin punctuations where possible---both of which are used.
There was a problem hiding this comment.
Please don't include Hangul here. Modern Korean uses spaces to separate words, so it's not relavant here. The function is better named as is_cj.
I used 'CJK' in the title of #792, that was a mistake. This issue only affects Chinese and Japanese.
There was a problem hiding this comment.
Thank you for the links, they're very helpful!
I can change to just CJ removing Hangul. I'll plan to move the function out of the lexer and leave the lexer behavior untouched (unless that should change too?)
However, I'd appreciate more thoughts on what to do for punctuation. It seems we have two categories of codepoints: non-ambiguous CJ punctuation and ambiguous CJ punctuation, such as left/right quotes. For non-ambiguous, I guess we can just treat them like all other CJ codepoints for collapsing, but I'm less sure what to do for ambiguous punctuation.
I presume it would be a good behavior for ambiguous punctuation followed by a CJ character (or vice-versa) to collapse a newline space, but what about an ambiguous punctuation next to another ambiguous punctuation? Are there any characters that would be likely to be split across lines? Should we look at the text language to determine this?
Also, some of the non-ambiguous CJ punctuation overlap in usage with Hangul. Should we be taking that into account?
This is also relevant for #5858, which has some related discussion around quotation marks.
There was a problem hiding this comment.
Another approach would be to just set the space collapsing behavior based on the text language, or an explicit property of say, par() (similar to the request in #710). This is more coarse-grained, but would simplify many of these considerations. Another tradeoff 😮💨
There was a problem hiding this comment.
@YDX-2147483647 According to https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt, The EAW of U+17A4 is N, not W or F. Did you confuse it with something different?
There was a problem hiding this comment.
The Script property of U+115F is Hangul and EAW of it is W.
There was a problem hiding this comment.
The EAW of U+17A4 is N, not W or F. Did you confuse it with something different?
Hi! I didn't make it clear. What I mean is as follows.
c.width()uses a complex rule to determine the width, and EAW is one of the factors.- The full rule is documented on https://docs.rs/unicode-width, and it says that U+17A4 and U+115F will give width 2. (For these two specific characters, EAW does not contribute to
c.width().) - Therefore, I don't think
c.width() == Some(2)is a good criterion.
There was a problem hiding this comment.
I agree with your opinion that unicode_with is not suitable for determining whether the character is CJ(K).
We should use the EAW property directly. Also, U+FF61 HALFWIDTH IDEOGRAPHIC FULL STOP is a (legacy) Japanese character but whose Script is not Katakana but Common and whose EAW is H. All non-Hangul/Korean characters whose EAW is H must be treated as Chinese/Japanese.
There was a problem hiding this comment.
If we have issues with determining whether characters are Korean, we should test their test cases in FIrefox and report them in https://bugzilla.mozilla.org/ (its bug tracker):
<div lang=ja><!-- ← or zh -->
Test
Case
Here
</div>Removing a newline around a punctuation is enabled only in Chinese and Japanese.
|
Note: I personally prefer the |
dd58d2b to
a70304f
Compare
|
@tats-u thanks! The link to the CSS WG Draft is also helpful. |
It should be just a link to tracker. No concrete specification is stipulated in CSS WG Draft (https://drafts.csswg.org/css-text-4/#line-break-transform) now. Prettier's issue (fixed): "K" should be excluded from the title, too. FYI, the following JS expression returns Iterator.from((function*() {for (let i = 0; i <= 0x10ffff; i++) yield i;})()).filter(cp => /[\p{P}&&\p{sc=Hang}]/v.test(String.fromCodePoint(cp))).toArray() |
9457d5c to
f2e43ab
Compare
|
I rebased off main and have refactored the space collapsing algorithm quite a bit. It now both depends on #7609 and reifies the kinds of actions that the algorithm takes in a new enum. This shouldn't change any of this PR's high-level behavior (we still discard newline spaces if either side is a space-discarding character), but the new organization really helps me keep the full algorithm in my head. Additionally, I've updated the space-discarding character set to include common-script characters only if their East Asian Width is F/W/H but they are not emoji (although I still anticipate that this can be improved). This is aided by the new test To determine the East Asian Width, I've moved to using the |
| /// Whether a character is part of the space-discarding set for Typst. These | ||
| /// characters discard adjacent spaces caused by newlines and allow Chinese and | ||
| /// Japanese text to be broken across lines in markup without producing spaces. | ||
| /// | ||
| /// Currently this checks if the character is in either the Chinese or Japanese | ||
| /// scripts, or it is Common script (mainly punctuation) and has a defined East | ||
| /// Asian Width property of H/F/W and is not an Emoji. | ||
| pub(crate) fn is_space_discarding(c: char) -> bool { | ||
| // TODO: Load ICU sets/maps from typst-assets or use data from a different | ||
| // crate altogether. I assume there are still more changes to make, so | ||
| // leaving as-is for now. | ||
| const SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> = | ||
| icu_properties::maps::script(); | ||
| const EAW_DATA: CodePointMapDataBorrowed<'static, EastAsianWidth> = | ||
| icu_properties::maps::east_asian_width(); | ||
| const EMOJI_DATA: CodePointSetDataBorrowed<'static> = icu_properties::sets::emoji(); | ||
|
|
||
| match SCRIPT_DATA.get(c) { | ||
| Script::Han | Script::Hiragana | Script::Katakana => true, | ||
| Script::Common => { | ||
| matches!( | ||
| EAW_DATA.get(c), | ||
| EastAsianWidth::Halfwidth | ||
| | EastAsianWidth::Fullwidth | ||
| | EastAsianWidth::Wide | ||
| ) && !EMOJI_DATA.contains(c) | ||
| } | ||
| _ => false, | ||
| } | ||
| } |
There was a problem hiding this comment.
Here is the new space-discarding character check.
There was a problem hiding this comment.
There seems to be some other East Asian scripts that prefers without-space-style e.g. Yi.
Also you should add // Especially Hangul above _ => false,.
There was a problem hiding this comment.
There seems to be some other East Asian scripts that prefers without-space-style e.g. Yi.
So imo the feature is more about scripts that does not use spaces in writing than CJK characters only. This reminds me of Tangut -- don't know if they are Script::Han, though. Also, do we need to take those languages that do not use space within sentence level into account? e.g. Tibetan only uses spaces after a punctuation mark like ། .
There was a problem hiding this comment.
The advantage of relying only on EAW for non-Hangul scripts recognition is that it eliminates the need to worry about about which scripts must be covered, including minor scripts like Yi or Tangut.
There was a problem hiding this comment.
't know if they are Script::Han, though.
A dedicated Script Property Value Tangut is assigned to Tangut since Unicode 9 (2016).
| --- newline-space-discarding-edge-cases paged --- | ||
| // Test newline space discarding for edge case characters. | ||
| // Characters inspired by clreq and jlreq: | ||
| // https://www.w3.org/TR/clreq | ||
| // https://www.w3.org/TR/jlreq | ||
|
|
||
| // Whether each string should discard an adjacent newline space. | ||
| #let should-discard = ( | ||
| // Basic characters in different languages | ||
| ("A", false), | ||
| ("漢", true), |
There was a problem hiding this comment.
Let me know what characters I should add to this test and whether any should change.
There was a problem hiding this comment.
- ア U+FF71 true ←EAW is H
- ㊙ U+3299 ? (false in my opinion) ←This is a default text presentation character like ©. Without a succeeding U+FE0F, it should not be displayed as emoji if a proper Japanese font is assigned. However, the current Firefox treats both 2 symbols as emoji because it treats every emoji character (a character that has an Emoji property) as emoji. There is Emoji_Presentation property.
- 한 U+D55C false ←Prose wrap options for Korean prettier/prettier#6516
- ₩ U+20A9 ? (false in my opinion) ←EAW is H but Korea-dedicated. Since the EAW of ¥ U+00A5 for Japan and PRC is Na, ₩ should not be treated as Chinese-or-Japanese, either. Shamefully
&& c != '\u{20A9}'is needed.
It is recommended to also check the character two positions away as necessary to check emojiness of the adjacent grapheme more accurately.
The reason is just that nobody got to it. But there is #7412. There's still some unresolved questions though.
Definitely ICU. |
4c7e973 to
e639bba
Compare
|
The first push rebases off the updated #7609, the second/third just add some of the mentioned edge case characters, although I haven't updated their implementation yet. |
e639bba to
b8f33e9
Compare
This PR makes spaces due to newlines between CJK characters collapse to avoid creating a space when rendered. I've done so by splitting the
Spacesyntax kind into two kinds to determine whether a space had a newline, and then by modifying the space-collapsing algorithm during Typst's realization step.This is the behavior I mentioned in #792 (comment) (I have since changed my username from
wrziantoisuffix).Closes #792
Besides the below tradeoffs, I also have a few
TODOcomments that I would like input on.Tradeoffs
This is a robust solution, but it makes multiple choices with tradeoffs that I'm not sure are desireable. I do not speak any CJK languages, so I'd appreciate feedback from the community about what is/isn't desired :)
CC @peng1999, @YDX-2147483647, @account-login
The main alternative design would be to solely resolve this in the parser or the AST, like YDX mentions in #792 (comment). That may improve or harm any of the tradeoffs below based on your opinion.
Each of the tradeoffs is exemplified in one or more new test cases:
Tradeoff: Collapsing happens during realization
Because collapsing happens after realization, a single newline space in the document may or may not collapse if its neighbors evaluate to CJK/non-CJK text dynamically.
Additionally, since a space element can itself be stored in a variable, static CJK characters can have different spacing due to stored space variables.
This is obviously one of the more contentious tradeoffs, but I think it's mostly fine. The first case is reasonable, and I doubt the second case is likely to affect real documents. But I am ok with changing either.
Test and output:
space-collapsing-cjk-dynamicTradeoff: Normal spaces collapse only if they are adjacent to a newline space
Spaces that are not from newlines are kept, as in
空 格(see the test case dropdown), but when adjacent to a newline space they will collapse.While the basic behavior of collapsing a newline space or a newline space followed by a comment are straightforward, it's less clear whether a normal space followed by a comment and a newline space should combine as one space and collapse together, or if they should act separately. In addition, it's unclear if spaces with different styling should be able to combine and collapse together.
Currently, all three of the cases in this codeblock will combine and collapse their spaces. For me, I think the first is good, and the second is probably desired, but I'm less sure about the third case. (these are rendered in the dropdown below)
Tests and output:
issue-792-space-collapsing-cjkandspace-collapsing-cjk-strongTradeoff: Treating space values as equal
This is the one I'm least certain about, and would be improved by ignoring spaces in the parser/AST.
There are a few other test cases (
list-indent-trivia-nesting,list-indent-bracket-nesting) that implicitly expect space elements to be equal to each other regardless of newlines. I feel like I also generally expect this behavior (I wrote those tetsts), so breaking them feels odd. I added a customPartialEqimplementation toSpaceElemthat always returns true to make this work. However, I'm not sure if this is sound with the way Comemo caches data.I'm totally ok with removing this if we stay with space-collapsing during realization, but it will require modifying the other test cases if we do, and we would probably also want to modify the
reprofSpaceElem.Test:
space-eq-newline