import Foundation import UIKit import TelegramCore import Display import TelegramPresentationData import TelegramUIPreferences import TelegramStringFormatting import MosaicLayout import TextFormat public final class InstantPageLayout { public let origin: CGPoint public let contentSize: CGSize public let items: [InstantPageItem] public init(origin: CGPoint, contentSize: CGSize, items: [InstantPageItem]) { self.origin = origin self.contentSize = contentSize self.items = items } public func flattenedItemsWithOrigin(_ origin: CGPoint) -> [InstantPageItem] { return self.items.map({ item in var item = item let itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y) item.frame = itemFrame return item }) } } private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, attributes: InstantPageTextAttributes) { stack.push(.textColor(attributes.color)) stack.push(.markerColor(theme.markerColor)) stack.push(.linkColor(theme.linkColor)) stack.push(.linkMarkerColor(theme.linkHighlightColor)) switch attributes.font.style { case .sans: stack.push(.fontSerif(false)) case .serif: stack.push(.fontSerif(true)) case .monospace: stack.push(.fontFixed(true)) } stack.push(.fontSize(attributes.font.size)) stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor)) if attributes.underline { stack.push(.underline) } } private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, category: InstantPageTextCategoryType, link: Bool) { setupStyleStack(stack, theme: theme, attributes: theme.textCategories.attributes(type: category, link: link)) } private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = false, italic: Bool = false, fixed: Bool = false) -> UIFont { let size = style.font.size if fixed { if bold && italic { return UIFont(name: "Menlo-BoldItalic", size: size) ?? Font.semiboldItalic(size) } else if bold { return UIFont(name: "Menlo-Bold", size: size) ?? Font.bold(size) } else if italic { return UIFont(name: "Menlo-Italic", size: size) ?? Font.italic(size) } else { return UIFont(name: "Menlo", size: size) ?? Font.regular(size) } } switch style.font.style { case .serif: if bold && italic { return UIFont(name: "Georgia-BoldItalic", size: size) ?? Font.semiboldItalic(size) } else if bold { return UIFont(name: "Georgia-Bold", size: size) ?? Font.bold(size) } else if italic { return UIFont(name: "Georgia-Italic", size: size) ?? Font.italic(size) } else { return UIFont(name: "Georgia", size: size) ?? Font.regular(size) } case .sans: if bold && italic { return Font.semiboldItalic(size) } else if bold { return Font.bold(size) } else if italic { return Font.italic(size) } else { return Font.regular(size) } case .monospace: if bold && italic { return Font.semiboldItalicMonospace(size) } else if bold { return Font.semiboldMonospace(size) } else if italic { return Font.italicMonospace(size) } else { return Font.monospace(size) } } } private func attributedStringForPreformattedText(_ text: RichText, language: String?, theme: InstantPageTheme, cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?) -> NSAttributedString { let paragraphAttributes = theme.textCategories.attributes(type: .paragraph, link: false) let textValue = text.plainText guard !textValue.isEmpty else { return NSAttributedString( string: "", attributes: [ .font: instantPageFont(style: paragraphAttributes, fixed: true), .foregroundColor: paragraphAttributes.color, NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute): paragraphAttributes.font.lineSpacingFactor as NSNumber ] ) } let attributedString = stringWithAppliedEntities( textValue, entities: [ MessageTextEntity(range: 0 ..< (textValue as NSString).length, type: .Pre(language: language)) ], baseColor: paragraphAttributes.color, linkColor: theme.linkColor, codeBlockTitleColor: paragraphAttributes.color, codeBlockAccentColor: paragraphAttributes.color, codeBlockBackgroundColor: theme.codeBlockBackgroundColor, baseFont: instantPageFont(style: paragraphAttributes), linkFont: instantPageFont(style: paragraphAttributes), boldFont: instantPageFont(style: paragraphAttributes, bold: true), italicFont: instantPageFont(style: paragraphAttributes, italic: true), boldItalicFont: instantPageFont(style: paragraphAttributes, bold: true, italic: true), fixedFont: instantPageFont(style: paragraphAttributes, fixed: true), blockQuoteFont: instantPageFont(style: paragraphAttributes), underlineLinks: false, message: nil, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight ).mutableCopy() as! NSMutableAttributedString attributedString.addAttribute( NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute), value: paragraphAttributes.font.lineSpacingFactor as NSNumber, range: NSRange(location: 0, length: attributedString.length) ) return attributedString } private let instantPageChecklistMarkerSize = CGSize(width: 18.0, height: 18.0) private func instantPageFirstTextLineMidY(in items: [InstantPageItem]) -> CGFloat? { for item in items { if let textItem = item as? InstantPageTextItem { if let line = textItem.lines.first { return textItem.frame.minY + line.frame.midY } else { return textItem.frame.midY } } else if let scrollableTextItem = item as? InstantPageScrollableTextItem { if let line = scrollableTextItem.item.lines.first { return scrollableTextItem.frame.minY + scrollableTextItem.item.frame.minY + line.frame.midY } else { return scrollableTextItem.frame.midY } } } return nil } public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool, isLast: Bool, fitToWidth: Bool) -> InstantPageLayout { let layoutCaption: (InstantPageCaption, CGSize) -> ([InstantPageItem], CGSize) = { caption, contentSize in var items: [InstantPageItem] = [] var offset = contentSize.height var contentSize = CGSize() var rtl = rtl if case .empty = caption.text { } else { contentSize.height += 14.0 offset += 14.0 let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let (textItem, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption.text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: offset), media: media, webpage: webpage, fitToWidth: fitToWidth) contentSize.height += captionContentSize.height offset += captionContentSize.height items.append(contentsOf: captionItems) rtl = textItem?.containsRTL ?? rtl } if case .empty = caption.credit { } else { if case .empty = caption.text { contentSize.height += 14.0 offset += 14.0 } else { contentSize.height += 10.0 offset += 10.0 } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .credit, link: false) let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption.credit, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: offset), media: media, webpage: webpage, fitToWidth: fitToWidth) contentSize.height += captionContentSize.height offset += captionContentSize.height items.append(contentsOf: captionItems) } if contentSize.height > 0.0 && isCover { contentSize.height += 14.0 } return (items, contentSize) } let stringForDate: (Int32) -> String = { date in let dateFormatter = DateFormatter() dateFormatter.locale = localeWithStrings(strings) dateFormatter.dateStyle = .long dateFormatter.timeStyle = .none return dateFormatter.string(from: Date(timeIntervalSince1970: Double(date))) } switch block { case let .cover(block): return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: true, fitToWidth: fitToWidth) case let .title(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .header, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .subtitle(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .subheader, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .authorDate(author: author, date: date): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) var text: RichText? if case .empty = author { if date != 0 { text = .plain(stringForDate(date)) } } else { if date != 0 { let dateText = RichText.plain(stringForDate(date)) let formatString = strings.InstantPage_AuthorAndDateTitle("%1$@", "%2$@").string let authorRange = formatString.range(of: "%1$@")! let dateRange = formatString.range(of: "%2$@")! if authorRange.lowerBound < dateRange.lowerBound { let byPart = String(formatString[formatString.startIndex ..< authorRange.lowerBound]) let middlePart = String(formatString[authorRange.upperBound ..< dateRange.lowerBound]) let endPart = String(formatString[dateRange.upperBound...]) text = .concat([.plain(byPart), author, .plain(middlePart), dateText, .plain(endPart)]) } else { let beforePart = String(formatString[formatString.startIndex ..< dateRange.lowerBound]) let middlePart = String(formatString[dateRange.upperBound ..< authorRange.lowerBound]) let endPart = String(formatString[authorRange.upperBound...]) text = .concat([.plain(beforePart), dateText, .plain(middlePart), author, .plain(endPart)]) } } else { text = author } } if let text = text { var previousItemHasRTL = false if let previousItem = previousItems.last as? InstantPageTextItem, previousItem.containsRTL { previousItemHasRTL = true } let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl || previousItemHasRTL ? .right : .natural, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } else { return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } case let .kicker(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .kicker, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .header(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .header, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .subheader(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .subheader, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .heading(text, level): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, attributes: theme.headingTextAttributes(level: level, link: false)) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .formula(latex): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) let attributes = styleStack.textAttributes() let textColor = (attributes[NSAttributedString.Key.foregroundColor] as? UIColor) ?? theme.textCategories.paragraph.color let fontSize = (attributes[NSAttributedString.Key.font] as? UIFont)?.pointSize ?? theme.textCategories.paragraph.font.size guard let attachment = instantPageMathAttachment(latex: latex, fontSize: fontSize, textColor: textColor, mode: .block) else { let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(.plain(latex), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } let availableWidth = boundingWidth - horizontalInset * 2.0 if attachment.rendered.size.width > availableWidth { let scrollableItem = InstantPageScrollableFormulaItem( frame: CGRect(origin: .zero, size: CGSize(width: boundingWidth, height: attachment.rendered.size.height)), attachment: attachment, totalWidth: attachment.rendered.size.width, horizontalInset: horizontalInset ) return InstantPageLayout(origin: CGPoint(), contentSize: scrollableItem.frame.size, items: [scrollableItem]) } else { let item = InstantPageFormulaItem( frame: CGRect( origin: CGPoint(x: horizontalInset + floor((availableWidth - attachment.rendered.size.width) / 2.0), y: 0.0), size: attachment.rendered.size ), attachment: attachment ) return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(width: boundingWidth, height: attachment.rendered.size.height), items: [item]) } case let .paragraph(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, horizontalInset: horizontalInset, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage, fitToWidth: fitToWidth) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .preformatted(text, language): let backgroundInset: CGFloat = 14.0 let attributedString: NSAttributedString if let language, !language.isEmpty { attributedString = attributedStringForPreformattedText(text, language: language, theme: theme, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight) } else { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) attributedString = attributedStringForRichText(text, styleStack: styleStack) } let (_, items, contentSize) = layoutTextItemWithString( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, offset: CGPoint(x: 17.0, y: backgroundInset), media: media, webpage: webpage, fitToWidth: fitToWidth, opaqueBackground: true ) let backgroundItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0)), shape: .rect, color: theme.codeBlockBackgroundColor) var allItems: [InstantPageItem] = [backgroundItem] allItems.append(contentsOf: items) return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0), items: allItems) case let .footer(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case .divider: if isLast { return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } else { let lineWidth = floor(boundingWidth / 2.0) let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color) return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem]) } case let .list(contentItems, ordered): var contentSize = CGSize(width: boundingWidth, height: 0.0) var maxIndexWidth: CGFloat = 0.0 var listItems: [InstantPageItem] = [] var indexItems: [InstantPageItem] = [] var hasTaskMarkers = false var hasNums = false if ordered { for item in contentItems { if item.checked != nil { hasTaskMarkers = true } else if let num = item.num, !num.isEmpty { hasNums = true } } } else { for item in contentItems { if item.checked != nil { hasTaskMarkers = true break } } } for i in 0 ..< contentItems.count { let item = contentItems[i] if let checked = item.checked { let checklistItem = InstantPageChecklistMarkerItem(frame: CGRect(origin: .zero, size: instantPageChecklistMarkerSize), checked: checked) if ordered { maxIndexWidth = max(maxIndexWidth, instantPageChecklistMarkerSize.width) } indexItems.append(checklistItem) } else if ordered { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) let value: String if hasNums { if let num = item.num { value = "\(num)." } else { value = " " } } else { value = "\(i + 1)." } let (textItem, _, _) = layoutTextItemWithString(attributedStringForRichText(.plain(value), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(), fitToWidth: fitToWidth) if let textItem = textItem, let line = textItem.lines.first { textItem.selectable = false maxIndexWidth = max(maxIndexWidth, line.frame.width) indexItems.append(textItem) } } else { let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 6.0, height: 12.0)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 3.0), size: CGSize(width: 6.0, height: 6.0)), shape: .ellipse, color: theme.textCategories.paragraph.color) indexItems.append(shapeItem) } } let indexSpacing: CGFloat = ordered ? (hasTaskMarkers ? 16.0 : 12.0) : (hasTaskMarkers ? 24.0 : 20.0) for (i, item) in contentItems.enumerated() { if i != 0 { if fitToWidth { contentSize.height += 12.0 } else { contentSize.height += 18.0 } } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) var effectiveItem = item if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { effectiveItem = .text(.plain(" "), num, checked) } switch effectiveItem { case let .text(text, _, _): let (textItem, textItems, textItemSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, offset: CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth) contentSize.height += textItemSize.height let indexItem = indexItems[i] var itemFrame = indexItem.frame var lineMidY: CGFloat = 0.0 if let textItem = textItem { if let line = textItem.lines.first { lineMidY = textItem.frame.minY + line.frame.midY } else { lineMidY = textItem.frame.midY } } if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first { itemFrame = itemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) } else if indexItem is InstantPageChecklistMarkerItem { if ordered { itemFrame = itemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - itemFrame.width, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) } else { itemFrame = itemFrame.offsetBy(dx: horizontalInset, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) } } else { itemFrame = itemFrame.offsetBy(dx: horizontalInset, dy: floorToScreenPixels(lineMidY - itemFrame.height / 2.0)) } indexItems[i].frame = itemFrame listItems.append(indexItems[i]) listItems.append(contentsOf: textItems) case let .blocks(blocks, _, _): var previousBlock: InstantPageBlock? var originY: CGFloat = contentSize.height var firstBlockLineMidY: CGFloat? for i in 0 ..< blocks.count { let subBlock = blocks[i] let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth) let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth, kind: .topLevel) : 0.0 let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing)) if previousBlock == nil { originY += spacing } if firstBlockLineMidY == nil { firstBlockLineMidY = instantPageFirstTextLineMidY(in: blockItems) } listItems.append(contentsOf: blockItems) contentSize.height += subLayout.contentSize.height + spacing previousBlock = subBlock } let indexItem = indexItems[i] var indexItemFrame = indexItem.frame if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: originY) } else if indexItem is InstantPageChecklistMarkerItem { let markerOriginY: CGFloat if let firstBlockLineMidY { markerOriginY = floorToScreenPixels(firstBlockLineMidY - indexItemFrame.height / 2.0) } else { markerOriginY = originY } if ordered { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - indexItemFrame.width, dy: markerOriginY) } else { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset, dy: markerOriginY) } } else { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset, dy: originY) } indexItems[i].frame = indexItemFrame listItems.append(indexItems[i]) break default: break } } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: listItems) case let .blockQuote(blocks, caption): let lineInset: CGFloat = 20.0 let verticalInset: CGFloat = 4.0 var contentSize = CGSize(width: boundingWidth, height: verticalInset) var items: [InstantPageItem] = [] let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset let innerHorizontalInset = horizontalInset + lineInset if blocks.count == 1, case let .paragraph(text) = blocks[0] { // Legacy single-paragraph fast path: italicized body (unchanged from prior behavior). let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) styleStack.push(.italic) let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: innerBoundingWidth, offset: CGPoint(x: innerHorizontalInset, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth) contentSize.height += textContentSize.height items.append(contentsOf: textItems) } else { var previousChildItems: [InstantPageItem] = [] // Fixed, compact gap between a quote's child blocks (the full page-flow // spacing around quotes is too airy when nested); the first child hugs // the top (only verticalInset above it). let childSpacing: CGFloat = 10.0 for (i, child) in blocks.enumerated() { let childLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: child, boundingWidth: innerBoundingWidth, horizontalInset: innerHorizontalInset, safeInset: safeInset, isCover: false, previousItems: previousChildItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: excludeCaptions, isLast: i == blocks.count - 1 && isLast, fitToWidth: fitToWidth) let spacing: CGFloat = i == 0 ? 0.0 : childSpacing contentSize.height += spacing var childItems: [InstantPageItem] = [] for var item in childLayout.items { item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height) childItems.append(item) } items.append(contentsOf: childItems) previousChildItems = childItems contentSize.height += childLayout.contentSize.height } } if case .empty = caption { } else { contentSize.height += 14.0 let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: innerBoundingWidth, offset: CGPoint(x: innerHorizontalInset, y: contentSize.height), media: media, webpage: webpage) contentSize.height += captionContentSize.height items.append(contentsOf: captionItems) } contentSize.height += verticalInset let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shape: .roundLine, color: theme.textCategories.paragraph.color) items.append(shapeItem) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .pullQuote(text, caption): let verticalInset: CGFloat = 4.0 var contentSize = CGSize(width: boundingWidth, height: verticalInset) var items: [InstantPageItem] = [] let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) styleStack.push(.italic) let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: .center, offset: CGPoint(x: 0.0, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth) for var item in textItems { item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) } contentSize.height += textContentSize.height items.append(contentsOf: textItems) if case .empty = caption { } else { contentSize.height += 14.0 let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: .center, offset: CGPoint(x: 0.0, y: contentSize.height), media: media, webpage: webpage) for var item in textItems { item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) } contentSize.height += captionContentSize.height items.append(contentsOf: captionItems) } contentSize.height += verticalInset return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .image(id, caption, url, webpageId): if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) { let imageSize = largest.dimensions var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0)) if let size = fillToSize { filledSize = size } else if isCover { filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0)) if !filledSize.height.isZero { filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0))) } } let mediaIndex = mediaIndexCounter mediaIndexCounter += 1 var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0) var items: [InstantPageItem] = [] var mediaUrl: InstantPageUrlItem? if let url = url { mediaUrl = InstantPageUrlItem(url: url, webpageId: webpageId) } let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: caption.text, credit: caption.credit), interactive: true, roundCorners: false, fit: false) items.append(mediaItem) contentSize.height += filledSize.height if !excludeCaptions { let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } else { return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } case let .video(id, caption, autoplay, _): if case let .file(file) = media[id], let dimensions = file.dimensions { let imageSize = dimensions var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0)) if let size = fillToSize { filledSize = size } else if isCover { filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0)) if !filledSize.height.isZero { filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0))) } } let mediaIndex = mediaIndexCounter mediaIndexCounter += 1 var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0) var items: [InstantPageItem] = [] if autoplay { let mediaItem = InstantPagePlayableVideoItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: caption.text, credit: caption.credit), interactive: true) items.append(mediaItem) } else { let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: caption.text, credit: caption.credit), interactive: true, roundCorners: false, fit: false) items.append(mediaItem) } contentSize.height += filledSize.height if !excludeCaptions { let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } else { return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } case let .collage(innerItems, caption): var items: [InstantPageItem] = [] var itemSizes: [CGSize] = [] for subItem in innerItems { var size = CGSize() switch subItem { case let .image(id, _, _, _): if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) { size = largest.dimensions.cgSize } case let .video(id, _, _, _): if case let .file(file) = media[id], let dimensions = file.dimensions { size = dimensions.cgSize } default: break } itemSizes.append(size) } let (mosaicLayout, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes) var i = 0 for subItem in innerItems { let frame = mosaicLayout[i].0 let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: true, isLast: false, fitToWidth: false) items.append(contentsOf: subLayout.flattenedItemsWithOrigin(frame.origin)) i += 1 } var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: mosaicSize.height) let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .postEmbed(_, _, avatarId, author, date, blocks, caption): var contentSize = CGSize(width: boundingWidth, height: 0.0) let lineInset: CGFloat = 20.0 let verticalInset: CGFloat = 4.0 let itemSpacing: CGFloat = 10.0 var avatarInset: CGFloat = 0.0 var avatarVerticalInset: CGFloat = 0.0 contentSize.height += verticalInset var items: [InstantPageItem] = [] if !author.isEmpty { let avatar: TelegramMediaImage? = avatarId.flatMap { id -> TelegramMediaImage? in if case let .image(image) = media[id] { return image } else { return nil } } if let avatar = avatar { let avatarItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: horizontalInset + lineInset + 1.0, y: contentSize.height - 2.0), size: CGSize(width: 50.0, height: 50.0)), webPage: webpage, media: InstantPageMedia(index: -1, media: .image(avatar), url: nil, caption: nil, credit: nil), interactive: false, roundCorners: true, fit: false) items.append(avatarItem) avatarInset += 62.0 avatarVerticalInset += 6.0 if date == 0 { avatarVerticalInset += 11.0 } } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) styleStack.push(.bold) let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(.plain(author), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset - avatarInset, offset: CGPoint(x: horizontalInset + lineInset + avatarInset, y: contentSize.height + avatarVerticalInset), media: media, webpage: webpage, fitToWidth: fitToWidth) items.append(contentsOf: textItems) contentSize.height += textContentSize.height + avatarVerticalInset } if date != 0 { if items.count != 0 { contentSize.height += itemSpacing } let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(date)), dateStyle: .long, timeStyle: .none) let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(.plain(dateString), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset - avatarInset, offset: CGPoint(x: horizontalInset + lineInset + avatarInset, y: contentSize.height), media: media, webpage: webpage) items.append(contentsOf: textItems) contentSize.height += textContentSize.height } if items.count != 0 { contentSize.height += itemSpacing } var previousBlock: InstantPageBlock? for i in 0 ..< blocks.count { let subBlock = blocks[i] let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing)) items.append(contentsOf: blockItems) contentSize.height += subLayout.contentSize.height + spacing previousBlock = subBlock } contentSize.height += verticalInset items.append(InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shape: .roundLine, color: theme.textCategories.paragraph.color)) let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .slideshow(items: subItems, caption: caption): var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] var itemMedias: [InstantPageMedia] = [] for subBlock in subItems { switch subBlock { case let .image(id, caption, url, webpageId): if case let .image(image) = media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions { let mediaIndex = mediaIndexCounter mediaIndexCounter += 1 let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0)) contentSize.height = max(contentSize.height, filledSize.height) var mediaUrl: InstantPageUrlItem? if let url = url { mediaUrl = InstantPageUrlItem(url: url, webpageId: webpageId) } itemMedias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: caption.text, credit: caption.credit)) } break default: break } } items.append(InstantPageSlideshowItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height)), webPage: webpage, medias: itemMedias)) let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .webEmbed(url, html, dimensions, caption, stretchToWidth, allowScrolling, coverId): var embedBoundingWidth = boundingWidth - horizontalInset * 2.0 if stretchToWidth { embedBoundingWidth = boundingWidth } let embedIndex = embedIndexCounter embedIndexCounter += 1 let size: CGSize if let dimensions = dimensions { if dimensions.width <= 0 { size = CGSize(width: embedBoundingWidth, height: dimensions.cgSize.height) } else { size = dimensions.cgSize.aspectFitted(CGSize(width: embedBoundingWidth, height: embedBoundingWidth)) } } else { if let height = webEmbedHeights[embedIndex] { size = CGSize(width: embedBoundingWidth, height: CGFloat(height)) } else { size = CGSize(width: embedBoundingWidth, height: 44.0) } } var items: [InstantPageItem] = [] var contentSize: CGSize let frame = CGRect(origin: CGPoint(x: floor((boundingWidth - size.width) / 2.0), y: 0.0), size: size) let item: InstantPageItem if let url = url, let coverId = coverId, case let .image(image) = media[coverId] { let loadedContent = TelegramMediaWebpageLoadedContent(url: url, displayUrl: url, hash: 0, type: "video", websiteName: nil, title: nil, text: nil, embedUrl: url, embedType: "video", embedSize: PixelDimensions(size), duration: nil, author: nil, isMediaLargeByDefault: nil, imageIsVideoCover: false, image: image, file: nil, story: nil, attributes: [], instantPage: nil) let content = TelegramMediaWebpageContent.Loaded(loadedContent) item = InstantPageImageItem(frame: frame, webPage: webpage, media: InstantPageMedia(index: embedIndex, media: .webpage(TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: Namespaces.Media.LocalWebpage, id: -1), content: content)), url: nil, caption: nil, credit: nil), attributes: [], interactive: true, roundCorners: false, fit: false) } else { item = InstantPageWebEmbedItem(frame: frame, url: url, html: html, enableScrolling: allowScrolling) } items.append(item) contentSize = item.frame.size let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .channelBanner(peer): var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] var offset: CGFloat = 0.0 var previousItemHasRTL = false if let previousItem = previousItems.last as? InstantPageTextItem { if previousItem.containsRTL { previousItemHasRTL = true } var minY = previousItem.frame.minY if let firstItem = previousItems.first { minY = firstItem.frame.maxY } offset = minY - previousItem.frame.maxY } if !offset.isZero { offset -= 40.0 + 14.0 } if let peer = peer { let item = InstantPagePeerReferenceItem(frame: CGRect(origin: CGPoint(x: 0.0, y: offset), size: CGSize(width: boundingWidth, height: 40.0)), initialPeer: .channel(peer), safeInset: safeInset, transparent: !offset.isZero, rtl: rtl || previousItemHasRTL) items.append(item) if offset.isZero { contentSize.height += 40.0 } } return InstantPageLayout(origin: CGPoint(x: 0.0, y: offset), contentSize: contentSize, items: items) case let .anchor(name): let item = InstantPageAnchorItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: 0.0)), anchor: name) return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) case let .audio(audioId, caption): var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] if case let .file(file) = media[audioId] { let mediaIndex = mediaIndexCounter mediaIndexCounter += 1 let item = InstantPageAudioItem(frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: boundingWidth, height: 48.0)), media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: nil, credit: nil), webpage: webpage) contentSize.height += item.frame.height items.append(item) let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .table(title, rows, bordered, striped): var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] var styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) let backgroundInset: CGFloat = 0.0 let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, alignment: .center, offset: CGPoint(), media: media, webpage: webpage) for var item in textItems { item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) } items.append(contentsOf: textItems) contentSize.height += textContentSize.height + 10.0 styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .table, link: false) let tableBoundingWidth = boundingWidth - horizontalInset * 2.0 let tableItem = layoutTableItem(rtl: rtl, rows: rows, styleStack: styleStack, theme: theme, bordered: bordered, striped: striped, boundingWidth: tableBoundingWidth, horizontalInset: horizontalInset, media: media, webpage: webpage) tableItem.frame = tableItem.frame.offsetBy(dx: 0.0, dy: contentSize.height) contentSize.height += tableItem.frame.height items.append(tableItem) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .details(title, blocks, expanded): var contentSize = CGSize(width: boundingWidth, height: 0.0) var subitems: [InstantPageItem] = [] let detailsIndex = detailsIndexCounter detailsIndexCounter += 1 var subDetailsIndex = 0 var previousBlock: InstantPageBlock? for i in 0 ..< blocks.count { let subBlock = blocks[i] let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) subitems.append(contentsOf: blockItems) contentSize.height += subLayout.contentSize.height + spacing previousBlock = subBlock } if !blocks.isEmpty { let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false, kind: .topLevel) contentSize.height += closingSpacing } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) styleStack.push(.lineSpacingFactor(0.685)) let detailsItem = layoutDetailsItem(theme: theme, title: attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth, items: subitems, contentSize: contentSize, safeInset: safeInset, rtl: rtl, initiallyExpanded: expanded, index: detailsIndex) return InstantPageLayout(origin: CGPoint(), contentSize: detailsItem.frame.size, items: [detailsItem]) case let .relatedArticles(title, articles): var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) styleStack.push(.bold) let backgroundInset: CGFloat = 14.0 let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, offset: CGPoint(x: horizontalInset, y: backgroundInset), media: media, webpage: webpage, fitToWidth: fitToWidth, opaqueBackground: true) let backgroundItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: textContentSize.height + backgroundInset * 2.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: textContentSize.height + backgroundInset * 2.0)), shape: .rect, color: theme.panelBackgroundColor) items.append(backgroundItem) items.append(contentsOf: textItems) contentSize.height += backgroundItem.frame.height for (i, article) in articles.enumerated() { var cover: TelegramMediaImage? if let coverId = article.photoId { if case let .image(image) = media[coverId] { cover = image } } var styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .article, link: false) let title = attributedStringForRichText(.plain(article.title ?? ""), styleStack: styleStack) styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .caption, link: false) var subtext: String? if article.author != nil || article.date != nil { if let author = article.author { if let date = article.date { subtext = strings.InstantPage_RelatedArticleAuthorAndDateTitle(author, stringForDate(date)).string } else { subtext = author } } else if let date = article.date { subtext = stringForDate(date) } } else { subtext = article.description } let description = attributedStringForRichText(.plain(subtext ?? ""), styleStack: styleStack) let item = layoutArticleItem(theme: theme, userLocation: userLocation, webPage: webpage, title: title, description: description, cover: cover, url: article.url, webpageId: article.webpageId, boundingWidth: boundingWidth, rtl: rtl) item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height) contentSize.height += item.frame.height items.append(item) if !isLast { let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0 let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel) let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor) items.append(shapeItem) } } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .map(latitude, longitude, zoom, dimensions, caption): let imageSize = dimensions var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0)) if let size = fillToSize { filledSize = size } else if isCover { filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0)) if !filledSize.height.isZero { filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0))) } } let map = TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, venue: nil, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil) let attributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: dimensions.cgSize)] var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0) var items: [InstantPageItem] = [] let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: -1, media: .geo(map), url: nil, caption: caption.text, credit: caption.credit), attributes: attributes, interactive: true, roundCorners: false, fit: false) items.append(mediaItem) contentSize.height += filledSize.height let (captionItems, captionSize) = layoutCaption(caption, contentSize) items.append(contentsOf: captionItems) contentSize.height += captionSize.height return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) default: return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } } public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, sideInset: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true, fitToWidth: Bool = false) -> InstantPageLayout { var maybeLoadedContent: TelegramMediaWebpageLoadedContent? if case let .Loaded(content) = webPage.content { maybeLoadedContent = content } guard let loadedContent = maybeLoadedContent, let instantPage else { return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) } let rtl = instantPage.rtl let pageBlocks = instantPage.blocks var contentSize = CGSize(width: boundingWidth, height: 0.0) var items: [InstantPageItem] = [] var media = instantPage.media.mapValues(EngineMedia.init) if let image = loadedContent.image, let id = image.id { media[id] = .image(image) } if let video = loadedContent.file, let id = video.id { media[id] = .file(video) } var mediaIndexCounter: Int = 0 var embedIndexCounter: Int = 0 var detailsIndexCounter: Int = 0 var previousBlock: InstantPageBlock? for i in 0 ..< pageBlocks.count { let block = pageBlocks[i] let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1, fitToWidth: fitToWidth) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth, kind: .topLevel) let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) items.append(contentsOf: blockItems) if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) { contentSize.height += blockLayout.contentSize.height + spacing previousBlock = block } } let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth, kind: .topLevel) contentSize.height += closingSpacing if webPage.webpageId.id != 0 && addFeedback { let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) contentSize.height += feedbackItem.frame.height items.append(feedbackItem) } if fitToWidth { contentSize.width = 0.0 for item in items { contentSize.width = max(contentSize.width, ceil(item.frame.maxX) + sideInset) } contentSize.width = min(contentSize.width, boundingWidth) } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) }