├── lib.typ ├── typst.toml ├── test.typ ├── LICENSE ├── remove-cjk-break-space.typ ├── README.md └── transform-childs.typ /lib.typ: -------------------------------------------------------------------------------- 1 | #import "remove-cjk-break-space.typ": remove-cjk-break-space 2 | #import "transform-childs.typ": transform-childs 3 | -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cjk-unbreak" 3 | version = "0.2.1" 4 | entrypoint = "lib.typ" 5 | authors = ["KZNS <@KZNS>"] 6 | license = "MIT" 7 | description = "Remove spaces caused by line breaks around CJK." 8 | repository = "https://github.com/KZNS/cjk-unbreak" 9 | keywords = ["cjk", "space", "line break"] 10 | categories = ["layout", "text", "languages"] 11 | -------------------------------------------------------------------------------- /test.typ: -------------------------------------------------------------------------------- 1 | #import "lib.typ": * 2 | 3 | #set page(margin: 1em, width: 30em, height: auto) 4 | 5 | #show: remove-cjk-break-space 6 | 7 | #set heading(numbering: "一") 8 | #outline() 9 | 10 | = 目录里“一”和标题间的空格保留 11 | 12 | 目录里“一”和标题间的空格在 typst 中生成,不在 AST 中显示 13 | 14 | 换行没有 15 | 空格,行内 保留空格 16 | 17 | - 列表 18 | 也是可以的 19 | - 有空 格 20 | - 没空 21 | 格 22 | 23 | 如果是有序列表 24 | 25 | 1. 有空 格 26 | 2. 没空 27 | 格 28 | 3. 29 | + 30 | 31 | #import "@preview/cuti:0.3.0": * 32 | #show: show-cn-fakebold 33 | 只检查 text 间的空格,其他类型不会去掉:\ 34 | 这种*粗体* 间的空格 *粗体*不会去掉\ 35 | 代码`code` 代码 `code`代码\ 36 | 代码`代码` 代码 `代码`代码\ 37 | 38 | #let f(x) = { [中文] } 39 | #f(1)中文\ 40 | #f(1) 中文\ 41 | #f(1) 42 | 中文 43 | 44 | 你好。你好\ 45 | 你好。 46 | 你好 47 | 48 | 你好。abc。你好\ 49 | 你好。 50 | abc。 51 | 你好 52 | 53 | 去掉的是 `[ ]` 这种空格 54 | 55 | #place(bottom, float: true)[23333] 56 | #align(center)[6666] 57 | 58 | $underbrace(a, b)$ a 应该在上面 \ 59 | #math.underbrace("a", "b") a 应该在上面 60 | 61 | #table( 62 | [a], 63 | [b], 64 | [c], 65 | ) 66 | 67 | http://example.com 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 KZNS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /remove-cjk-break-space.typ: -------------------------------------------------------------------------------- 1 | #import "transform-childs.typ": transform-childs 2 | #import "@preview/touying:0.6.1": utils 3 | 4 | #let cn-char = "\p{Han},。;:!?‘’“”()「」【】…—" 5 | #let jp-char = "\p{Hiragana}\p{Katakana}" 6 | #let cjk-char-regex = regex("[" + cn-char + jp-char + "]") 7 | 8 | #let ends-with-cjk(it) = ( 9 | it != none 10 | and ( 11 | (it.has("text") and it.text.ends-with(cjk-char-regex)) or (it.has("body") and ends-with-cjk(it.body)) 12 | ) 13 | ) 14 | 15 | #let start-with-cjk(it) = ( 16 | it != none 17 | and ( 18 | (it.has("text") and it.text.starts-with(cjk-char-regex)) or (it.has("body") and start-with-cjk(it.body)) 19 | ) 20 | ) 21 | 22 | #let is-text(it) = ( 23 | it != none and it.func() == text 24 | ) 25 | 26 | #let remove-cjk-break-space(rest) = { 27 | rest = transform-childs(rest, remove-cjk-break-space) 28 | if utils.is-sequence(rest) { 29 | let first = none 30 | let mid = none 31 | for third in rest.children { 32 | // first, mid, third 33 | if mid == [ ] and is-text(first) and is-text(third) and (ends-with-cjk(first) or start-with-cjk(third)) { 34 | mid = none 35 | } 36 | first 37 | first = mid 38 | mid = third 39 | } 40 | first 41 | mid 42 | } else { 43 | rest 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cjk-unbreak 2 | 3 | cjk-unbreak is a package that removes spaces around CJK characters caused by 4 | line breaks in the Typst document. 5 | 6 | This allows Typst like: 7 | 8 | ```typst 9 | 中文段落中 10 | 换行。 11 | ``` 12 | 13 | to be rendered as: 14 | 15 | ```text 16 | 中文段落中换行。 17 | ``` 18 | 19 | ## Usage 20 | 21 | Add the following code at the beginning of your document: 22 | 23 | ```typst 24 | #import "@preview/cjk-unbreak:0.2.1": remove-cjk-break-space 25 | #show: remove-cjk-break-space 26 | ``` 27 | 28 | The `remove-cjk-break-space` function transforms the content and removes 29 | spaces between the `text` element such as `[ ]` in 30 | `sequence([中文], [ ], [字符])`. 31 | 32 | It will not modify spaces within a single `text` element such as `[中文 字符]` or 33 | between elements that are not both `text` such as 34 | `sequence([中文], [ ], strong(body: [字符]))`. 35 | 36 | ### Transform 37 | 38 | cjk-unbreak use a function called `transform-childs` to modify the AST of the 39 | content. 40 | Unlike `show` in Typst, which applies multiple times until the content 41 | stabilizes, the transform is applid only once. 42 | 43 | See the source code of `remove-cjk-break-space` to learn more about how to use 44 | `transform-childs`. 45 | 46 | ## Other information 47 | 48 | This package is a temporary solution to Typst 49 | [Issue#792](https://github.com/typst/typst/issues/792). 50 | 51 | Thanks to [admk for providing the idea](https://github.com/typst/typst/issues/792#issuecomment-2310139085) 52 | to remove `[ ]` from the sequence, 53 | and to [touying](https://typst.app/universe/package/touying/), who demonstrated 54 | how to modify the AST of content. 55 | -------------------------------------------------------------------------------- /transform-childs.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/touying:0.6.1": utils 2 | 3 | #let last-positional-param-is-variadic(it) = { 4 | if it.has("children") { 5 | true 6 | } else if it.func() in (math.binom, math.mat) { 7 | true 8 | } else { 9 | false 10 | } 11 | } 12 | 13 | #let get-positional-param-names(it) = { 14 | let func = it.func() 15 | if it.has("body") { 16 | if func == math.class { 17 | ("class", "body") 18 | } else if ( 19 | func 20 | in ( 21 | math.underbrace, 22 | math.overbrace, 23 | math.underbracket, 24 | math.overbracket, 25 | math.underparen, 26 | math.overparen, 27 | math.undershell, 28 | math.overshell, 29 | ) 30 | ) { 31 | ("body", "annotation") 32 | } else if func == link { 33 | ("dest", "body") 34 | } else if func == enum.item { 35 | ("number", "body") 36 | } else if func in (place, align) { 37 | ("alignment", "body") 38 | } else { 39 | ("body",) 40 | } 41 | } else if it.has("children") { 42 | ("children",) 43 | } else { 44 | if func == math.accent { 45 | ("base", "accent") 46 | } else if func == math.attach { 47 | ("base",) 48 | } else if func == math.binom { 49 | ("upper", "lower") 50 | } else if func == math.frac { 51 | ("num", "denom") 52 | } else if func == math.mat { 53 | ("rows",) 54 | } else if func == math.primes { 55 | ("count",) 56 | } else if func == math.root { 57 | ("index", "radicand") 58 | } else if func == math.sqrt { 59 | ("radicand",) 60 | } else if func == math.op { 61 | ("text",) 62 | } else { 63 | // has no fields? 64 | none 65 | } 66 | } 67 | } 68 | 69 | #let reconstruct(it, named-params, positional-params) = { 70 | let label = named-params.remove("label", default: none) 71 | if label != none { 72 | return utils.label-it((it.func())(..named-params, ..positional-params), label) 73 | } else { 74 | return (it.func())(..named-params, ..positional-params) 75 | } 76 | } 77 | 78 | #let transform-childs(it, transform-func) = { 79 | if type(it) == content { 80 | if utils.is-sequence(it) { 81 | for item in it.children { 82 | transform-func(item) 83 | } 84 | } else if utils.is-styled(it) { 85 | let child = transform-func(it.child) 86 | utils.reconstruct-styled(it, child) 87 | } else { 88 | let positional-param-names = get-positional-param-names(it) 89 | if positional-param-names != none { 90 | let fields = it.fields() 91 | let positional-params = if positional-param-names.len() == 0 { 92 | () 93 | } else { 94 | let names = positional-param-names 95 | let variadic-name = none 96 | if last-positional-param-is-variadic(it) { 97 | names = positional-param-names.slice(0, -1) 98 | variadic-name = positional-param-names.last() 99 | } 100 | for name in names { 101 | let x = fields.remove(name, default: none) 102 | (transform-func(x),) 103 | } 104 | if variadic-name != none { 105 | let x = fields.remove(variadic-name, default: none) 106 | x.map(i => transform-func(i)) 107 | } 108 | } 109 | for (key, value) in fields { 110 | fields.insert(key, transform-func(value)) 111 | } 112 | reconstruct(it, fields, positional-params) 113 | } else { 114 | // has no fields 115 | it 116 | } 117 | } 118 | } else if type(it) == array { 119 | it.map(i => transform-func(i)) 120 | } else { 121 | it 122 | } 123 | } 124 | --------------------------------------------------------------------------------