Implement shaping in parley_core - #675
Conversation
| pub fn shape_item( | ||
| &mut self, | ||
| text: &str, | ||
| analysis: &Analysis, | ||
| item: &Item, | ||
| options: &ShapeOptions<'_>, | ||
| select_font: impl FnMut(&mut CharCluster) -> Option<FontInstance>, | ||
| analysis_data_sources: &AnalysisDataSources, | ||
| shaped_runs: impl FnMut(ShapedRun<'_>), | ||
| ) { |
There was a problem hiding this comment.
For font selection specifically, using a trait instead of the callback would have the potential benefit of being able to take the font Blob by reference, which could be useful in case users are doing multi-threaded layout (as the Blob's Arc refcount bumps may then be contended). Perhaps something like the following.
pub trait SelectFont {
fn select_font(&mut self, char_cluster: &mut CharCluster) -> Option<FontInstanceRef<'_>>;
}There was a problem hiding this comment.
(Also, and very much separately, I believe parley's charmap test is not quite sufficient, and glyphs can still become .notdef. I think we need to try to shape with the font and move to the next candidate font if that fails.)
| font: font.clone(), | ||
| attrs: self.attrs, | ||
| }); | ||
| selected_font = Some(SelectedFont { font: font.clone() }); |
There was a problem hiding this comment.
I've dropped the SelectedFont::attrs field (a bit of a drive-by) as these are just properties taken from the run's style, so are already available there.
There was a problem hiding this comment.
Hmm. I believed that they were useful for something like "how much fake bold do I need", but I'm possibly thinking of something else?
There was a problem hiding this comment.
We're still passing the attributes through, but the old code was getting the attributes from the run's style, then storing them in the selected font, and then pushing them to the layout from there. The new code gets them directly from the run's style when pushing the run to the layout. Compare old vs new. Actually, sticking close to the old version would be a bit messy as parley_core would have to plumb it through (and it only cares about the selected font, not the attributes).
(For e.g. emboldening, you'd perhaps only check the Synthesis::embolden flag, though Attributes::weight is still available if you want to calculate a delta. (Maybe fontique should store the delta in Synthesis...))
| NFD, | ||
| NFC, | ||
| Nfd, | ||
| Nfc, |
There was a problem hiding this comment.
(This was simpler than figuring out text for the reason field that parley_core's lints require. 🙃 )
| // TODO: For `select_font`, on `None`, the previous font is taken (and the run is dropped if | ||
| // `None` is returned on the first call). This is identical to Parley's old behavior, but we | ||
| // probably want the commented-out documented behavior that follows, as returning a font is | ||
| // cheap and it probably doesn't make a ton of sense to hardcode some font fallback behavior | ||
| // here. | ||
| // | ||
| // /// Return `None` if there are no fonts available at all. The character cluster's text will be | ||
| // /// omitted from the shaped result. Instead, you probably want to render a `.notdef` glyph from a | ||
| // /// font you do have available, in which case you can return the previous font or some | ||
| // /// last-resort fallback font instead. |
There was a problem hiding this comment.
The behavior of using the result of font selection is copied from parley in this PR, but I think if the user's font selection returns None, we should not assume they want the previous font used, and instead should not render the text.
There was a problem hiding this comment.
At some point, we'll need to work out how to tofu (and/or TOFU with number).
There was a problem hiding this comment.
At some point, we'll need to work out how to tofu (and/or TOFU with number).
I believe we currently tofu from the primary font! But tofu'ing with a number could be nice (or in general letting the user render some arbitrary tofu). I suppose we could let users pass in an optional callback to replace .notdef with something returning metrics instead, and at rendering-time they'd render whatever path they want.
| /// The item is broken into runs of maximal sequences of character clusters for which | ||
| /// `select_font` returns the same font. The `shaped_runs` callback is called with each shaped | ||
| /// run. |
There was a problem hiding this comment.
We could probably start guaranteeing these "character clusters" to be graphemes, but that requires a careful pass.
There was a problem hiding this comment.
I would love that. But yes, definitely out of scope of this PR.
|
Re: performance. We should probably be adding |
b336e70 to
0f94d2f
Compare
Font selection remains in `parley`.
0f94d2f to
e4b3d91
Compare
taj-p
left a comment
There was a problem hiding this comment.
Nice!
If the intent is to re-audit the public API surface of parley_core in a separate PR to keep this PR minimal, LGTM.
If you want to re-think the API surface within this PR, please re-request review after those changes are in place.
| #[derive(Debug, Default)] | ||
| pub(crate) struct CharCluster { | ||
| pub struct CharCluster { | ||
| pub chars: Vec<Char>, |
There was a problem hiding this comment.
I'm not sure we want to allow consumers to mutate chars because it would invalidate our other fields within CharCluster
There was a problem hiding this comment.
Fair! I wanted to keep the diff small, but probably better to get visibility more correct. I've made the fields private with read accessors, but will do a bit more clean up as a new PR.
There was a problem hiding this comment.
Moving ClusterData as is into parley_core means that we've increased the effective surface area of ClusterData from pub(crate) to pub. This means that its internal details like glyph_len == 0xFF => inline glyph in glyph_offsetnow need to be parsed by external consumers and itspub` fields can be writable.
To what extent do we want to address this increase in surface area in this PR? IMO, I think ClusterData shouldn't have writable fields for consumers outside the crate and we could reduce the number of footguns across its API.
For example, this is an example that currently lives in parley/src/layout:
pub fn glyphs(&self) -> impl Iterator<Item = Glyph> + 'a + Clone + use<'a, B> {
if self.data.glyph_len == 0xFF {
GlyphIter::Single(Some(Glyph {
id: self.data.glyph_offset,
x: 0.,
y: 0.,
advance: self.data.advance,
}))
} else {
let start = self.run.data.glyph_start + self.data.glyph_offset as usize;
GlyphIter::Slice(
self.run.layout.data.glyphs[start..start + self.data.glyph_len as usize].iter(),
)
}
}I think GlyphIter might be more a parley_core struct than a parley struct. I'm also unsure whether we want consumers to branch on glyph_len == 0xFF.
The same goes for many of the newly pub structs exposed from parley_core - but I assume that some of these will become pub(crate) again after the migration is complete? I'm happy to audit the newly exposed structs, but I'm assuming that the plan is to perform that audit in a separate pass?
There was a problem hiding this comment.
Moving
ClusterDataas is intoparley_coremeans that we've increased the effective surface area ofClusterDatafrompub(crate)topub. This means that its internal details likeglyph_len == 0xFF => inline glyph inglyph_offsetnow need to be parsed by external consumers and itspub` fields can be writable.To what extent do we want to address this increase in surface area in this PR? IMO, I think
ClusterDatashouldn't have writable fields for consumers outside the crate and we could reduce the number of footguns across its API.
I also prefer an API for parley_core that exposes just what's necessary or potentially useful, and ideally core's invariants can't be broken in subtle ways by users. There's a somewhat similar discussion here: #679 (comment).
In opening this PR my intention was to introduce as little changes as possible to ease review of the migration, but we definitely need to do a pass over what we expose.
There was a problem hiding this comment.
I'm assuming this was copied verbatim 🙏
| @@ -44,13 +44,13 @@ core_maths = { version = "0.1.1", optional = true } | |||
| accesskit = { workspace = true, optional = true } | |||
| hashbrown = { workspace = true } | |||
| harfrust = { workspace = true } | |||
There was a problem hiding this comment.
Will we need to continue depending on harfrust in parley following the migration to parley_core?
There was a problem hiding this comment.
Nope! Currently we just need to name some types. In the ShapedText PR (#675), parley drop the harfrust dependency (along with some icu4x crates, all have moved to parley_core).
| /// The item is broken into runs of maximal sequences of character clusters for which | ||
| /// `select_font` returns the same font. The `shaped_runs` callback is called with each shaped | ||
| /// run. |
There was a problem hiding this comment.
I would love that. But yes, definitely out of scope of this PR.
|
|
||
| fn variations_iter<'a>( | ||
| synthesis: &'a fontique::Synthesis, | ||
| item: Option<&'a [FontVariation]>, |
There was a problem hiding this comment.
Every consumer of variations_iter (just the 1) passes a Some(...) item, so I think we can drop the Option here
| item: Option<&'a [FontVariation]>, | |
| item: &'a [FontVariation], |
There was a problem hiding this comment.
Nice! (Though note I didn't look too closely at the code, it was copied from parley, and there may be more clean ups possible!)
| &char_style_indices[shaped_run.range.char_range.clone()]; | ||
| layout.data.push_run( | ||
| FontData::new(shaped_run.font.blob, shaped_run.font.index), | ||
| style.font_size, |
There was a problem hiding this comment.
| style.font_size, | |
| run_style.font_size, |
| style.word_spacing, | ||
| style.letter_spacing, |
There was a problem hiding this comment.
| style.word_spacing, | |
| style.letter_spacing, | |
| run_style.word_spacing, | |
| run_style.letter_spacing, |
There was a problem hiding this comment.
I believe these fields are guaranteed to be the same as run_style?
There was a problem hiding this comment.
Yes, but specifically they're guaranteed to be nearly_eq. I think it's perhaps better to take them from style. We itemize based on these (and also the font_size you marked above):
parley/parley/src/shape/mod.rs
Lines 118 to 129 in a5ea9a7
That means that these are constant (within the nearly_eq decision to put them into a single item) over all the runs produced for this item (and new runs are created due to font selection falling differently and such). That means that taking them from the run could potentially get a slightly different result, which sort of conflicts with putting them in a single item.
I'm thinking to leave it as this for now, but I'm not too attached to it, and am happy for suggestions or revisiting this.
| font.font.synthesis, | ||
| &glyph_buffer, | ||
| item.level, | ||
| item.style_index, |
There was a problem hiding this comment.
I think we are producing different behaviour with these changes. Before, we only considered the item's style index, but today we're considering the run's style index. I think the proposal is more correct because it means that "the big text" resolves a different run for "big" so if it's line height, for example, changes, we use the new line height rather than the line height of "the".
Does that make sense?
There was a problem hiding this comment.
Ah, I don't now remember whether I changed the item.style_index to run_style_index purposefully in this PR (I was sticking close to the original behavior), but the run style index is in principle the correct style index for things like font weight. There's a caveat that we nearly_eq to itemize for some shaping-relevant styles (more on that in the other comment: #675 (comment)). That means that we need to be careful to take the styles that were considered equal for itemization (and thus considered equal for shaping) from the item's style, and not the run's.
There was a problem hiding this comment.
(And perhaps the simplest way to get trivial correctness is just dropping the nearly_eq!)
4392464 to
6e1a32e
Compare
becc100 to
d0b2c0c
Compare
|
I believe I've addressed the feedback as it relates to this PR in the narrow sense; thanks a lot for the careful review! 🙏 Merging, but expect some follow up! |
This implements shaping in
parley_core, taking the result of the new itemization that just landed, some additional shaping options, plus an API that keeps specifics of font selection up to the caller. Taking this form, we should be able to drop thefontiquedependency fromparley_core, but I've not done so here as it inflates the diff and would benefit from separate discussion.The next step after landing this would be the introduction of a
ShapedText. I've left some notes inparley_core/src/shaped_text.rsabout what that may look like. AShapedTextis going to be useful for reshaping; I have something in mind that I think is better than what I proposed in #634, but more on that later/elsewhere!The
ShaperContext::shape_itemmethod is somewhat callback-heavy. I wonder if we should start introducing traits instead. (Though the|shaped_run|callback is going to become a&mut ShapedTextsoon enough I suppose.)This benches neutral to ~+1.5% on my machine.
Slight changes are getting compilation and the benches to fall somewhat differently, e.g., taking a trait for font selection seems to have a very slight positive effect, but it seems mostly up to the whims of the compiler. (As a performance aside, the whole font fallback code is quite hot as we're calling it for each character, and we could optimize, e.g., under the assumption that the preferred font is likely to match.)
Small review guide:
parley's behavior should be unchanged, and I copied the shaping code verbatim. The first commit just moves blocks around (it compiles and passes tests). The second commit moves some blocks around, with some changes to get stuff to compile, but does not wire upparleyto use the shaping that's been moved out. The third commit wires everything up and passes tests, and the commits after that are clean up, more docs, etc.