longform: fix stretched/cut-off images in longform notes

Pre-process markdown with ensureBlockLevelImages() to add paragraph breaks
around standalone images, forcing proper block-level parsing. Creates
KingfisherImageProvider for MarkdownUI to handle proper aspect ratio and
image caching.

Changelog-Fixed: Fixed stretched/cut-off images in longform notes
Closes: https://github.com/damus-io/damus/pull/3489
Closes: https://github.com/damus-io/damus/pull/3496
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
alltheseas
2026-01-04 18:27:26 -06:00
committed by Daniel D’Aquino
parent 4f401c6ce9
commit 767b318763
5 changed files with 280 additions and 4 deletions

View File

@@ -628,6 +628,7 @@
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; };
7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; };
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFF6316299FEFE5005D382A /* SelectableText.swift */; };
82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D6FA992CD9820500C925F4 /* ShareViewController.swift */; };
82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -746,6 +747,7 @@
82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
82D6FB332CD99F7900C925F4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
@@ -1361,6 +1363,7 @@
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
@@ -2686,6 +2689,7 @@
7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = "<group>"; };
7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = "<group>"; };
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = "<group>"; };
82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -5103,6 +5107,7 @@
isa = PBXGroup;
children = (
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */,
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */,
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */,
D72E12772BEED22400F4F781 /* Array.swift */,
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */,
@@ -5951,6 +5956,7 @@
4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */,
4C2B7BF22A71B6540049DEE7 /* Id.swift in Sources */,
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */,
K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */,
4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */,
4C463CBF2B960B96008A8C36 /* PurpleBackdrop.swift in Sources */,
5C8F97502EBD704A009399B1 /* LabsToggleView.swift in Sources */,
@@ -6510,6 +6516,7 @@
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */,
5C8F970B2EB45E8C009399B1 /* LiveChatModel.swift in Sources */,
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */,
FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */,
5C8F972F2EB46116009399B1 /* LiveStreamStatus.swift in Sources */,
D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */,
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */,
@@ -6990,6 +6997,7 @@
D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */,
3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */,
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */,
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */,
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */,

View File

@@ -382,10 +382,176 @@ struct LongformContent {
let words: Int
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
// Pre-process markdown to ensure images are block-level (have blank lines around them)
// This prevents images from being parsed as inline within text paragraphs
let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown)
let blocks = [BlockNode].init(markdown: processedMarkdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
/// Ensures markdown images have blank lines around them so they parse as block-level elements.
/// Without blank lines, images followed by text are parsed as inline within a paragraph,
/// causing them to be clipped by SwiftUI's Text view.
///
/// Safety: This function excludes:
/// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code
/// - Images inside lists (lines starting with -, *, + followed by space)
/// - Images inside blockquotes (lines starting with >)
/// - Images inside tables (lines containing | with table structure)
///
/// Known limitations (images remain inline, may clip if mixed with text):
/// - Multi-line list items with images on continuation lines
/// - Reference-style images: ![alt][id]
/// - HTML <img> tags
/// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters)
private static func ensureBlockLevelImages(_ markdown: String) -> String {
// First, identify regions to exclude (fenced code blocks and inline code)
let excludedRanges = findExcludedRanges(in: markdown)
// Pattern matches markdown images: ![alt](url) or ![alt](url "title")
// Handles URLs with parentheses by matching balanced parens or escaped parens
let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"#
guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else {
return markdown
}
var result = markdown
let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result))
// Process matches in reverse order to preserve indices
for match in matches.reversed() {
guard let range = Range(match.range, in: result) else { continue }
// Skip if this match is inside an excluded region (code block/inline code)
// Use String.Index comparison for correctness with non-ASCII content
if excludedRanges.contains(where: { $0.overlaps(range) }) {
continue
}
// Skip if inside a list, blockquote, or table context
if isInStructuredContext(result, at: range.lowerBound) {
continue
}
let imageMarkdown = String(result[range])
// Check what's before the image
let beforeIndex = range.lowerBound
let hasParagraphBreakBefore = beforeIndex == result.startIndex ||
result[result.index(before: beforeIndex)] == "\n" && (
beforeIndex == result.index(after: result.startIndex) ||
result[result.index(beforeIndex, offsetBy: -2)] == "\n"
)
// Check what's after the image
let afterIndex = range.upperBound
let hasParagraphBreakAfter = afterIndex == result.endIndex ||
result[afterIndex] == "\n" && (
result.index(after: afterIndex) == result.endIndex ||
result[result.index(after: afterIndex)] == "\n"
)
// Build replacement with proper paragraph breaks
var replacement = imageMarkdown
if !hasParagraphBreakBefore && beforeIndex != result.startIndex {
replacement = "\n\n" + replacement
}
if !hasParagraphBreakAfter && afterIndex != result.endIndex {
replacement = replacement + "\n\n"
}
if replacement != imageMarkdown {
result.replaceSubrange(range, with: replacement)
}
}
return result
}
/// Checks if the position is inside a list, blockquote, or table context.
/// Returns true if modifying this position would break markdown structure.
private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool {
// Find the start of the current line
var lineStart = position
while lineStart > markdown.startIndex {
let prevIndex = markdown.index(before: lineStart)
if markdown[prevIndex] == "\n" {
break
}
lineStart = prevIndex
}
// Get the line prefix (content before the image on this line)
let linePrefix = String(markdown[lineStart..<position])
let trimmedPrefix = linePrefix.trimmingCharacters(in: .whitespaces)
// Check for list markers: -, *, + followed by space (to avoid false positives)
if (trimmedPrefix.hasPrefix("- ") || trimmedPrefix.hasPrefix("* ") ||
trimmedPrefix.hasPrefix("+ ")) {
return true
}
// Check for numbered list: digit(s) followed by . or ) and space
let numberedListPattern = #"^\d+[.)]\s"#
if let numberedRegex = try? NSRegularExpression(pattern: numberedListPattern, options: []),
numberedRegex.firstMatch(in: trimmedPrefix, options: [], range: NSRange(trimmedPrefix.startIndex..., in: trimmedPrefix)) != nil {
return true
}
// Check for blockquote marker: >
if trimmedPrefix.hasPrefix(">") {
return true
}
// Check for table context: line has | at both start area and elsewhere (actual table row)
var lineEnd = position
while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" {
lineEnd = markdown.index(after: lineEnd)
}
let fullLine = String(markdown[lineStart..<lineEnd])
// Only treat as table if line has multiple | characters (actual table structure)
if fullLine.filter({ $0 == "|" }).count >= 2 {
return true
}
return false
}
/// Finds ranges in the markdown that should be excluded from image processing.
/// Returns ranges as String.Index for correct handling of non-ASCII content.
private static func findExcludedRanges(in markdown: String) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
let fullRange = NSRange(markdown.startIndex..., in: markdown)
// Find fenced code blocks (3+ backticks or tildes)
let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"#
guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else {
return ranges
}
ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange)
.compactMap { Range($0.range, in: markdown) })
// Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line)
// Blank line may contain whitespace: \n followed by optional spaces/tabs then \n
let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?: |\t).+\n?)+)"#
guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else {
return ranges
}
ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange)
.filter { $0.numberOfRanges > 1 }
.compactMap { Range($0.range(at: 1), in: markdown) })
// Find inline code (1+ backticks, matching pairs)
let inlinePattern = #"(`+)(?!`)[^`]*?\1"#
guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else {
return ranges
}
ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange)
.compactMap { Range($0.range, in: markdown) })
return ranges
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {

View File

@@ -361,7 +361,10 @@ struct NoteContentView: View {
Group {
switch self.note_artifacts {
case .longform(let md):
// Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding
Markdown(md.markdown)
.markdownImageProvider(.kingfisher(disable_animation: damus_state.settings.disable_animation))
.markdownInlineImageProvider(.kingfisher)
.padding([.leading, .trailing, .top])
case .separated(let separated):
if #available(iOS 17.4, macOS 14.4, *) {
@@ -369,12 +372,13 @@ struct NoteContentView: View {
#if !targetEnvironment(macCatalyst)
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
#endif
.fixedSize(horizontal: false, vertical: true)
} else {
MainContent(artifacts: separated)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.fixedSize(horizontal: false, vertical: true)
}
var normalizedHighlightTerms: [String] {

View File

@@ -11,13 +11,13 @@ struct LongformView: View {
let state: DamusState
let event: LongformEvent
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, event: LongformEvent, artifacts: NoteArtifactsModel? = nil) {
self.state = state
self.event = event
self._artifacts = ObservedObject(wrappedValue: artifacts ?? state.events.get_cache_data(event.event.id).artifacts_model)
}
var options: EventViewOptions {
return [.wide, .no_mentions, .no_replying_to]
}

View File

@@ -0,0 +1,98 @@
//
// KingfisherImageProvider.swift
// damus
//
// Created by alltheseas on 2026-01-03.
//
import SwiftUI
import Kingfisher
import MarkdownUI
/// A custom image provider for MarkdownUI that uses Kingfisher for image loading.
/// This provides proper aspect ratio handling and caching for images in longform markdown content.
struct KingfisherImageProvider: ImageProvider {
let disable_animation: Bool
init(disable_animation: Bool = false) {
self.disable_animation = disable_animation
}
func makeImage(url: URL?) -> some View {
KingfisherMarkdownImage(url: url, disable_animation: disable_animation)
}
}
extension ImageProvider where Self == KingfisherImageProvider {
/// A Kingfisher-based image provider for loading images with proper caching and aspect ratio handling.
static var kingfisher: Self { .init() }
/// A Kingfisher-based image provider with animation disabled.
static func kingfisher(disable_animation: Bool) -> Self {
.init(disable_animation: disable_animation)
}
}
// MARK: - InlineImageProvider (for images mixed with text)
/// A custom inline image provider for MarkdownUI that uses Kingfisher for loading inline images.
/// This handles images that appear within text content (not standalone image paragraphs).
struct KingfisherInlineImageProvider: InlineImageProvider {
func image(with url: URL, label: String) async throws -> Image {
return try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: url) { result in
switch result {
case .success(let imageResult):
continuation.resume(returning: Image(uiImage: imageResult.image))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
extension InlineImageProvider where Self == KingfisherInlineImageProvider {
/// A Kingfisher-based inline image provider for loading images within text.
static var kingfisher: Self { .init() }
}
// MARK: - ImageProvider View (for standalone image paragraphs)
/// Internal view that handles the actual Kingfisher image loading for markdown content.
/// Uses state to track loaded image dimensions for proper aspect ratio sizing.
private struct KingfisherMarkdownImage: View {
let url: URL?
let disable_animation: Bool
@State private var imageSize: CGSize?
/// Returns a valid aspect ratio, guarding against zero/invalid dimensions.
private var safeAspectRatio: CGSize {
guard let size = imageSize, size.width > 0, size.height > 0 else {
return CGSize(width: 1, height: 1)
}
return size
}
var body: some View {
if let url {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos: .background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: disable_animation)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.observe_image_size { size in
imageSize = size
}
.aspectRatio(safeAspectRatio, contentMode: .fit)
.frame(maxWidth: .infinity)
.kfClickable()
} else {
EmptyView()
}
}
}