diff --git a/.mapping.json b/.mapping.json index 0caff7eba..0ccb5aeea 100644 --- a/.mapping.json +++ b/.mapping.json @@ -21050,6 +21050,7 @@ "client/ios/DivKit/Extensions/DivSwitchExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivSwitchExtensions.swift", "client/ios/DivKit/Extensions/DivTabsExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTabsExtensions.swift", "client/ios/DivKit/Extensions/DivTextExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTextExtensions.swift", + "client/ios/DivKit/Extensions/DivTextImageBuilderExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTextImageBuilderExtensions.swift", "client/ios/DivKit/Extensions/DivTextRangeBuilderExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTextRangeBuilderExtensions.swift", "client/ios/DivKit/Extensions/DivTooltipExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTooltipExtensions.swift", "client/ios/DivKit/Extensions/DivTransformExtensions.swift":"divkit/public/client/ios/DivKit/Extensions/DivTransformExtensions.swift", @@ -23276,6 +23277,7 @@ "client/ios/Tests/reference_snapshots/div-text/image-background_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/image-background_414@3x.png", "client/ios/Tests/reference_snapshots/div-text/image-reverse-indexing_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/image-reverse-indexing_414@3x.png", "client/ios/Tests/reference_snapshots/div-text/image-tint-mode_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/image-tint-mode_414@3x.png", + "client/ios/Tests/reference_snapshots/div-text/image_builder_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/image_builder_414@3x.png", "client/ios/Tests/reference_snapshots/div-text/images_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/images_414@3x.png", "client/ios/Tests/reference_snapshots/div-text/line-height/multi-line-text-with-image_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/line-height/multi-line-text-with-image_414@3x.png", "client/ios/Tests/reference_snapshots/div-text/line-height/multi-line-text-with-text-size-ranges_414@3x.png":"divkit/public/client/ios/Tests/reference_snapshots/div-text/line-height/multi-line-text-with-text-size-ranges_414@3x.png", @@ -29641,6 +29643,7 @@ "test_data/snapshot_test_data/div-text/image-background.json":"divkit/public/test_data/snapshot_test_data/div-text/image-background.json", "test_data/snapshot_test_data/div-text/image-reverse-indexing.json":"divkit/public/test_data/snapshot_test_data/div-text/image-reverse-indexing.json", "test_data/snapshot_test_data/div-text/image-tint-mode.json":"divkit/public/test_data/snapshot_test_data/div-text/image-tint-mode.json", + "test_data/snapshot_test_data/div-text/image_builder.json":"divkit/public/test_data/snapshot_test_data/div-text/image_builder.json", "test_data/snapshot_test_data/div-text/images.json":"divkit/public/test_data/snapshot_test_data/div-text/images.json", "test_data/snapshot_test_data/div-text/images_hyphenation.json":"divkit/public/test_data/snapshot_test_data/div-text/images_hyphenation.json", "test_data/snapshot_test_data/div-text/line-height/multi-line-text-with-image.json":"divkit/public/test_data/snapshot_test_data/div-text/line-height/multi-line-text-with-image.json", diff --git a/client/ios/DivKit/Extensions/DivTextExtensions.swift b/client/ios/DivKit/Extensions/DivTextExtensions.swift index 172a4f2a9..420442adb 100644 --- a/client/ios/DivKit/Extensions/DivTextExtensions.swift +++ b/client/ios/DivKit/Extensions/DivTextExtensions.swift @@ -109,8 +109,7 @@ extension DivText: DivBlockModeling { } let images = makeInlineImages( - context: context, - images: self.images, + images: makeImages(self.images, imageBuilder: imageBuilder, context: context), text: attributedString ) @@ -124,8 +123,7 @@ extension DivText: DivBlockModeling { ) } let truncationImages = makeInlineImages( - context: context, - images: ellipsis?.images, + images: makeImages(ellipsis?.images, imageBuilder: nil, context: context), text: truncationToken ) @@ -183,17 +181,17 @@ extension DivText: DivBlockModeling { } private func makeInlineImages( - context: DivBlockModelingContext, - images: [Image]?, + images: [(Image, DivBlockModelingContext)], text: NSAttributedString? ) -> [TextBlock.InlineImage] { guard let text else { return [] } - return (images ?? []).compactMap { - $0.makeImage( - context: context, - textLength: CFAttributedStringGetLength(text) + let textLength = CFAttributedStringGetLength(text) + return images.compactMap { image, imageContext in + image.makeImage( + context: imageContext, + textLength: textLength ) } } diff --git a/client/ios/DivKit/Extensions/DivTextImageBuilderExtensions.swift b/client/ios/DivKit/Extensions/DivTextImageBuilderExtensions.swift new file mode 100644 index 000000000..c8e6e7dfc --- /dev/null +++ b/client/ios/DivKit/Extensions/DivTextImageBuilderExtensions.swift @@ -0,0 +1,48 @@ +import Foundation +import LayoutKit +import VGSL + +extension DivText { + func makeImages( + _ images: [Image]?, + imageBuilder: ImageBuilder?, + context: DivBlockModelingContext + ) -> [(Image, DivBlockModelingContext)] { + if let imageBuilder { + return imageBuilder.makeImages(context: context) + } + return (images ?? []).map { ($0, context) } + } +} + +extension DivText.ImageBuilder { + func makeImages( + context: DivBlockModelingContext + ) -> [(DivText.Image, DivBlockModelingContext)] { + let items = resolveData(context.expressionResolver) ?? [] + return items.enumerated().compactMap { index, item -> ( + DivText.Image, + DivBlockModelingContext + )? in + let itemDict = item as? DivDictionary ?? [:] + let itemContext = context.modifying( + pathSuffix: String(index), + prototypeParams: PrototypeParams( + index: index, + variableName: dataElementName, + value: itemDict + ) + ) + itemContext.variablesStorage.initializeIfNeeded(path: itemContext.path, variables: [ + DivVariableName(rawValue: dataElementName): .dict(itemDict), + "index": .integer(index), + ]) + guard let prototype = prototypes.first(where: { + $0.resolveSelector(itemContext.expressionResolver) + }) else { + return nil + } + return (prototype.image, itemContext) + } + } +} diff --git a/client/ios/DivKit/generated_sources/DivText.swift b/client/ios/DivKit/generated_sources/DivText.swift index 316824d43..ba97c8298 100644 --- a/client/ios/DivKit/generated_sources/DivText.swift +++ b/client/ios/DivKit/generated_sources/DivText.swift @@ -167,6 +167,61 @@ public final class DivText: DivBase, @unchecked Sendable { } } + public final class ImageBuilder: @unchecked Sendable { + public final class Prototype: Sendable { + public let image: Image + public let selector: Expression // default value: true + + public func resolveSelector(_ resolver: ExpressionResolver) -> Bool { + resolver.resolveNumeric(selector) ?? true + } + + public convenience init(dictionary: [String: Any], context: ParsingContext) throws { + self.init( + image: try dictionary.getField("image", transform: { (dict: [String: Any]) in try DivText.Image(dictionary: dict, context: context) }, context: context), + selector: try dictionary.getOptionalExpressionField("selector", context: context) + ) + } + + init( + image: Image, + selector: Expression? = nil + ) { + self.image = image + self.selector = selector ?? .value(true) + } + } + + public let data: Expression<[Any]> + public let dataElementName: String // default value: it + public let prototypes: [Prototype] // at least 1 elements + + public func resolveData(_ resolver: ExpressionResolver) -> [Any]? { + resolver.resolveArray(data) + } + + static let prototypesValidator: AnyArrayValueValidator = + makeArrayValidator(minItems: 1) + + public convenience init(dictionary: [String: Any], context: ParsingContext) throws { + self.init( + data: try dictionary.getExpressionField("data", context: context), + dataElementName: try dictionary.getOptionalField("data_element_name", context: context), + prototypes: try dictionary.getArray("prototypes", transform: { (dict: [String: Any]) in try? DivText.ImageBuilder.Prototype(dictionary: dict, context: context) }, validator: Self.prototypesValidator, context: context) + ) + } + + init( + data: Expression<[Any]>, + dataElementName: String? = nil, + prototypes: [Prototype] + ) { + self.data = data + self.dataElementName = dataElementName ?? "it" + self.prototypes = prototypes + } + } + public final class Range: @unchecked Sendable { public let actions: [DivAction]? public let alignmentVertical: Expression? @@ -440,6 +495,7 @@ public final class DivText: DivBase, @unchecked Sendable { public let hoverEndActions: [DivAction]? public let hoverStartActions: [DivAction]? public let id: String? + public let imageBuilder: ImageBuilder? public let images: [Image]? public let layoutProvider: DivLayoutProvider? public let letterSpacing: Expression // default value: 0 @@ -661,6 +717,7 @@ public final class DivText: DivBase, @unchecked Sendable { hoverEndActions: try dictionary.getOptionalArray("hover_end_actions", transform: { (dict: [String: Any]) in try? DivAction(dictionary: dict, context: context) }), hoverStartActions: try dictionary.getOptionalArray("hover_start_actions", transform: { (dict: [String: Any]) in try? DivAction(dictionary: dict, context: context) }), id: try dictionary.getOptionalField("id", context: context), + imageBuilder: try dictionary.getOptionalField("image_builder", transform: { (dict: [String: Any]) in try DivText.ImageBuilder(dictionary: dict, context: context) }), images: try dictionary.getOptionalArray("images", transform: { (dict: [String: Any]) in try? DivText.Image(dictionary: dict, context: context) }), layoutProvider: try dictionary.getOptionalField("layout_provider", transform: { (dict: [String: Any]) in try DivLayoutProvider(dictionary: dict, context: context) }), letterSpacing: try dictionary.getOptionalExpressionField("letter_spacing", context: context), @@ -736,6 +793,7 @@ public final class DivText: DivBase, @unchecked Sendable { hoverEndActions: [DivAction]? = nil, hoverStartActions: [DivAction]? = nil, id: String? = nil, + imageBuilder: ImageBuilder? = nil, images: [Image]? = nil, layoutProvider: DivLayoutProvider? = nil, letterSpacing: Expression? = nil, @@ -808,6 +866,7 @@ public final class DivText: DivBase, @unchecked Sendable { self.hoverEndActions = hoverEndActions self.hoverStartActions = hoverStartActions self.id = id + self.imageBuilder = imageBuilder self.images = images self.layoutProvider = layoutProvider self.letterSpacing = letterSpacing ?? .value(0) @@ -926,97 +985,98 @@ extension DivText: Equatable { return false } guard + lhs.imageBuilder == rhs.imageBuilder, lhs.images == rhs.images, - lhs.layoutProvider == rhs.layoutProvider, - lhs.letterSpacing == rhs.letterSpacing + lhs.layoutProvider == rhs.layoutProvider else { return false } guard + lhs.letterSpacing == rhs.letterSpacing, lhs.lineHeight == rhs.lineHeight, - lhs.longtapActions == rhs.longtapActions, - lhs.margins == rhs.margins + lhs.longtapActions == rhs.longtapActions else { return false } guard + lhs.margins == rhs.margins, lhs.maxLines == rhs.maxLines, - lhs.minHiddenLines == rhs.minHiddenLines, - lhs.paddings == rhs.paddings + lhs.minHiddenLines == rhs.minHiddenLines else { return false } guard + lhs.paddings == rhs.paddings, lhs.pressEndActions == rhs.pressEndActions, - lhs.pressStartActions == rhs.pressStartActions, - lhs.rangeBuilder == rhs.rangeBuilder + lhs.pressStartActions == rhs.pressStartActions else { return false } guard + lhs.rangeBuilder == rhs.rangeBuilder, lhs.ranges == rhs.ranges, - lhs.reuseId == rhs.reuseId, - lhs.rowSpan == rhs.rowSpan + lhs.reuseId == rhs.reuseId else { return false } guard + lhs.rowSpan == rhs.rowSpan, lhs.selectable == rhs.selectable, - lhs.selectedActions == rhs.selectedActions, - lhs.strike == rhs.strike + lhs.selectedActions == rhs.selectedActions else { return false } guard + lhs.strike == rhs.strike, lhs.text == rhs.text, - lhs.textAlignmentHorizontal == rhs.textAlignmentHorizontal, - lhs.textAlignmentVertical == rhs.textAlignmentVertical + lhs.textAlignmentHorizontal == rhs.textAlignmentHorizontal else { return false } guard + lhs.textAlignmentVertical == rhs.textAlignmentVertical, lhs.textColor == rhs.textColor, - lhs.textGradient == rhs.textGradient, - lhs.textShadow == rhs.textShadow + lhs.textGradient == rhs.textGradient else { return false } guard + lhs.textShadow == rhs.textShadow, lhs.tightenWidth == rhs.tightenWidth, - lhs.tooltips == rhs.tooltips, - lhs.transform == rhs.transform + lhs.tooltips == rhs.tooltips else { return false } guard + lhs.transform == rhs.transform, lhs.transformations == rhs.transformations, - lhs.transitionChange == rhs.transitionChange, - lhs.transitionIn == rhs.transitionIn + lhs.transitionChange == rhs.transitionChange else { return false } guard + lhs.transitionIn == rhs.transitionIn, lhs.transitionOut == rhs.transitionOut, - lhs.transitionTriggers == rhs.transitionTriggers, - lhs.truncate == rhs.truncate + lhs.transitionTriggers == rhs.transitionTriggers else { return false } guard + lhs.truncate == rhs.truncate, lhs.underline == rhs.underline, - lhs.variableTriggers == rhs.variableTriggers, - lhs.variables == rhs.variables + lhs.variableTriggers == rhs.variableTriggers else { return false } guard + lhs.variables == rhs.variables, lhs.visibility == rhs.visibility, - lhs.visibilityAction == rhs.visibilityAction, - lhs.visibilityActions == rhs.visibilityActions + lhs.visibilityAction == rhs.visibilityAction else { return false } guard + lhs.visibilityActions == rhs.visibilityActions, lhs.width == rhs.width else { return false @@ -1062,6 +1122,7 @@ extension DivText: Serializable { result["hover_end_actions"] = hoverEndActions?.map { $0.toDictionary() } result["hover_start_actions"] = hoverStartActions?.map { $0.toDictionary() } result["id"] = id + result["image_builder"] = imageBuilder?.toDictionary() result["images"] = images?.map { $0.toDictionary() } result["layout_provider"] = layoutProvider?.toDictionary() result["letter_spacing"] = letterSpacing.toValidSerializationValue() @@ -1106,6 +1167,21 @@ extension DivText: Serializable { } } +#if DEBUG +// WARNING: this == is incomplete because of [String: Any] in class fields +extension DivText.ImageBuilder: Equatable { + public static func ==(lhs: DivText.ImageBuilder, rhs: DivText.ImageBuilder) -> Bool { + guard + lhs.dataElementName == rhs.dataElementName, + lhs.prototypes == rhs.prototypes + else { + return false + } + return true + } +} +#endif + #if DEBUG // WARNING: this == is incomplete because of [String: Any] in class fields extension DivText.Range: Equatable { @@ -1247,6 +1323,20 @@ extension DivText.Image: Equatable { } #endif +#if DEBUG +extension DivText.ImageBuilder.Prototype: Equatable { + public static func ==(lhs: DivText.ImageBuilder.Prototype, rhs: DivText.ImageBuilder.Prototype) -> Bool { + guard + lhs.image == rhs.image, + lhs.selector == rhs.selector + else { + return false + } + return true + } +} +#endif + #if DEBUG extension DivText.RangeBuilder.Prototype: Equatable { public static func ==(lhs: DivText.RangeBuilder.Prototype, rhs: DivText.RangeBuilder.Prototype) -> Bool { @@ -1301,6 +1391,27 @@ extension DivText.Image: Serializable { } } +extension DivText.ImageBuilder.Prototype: Serializable { + @_optimize(size) + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["image"] = image.toDictionary() + result["selector"] = selector.toValidSerializationValue() + return result + } +} + +extension DivText.ImageBuilder: Serializable { + @_optimize(size) + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["data"] = data.toValidSerializationValue() + result["data_element_name"] = dataElementName + result["prototypes"] = prototypes.map { $0.toDictionary() } + return result + } +} + extension DivText.Range: Serializable { @_optimize(size) public func toDictionary() -> [String: ValidSerializationValue] { diff --git a/client/ios/DivKit/generated_sources/DivTextTemplate.swift b/client/ios/DivKit/generated_sources/DivTextTemplate.swift index 77b82c133..f62b3146e 100644 --- a/client/ios/DivKit/generated_sources/DivTextTemplate.swift +++ b/client/ios/DivKit/generated_sources/DivTextTemplate.swift @@ -159,6 +159,255 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { } } + public final class ImageBuilderTemplate: TemplateValue, @unchecked Sendable { + public final class PrototypeTemplate: TemplateValue, Sendable { + public let image: Field? + public let selector: Field>? // default value: true + + public convenience init(dictionary: [String: Any], templateToType: [TemplateName: String]) throws { + self.init( + image: dictionary.getOptionalField("image", templateToType: templateToType), + selector: dictionary.getOptionalExpressionField("selector") + ) + } + + init( + image: Field? = nil, + selector: Field>? = nil + ) { + self.image = image + self.selector = selector + } + + private static func resolveOnlyLinks(context: TemplatesContext, parent: PrototypeTemplate?) -> DeserializationResult { + let imageValue = { parent?.image?.resolveValue(context: context, useOnlyLinks: true) ?? .noValue }() + let selectorValue = { parent?.selector?.resolveOptionalValue(context: context) ?? .noValue }() + var errors = mergeErrors( + imageValue.errorsOrWarnings?.map { .nestedObjectError(field: "image", error: $0) }, + selectorValue.errorsOrWarnings?.map { .nestedObjectError(field: "selector", error: $0) } + ) + if case .noValue = imageValue { + errors.append(.requiredFieldIsMissing(field: "image")) + } + guard + let imageNonNil = imageValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = DivText.ImageBuilder.Prototype( + image: { imageNonNil }(), + selector: { selectorValue.value }() + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: TemplatesContext, parent: PrototypeTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var imageValue: DeserializationResult = .noValue + var selectorValue: DeserializationResult> = { parent?.selector?.value() ?? .noValue }() + _ = { + // Each field is parsed in its own lambda to keep the stack size managable + // Otherwise the compiler will allocate stack for each intermediate variable + // upfront even when we don't actually visit a relevant branch + for (key, __dictValue) in context.templateData { + _ = { + if key == "image" { + imageValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageTemplate.self).merged(with: imageValue) + } + }() + _ = { + if key == "selector" { + selectorValue = deserialize(__dictValue).merged(with: selectorValue) + } + }() + _ = { + if key == parent?.image?.link { + imageValue = imageValue.merged(with: { deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageTemplate.self) }) + } + }() + _ = { + if key == parent?.selector?.link { + selectorValue = selectorValue.merged(with: { deserialize(__dictValue) }) + } + }() + } + }() + if let parent = parent { + _ = { imageValue = imageValue.merged(with: { parent.image?.resolveValue(context: context, useOnlyLinks: true) }) }() + } + var errors = mergeErrors( + imageValue.errorsOrWarnings?.map { .nestedObjectError(field: "image", error: $0) }, + selectorValue.errorsOrWarnings?.map { .nestedObjectError(field: "selector", error: $0) } + ) + if case .noValue = imageValue { + errors.append(.requiredFieldIsMissing(field: "image")) + } + guard + let imageNonNil = imageValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = DivText.ImageBuilder.Prototype( + image: { imageNonNil }(), + selector: { selectorValue.value }() + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: [TemplateName: Any]) throws -> PrototypeTemplate { + return self + } + + public func resolveParent(templates: [TemplateName: Any]) throws -> PrototypeTemplate { + let merged = try mergedWithParent(templates: templates) + + return PrototypeTemplate( + image: try merged.image?.resolveParent(templates: templates), + selector: merged.selector + ) + } + } + + public let data: Field>? + public let dataElementName: Field? // default value: it + public let prototypes: Field<[PrototypeTemplate]>? // at least 1 elements + + public convenience init(dictionary: [String: Any], templateToType: [TemplateName: String]) throws { + self.init( + data: dictionary.getOptionalExpressionField("data"), + dataElementName: dictionary.getOptionalField("data_element_name"), + prototypes: dictionary.getOptionalArray("prototypes", templateToType: templateToType) + ) + } + + init( + data: Field>? = nil, + dataElementName: Field? = nil, + prototypes: Field<[PrototypeTemplate]>? = nil + ) { + self.data = data + self.dataElementName = dataElementName + self.prototypes = prototypes + } + + private static func resolveOnlyLinks(context: TemplatesContext, parent: ImageBuilderTemplate?) -> DeserializationResult { + let dataValue = { parent?.data?.resolveValue(context: context) ?? .noValue }() + let dataElementNameValue = { parent?.dataElementName?.resolveOptionalValue(context: context) ?? .noValue }() + let prototypesValue = { parent?.prototypes?.resolveValue(context: context, validator: ResolvedValue.prototypesValidator, useOnlyLinks: true) ?? .noValue }() + var errors = mergeErrors( + dataValue.errorsOrWarnings?.map { .nestedObjectError(field: "data", error: $0) }, + dataElementNameValue.errorsOrWarnings?.map { .nestedObjectError(field: "data_element_name", error: $0) }, + prototypesValue.errorsOrWarnings?.map { .nestedObjectError(field: "prototypes", error: $0) } + ) + if case .noValue = dataValue { + errors.append(.requiredFieldIsMissing(field: "data")) + } + if case .noValue = prototypesValue { + errors.append(.requiredFieldIsMissing(field: "prototypes")) + } + guard + let dataNonNil = dataValue.value, + let prototypesNonNil = prototypesValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = DivText.ImageBuilder( + data: { dataNonNil }(), + dataElementName: { dataElementNameValue.value }(), + prototypes: { prototypesNonNil }() + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: TemplatesContext, parent: ImageBuilderTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var dataValue: DeserializationResult> = { parent?.data?.value() ?? .noValue }() + var dataElementNameValue: DeserializationResult = { parent?.dataElementName?.value() ?? .noValue }() + var prototypesValue: DeserializationResult<[DivText.ImageBuilder.Prototype]> = .noValue + _ = { + // Each field is parsed in its own lambda to keep the stack size managable + // Otherwise the compiler will allocate stack for each intermediate variable + // upfront even when we don't actually visit a relevant branch + for (key, __dictValue) in context.templateData { + _ = { + if key == "data" { + dataValue = deserialize(__dictValue).merged(with: dataValue) + } + }() + _ = { + if key == "data_element_name" { + dataElementNameValue = deserialize(__dictValue).merged(with: dataElementNameValue) + } + }() + _ = { + if key == "prototypes" { + prototypesValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.prototypesValidator, type: DivTextTemplate.ImageBuilderTemplate.PrototypeTemplate.self).merged(with: prototypesValue) + } + }() + _ = { + if key == parent?.data?.link { + dataValue = dataValue.merged(with: { deserialize(__dictValue) }) + } + }() + _ = { + if key == parent?.dataElementName?.link { + dataElementNameValue = dataElementNameValue.merged(with: { deserialize(__dictValue) }) + } + }() + _ = { + if key == parent?.prototypes?.link { + prototypesValue = prototypesValue.merged(with: { deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.prototypesValidator, type: DivTextTemplate.ImageBuilderTemplate.PrototypeTemplate.self) }) + } + }() + } + }() + if let parent = parent { + _ = { prototypesValue = prototypesValue.merged(with: { parent.prototypes?.resolveValue(context: context, validator: ResolvedValue.prototypesValidator, useOnlyLinks: true) }) }() + } + var errors = mergeErrors( + dataValue.errorsOrWarnings?.map { .nestedObjectError(field: "data", error: $0) }, + dataElementNameValue.errorsOrWarnings?.map { .nestedObjectError(field: "data_element_name", error: $0) }, + prototypesValue.errorsOrWarnings?.map { .nestedObjectError(field: "prototypes", error: $0) } + ) + if case .noValue = dataValue { + errors.append(.requiredFieldIsMissing(field: "data")) + } + if case .noValue = prototypesValue { + errors.append(.requiredFieldIsMissing(field: "prototypes")) + } + guard + let dataNonNil = dataValue.value, + let prototypesNonNil = prototypesValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = DivText.ImageBuilder( + data: { dataNonNil }(), + dataElementName: { dataElementNameValue.value }(), + prototypes: { prototypesNonNil }() + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: [TemplateName: Any]) throws -> ImageBuilderTemplate { + return self + } + + public func resolveParent(templates: [TemplateName: Any]) throws -> ImageBuilderTemplate { + let merged = try mergedWithParent(templates: templates) + + return ImageBuilderTemplate( + data: merged.data, + dataElementName: merged.dataElementName, + prototypes: try merged.prototypes?.resolveParent(templates: templates) + ) + } + } + public final class ImageTemplate: TemplateValue, Sendable { public final class AccessibilityTemplate: TemplateValue, Sendable { public typealias Kind = DivText.Image.Accessibility.Kind @@ -1339,6 +1588,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { public let hoverEndActions: Field<[DivActionTemplate]>? public let hoverStartActions: Field<[DivActionTemplate]>? public let id: Field? + public let imageBuilder: Field? public let images: Field<[ImageTemplate]>? public let layoutProvider: Field? public let letterSpacing: Field>? // default value: 0 @@ -1414,6 +1664,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: dictionary.getOptionalArray("hover_end_actions", templateToType: templateToType), hoverStartActions: dictionary.getOptionalArray("hover_start_actions", templateToType: templateToType), id: dictionary.getOptionalField("id"), + imageBuilder: dictionary.getOptionalField("image_builder", templateToType: templateToType), images: dictionary.getOptionalArray("images", templateToType: templateToType), layoutProvider: dictionary.getOptionalField("layout_provider", templateToType: templateToType), letterSpacing: dictionary.getOptionalExpressionField("letter_spacing"), @@ -1490,6 +1741,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: Field<[DivActionTemplate]>? = nil, hoverStartActions: Field<[DivActionTemplate]>? = nil, id: Field? = nil, + imageBuilder: Field? = nil, images: Field<[ImageTemplate]>? = nil, layoutProvider: Field? = nil, letterSpacing: Field>? = nil, @@ -1563,6 +1815,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { self.hoverEndActions = hoverEndActions self.hoverStartActions = hoverStartActions self.id = id + self.imageBuilder = imageBuilder self.images = images self.layoutProvider = layoutProvider self.letterSpacing = letterSpacing @@ -1637,6 +1890,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { let hoverEndActionsValue = { parent?.hoverEndActions?.resolveOptionalValue(context: context, useOnlyLinks: true) ?? .noValue }() let hoverStartActionsValue = { parent?.hoverStartActions?.resolveOptionalValue(context: context, useOnlyLinks: true) ?? .noValue }() let idValue = { parent?.id?.resolveOptionalValue(context: context) ?? .noValue }() + let imageBuilderValue = { parent?.imageBuilder?.resolveOptionalValue(context: context, useOnlyLinks: true) ?? .noValue }() let imagesValue = { parent?.images?.resolveOptionalValue(context: context, useOnlyLinks: true) ?? .noValue }() let layoutProviderValue = { parent?.layoutProvider?.resolveOptionalValue(context: context, useOnlyLinks: true) ?? .noValue }() let letterSpacingValue = { parent?.letterSpacing?.resolveOptionalValue(context: context) ?? .noValue }() @@ -1709,6 +1963,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActionsValue.errorsOrWarnings?.map { .nestedObjectError(field: "hover_end_actions", error: $0) }, hoverStartActionsValue.errorsOrWarnings?.map { .nestedObjectError(field: "hover_start_actions", error: $0) }, idValue.errorsOrWarnings?.map { .nestedObjectError(field: "id", error: $0) }, + imageBuilderValue.errorsOrWarnings?.map { .nestedObjectError(field: "image_builder", error: $0) }, imagesValue.errorsOrWarnings?.map { .nestedObjectError(field: "images", error: $0) }, layoutProviderValue.errorsOrWarnings?.map { .nestedObjectError(field: "layout_provider", error: $0) }, letterSpacingValue.errorsOrWarnings?.map { .nestedObjectError(field: "letter_spacing", error: $0) }, @@ -1790,6 +2045,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: { hoverEndActionsValue.value }(), hoverStartActions: { hoverStartActionsValue.value }(), id: { idValue.value }(), + imageBuilder: { imageBuilderValue.value }(), images: { imagesValue.value }(), layoutProvider: { layoutProviderValue.value }(), letterSpacing: { letterSpacingValue.value }(), @@ -1869,6 +2125,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { var hoverEndActionsValue: DeserializationResult<[DivAction]> = .noValue var hoverStartActionsValue: DeserializationResult<[DivAction]> = .noValue var idValue: DeserializationResult = { parent?.id?.value() ?? .noValue }() + var imageBuilderValue: DeserializationResult = .noValue var imagesValue: DeserializationResult<[DivText.Image]> = .noValue var layoutProviderValue: DeserializationResult = .noValue var letterSpacingValue: DeserializationResult> = { parent?.letterSpacing?.value() ?? .noValue }() @@ -2069,6 +2326,11 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { idValue = deserialize(__dictValue).merged(with: idValue) } }() + _ = { + if key == "image_builder" { + imageBuilderValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageBuilderTemplate.self).merged(with: imageBuilderValue) + } + }() _ = { if key == "images" { imagesValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageTemplate.self).merged(with: imagesValue) @@ -2424,6 +2686,11 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { idValue = idValue.merged(with: { deserialize(__dictValue) }) } }() + _ = { + if key == parent?.imageBuilder?.link { + imageBuilderValue = imageBuilderValue.merged(with: { deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageBuilderTemplate.self) }) + } + }() _ = { if key == parent?.images?.link { imagesValue = imagesValue.merged(with: { deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTextTemplate.ImageTemplate.self) }) @@ -2643,6 +2910,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { _ = { heightValue = heightValue.merged(with: { parent.height?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() _ = { hoverEndActionsValue = hoverEndActionsValue.merged(with: { parent.hoverEndActions?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() _ = { hoverStartActionsValue = hoverStartActionsValue.merged(with: { parent.hoverStartActions?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() + _ = { imageBuilderValue = imageBuilderValue.merged(with: { parent.imageBuilder?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() _ = { imagesValue = imagesValue.merged(with: { parent.images?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() _ = { layoutProviderValue = layoutProviderValue.merged(with: { parent.layoutProvider?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() _ = { longtapActionsValue = longtapActionsValue.merged(with: { parent.longtapActions?.resolveOptionalValue(context: context, useOnlyLinks: true) }) }() @@ -2699,6 +2967,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActionsValue.errorsOrWarnings?.map { .nestedObjectError(field: "hover_end_actions", error: $0) }, hoverStartActionsValue.errorsOrWarnings?.map { .nestedObjectError(field: "hover_start_actions", error: $0) }, idValue.errorsOrWarnings?.map { .nestedObjectError(field: "id", error: $0) }, + imageBuilderValue.errorsOrWarnings?.map { .nestedObjectError(field: "image_builder", error: $0) }, imagesValue.errorsOrWarnings?.map { .nestedObjectError(field: "images", error: $0) }, layoutProviderValue.errorsOrWarnings?.map { .nestedObjectError(field: "layout_provider", error: $0) }, letterSpacingValue.errorsOrWarnings?.map { .nestedObjectError(field: "letter_spacing", error: $0) }, @@ -2780,6 +3049,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: { hoverEndActionsValue.value }(), hoverStartActions: { hoverStartActionsValue.value }(), id: { idValue.value }(), + imageBuilder: { imageBuilderValue.value }(), images: { imagesValue.value }(), layoutProvider: { layoutProviderValue.value }(), letterSpacing: { letterSpacingValue.value }(), @@ -2864,6 +3134,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: hoverEndActions ?? mergedParent.hoverEndActions, hoverStartActions: hoverStartActions ?? mergedParent.hoverStartActions, id: id ?? mergedParent.id, + imageBuilder: imageBuilder ?? mergedParent.imageBuilder, images: images ?? mergedParent.images, layoutProvider: layoutProvider ?? mergedParent.layoutProvider, letterSpacing: letterSpacing ?? mergedParent.letterSpacing, @@ -2943,6 +3214,7 @@ public final class DivTextTemplate: TemplateValue, @unchecked Sendable { hoverEndActions: merged.hoverEndActions?.tryResolveParent(templates: templates), hoverStartActions: merged.hoverStartActions?.tryResolveParent(templates: templates), id: merged.id, + imageBuilder: merged.imageBuilder?.tryResolveParent(templates: templates), images: merged.images?.tryResolveParent(templates: templates), layoutProvider: merged.layoutProvider?.tryResolveParent(templates: templates), letterSpacing: merged.letterSpacing, diff --git a/client/ios/Tests/reference_snapshots/div-text/image_builder_414@3x.png b/client/ios/Tests/reference_snapshots/div-text/image_builder_414@3x.png new file mode 100644 index 000000000..f8349e875 Binary files /dev/null and b/client/ios/Tests/reference_snapshots/div-text/image_builder_414@3x.png differ diff --git a/schema/div-text.json b/schema/div-text.json index 77947e014..b9848d4e3 100644 --- a/schema/div-text.json +++ b/schema/div-text.json @@ -190,6 +190,48 @@ ], "$description": "translations.json#/div_text_range_builder" }, + "image_builder": { + "type": "object", + "properties": { + "data": { + "type": "array", + "$description": "translations.json#/div_text_image_builder_data" + }, + "data_element_name": { + "type": "string", + "default_value": "it", + "supports_expressions": false, + "$description": "translations.json#/div_text_image_builder_data_element_name" + }, + "prototypes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "selector": { + "$ref": "common.json#/boolean_int", + "default_value": "true", + "$description": "translations.json#/div_text_image_builder_prototype_selector" + }, + "image": { + "$ref": "#/definitions/image", + "$description": "translations.json#/div_text_image_builder_prototype_image" + } + }, + "required": [ + "image" + ] + }, + "$description": "translations.json#/div_text_image_builder_prototypes" + } + }, + "required": [ + "data", + "prototypes" + ], + "$description": "translations.json#/div_text_image_builder" + }, "image": { "type": "object", "platforms": [ @@ -470,6 +512,12 @@ "web" ] }, + "image_builder": { + "$ref": "#/definitions/image_builder", + "platforms": [ + "ios" + ] + }, "ellipsis": { "$ref": "#/definitions/ellipsis" }, diff --git a/schema/translations.json b/schema/translations.json index 1d25a39e9..a0616da0d 100644 --- a/schema/translations.json +++ b/schema/translations.json @@ -2551,6 +2551,30 @@ "en": "A condition that is used to select the prototype for the next character range. If there is more than 1 true condition, the earlier prototype is selected. If none of the conditions are met, the element from `data` is skipped.", "ru": "Условие, которое используется для выбора прототипа для очередного диапазона символов. В случае, если будет больше 1 истинного условия, будет выбран прототип, который находится раньше. Если не выполнено ни одно условие, элемент из `data` будет пропущен." }, + "div_text_image_builder": { + "en": "Sets inline images dynamically using `data` and `prototypes`.", + "ru": "Задает встроенные изображения динамически при помощи `data` и `prototypes`." + }, + "div_text_image_builder_data": { + "en": "Data that will be used to create inline images.", + "ru": "Данные, которые будут использоваться для создания встроенных изображений." + }, + "div_text_image_builder_data_element_name": { + "en": "Name for accessing the next `data` element in the prototype. Working with this element is the same as with dictionaries.", + "ru": "Имя для обращения к очередному элементу `data` в прототипе. Работа с этим элементом ведется так же, как со словарями." + }, + "div_text_image_builder_prototypes": { + "en": "Array of `image` elements from which the inline images will be created.", + "ru": "Массив `image`, из которых будут созданы встроенные изображения." + }, + "div_text_image_builder_prototype_image": { + "en": "`image` from which the inline image will be created. In `image`, you can use expressions using data from `data`. To access the next `data` element, you need to use the same name as in `data_element_name`.", + "ru": "`image`, из которого будет создано встроенное изображение. В `image` можно использовать выражения с использованием данных из `data`. Для обращения к очередному элементу `data` нужно использовать такое же имя, как и в `data_element_name`." + }, + "div_text_image_builder_prototype_selector": { + "en": "A condition that is used to select the prototype for the next inline image. If there is more than 1 true condition, the earlier prototype is selected. If none of the conditions are met, the element from `data` is skipped.", + "ru": "Условие, которое используется для выбора прототипа для очередного встроенного изображения. В случае, если будет больше 1 истинного условия, будет выбран прототип, который находится раньше. Если не выполнено ни одно условие, элемент из `data` будет пропущен." + }, "div_text_range_actions": { "en": "Action when clicking on text.", "ru": "Действие при нажатии на текст." diff --git a/test_data/snapshot_test_data/div-text/image_builder.json b/test_data/snapshot_test_data/div-text/image_builder.json new file mode 100644 index 000000000..acaa75f4d --- /dev/null +++ b/test_data/snapshot_test_data/div-text/image_builder.json @@ -0,0 +1,70 @@ +{ + "description": "Text with image_builder: inline images generated from a data array, with selector-based prototype choice.", + "platforms": [ + "ios" + ], + "card": { + "log_id": "snapshot_test_card", + "states": [ + { + "state_id": 0, + "div": { + "type": "container", + "width": { + "type": "wrap_content" + }, + "background": [ + { + "type": "solid", + "color": "#FFF" + } + ], + "items": [ + { + "variables": [ + { + "name": "source", + "type": "array", + "value": [ + { "pos": 2, "kind": "chess" }, + { "pos": 5, "kind": "star" }, + { "pos": 8, "kind": "chess" } + ] + } + ], + "type": "text", + "text": "0123456789", + "font_size": 20, + "width": { + "type": "wrap_content" + }, + "image_builder": { + "data": "@{source}", + "data_element_name": "it", + "prototypes": [ + { + "selector": "@{getStringFromDict(it, 'kind') == 'star'}", + "image": { + "start": "@{getIntegerFromDict(it, 'pos')}", + "url": "https://yastatic.net/s3/home/divkit/star.png", + "width": { "type": "fixed", "value": 24 }, + "height": { "type": "fixed", "value": 24 } + } + }, + { + "image": { + "start": "@{getIntegerFromDict(it, 'pos')}", + "url": "https://yastatic.net/s3/home/divkit/chess.png", + "width": { "type": "fixed", "value": 24 }, + "height": { "type": "fixed", "value": 18 } + } + } + ] + } + } + ] + } + } + ] + } +}