Skip to content

Commit

Permalink
Merge pull request #411 from henry0312/update_cond_east_asian_line_br…
Browse files Browse the repository at this point in the history
…eaks

Define line break styles for east asian characters as options
  • Loading branch information
yuin committed Oct 28, 2023
2 parents 6442ae1 + 6b3067e commit a89ad04
Show file tree
Hide file tree
Showing 6 changed files with 708 additions and 33 deletions.
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,48 @@ This extension provides additional options for CJK users.

| Functional option | Type | Description |
| ----------------- | ---- | ----------- |
| `extension.WithEastAsianLineBreaks` | `-` | Soft line breaks are rendered as a newline. Some asian users will see it as an unnecessary space. With this option, soft line breaks between east asian wide characters will be ignored. |
| `extension.WithEastAsianLineBreaks` | `...extension.EastAsianLineBreaksStyle` | Soft line breaks are rendered as a newline. Some asian users will see it as an unnecessary space. With this option, soft line breaks between east asian wide characters will be ignored. |
| `extension.WithEscapedSpace` | `-` | Without spaces around an emphasis started with east asian punctuations, it is not interpreted as an emphasis(as defined in CommonMark spec). With this option, you can avoid this inconvenient behavior by putting 'not rendered' spaces around an emphasis like `太郎は\ **「こんにちわ」**\ といった`. |


#### Styles of Line Breaking

| Style | Description |
| ----- | ----------- |
| `EastAsianLineBreaksStyleSimple` | Soft line breaks are ignored if both sides of the break are east asian wide character. This behavior is the same as [`east_asian_line_breaks`](https://pandoc.org/MANUAL.html#extension-east_asian_line_breaks) in Pandoc. |
| `EastAsianLineBreaksCSS3Draft` | This option implements CSS text level3 [Segment Break Transformation Rules](https://drafts.csswg.org/css-text-3/#line-break-transform) with [some enhancements](https://github.com/w3c/csswg-drafts/issues/5086). |

#### Example of `EastAsianLineBreaksStyleSimple`

Input Markdown:

```md
私はプログラマーです。
東京の会社に勤めています。
GoでWebアプリケーションを開発しています。
```

Output:

```html
<p>私はプログラマーです。東京の会社に勤めています。\nGoでWebアプリケーションを開発しています。</p>
```

#### Example of `EastAsianLineBreaksCSS3Draft`

Input Markdown:

```md
私はプログラマーです。
東京の会社に勤めています。
GoでWebアプリケーションを開発しています。
```

Output:

```html
<p>私はプログラマーです。東京の会社に勤めています。GoでWebアプリケーションを開発しています。</p>
```

Security
--------------------
By default, goldmark does not render raw HTML or potentially-dangerous URLs.
Expand Down
41 changes: 36 additions & 5 deletions extension/cjk.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,30 @@ import (
// A CJKOption sets options for CJK support mostly for HTML based renderers.
type CJKOption func(*cjk)

// A EastAsianLineBreaksStyle is a style of east asian line breaks.
type EastAsianLineBreaksStyle int

const (
// EastAsianLineBreaksStyleSimple is a style where soft line breaks are ignored
// if both sides of the break are east asian wide characters.
EastAsianLineBreaksStyleSimple EastAsianLineBreaksStyle = iota
// EastAsianLineBreaksCSS3Draft is a style where soft line breaks are ignored
// even if only one side of the break is an east asian wide character.
EastAsianLineBreaksCSS3Draft
)

// WithEastAsianLineBreaks is a functional option that indicates whether softline breaks
// between east asian wide characters should be ignored.
func WithEastAsianLineBreaks() CJKOption {
func WithEastAsianLineBreaks(style ...EastAsianLineBreaksStyle) CJKOption {
return func(c *cjk) {
c.EastAsianLineBreaks = true
e := &eastAsianLineBreaks{
Enabled: true,
EastAsianLineBreaksStyle: EastAsianLineBreaksStyleSimple,
}
for _, s := range style {
e.EastAsianLineBreaksStyle = s
}
c.EastAsianLineBreaks = e
}
}

Expand All @@ -25,10 +44,15 @@ func WithEscapedSpace() CJKOption {
}

type cjk struct {
EastAsianLineBreaks bool
EastAsianLineBreaks *eastAsianLineBreaks
EscapedSpace bool
}

type eastAsianLineBreaks struct {
Enabled bool
EastAsianLineBreaksStyle EastAsianLineBreaksStyle
}

// CJK is a goldmark extension that provides functionalities for CJK languages.
var CJK = NewCJK(WithEastAsianLineBreaks(), WithEscapedSpace())

Expand All @@ -42,8 +66,15 @@ func NewCJK(opts ...CJKOption) goldmark.Extender {
}

func (e *cjk) Extend(m goldmark.Markdown) {
if e.EastAsianLineBreaks {
m.Renderer().AddOptions(html.WithEastAsianLineBreaks())
if e.EastAsianLineBreaks != nil {
if e.EastAsianLineBreaks.Enabled {
style := html.EastAsianLineBreaksStyleSimple
switch e.EastAsianLineBreaks.EastAsianLineBreaksStyle {
case EastAsianLineBreaksCSS3Draft:
style = html.EastAsianLineBreaksCSS3Draft
}
m.Renderer().AddOptions(html.WithEastAsianLineBreaks(style))
}
}
if e.EscapedSpace {
m.Renderer().AddOptions(html.WithWriter(html.NewWriter(html.WithEscapedSpace())))
Expand Down
58 changes: 58 additions & 0 deletions extension/cjk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func TestEastAsianLineBreaks(t *testing.T) {
t,
)

// Tests with EastAsianLineBreaksStyleSimple
markdown = goldmark.New(goldmark.WithRendererOptions(
html.WithXHTML(),
html.WithUnsafe(),
Expand Down Expand Up @@ -208,4 +209,61 @@ func TestEastAsianLineBreaks(t *testing.T) {
},
t,
)
no = 9
testutil.DoTestCase(
markdown,
testutil.MarkdownTestCase{
No: no,
Description: "Soft line breaks between an east asian wide character and a western character are ignored",
Markdown: "私はプログラマーです。\n東京の会社に勤めています。\nGoでWebアプリケーションを開発しています。",
Expected: "<p>私はプログラマーです。東京の会社に勤めています。\nGoでWebアプリケーションを開発しています。</p>",
},
t,
)

// Tests with EastAsianLineBreaksCSS3Draft
markdown = goldmark.New(goldmark.WithRendererOptions(
html.WithXHTML(),
html.WithUnsafe(),
),
goldmark.WithExtensions(
NewCJK(WithEastAsianLineBreaks(EastAsianLineBreaksCSS3Draft)),
),
)
no = 10
testutil.DoTestCase(
markdown,
testutil.MarkdownTestCase{
No: no,
Description: "Soft line breaks between a western character and an east asian wide character are ignored",
Markdown: "太郎は\\ **「こんにちわ」**\\ と言ったa\nんです",
Expected: "<p>太郎は\\ <strong>「こんにちわ」</strong>\\ と言ったaんです</p>",
},
t,
)

no = 11
testutil.DoTestCase(
markdown,
testutil.MarkdownTestCase{
No: no,
Description: "Soft line breaks between an east asian wide character and a western character are ignored",
Markdown: "太郎は\\ **「こんにちわ」**\\ と言った\nbんです",
Expected: "<p>太郎は\\ <strong>「こんにちわ」</strong>\\ と言ったbんです</p>",
},
t,
)

no = 12
testutil.DoTestCase(
markdown,
testutil.MarkdownTestCase{
No: no,
Description: "Soft line breaks between an east asian wide character and a western character are ignored",
Markdown: "私はプログラマーです。\n東京の会社に勤めています。\nGoでWebアプリケーションを開発しています。",
Expected: "<p>私はプログラマーです。東京の会社に勤めています。GoでWebアプリケーションを開発しています。</p>",
},
t,
)

}
115 changes: 105 additions & 10 deletions renderer/html/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"strconv"
"unicode"
"unicode/utf8"

"github.com/yuin/goldmark/ast"
Expand All @@ -16,7 +17,7 @@ import (
type Config struct {
Writer Writer
HardWraps bool
EastAsianLineBreaks bool
EastAsianLineBreaks eastAsianLineBreaks
XHTML bool
Unsafe bool
}
Expand All @@ -26,7 +27,7 @@ func NewConfig() Config {
return Config{
Writer: DefaultWriter,
HardWraps: false,
EastAsianLineBreaks: false,
EastAsianLineBreaks: eastAsianLineBreaks{},
XHTML: false,
Unsafe: false,
}
Expand All @@ -38,7 +39,7 @@ func (c *Config) SetOption(name renderer.OptionName, value interface{}) {
case optHardWraps:
c.HardWraps = value.(bool)
case optEastAsianLineBreaks:
c.EastAsianLineBreaks = value.(bool)
c.EastAsianLineBreaks = value.(eastAsianLineBreaks)
case optXHTML:
c.XHTML = value.(bool)
case optUnsafe:
Expand Down Expand Up @@ -103,24 +104,119 @@ func WithHardWraps() interface {
// EastAsianLineBreaks is an option name used in WithEastAsianLineBreaks.
const optEastAsianLineBreaks renderer.OptionName = "EastAsianLineBreaks"

// A EastAsianLineBreaksStyle is a style of east asian line breaks.
type EastAsianLineBreaksStyle int

const (
// EastAsianLineBreaksStyleSimple follows east_asian_line_breaks in Pandoc.
EastAsianLineBreaksStyleSimple EastAsianLineBreaksStyle = iota
// EastAsianLineBreaksCSS3Draft follows CSS text level3 "Segment Break Transformation Rules" with some enhancements.
EastAsianLineBreaksCSS3Draft
)

type eastAsianLineBreaker interface {
SoftLineBreak(thisLastRune rune, siblingFirstRune rune) bool
}

type eastAsianLineBreaksSimple struct{}

func (e *eastAsianLineBreaksSimple) SoftLineBreak(thisLastRune rune, siblingFirstRune rune) bool {
return !(util.IsEastAsianWideRune(thisLastRune) && util.IsEastAsianWideRune(siblingFirstRune))
}

type eastAsianLineBreaksCSS3Draft struct{}

func (e *eastAsianLineBreaksCSS3Draft) SoftLineBreak(thisLastRune rune, siblingFirstRune rune) bool {
// Implements CSS text level3 Segment Break Transformation Rules with some enhancements.
// References:
// - https://www.w3.org/TR/2020/WD-css-text-3-20200429/#line-break-transform
// - https://github.com/w3c/csswg-drafts/issues/5086

// Rule1:
// If the character immediately before or immediately after the segment break is
// the zero-width space character (U+200B), then the break is removed, leaving behind the zero-width space.
if thisLastRune == '\u200B' || siblingFirstRune == '\u200B' {
return false
}

// Rule2:
// Otherwise, if the East Asian Width property of both the character before and after the segment break is
// F, W, or H (not A), and neither side is Hangul, then the segment break is removed.
thisLastRuneEastAsianWidth := util.EastAsianWidth(thisLastRune)
siblingFirstRuneEastAsianWidth := util.EastAsianWidth(siblingFirstRune)
if (thisLastRuneEastAsianWidth == "F" ||
thisLastRuneEastAsianWidth == "W" ||
thisLastRuneEastAsianWidth == "H") &&
(siblingFirstRuneEastAsianWidth == "F" ||
siblingFirstRuneEastAsianWidth == "W" ||
siblingFirstRuneEastAsianWidth == "H") {
return unicode.Is(unicode.Hangul, thisLastRune) || unicode.Is(unicode.Hangul, siblingFirstRune)
}

// Rule3:
// Otherwise, if either the character before or after the segment break belongs to
// the space-discarding character set and it is a Unicode Punctuation (P*) or U+3000,
// then the segment break is removed.
if util.IsSpaceDiscardingUnicodeRune(thisLastRune) ||
unicode.IsPunct(thisLastRune) ||
thisLastRune == '\u3000' ||
util.IsSpaceDiscardingUnicodeRune(siblingFirstRune) ||
unicode.IsPunct(siblingFirstRune) ||
siblingFirstRune == '\u3000' {
return false
}

// Rule4:
// Otherwise, the segment break is converted to a space (U+0020).
return true
}

type eastAsianLineBreaks struct {
Enabled bool
EastAsianLineBreaksFunction eastAsianLineBreaker
}

type withEastAsianLineBreaks struct {
eastAsianLineBreaksStyle EastAsianLineBreaksStyle
}

func (o *withEastAsianLineBreaks) SetConfig(c *renderer.Config) {
c.Options[optEastAsianLineBreaks] = true
switch o.eastAsianLineBreaksStyle {
case EastAsianLineBreaksStyleSimple:
c.Options[optEastAsianLineBreaks] = eastAsianLineBreaks{
Enabled: true,
EastAsianLineBreaksFunction: &eastAsianLineBreaksSimple{},
}
case EastAsianLineBreaksCSS3Draft:
c.Options[optEastAsianLineBreaks] = eastAsianLineBreaks{
Enabled: true,
EastAsianLineBreaksFunction: &eastAsianLineBreaksCSS3Draft{},
}
}
}

func (o *withEastAsianLineBreaks) SetHTMLOption(c *Config) {
c.EastAsianLineBreaks = true
switch o.eastAsianLineBreaksStyle {
case EastAsianLineBreaksStyleSimple:
c.EastAsianLineBreaks = eastAsianLineBreaks{
Enabled: true,
EastAsianLineBreaksFunction: &eastAsianLineBreaksSimple{},
}
case EastAsianLineBreaksCSS3Draft:
c.EastAsianLineBreaks = eastAsianLineBreaks{
Enabled: true,
EastAsianLineBreaksFunction: &eastAsianLineBreaksCSS3Draft{},
}
}
}

// WithEastAsianLineBreaks is a functional option that indicates whether softline breaks
// between east asian wide characters should be ignored.
func WithEastAsianLineBreaks() interface {
func WithEastAsianLineBreaks(style EastAsianLineBreaksStyle) interface {
renderer.Option
Option
} {
return &withEastAsianLineBreaks{}
return &withEastAsianLineBreaks{style}
}

// XHTML is an option name used in WithXHTML.
Expand Down Expand Up @@ -663,14 +759,13 @@ func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, en
_, _ = w.WriteString("<br>\n")
}
} else if n.SoftLineBreak() {
if r.EastAsianLineBreaks && len(value) != 0 {
if r.EastAsianLineBreaks.Enabled && len(value) != 0 {
sibling := node.NextSibling()
if sibling != nil && sibling.Kind() == ast.KindText {
if siblingText := sibling.(*ast.Text).Text(source); len(siblingText) != 0 {
thisLastRune := util.ToRune(value, len(value)-1)
siblingFirstRune, _ := utf8.DecodeRune(siblingText)
if !(util.IsEastAsianWideRune(thisLastRune) &&
util.IsEastAsianWideRune(siblingFirstRune)) {
if r.EastAsianLineBreaks.EastAsianLineBreaksFunction.SoftLineBreak(thisLastRune, siblingFirstRune) {
_ = w.WriteByte('\n')
}
}
Expand Down
16 changes: 0 additions & 16 deletions util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -836,22 +836,6 @@ func IsAlphaNumeric(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
}

// IsEastAsianWideRune returns trhe if the given rune is an east asian wide character, otherwise false.
func IsEastAsianWideRune(r rune) bool {
// https://en.wikipedia.org/wiki/CJK_Symbols_and_Punctuation
var CJKSymbolsAndPunctuation = &unicode.RangeTable{
R16: []unicode.Range16{
{0x3000, 0x303F, 1},
},
}
return unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Lm, r) ||
unicode.Is(unicode.Hangul, r) ||
unicode.Is(CJKSymbolsAndPunctuation, r)
}

// A BufWriter is a subset of the bufio.Writer .
type BufWriter interface {
io.Writer
Expand Down

0 comments on commit a89ad04

Please sign in to comment.