go-text / typesetting

High quality text shaping in pure Go.
Other
88 stars 11 forks source link

Custom spacing #90

Closed benoitkugler closed 1 month ago

benoitkugler commented 11 months ago

This is a draft PR to support custom spacing between words or letters.

The core implementation is complete, but I'm not sure yet what the exported API should be.

My use case will be for an 'all-in-one' segmenting-shaping-spacing-wrapping-justifying function, so that the addWordSpacing and addLetterSpacing could stay hidden, but perhaps you would have other use cases in mind ?

whereswaldon commented 11 months ago

This is a draft PR to support custom spacing between words or letters.

The core implementation is complete, but I'm not sure yet what the exported API should be.

My use case will be for an 'all-in-one' segmenting-shaping-spacing-wrapping-justifying function, so that the addWordSpacing and addLetterSpacing could stay hidden, but perhaps you would have other use cases in mind ?

This is also my use-case, but Gio implements its own all-in-one operation for this right now. Maybe we will eventually be able to use one provided by go-text, but for now I'd prefer to have the flexibility of keeping it in Gio. So I would want these operations exported.

I don't think I understand how to use addLetterSpacing realistically though. I guess you can apply it before line wrapping in order to space out glyphs, but then you didn't know where the start/end of lines were, so you end up with extra space at the beginning/end of line wrap locations. If you were going to use this for justifying text post-wrapping, it would seem nicer to offer an API that you could supply the advance that you want to "spread" among the glyph clusters and let that do all of the work.

I'm also not sure it makes sense to define these as operations on single runs. Both word spacing and letter spacing make sense at run boundaries in multi-run text like bidi or just rich text with varying styles. Maybe these should accept []Output to act on?

benoitkugler commented 11 months ago

Thank you very much for your comments, which enlighten many shortcomings.

I don't think I understand how to use addLetterSpacing realistically though. I guess you can apply it before line wrapping in order to space out glyphs, but then you didn't know where the start/end of lines were, so you end up with extra space at the beginning/end of line wrap locations. If you were going to use this for justifying text post-wrapping, it would seem nicer to offer an API that you could supply the advance that you want to "spread" among the glyph clusters and let that do all of the work.

Fair point.. but the additional spacing will also influence line wrapping, so we would need something like

Does it seems correct ?

I'm also not sure it makes sense to define these as operations on single runs. Both word spacing and letter spacing make sense at run boundaries in multi-run text like bidi or just rich text with varying styles. Maybe these should accept []Output to act on?

Hum yes, you right. We need a finer handling of run boundaries..

andydotxyz commented 11 months ago

My use case will be for an 'all-in-one' segmenting-shaping-spacing-wrapping-justifying function, so that the addWordSpacing and addLetterSpacing could stay hidden, but perhaps you would have other use cases in mind ?

If we are looking all in one then it also needs to encompass tab alignment as well (where both initial and also mid-text tab characters need to align with something outside the current shaping info). I would expect that is outside the scope, and so like Gio I expect Fyne might need to keep it internally.

It does sound like this is the right place for inserting the extra space as you say, but perhaps stopping short of an all-in-one in terms of its usage?

benoitkugler commented 11 months ago

The last commit tries to address your comments.

There is now 3 exported methods, to be used like this :

Do you think such an approach is usable ?

We will probably need a similar mechanism for word spacing. By the way, how do you handle spaces at the line boundaries in the toolkits ? Does the line wrapper make sure no line begin with a space ?

whereswaldon commented 11 months ago

The last commit tries to address your comments.

There is now 3 exported methods, to be used like this :

* for each run, add word spacing and letter spacing (that could be done by an implementation of `RunIterator`)

* wrap

* for each line, call `TrimLetterSpacing` to cleanup the line

Do you think such an approach is usable ?

Maybe. I'd like to talk through your next question and circle back to this.

We will probably need a similar mechanism for word spacing. By the way, how do you handle spaces at the line boundaries in the toolkits ? Does the line wrapper make sure no line begin with a space ?

This is actually a problem that I've been grappling with in Gio. In the default case it isn't an issue, but if we have LTR text right-aligned or RTL text left-aligned, the current line wrapper will make the glyph at the edge of the line a space. This can look terrible. Consider wrapping "the quick brown fox" in LTR but right-aligned. I'll mock out what happens in monospace below (using an underscore to indicate actual space glyphs):

  The_
quick_
brown_
   fox

One way to solve this problem would be to make the line wrapper alignment-aware so that it could instead choose:

   The
_quick
_brown
  _fox

However, this both makes the results harder to cache and the algorithm more complex.

It's tempting to say that we can just trim the space characters at the end of lines, but that doesn't work well with text editors. If you trim the space, there's no visible difference between the cursor being before and after that space glyph. It makes for a somewhat confusing editing experience.

The line wrapper also currently always respects the width of whitespace glyphs. The above examples wrap to a width of 6 monospace glyphs, but would actually behave unexpectedly if wrapping to width 5 (with WhenNecessary as a break policy):

 The_
quick
_brow
n_fox

Our current algorithm isn't smart enough to collapse the space after "quick", and thus we break "brown" across lines.

I've been meaning to research how other text toolkits and browsers handle these cases to see what the reasonable thing to do is, but I haven't gotten to it yet.

@benoitkugler Do you happen to already know how this stuff is usually handled?

benoitkugler commented 11 months ago

I've quickly look at Pango, and what they do is actually zeroing the advance of a space who ends up at the end of a line.

If you trim the space, there's no visible difference between the cursor being before and after that space glyph.

Are you sure this a problem across line boundaries ? It seems that the line break is a sufficient visual indicator, isn't it ?

The Pango behavior seems similar to what browsers are doing. See for instance the textearea this comment is written into..

whereswaldon commented 11 months ago

I've quickly look at Pango, and what they do is actually zeroing the advance of a space who ends up at the end of a line.

If you trim the space, there's no visible difference between the cursor being before and after that space glyph.

Are you sure this a problem across line boundaries ? It seems that the line break is a sufficient visual indicator, isn't it ?

The Pango behavior seems similar to what browsers are doing. See for instance the textearea this comment is written into..

That's fair. It may simply be that I need to generate cursor positions more intelligently in Gio (that's some of our gnarliest code) to handle this case better.

If we apply this space-trimming as a post-processing step, it doesn't seem like it can help us solve this though:

 The_
quick
_brow
n_fox

For this problem to be solved, we'd need the wrapper itself to be aware that it can choose to make whitespace at the end of a line have zero width. That, in turn, would mean adding a mechanism to know what whitespace actually is. Right now the wrapper treats all glyphs the same and only cares about where UAX says the segment boundaries are.

I confess that it feels a little awkward to ask consumers to "call this method on each shaped run" and to then "call this method on each line after shaping with the same value". However, I can't think of an abstraction that would unify those without taking a lot of control away from consumers, so I guess I'm okay with this approach.

benoitkugler commented 11 months ago

I confess that it feels a little awkward to ask consumers to "call this method on each shaped run" and to then "call this method on each line after shaping with the same value".

Agreed... That is also why an all in one function hiding this complexity could have some value.

benoitkugler commented 11 months ago

That, in turn, would mean adding a mechanism to know what whitespace actually is

Pango indeed registers whitespace runes. I'll check how it is used in the line breaking code.

andydotxyz commented 11 months ago

This is basically what Fyne does too - find the white space to wrap on, then end the line before it and start the next after it. So it understands (most) white space. Sadly I can't just contribute the code due to license issues discussed before.

Obviously a character breaking algorithm can't do quite the same and if we introduce hyphenated breaking in-word it would have to /add/ (like ellipsis truncation) not subtract from the space required.