Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rich text and color support #95

Closed
wants to merge 15 commits into from

Conversation

stefcameron
Copy link

@geongeorge From one OSS maintainer to another, I apologize for the sheer size of this PR and the extent of the changes. I know it may be daunting, maybe even frustrating. I would have made smaller PRs over time, but I was time-crunched for a POC and your library was the perfect foundation to achieve what I needed: Wrapped rich text rendering on canvas, with the ability to do all calculations on an OffscreenCanvas in a Web Worker thread.

I tried my very best to keep the existing interface to splitText() but couldn't, so this is also a breaking change (major version...), though not a huge one. I also changed the output file names since the .mjs was causing issues with our Webpack build and .js was the path of least resistance.

I wanted to give you an opportunity to consider taking in these changes, but I totally understand if this is too much and you don't care for them. If that's the case, please let me know and I'll publish this to my own new canvas-rich-text package with credits to you and your previous contributors.


Adds rich text and color support by introducing the concept of "Words". Giving a simple string to drawText() will now, internally, convert it to a Word[] array. Each Word can have a format and can also prime the splitWords() calculations with previously-calculated metrics to improve performance (meaning context.measureText() will be skipped for that Word).

Moves the bulk of the code out of drawText() and into splitWords() (which is now called by the existing splitText()) so that splitWords() returns a "render spec" with a PositionedWord[] array and all drawText() needs to do now is loop over the words and draw them on the canvas at the pre-calculated positions.

As an easy bonus, while color has no effect on word splitting, drawText() now supports it. Each Word can have its own color.

I enhanced the demo to always render two words in a different color and font style.

The only thing that remains a "TODO" is handling different font sizes. It will render, but not nicely, because the code still assumes the text baseline is always the same while it probably needs to be different, and the words need to be aligned to it in some way. But sticking with a single font size and a single font family, the output is nice with different colors, bolded/italic text, etc.

Text still wraps and aligns as before.

Web demo

CanvasTxt-richtext-demo

Node demo output

output

__WARNING:__ Still a WIP. See `// DEBUG` comments throughout.

Instead of dealing with text as a string, introduce the concept of Words
where a Word is either a visible, written word, or whitespace, and have
the option to infer whitespace when an array of Words is given instead
of a string.

A Word then has its own formatting options aside from the "base" formatting
set in the `CanvasTextConfig` object. If a Word doesn't have `format` options,
it will simply use the "default" format in the 2D context, which may have been
modified based on the `CanvasTextConfig` object provided to `drawText()`.

To preserve the existing `drawText()` and `splitText()` APIs, `splitText()`
splits the text into an array of Words and then calls the new `splitWords()`
function that does all the work.

For now, providing an array of Words to `drawText()` throws an error since
this isn't supported yet. But everything else workds as before when providing
a string: All supported alignments, including justification.

Other fixes/changes:
- Breaks cannot occur within a word anymore, even when the box width gets
  too narrow to fit anything. At this point, a minimum of one word is rendered
  per line, on top of any hard line breaks specified with newline characters.
- The context's state is saved and restored in `drawText()`, meaning that
  after the call, the context's font, etc, styles/settings are back to what
  they were prior to calling it instead of being set to what was specified
  in the `CanvasTextConfig` object.
__WARNING:__ See remaining `// DEBUG` comments.

In order to make it possible to use a library other than canvas-txt to
actually draw the text, yet still leverage this library's text wrapping
and rich text features, `splitWords()` now does all the positioning
that `drawText()` use to do, which means that `splitText()`, which calls
`splitWords()` internally, must now be given many more parameters than
it used to.

Since `splitText()` was already exported as a utility from this library,
this must be a breaking change (major version update).

So now, `splitWords()` returns the positioned words along with the
required `textAlign` and `textBaseline` to set on the 2D context
in order to render the text what is now a very simple loop.

This makes it possible to call `splitWords()` directly and then use
the output to render each word using another library such as Konva
where each word could then be dragged or hit-tested, or do it
without a library altogether.

Plain text (single text format) works at this point, but rich text
still does not render properly due to misalignment issues.
__WARNING:__ See `// DEBUG` comments for what still needs doing/fixing.

Works quite well now, especially when NOT using different font sizes
for different words. If all words use the same size, just alter their
style, weight, or variant, it renders nicely.

Updated the demo app to always set "ipsum" to italic, and "consectetur"
to bold, if either is found in the sample text to render.
__WARNING:__ There are still remaining `// DEBUG` items to address

- Performance: Allow consumer to provide pre-computed `TextMetrics` objects
  in the `Words[]`, which would typically come from a previous call to
  `splitWords()` since the function now sets a new `Word.metrics` property
  to the measured metrics for the `Word.format` (if any). This will
  dramatically speed up the process when most Words have already been
  measured from a previous pass (assuming formatting hasn't changed).
- Performance: Change `WordMap` key to a string hash of the Word's
  `text` and `format` in order to reuse associated metrics between
  identical Words. Also impacts memory use.
- Memory: Re-use metrics between identical Words instead of re-measuring
  them unnecessarily. This is particularly significant with whitespace
  characters which show up in __huge__ numbers in any given set of Words
  and are nearly all identical.
Native Canvas `TextMetrics` objects fail to serialize (cause an exception)
when given as data to the `postMessage()` of a Web Worker thread (or from
the main thread).

Therefore, work with objects having a similar interface but not being
actual `TextMetrics` instances.

Also provide two JSON stringify helpers: `specToJson()` and `wordsToJson()`
This should help consumers to deal with native `TextMetrics` objects.
…ype, remove self as contributor from package
Node-based `canvas` rendering does not currently support the newer
`fontBoundingBox*` TextMetrics properties, so use a `textBaseline='bottom'`
fallback trick in that case.
Copy link

vercel bot commented Feb 3, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
canvas-txt ✅ Ready (Inspect) Visit Preview 💬 Add feedback Feb 3, 2024 8:05pm

@geongeorge
Copy link
Owner

Hi @stefcameron Thank you so much for this PR.
The demo looks great. Rich text takes this to another level. I need a little time to look at all of the changes and figure out how this would impact the API.

@stefcameron
Copy link
Author

@geongeorge

Hi @stefcameron Thank you so much for this PR. The demo looks great. Rich text takes this to another level. I need a little time to look at all of the changes and figure out how this would impact the API.

You're welcome, and I'm glad you like the updated demo! 😄 When making changes to the API, I tried my best to maintain compatibility for the 3 exported functions, but as I mentioned, I wasn't able to. I had to introduce a small change, but that's still a breaking change, unfortunately. So I'd recommend publishing these changes under a new major version.

I also exported some additional functions to help with converting a string to a Word[] so it's easier to apply formatting to a small subset of them, as well as with passing a Word[] to a Web Worker, or a RenderSpec back to the main thread, via postMessage() (which requires JSON stringification, which causes issues when Canvas TextMetrics objects are involved because they don't have own-properties, only getters/setters).

And finally, I exported the new splitWords() function alongside the existing splitText(). splitText() now simply converts the string to a Word[] and passes the result to splitWords(), and then even though splitWords() returns a RenderSpec, splitText() continues to return a string[] as before.

Please LMK if you have any questions about the changes I made to the API.

@stefcameron
Copy link
Author

@geongeorge Are there any specific questions about the API changes I could answer to help you with your review?

@stefcameron
Copy link
Author

Thank you for considering my changes. I realize this is a huge PR and hard to take in, but I do need to get it captured and published. On Monday (Mar 4), I will close this PR and start work on publishing it to a new package from a new repo so that these improvements can be captured more permanently.

@geongeorge
Copy link
Owner

@stefcameron Thank you for your pull request. Currently, I don't have the availability to review these changes before the specified date. Please feel free to release a new package :)

@stefcameron
Copy link
Author

Published as [email protected]

@geongeorge
Copy link
Owner

@stefcameron Amazing work! Wonderful name as well :)

@stefcameron stefcameron deleted the richtext branch March 15, 2024 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants