/* * Copyright 2019 Google Inc. * * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ #include "modules/skottie/src/text/TextAdapter.h" #include "include/core/SkFontMgr.h" #include "include/core/SkM44.h" #include "include/private/SkTPin.h" #include "modules/skottie/src/SkottieJson.h" #include "modules/skottie/src/text/RangeSelector.h" #include "modules/skottie/src/text/TextAnimator.h" #include "modules/sksg/include/SkSGDraw.h" #include "modules/sksg/include/SkSGGroup.h" #include "modules/sksg/include/SkSGPaint.h" #include "modules/sksg/include/SkSGRect.h" #include "modules/sksg/include/SkSGRenderEffect.h" #include "modules/sksg/include/SkSGText.h" #include "modules/sksg/include/SkSGTransform.h" namespace skottie { namespace internal { sk_sp TextAdapter::Make(const skjson::ObjectValue& jlayer, const AnimationBuilder* abuilder, sk_sp fontmgr, sk_sp logger) { // General text node format: // "t": { // "a": [], // animators (see TextAnimator) // "d": { // "k": [ // { // "s": { // "f": "Roboto-Regular", // "fc": [ // 0.42, // 0.15, // 0.15 // ], // "j": 1, // "lh": 60, // "ls": 0, // "s": 50, // "t": "text align right", // "tr": 0 // }, // "t": 0 // } // ] // }, // "m": { // "more options" // "g": 1, // Anchor Point Grouping // "a": {...} // Grouping Alignment // }, // "p": {} // "path options" (TODO) // }, const skjson::ObjectValue* jt = jlayer["t"]; const skjson::ObjectValue* jd = jt ? static_cast((*jt)["d"]) : nullptr; if (!jd) { abuilder->log(Logger::Level::kError, &jlayer, "Invalid text layer."); return nullptr; } // "More options" const skjson::ObjectValue* jm = (*jt)["m"]; static constexpr AnchorPointGrouping gGroupingMap[] = { AnchorPointGrouping::kCharacter, // 'g': 1 AnchorPointGrouping::kWord, // 'g': 2 AnchorPointGrouping::kLine, // 'g': 3 AnchorPointGrouping::kAll, // 'g': 4 }; const auto apg = jm ? SkTPin(ParseDefault((*jm)["g"], 1), 1, SK_ARRAY_COUNT(gGroupingMap)) : 1; auto adapter = sk_sp(new TextAdapter(std::move(fontmgr), std::move(logger), gGroupingMap[SkToSizeT(apg - 1)])); adapter->bind(*abuilder, jd, adapter->fText.fCurrentValue); if (jm) { adapter->bind(*abuilder, (*jm)["a"], adapter->fGroupingAlignment); } // Animators if (const skjson::ArrayValue* janimators = (*jt)["a"]) { adapter->fAnimators.reserve(janimators->size()); for (const skjson::ObjectValue* janimator : *janimators) { if (auto animator = TextAnimator::Make(janimator, abuilder, adapter.get())) { adapter->fHasBlurAnimator |= animator->hasBlur(); adapter->fRequiresAnchorPoint |= animator->requiresAnchorPoint(); adapter->fAnimators.push_back(std::move(animator)); } } } abuilder->dispatchTextProperty(adapter); return adapter; } TextAdapter::TextAdapter(sk_sp fontmgr, sk_sp logger, AnchorPointGrouping apg) : fRoot(sksg::Group::Make()) , fFontMgr(std::move(fontmgr)) , fLogger(std::move(logger)) , fAnchorPointGrouping(apg) , fHasBlurAnimator(false) , fRequiresAnchorPoint(false) {} TextAdapter::~TextAdapter() = default; void TextAdapter::addFragment(const Shaper::Fragment& frag) { // For a given shaped fragment, build a corresponding SG fragment: // // [TransformEffect] -> [Transform] // [Group] // [Draw] -> [TextBlob*] [FillPaint] // [Draw] -> [TextBlob*] [StrokePaint] // // * where the blob node is shared auto blob_node = sksg::TextBlob::Make(frag.fBlob); FragmentRec rec; rec.fOrigin = frag.fPos; rec.fAdvance = frag.fAdvance; rec.fAscent = frag.fAscent; rec.fMatrixNode = sksg::Matrix::Make(SkM44::Translate(frag.fPos.x(), frag.fPos.y())); std::vector> draws; draws.reserve(static_cast(fText->fHasFill) + static_cast(fText->fHasStroke)); SkASSERT(fText->fHasFill || fText->fHasStroke); auto add_fill = [&]() { if (fText->fHasFill) { rec.fFillColorNode = sksg::Color::Make(fText->fFillColor); rec.fFillColorNode->setAntiAlias(true); draws.push_back(sksg::Draw::Make(blob_node, rec.fFillColorNode)); } }; auto add_stroke = [&] { if (fText->fHasStroke) { rec.fStrokeColorNode = sksg::Color::Make(fText->fStrokeColor); rec.fStrokeColorNode->setAntiAlias(true); rec.fStrokeColorNode->setStyle(SkPaint::kStroke_Style); rec.fStrokeColorNode->setStrokeWidth(fText->fStrokeWidth); draws.push_back(sksg::Draw::Make(blob_node, rec.fStrokeColorNode)); } }; if (fText->fPaintOrder == TextPaintOrder::kFillStroke) { add_fill(); add_stroke(); } else { add_stroke(); add_fill(); } SkASSERT(!draws.empty()); if (0) { // enable to visualize fragment ascent boxes auto box_color = sksg::Color::Make(0xff0000ff); box_color->setStyle(SkPaint::kStroke_Style); box_color->setStrokeWidth(1); box_color->setAntiAlias(true); auto box = SkRect::MakeLTRB(0, rec.fAscent, rec.fAdvance, 0); draws.push_back(sksg::Draw::Make(sksg::Rect::Make(box), std::move(box_color))); } auto draws_node = (draws.size() > 1) ? sksg::Group::Make(std::move(draws)) : std::move(draws[0]); if (fHasBlurAnimator) { // Optional blur effect. rec.fBlur = sksg::BlurImageFilter::Make(); draws_node = sksg::ImageFilterEffect::Make(std::move(draws_node), rec.fBlur); } fRoot->addChild(sksg::TransformEffect::Make(std::move(draws_node), rec.fMatrixNode)); fFragments.push_back(std::move(rec)); } void TextAdapter::buildDomainMaps(const Shaper::Result& shape_result) { fMaps.fNonWhitespaceMap.clear(); fMaps.fWordsMap.clear(); fMaps.fLinesMap.clear(); size_t i = 0, line = 0, line_start = 0, word_start = 0; float word_advance = 0, word_ascent = 0, line_advance = 0, line_ascent = 0; bool in_word = false; // TODO: use ICU for building the word map? for (; i < shape_result.fFragments.size(); ++i) { const auto& frag = shape_result.fFragments[i]; if (frag.fIsWhitespace) { if (in_word) { in_word = false; fMaps.fWordsMap.push_back({word_start, i - word_start, word_advance, word_ascent}); } } else { fMaps.fNonWhitespaceMap.push_back({i, 1, 0, 0}); if (!in_word) { in_word = true; word_start = i; word_advance = word_ascent = 0; } word_advance += frag.fAdvance; word_ascent = std::min(word_ascent, frag.fAscent); // negative ascent } if (frag.fLineIndex != line) { SkASSERT(frag.fLineIndex == line + 1); fMaps.fLinesMap.push_back({line_start, i - line_start, line_advance, line_ascent}); line = frag.fLineIndex; line_start = i; line_advance = line_ascent = 0; } line_advance += frag.fAdvance; line_ascent = std::min(line_ascent, frag.fAscent); // negative ascent } if (i > word_start) { fMaps.fWordsMap.push_back({word_start, i - word_start, word_advance, word_ascent}); } if (i > line_start) { fMaps.fLinesMap.push_back({line_start, i - line_start, line_advance, line_ascent}); } } void TextAdapter::setText(const TextValue& txt) { fText.fCurrentValue = txt; this->onSync(); } uint32_t TextAdapter::shaperFlags() const { uint32_t flags = Shaper::Flags::kNone; SkASSERT(!(fRequiresAnchorPoint && fAnimators.empty())); if (!fAnimators.empty() ) flags |= Shaper::Flags::kFragmentGlyphs; if (fRequiresAnchorPoint) flags |= Shaper::Flags::kTrackFragmentAdvanceAscent; return flags; } void TextAdapter::reshape() { const Shaper::TextDesc text_desc = { fText->fTypeface, fText->fTextSize, fText->fMinTextSize, fText->fMaxTextSize, fText->fLineHeight, fText->fLineShift, fText->fAscent, fText->fHAlign, fText->fVAlign, fText->fResize, fText->fLineBreak, fText->fDirection, this->shaperFlags(), }; const auto shape_result = Shaper::Shape(fText->fText, text_desc, fText->fBox, fFontMgr); if (fLogger && shape_result.fMissingGlyphCount > 0) { const auto msg = SkStringPrintf("Missing %zu glyphs for '%s'.", shape_result.fMissingGlyphCount, fText->fText.c_str()); fLogger->log(Logger::Level::kWarning, msg.c_str()); // This may trigger repeatedly when the text is animating. // To avoid spamming, only log once. fLogger = nullptr; } // Rebuild all fragments. // TODO: we can be smarter here and try to reuse the existing SG structure if needed. fRoot->clear(); fFragments.clear(); for (const auto& frag : shape_result.fFragments) { this->addFragment(frag); } if (!fAnimators.empty()) { // Range selectors require fragment domain maps. this->buildDomainMaps(shape_result); } #if (0) // Enable for text box debugging/visualization. auto box_color = sksg::Color::Make(0xffff0000); box_color->setStyle(SkPaint::kStroke_Style); box_color->setStrokeWidth(1); box_color->setAntiAlias(true); auto bounds_color = sksg::Color::Make(0xff00ff00); bounds_color->setStyle(SkPaint::kStroke_Style); bounds_color->setStrokeWidth(1); bounds_color->setAntiAlias(true); fRoot->addChild(sksg::Draw::Make(sksg::Rect::Make(fText->fBox), std::move(box_color))); fRoot->addChild(sksg::Draw::Make(sksg::Rect::Make(shape_result.computeVisualBounds()), std::move(bounds_color))); #endif } void TextAdapter::onSync() { if (!fText->fHasFill && !fText->fHasStroke) { return; } if (fText.hasChanged()) { this->reshape(); } if (fFragments.empty()) { return; } // Seed props from the current text value. TextAnimator::ResolvedProps seed_props; seed_props.fill_color = fText->fFillColor; seed_props.stroke_color = fText->fStrokeColor; TextAnimator::ModulatorBuffer buf; buf.resize(fFragments.size(), { seed_props, 0 }); // Apply all animators to the modulator buffer. for (const auto& animator : fAnimators) { animator->modulateProps(fMaps, buf); } const TextAnimator::DomainMap* grouping_domain = nullptr; switch (fAnchorPointGrouping) { // for word/line grouping, we rely on domain map info case AnchorPointGrouping::kWord: grouping_domain = &fMaps.fWordsMap; break; case AnchorPointGrouping::kLine: grouping_domain = &fMaps.fLinesMap; break; // remaining grouping modes (character/all) do not need (or have) domain map data default: break; } size_t grouping_span_index = 0; SkV2 line_offset = { 0, 0 }; // cumulative line spacing // Finally, push all props to their corresponding fragment. for (const auto& line_span : fMaps.fLinesMap) { SkV2 line_spacing = { 0, 0 }; float line_tracking = 0; bool line_has_tracking = false; // Tracking requires special treatment: unlike other props, its effect is not localized // to a single fragment, but requires re-alignment of the whole line. for (size_t i = line_span.fOffset; i < line_span.fOffset + line_span.fCount; ++i) { // Track the grouping domain span in parallel. if (grouping_domain && i >= (*grouping_domain)[grouping_span_index].fOffset + (*grouping_domain)[grouping_span_index].fCount) { grouping_span_index += 1; SkASSERT(i < (*grouping_domain)[grouping_span_index].fOffset + (*grouping_domain)[grouping_span_index].fCount); } const auto& props = buf[i].props; const auto& frag = fFragments[i]; this->pushPropsToFragment(props, frag, fGroupingAlignment * .01f, // percentage grouping_domain ? &(*grouping_domain)[grouping_span_index] : nullptr); line_tracking += props.tracking; line_has_tracking |= !SkScalarNearlyZero(props.tracking); line_spacing += props.line_spacing; } // line spacing of the first line is ignored (nothing to "space" against) if (&line_span != &fMaps.fLinesMap.front()) { // For each line, the actual spacing is an average of individual fragment spacing // (to preserve the "line"). line_offset += line_spacing / line_span.fCount; } if (line_offset != SkV2{0, 0} || line_has_tracking) { this->adjustLineProps(buf, line_span, line_offset, line_tracking); } } } SkV2 TextAdapter::fragmentAnchorPoint(const FragmentRec& rec, const SkV2& grouping_alignment, const TextAnimator::DomainSpan* grouping_span) const { // Construct the following 2x ascent box: // // ------------- // | | // | | ascent // | | // ----+-------------+---------- baseline // (pos) | // | | ascent // | | // ------------- // advance auto make_box = [](const SkPoint& pos, float advance, float ascent) { // note: negative ascent return SkRect::MakeXYWH(pos.fX, pos.fY + ascent, advance, -2 * ascent); }; // Compute a grouping-dependent anchor point box. // The default anchor point is at the center, and gets adjusted relative to the bounds // based on |grouping_alignment|. auto anchor_box = [&]() -> SkRect { switch (fAnchorPointGrouping) { case AnchorPointGrouping::kCharacter: // Anchor box relative to each individual fragment. return make_box(rec.fOrigin, rec.fAdvance, rec.fAscent); case AnchorPointGrouping::kWord: // Fall through case AnchorPointGrouping::kLine: { SkASSERT(grouping_span); // Anchor box relative to the first fragment in the word/line. const auto& first_span_fragment = fFragments[grouping_span->fOffset]; return make_box(first_span_fragment.fOrigin, grouping_span->fAdvance, grouping_span->fAscent); } case AnchorPointGrouping::kAll: // Anchor box is the same as the text box. return fText->fBox; } SkUNREACHABLE; }; const auto ab = anchor_box(); // Apply grouping alignment. const auto ap = SkV2 { ab.centerX() + ab.width() * 0.5f * grouping_alignment.x, ab.centerY() + ab.height() * 0.5f * grouping_alignment.y }; // The anchor point is relative to the fragment position. return ap - SkV2 { rec.fOrigin.fX, rec.fOrigin.fY }; } void TextAdapter::pushPropsToFragment(const TextAnimator::ResolvedProps& props, const FragmentRec& rec, const SkV2& grouping_alignment, const TextAnimator::DomainSpan* grouping_span) const { const auto anchor_point = this->fragmentAnchorPoint(rec, grouping_alignment, grouping_span); rec.fMatrixNode->setMatrix( SkM44::Translate(props.position.x + rec.fOrigin.x() + anchor_point.x, props.position.y + rec.fOrigin.y() + anchor_point.y, props.position.z) * SkM44::Rotate({ 1, 0, 0 }, SkDegreesToRadians(props.rotation.x)) * SkM44::Rotate({ 0, 1, 0 }, SkDegreesToRadians(props.rotation.y)) * SkM44::Rotate({ 0, 0, 1 }, SkDegreesToRadians(props.rotation.z)) * SkM44::Scale(props.scale.x, props.scale.y, props.scale.z) * SkM44::Translate(-anchor_point.x, -anchor_point.y, 0)); const auto scale_alpha = [](SkColor c, float o) { return SkColorSetA(c, SkScalarRoundToInt(o * SkColorGetA(c))); }; if (rec.fFillColorNode) { rec.fFillColorNode->setColor(scale_alpha(props.fill_color, props.opacity)); } if (rec.fStrokeColorNode) { rec.fStrokeColorNode->setColor(scale_alpha(props.stroke_color, props.opacity)); } if (rec.fBlur) { rec.fBlur->setSigma({ props.blur.x * kBlurSizeToSigma, props.blur.y * kBlurSizeToSigma }); } } void TextAdapter::adjustLineProps(const TextAnimator::ModulatorBuffer& buf, const TextAnimator::DomainSpan& line_span, const SkV2& line_offset, float total_tracking) const { SkASSERT(line_span.fCount > 0); // AE tracking is defined per glyph, based on two components: |before| and |after|. // BodyMovin only exports "balanced" tracking values, where before == after == tracking / 2. // // Tracking is applied as a local glyph offset, and contributes to the line width for alignment // purposes. // The first glyph does not contribute |before| tracking, and the last one does not contribute // |after| tracking. Rather than spill this logic into applyAnimators, post-adjust here. total_tracking -= 0.5f * (buf[line_span.fOffset].props.tracking + buf[line_span.fOffset + line_span.fCount - 1].props.tracking); static const auto align_factor = [](SkTextUtils::Align a) { switch (a) { case SkTextUtils::kLeft_Align : return 0.0f; case SkTextUtils::kCenter_Align: return -0.5f; case SkTextUtils::kRight_Align : return -1.0f; } SkASSERT(false); return 0.0f; }; const auto align_offset = total_tracking * align_factor(fText->fHAlign); float tracking_acc = 0; for (size_t i = line_span.fOffset; i < line_span.fOffset + line_span.fCount; ++i) { const auto& props = buf[i].props; // No |before| tracking for the first glyph, nor |after| tracking for the last one. const auto track_before = i > line_span.fOffset ? props.tracking * 0.5f : 0.0f, track_after = i < line_span.fOffset + line_span.fCount - 1 ? props.tracking * 0.5f : 0.0f, fragment_offset = align_offset + tracking_acc + track_before; const auto& frag = fFragments[i]; const auto m = SkM44::Translate(line_offset.x + fragment_offset, line_offset.y) * frag.fMatrixNode->getMatrix(); frag.fMatrixNode->setMatrix(m); tracking_acc += track_before + track_after; } } } // namespace internal } // namespace skottie