├── .gitignore ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme ├── Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── Example │ └── App.swift ├── LICENSE ├── Package.swift ├── README.md ├── Resources └── Simulator Screenshot - iPad mini (A17 Pro) - 2025-05-27 at 03.03.27.png └── Sources ├── MarkdownParser ├── MarkdownBlockNode │ ├── MarkdownBlockNode+Rewrite.swift │ └── MarkdownBlockNode.swift ├── MarkdownInlineNode │ ├── MarkdownInlineNode+Collect.swift │ ├── MarkdownInlineNode+Rewrite.swift │ └── MarkdownInlineNode.swift ├── MarkdownParser │ ├── MarkdownParser+MathContext.swift │ ├── MarkdownParser+Node.swift │ ├── MarkdownParser+ReorderContext.swift │ └── MarkdownParser.swift └── Utils │ ├── Converters.swift │ └── Ext+Array.swift └── MarkdownView ├── Components ├── CodeView │ ├── CodeHighlighter.swift │ ├── CodeView.swift │ └── CodeViewConfiguration.swift └── TableView │ ├── GridView.swift │ ├── TableView.swift │ ├── TableViewCellManager.swift │ └── TableViewExtensions.swift ├── MarkdownTheme ├── MarkdownTheme+Code.swift └── MarkdownTheme.swift ├── MarkdownView ├── BlockProcessor.swift ├── DrawingViewProvider.swift ├── ListProcessor.swift ├── MarkdownTextView.swift ├── TextBuilder.swift └── TextBuilderTypes.swift └── Supplements ├── CFRange+Extension.swift ├── CTLine+Extension.swift ├── CTRun+Extension.swift ├── ImageAttachmentView.swift ├── InlineNode+Render.swift ├── LTXAttachment+Extension.swift ├── MathRenderer.swift ├── NSAttributedString+Extension.swift ├── RenderedItem.swift ├── UIColor+Extension.swift └── UIFont+Extension.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | # Xcode 11 | # 12 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 13 | 14 | ## User settings 15 | xcuserdata/ 16 | 17 | ## Obj-C/Swift specific 18 | *.hmap 19 | 20 | ## App packaging 21 | *.ipa 22 | *.dSYM.zip 23 | *.dSYM 24 | 25 | ## Playgrounds 26 | timeline.xctimeline 27 | playground.xcworkspace 28 | 29 | # Swift Package Manager 30 | # 31 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 32 | # Packages/ 33 | # Package.pins 34 | # Package.resolved 35 | # *.xcodeproj 36 | # 37 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 38 | # hence it is not needed unless you have added a package configuration file to your project 39 | # .swiftpm 40 | 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | # 51 | # Add this line if you want to avoid checking in source code from the Xcode workspace 52 | # *.xcworkspace 53 | 54 | # Carthage 55 | # 56 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 57 | # Carthage/Checkouts 58 | 59 | Carthage/Build/ 60 | 61 | # fastlane 62 | # 63 | # It is recommended to not store the screenshots in the git repo. 64 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 65 | # For more information about the recommended setup visit: 66 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 67 | 68 | fastlane/report.xml 69 | fastlane/Preview.html 70 | fastlane/screenshots/**/*.png 71 | fastlane/test_output 72 | 73 | # Xcode 74 | # 75 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 76 | 77 | ## User settings 78 | xcuserdata/ 79 | 80 | ## Obj-C/Swift specific 81 | *.hmap 82 | 83 | ## App packaging 84 | *.ipa 85 | *.dSYM.zip 86 | *.dSYM 87 | 88 | # CocoaPods 89 | # 90 | # We recommend against adding the Pods directory to your .gitignore. However 91 | # you should judge for yourself, the pros and cons are mentioned at: 92 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 93 | # 94 | # Pods/ 95 | # 96 | # Add this line if you want to avoid checking in source code from the Xcode workspace 97 | # *.xcworkspace 98 | 99 | # Carthage 100 | # 101 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 102 | # Carthage/Checkouts 103 | 104 | Carthage/Build/ 105 | 106 | # fastlane 107 | # 108 | # It is recommended to not store the screenshots in the git repo. 109 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 110 | # For more information about the recommended setup visit: 111 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 112 | 113 | fastlane/report.xml 114 | fastlane/Preview.html 115 | fastlane/screenshots/**/*.png 116 | fastlane/test_output 117 | 118 | # General 119 | .DS_Store 120 | .AppleDouble 121 | .LSOverride 122 | Icon[ 123 | ] 124 | 125 | # Thumbnails 126 | ._* 127 | 128 | # Files that might appear in the root of a volume 129 | .DocumentRevisions-V100 130 | .fseventsd 131 | .Spotlight-V100 132 | .TemporaryItems 133 | .Trashes 134 | .VolumeIcon.icns 135 | .com.apple.timemachine.donotpresent 136 | 137 | # Directories potentially created on remote AFP share 138 | .AppleDB 139 | .AppleDesktop 140 | Network Trash Folder 141 | Temporary Items 142 | .apdisk 143 | 144 | ## User settings 145 | xcuserdata/ 146 | Package.resolved 147 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 50219C662D3E2304006CB93C /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50219C652D3E2304006CB93C /* App.swift */; }; 11 | 507C16722D2719F100B478D2 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = 507C16712D2719F100B478D2 /* MarkdownView */; }; 12 | 5084C6742D281A41007310F0 /* LookinServer in Frameworks */ = {isa = PBXBuildFile; productRef = 5084C6732D281A41007310F0 /* LookinServer */; }; 13 | 50A350BB2DE4B651008F094E /* MarkdownParser in Frameworks */ = {isa = PBXBuildFile; productRef = 50A350BA2DE4B651008F094E /* MarkdownParser */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | 50219C652D3E2304006CB93C /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 18 | 505E99EA2D26D8380014A6D3 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 19 | /* End PBXFileReference section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | 505E99E72D26D8380014A6D3 /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | 50A350BB2DE4B651008F094E /* MarkdownParser in Frameworks */, 27 | 507C16722D2719F100B478D2 /* MarkdownView in Frameworks */, 28 | 5084C6742D281A41007310F0 /* LookinServer in Frameworks */, 29 | ); 30 | runOnlyForDeploymentPostprocessing = 0; 31 | }; 32 | /* End PBXFrameworksBuildPhase section */ 33 | 34 | /* Begin PBXGroup section */ 35 | 5015F6D32D26DCFB005FA7D2 /* Frameworks */ = { 36 | isa = PBXGroup; 37 | children = ( 38 | ); 39 | name = Frameworks; 40 | sourceTree = ""; 41 | }; 42 | 50219C642D3E22FB006CB93C /* Example */ = { 43 | isa = PBXGroup; 44 | children = ( 45 | 50219C652D3E2304006CB93C /* App.swift */, 46 | ); 47 | path = Example; 48 | sourceTree = ""; 49 | }; 50 | 505E99E12D26D8380014A6D3 = { 51 | isa = PBXGroup; 52 | children = ( 53 | 50219C642D3E22FB006CB93C /* Example */, 54 | 5015F6D32D26DCFB005FA7D2 /* Frameworks */, 55 | 505E99EB2D26D8380014A6D3 /* Products */, 56 | ); 57 | sourceTree = ""; 58 | }; 59 | 505E99EB2D26D8380014A6D3 /* Products */ = { 60 | isa = PBXGroup; 61 | children = ( 62 | 505E99EA2D26D8380014A6D3 /* Example.app */, 63 | ); 64 | name = Products; 65 | sourceTree = ""; 66 | }; 67 | /* End PBXGroup section */ 68 | 69 | /* Begin PBXNativeTarget section */ 70 | 505E99E92D26D8380014A6D3 /* Example */ = { 71 | isa = PBXNativeTarget; 72 | buildConfigurationList = 505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */; 73 | buildPhases = ( 74 | 5015F6CD2D26DB1B005FA7D2 /* Format Source */, 75 | 505E99E62D26D8380014A6D3 /* Sources */, 76 | 505E99E72D26D8380014A6D3 /* Frameworks */, 77 | 505E99E82D26D8380014A6D3 /* Resources */, 78 | ); 79 | buildRules = ( 80 | ); 81 | dependencies = ( 82 | ); 83 | name = Example; 84 | packageProductDependencies = ( 85 | 507C16712D2719F100B478D2 /* MarkdownView */, 86 | 5084C6732D281A41007310F0 /* LookinServer */, 87 | 50A350BA2DE4B651008F094E /* MarkdownParser */, 88 | ); 89 | productName = Example; 90 | productReference = 505E99EA2D26D8380014A6D3 /* Example.app */; 91 | productType = "com.apple.product-type.application"; 92 | }; 93 | /* End PBXNativeTarget section */ 94 | 95 | /* Begin PBXProject section */ 96 | 505E99E22D26D8380014A6D3 /* Project object */ = { 97 | isa = PBXProject; 98 | attributes = { 99 | BuildIndependentTargetsInParallel = 1; 100 | LastSwiftUpdateCheck = 1620; 101 | LastUpgradeCheck = 1620; 102 | TargetAttributes = { 103 | 505E99E92D26D8380014A6D3 = { 104 | CreatedOnToolsVersion = 16.2; 105 | LastSwiftMigration = 1620; 106 | }; 107 | }; 108 | }; 109 | buildConfigurationList = 505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */; 110 | developmentRegion = en; 111 | hasScannedForEncodings = 0; 112 | knownRegions = ( 113 | en, 114 | Base, 115 | ); 116 | mainGroup = 505E99E12D26D8380014A6D3; 117 | minimizedProjectReferenceProxies = 1; 118 | packageReferences = ( 119 | 5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */, 120 | ); 121 | preferredProjectObjectVersion = 77; 122 | productRefGroup = 505E99EB2D26D8380014A6D3 /* Products */; 123 | projectDirPath = ""; 124 | projectRoot = ""; 125 | targets = ( 126 | 505E99E92D26D8380014A6D3 /* Example */, 127 | ); 128 | }; 129 | /* End PBXProject section */ 130 | 131 | /* Begin PBXResourcesBuildPhase section */ 132 | 505E99E82D26D8380014A6D3 /* Resources */ = { 133 | isa = PBXResourcesBuildPhase; 134 | buildActionMask = 2147483647; 135 | files = ( 136 | ); 137 | runOnlyForDeploymentPostprocessing = 0; 138 | }; 139 | /* End PBXResourcesBuildPhase section */ 140 | 141 | /* Begin PBXShellScriptBuildPhase section */ 142 | 5015F6CD2D26DB1B005FA7D2 /* Format Source */ = { 143 | isa = PBXShellScriptBuildPhase; 144 | alwaysOutOfDate = 1; 145 | buildActionMask = 2147483647; 146 | files = ( 147 | ); 148 | inputFileListPaths = ( 149 | ); 150 | inputPaths = ( 151 | ); 152 | name = "Format Source"; 153 | outputFileListPaths = ( 154 | ); 155 | outputPaths = ( 156 | ); 157 | runOnlyForDeploymentPostprocessing = 0; 158 | shellPath = /bin/sh; 159 | shellScript = "/opt/homebrew/bin/swiftformat . --swiftversion 6.0\n\n"; 160 | }; 161 | /* End PBXShellScriptBuildPhase section */ 162 | 163 | /* Begin PBXSourcesBuildPhase section */ 164 | 505E99E62D26D8380014A6D3 /* Sources */ = { 165 | isa = PBXSourcesBuildPhase; 166 | buildActionMask = 2147483647; 167 | files = ( 168 | 50219C662D3E2304006CB93C /* App.swift in Sources */, 169 | ); 170 | runOnlyForDeploymentPostprocessing = 0; 171 | }; 172 | /* End PBXSourcesBuildPhase section */ 173 | 174 | /* Begin XCBuildConfiguration section */ 175 | 505E99F62D26D8390014A6D3 /* Debug */ = { 176 | isa = XCBuildConfiguration; 177 | buildSettings = { 178 | ALWAYS_SEARCH_USER_PATHS = NO; 179 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 180 | CLANG_ANALYZER_NONNULL = YES; 181 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 182 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 183 | CLANG_ENABLE_MODULES = YES; 184 | CLANG_ENABLE_OBJC_ARC = YES; 185 | CLANG_ENABLE_OBJC_WEAK = YES; 186 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 187 | CLANG_WARN_BOOL_CONVERSION = YES; 188 | CLANG_WARN_COMMA = YES; 189 | CLANG_WARN_CONSTANT_CONVERSION = YES; 190 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 191 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 192 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 193 | CLANG_WARN_EMPTY_BODY = YES; 194 | CLANG_WARN_ENUM_CONVERSION = YES; 195 | CLANG_WARN_INFINITE_RECURSION = YES; 196 | CLANG_WARN_INT_CONVERSION = YES; 197 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 198 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 199 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 200 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 201 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 202 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 203 | CLANG_WARN_STRICT_PROTOTYPES = YES; 204 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 205 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 206 | CLANG_WARN_UNREACHABLE_CODE = YES; 207 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 208 | COPY_PHASE_STRIP = NO; 209 | DEBUG_INFORMATION_FORMAT = dwarf; 210 | ENABLE_STRICT_OBJC_MSGSEND = YES; 211 | ENABLE_TESTABILITY = YES; 212 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 213 | GCC_C_LANGUAGE_STANDARD = gnu17; 214 | GCC_DYNAMIC_NO_PIC = NO; 215 | GCC_NO_COMMON_BLOCKS = YES; 216 | GCC_OPTIMIZATION_LEVEL = 0; 217 | GCC_PREPROCESSOR_DEFINITIONS = ( 218 | "DEBUG=1", 219 | "$(inherited)", 220 | ); 221 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 222 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 223 | GCC_WARN_UNDECLARED_SELECTOR = YES; 224 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 225 | GCC_WARN_UNUSED_FUNCTION = YES; 226 | GCC_WARN_UNUSED_VARIABLE = YES; 227 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 228 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 229 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 230 | MTL_FAST_MATH = YES; 231 | ONLY_ACTIVE_ARCH = YES; 232 | SDKROOT = iphoneos; 233 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 234 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 235 | }; 236 | name = Debug; 237 | }; 238 | 505E99F72D26D8390014A6D3 /* Release */ = { 239 | isa = XCBuildConfiguration; 240 | buildSettings = { 241 | ALWAYS_SEARCH_USER_PATHS = NO; 242 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 243 | CLANG_ANALYZER_NONNULL = YES; 244 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 245 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 246 | CLANG_ENABLE_MODULES = YES; 247 | CLANG_ENABLE_OBJC_ARC = YES; 248 | CLANG_ENABLE_OBJC_WEAK = YES; 249 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 250 | CLANG_WARN_BOOL_CONVERSION = YES; 251 | CLANG_WARN_COMMA = YES; 252 | CLANG_WARN_CONSTANT_CONVERSION = YES; 253 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 254 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 255 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 256 | CLANG_WARN_EMPTY_BODY = YES; 257 | CLANG_WARN_ENUM_CONVERSION = YES; 258 | CLANG_WARN_INFINITE_RECURSION = YES; 259 | CLANG_WARN_INT_CONVERSION = YES; 260 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 261 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 262 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 263 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 264 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 265 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 266 | CLANG_WARN_STRICT_PROTOTYPES = YES; 267 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 268 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 269 | CLANG_WARN_UNREACHABLE_CODE = YES; 270 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 271 | COPY_PHASE_STRIP = NO; 272 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 273 | ENABLE_NS_ASSERTIONS = NO; 274 | ENABLE_STRICT_OBJC_MSGSEND = YES; 275 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 276 | GCC_C_LANGUAGE_STANDARD = gnu17; 277 | GCC_NO_COMMON_BLOCKS = YES; 278 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 279 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 280 | GCC_WARN_UNDECLARED_SELECTOR = YES; 281 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 282 | GCC_WARN_UNUSED_FUNCTION = YES; 283 | GCC_WARN_UNUSED_VARIABLE = YES; 284 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 285 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 286 | MTL_ENABLE_DEBUG_INFO = NO; 287 | MTL_FAST_MATH = YES; 288 | SDKROOT = iphoneos; 289 | SWIFT_COMPILATION_MODE = wholemodule; 290 | VALIDATE_PRODUCT = YES; 291 | }; 292 | name = Release; 293 | }; 294 | 505E99F92D26D8390014A6D3 /* Debug */ = { 295 | isa = XCBuildConfiguration; 296 | buildSettings = { 297 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 298 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 299 | CLANG_ENABLE_MODULES = YES; 300 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 301 | CODE_SIGN_STYLE = Automatic; 302 | CURRENT_PROJECT_VERSION = 1; 303 | DEVELOPMENT_TEAM = 964G86XT2P; 304 | ENABLE_PREVIEWS = YES; 305 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 306 | GENERATE_INFOPLIST_FILE = YES; 307 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 308 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 309 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 310 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 311 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 312 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 313 | LD_RUNPATH_SEARCH_PATHS = ( 314 | "$(inherited)", 315 | "@executable_path/Frameworks", 316 | ); 317 | MARKETING_VERSION = 1.0; 318 | PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example; 319 | PRODUCT_NAME = "$(TARGET_NAME)"; 320 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 321 | SUPPORTS_MACCATALYST = YES; 322 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 323 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 324 | SWIFT_EMIT_LOC_STRINGS = YES; 325 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 326 | SWIFT_VERSION = 5.0; 327 | TARGETED_DEVICE_FAMILY = "1,2"; 328 | }; 329 | name = Debug; 330 | }; 331 | 505E99FA2D26D8390014A6D3 /* Release */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 335 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 336 | CLANG_ENABLE_MODULES = YES; 337 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 338 | CODE_SIGN_STYLE = Automatic; 339 | CURRENT_PROJECT_VERSION = 1; 340 | DEVELOPMENT_TEAM = 964G86XT2P; 341 | ENABLE_PREVIEWS = YES; 342 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 343 | GENERATE_INFOPLIST_FILE = YES; 344 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 345 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 346 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 347 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 348 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 349 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 350 | LD_RUNPATH_SEARCH_PATHS = ( 351 | "$(inherited)", 352 | "@executable_path/Frameworks", 353 | ); 354 | MARKETING_VERSION = 1.0; 355 | PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example; 356 | PRODUCT_NAME = "$(TARGET_NAME)"; 357 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 358 | SUPPORTS_MACCATALYST = YES; 359 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 360 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 361 | SWIFT_EMIT_LOC_STRINGS = YES; 362 | SWIFT_VERSION = 5.0; 363 | TARGETED_DEVICE_FAMILY = "1,2"; 364 | }; 365 | name = Release; 366 | }; 367 | /* End XCBuildConfiguration section */ 368 | 369 | /* Begin XCConfigurationList section */ 370 | 505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */ = { 371 | isa = XCConfigurationList; 372 | buildConfigurations = ( 373 | 505E99F62D26D8390014A6D3 /* Debug */, 374 | 505E99F72D26D8390014A6D3 /* Release */, 375 | ); 376 | defaultConfigurationIsVisible = 0; 377 | defaultConfigurationName = Release; 378 | }; 379 | 505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */ = { 380 | isa = XCConfigurationList; 381 | buildConfigurations = ( 382 | 505E99F92D26D8390014A6D3 /* Debug */, 383 | 505E99FA2D26D8390014A6D3 /* Release */, 384 | ); 385 | defaultConfigurationIsVisible = 0; 386 | defaultConfigurationName = Release; 387 | }; 388 | /* End XCConfigurationList section */ 389 | 390 | /* Begin XCRemoteSwiftPackageReference section */ 391 | 5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */ = { 392 | isa = XCRemoteSwiftPackageReference; 393 | repositoryURL = "https://github.com/QMUI/LookinServer/"; 394 | requirement = { 395 | kind = upToNextMajorVersion; 396 | minimumVersion = 1.2.8; 397 | }; 398 | }; 399 | /* End XCRemoteSwiftPackageReference section */ 400 | 401 | /* Begin XCSwiftPackageProductDependency section */ 402 | 507C16712D2719F100B478D2 /* MarkdownView */ = { 403 | isa = XCSwiftPackageProductDependency; 404 | productName = MarkdownView; 405 | }; 406 | 5084C6732D281A41007310F0 /* LookinServer */ = { 407 | isa = XCSwiftPackageProductDependency; 408 | package = 5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */; 409 | productName = LookinServer; 410 | }; 411 | 50A350BA2DE4B651008F094E /* MarkdownParser */ = { 412 | isa = XCSwiftPackageProductDependency; 413 | productName = MarkdownParser; 414 | }; 415 | /* End XCSwiftPackageProductDependency section */ 416 | }; 417 | rootObject = 505E99E22D26D8380014A6D3 /* Project object */; 418 | } 419 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "litext", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Lakr233/Litext", 7 | "state" : { 8 | "revision" : "e4dc0ef6db05932105c3c7cb3cba9dad78e3ef52", 9 | "version" : "0.4.3" 10 | } 11 | }, 12 | { 13 | "identity" : "lookinserver", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/QMUI/LookinServer/", 16 | "state" : { 17 | "revision" : "e553d1b689d147817dc54ad5c28fcff71e860101", 18 | "version" : "1.2.8" 19 | } 20 | }, 21 | { 22 | "identity" : "lrucache", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/nicklockwood/LRUCache", 25 | "state" : { 26 | "revision" : "542f0449556327415409ededc9c43a4bd0a397dc", 27 | "version" : "1.0.7" 28 | } 29 | }, 30 | { 31 | "identity" : "splash", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/Lakr233/Splash", 34 | "state" : { 35 | "revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88", 36 | "version" : "0.17.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-cmark", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/swiftlang/swift-cmark", 43 | "state" : { 44 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", 45 | "version" : "0.6.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-collections", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-collections", 52 | "state" : { 53 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 54 | "version" : "1.2.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftmath", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/mgriebling/SwiftMath", 61 | "state" : { 62 | "revision" : "606f9be66db6afe0c41c3b064723a57061723db7", 63 | "version" : "1.7.1" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /Example/Example/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // Example 4 | // 5 | // Created by 秋星桥 on 1/20/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct TheApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | NavigationView { 15 | Content() 16 | .toolbar { 17 | ToolbarItem { 18 | Button { 19 | NotificationCenter.default.post(name: .init("Play"), object: nil) 20 | } label: { 21 | Image(systemName: "play") 22 | } 23 | } 24 | } 25 | .navigationTitle("MarkdownView") 26 | .navigationBarTitleDisplayMode(.inline) 27 | } 28 | .navigationViewStyle(.stack) 29 | .frame(minWidth: 200, maxWidth: .infinity) 30 | } 31 | } 32 | } 33 | 34 | import MarkdownParser 35 | import MarkdownView 36 | 37 | final class ContentController: UIViewController { 38 | let scrollView = UIScrollView() 39 | let measureLabel = UILabel() 40 | 41 | private var markdownTextView: MarkdownTextView! 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | 46 | view.addSubview(scrollView) 47 | 48 | markdownTextView = MarkdownTextView() 49 | scrollView.addSubview(markdownTextView) 50 | 51 | measureLabel.numberOfLines = 0 52 | measureLabel.font = UIFont.preferredFont(forTextStyle: .footnote) 53 | measureLabel.textColor = .label 54 | 55 | NotificationCenter.default.addObserver( 56 | self, 57 | selector: #selector(play), 58 | name: .init("Play"), 59 | object: nil 60 | ) 61 | } 62 | 63 | private var streamDocument = "" 64 | 65 | @objc func play() { 66 | print(#function, Date()) 67 | DispatchQueue.global().async { [self] in 68 | for char in testDocument { 69 | streamDocument.append(char) 70 | autoreleasepool { 71 | let parser = MarkdownParser() 72 | let result = parser.parse(streamDocument) 73 | let theme = markdownTextView.theme 74 | var renderedContexts: [String: RenderedItem] = [:] 75 | for (key, value) in result.mathContext { 76 | let image = MathRenderer.renderToImage( 77 | latex: value, 78 | fontSize: theme.fonts.body.pointSize, 79 | textColor: theme.colors.body 80 | )?.withRenderingMode(.alwaysTemplate) 81 | let renderedContext = RenderedItem( 82 | image: image, 83 | text: value 84 | ) 85 | renderedContexts["math://\(key)"] = renderedContext 86 | } 87 | DispatchQueue.main.asyncAndWait { 88 | let date = Date() 89 | markdownTextView.setMarkdown(result.document, renderedContent: renderedContexts) 90 | self.view.setNeedsLayout() 91 | self.view.layoutIfNeeded() 92 | let time = Date().timeIntervalSince(date) 93 | self.measureLabel.text = String(format: "Time: %.4f ms", time * 1000) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | override func viewWillLayoutSubviews() { 101 | super.viewWillLayoutSubviews() 102 | 103 | scrollView.frame = view.bounds 104 | let width = view.bounds.width - 32 105 | 106 | let contentSize = markdownTextView.boundingSize(for: width) 107 | scrollView.contentSize = contentSize 108 | markdownTextView.frame = .init( 109 | x: 16, 110 | y: 16, 111 | width: width, 112 | height: contentSize.height 113 | ) 114 | 115 | measureLabel.removeFromSuperview() 116 | measureLabel.frame = .init( 117 | x: 16, 118 | y: (scrollView.subviews.map(\.frame.maxY).max() ?? 0) + 16, 119 | width: width, 120 | height: 50 121 | ) 122 | scrollView.addSubview(measureLabel) 123 | scrollView.contentSize = .init( 124 | width: width, 125 | height: measureLabel.frame.maxY + 16 126 | ) 127 | 128 | let offset = CGPoint( 129 | x: 0, 130 | y: scrollView.contentSize.height - scrollView.frame.height 131 | ) 132 | _ = offset 133 | scrollView.setContentOffset(offset, animated: false) 134 | } 135 | } 136 | 137 | struct Content: UIViewControllerRepresentable { 138 | func makeUIViewController(context _: Context) -> ContentController { 139 | ContentController() 140 | } 141 | 142 | func updateUIViewController(_: ContentController, context _: Context) {} 143 | } 144 | 145 | let testDocument = ###""" 146 | ## Markdown 测试数据 147 | 148 | \[ 149 | \sqrt{a} = b \quad \Leftrightarrow \quad b^2 = a 150 | \] 151 | 152 | 153 | 计算圆周率 \\( \pi \\) 的方法有很多种,从古老的几何方法到现代的高精度算法,每一种方法都有其独特的原理。由于 \\( \pi \\) 是一个无理数(不能表示为两个整数的比值)和超越数(不是任何整系数多项式方程的根),它的小数表示是无限不循环的,所以我们只能计算出它的近似值,但可以达到任意所需的精度。 154 | 155 | 以下是一些计算 \\( \pi \\) 的主要方法: 156 | 157 | 1. **几何法 (例如:阿基米德方法)** 158 | 这是最早的计算 \\( \pi \\) 的方法之一。原理是在圆内画内接正多边形,在圆外画外切正多边形。随着多边形的边数增加,它们的周长会越来越接近圆的周长。 159 | * **步骤:** 160 | * 从一个简单的多边形开始(如正六边形)。 161 | * 通过增加多边形的边数(例如,从正n边形加倍到正2n边形),计算新的内接和外切多边形的周长。 162 | * 圆的周长 C 介于内接多边形周长和外切多边形周长之间。 163 | * 由于 \\( \pi = C/d \\) (d是直径),所以 \\( \pi \\) 的值也被限定在一个范围内。 164 | * **特点:** 概念直观,但收敛速度慢,计算高精度 \\( \pi \\) 非常困难。 165 | 166 | 2. **无穷级数法** 167 | 这是现代计算 \\( \pi \\) 的主要方法之一。许多数学级数收敛于 \\( \pi \\) 或与 \\( \pi \\) 相关的数值(如 \\( \pi/4 \\))。通过计算级数的前面足够多的项,可以得到 \\( \pi \\) 的高精度近似值。 168 | * **例子:** 169 | * **莱布尼茨公式 (Leibniz formula):** 170 | \\( \frac{\pi}{4} = 1 - \frac{1}{3} + \frac{1}{5} - \frac{1}{7} + \frac{1}{9} - \cdots \\) 171 | 这个级数非常简单,但收敛速度极慢。 172 | * **Machin-like Formulas:** (马青公式及其变体) 173 | 如马青公式本人发现的: \\( \frac{\pi}{4} = 4 \arctan\left(\frac{1}{5}\right) - \arctan\left(\frac{1}{239}\right) \\) 174 | 通过 \\( \arctan(x) \\) 的泰勒级数展开式 \\( \arctan(x) = x - \frac{x^3}{3} + \frac{x^5}{5} - \frac{x^7}{7} + \cdots \\),可以将这些公式转化为计算 \\( \pi \\) 的级数。这类公式收敛速度比莱布尼茨公式快很多。 175 | * **其他级数:** 还有许多其他更复杂的级数,如拉马努金级数 (Ramanujan series) 等,具有更快的收敛速度,用于计算极高精度的 \\( \pi \\)。 176 | 177 | 3. **迭代算法** 178 | 一些现代算法通过迭代过程快速收敛到 \\( \pi \\)。 179 | * **例子:** 180 | * **高斯-勒让德算法 (Gauss–Legendre algorithm):** 结合了算术平均和几何平均的概念,收敛速度非常快(二次收敛),每迭代一次,正确数字位数大约翻倍。 181 | * **Borwein 算法、Chudnovsky 算法:** 这些算法收敛速度更快,特别是 Chudnovsky 算法,被用于创造计算 \\( \pi \\) 小数点后最多位数的记录,它基于超几何级数。 182 | 183 | 4. **蒙特卡洛方法 (Monte Carlo Method)** 184 | 这是一种概率方法,虽然不适用于计算极高精度的 \\( \pi \\),但提供了一种有趣的视角。 185 | * **方法:** 在一个边长为2的正方形内(面积为4),内切一个半径为1的圆(面积为 \\( \pi \times 1^2 = \pi \\))。随机向正方形内“投点”,落在圆内的点的数量与总投点数量的比值,近似等于圆的面积与正方形面积的比值,即 \\( \frac{\text{落在圆内的点数}}{\text{总投点数}} \approx \frac{\pi}{4} \\)。 186 | * **特点:** 概念简单直观,但收敛速度非常慢,精度有限。 187 | 188 | **总结:** 189 | 190 | 早期主要通过几何方法(多边形逼近)计算 \\( \pi \\) 的近似值。现代则主要依赖于收敛速度快的无穷级数和迭代算法,结合强大的计算机算力,才能计算出 \\( \pi \\) 小数点后数万亿位的精确值。计算 \\( \pi \\) 不仅是数学上的挑战,也常被用来测试计算机的性能。 191 | 192 | 1. **Dear Haydy, / Sincerely, The FlowDown Team:** 保持了标准商务信函的开头和结尾格式。 193 | 2. **rather unique proposal:** 译为“相当独特的提议”,保留了原文略带委婉语气的评价。 194 | 3. **priced at $19.99:** 保留了价格和货币符号 `$19.99`。 195 | 4. **pre-compiled, ready-to-install version:** 译为“预编译、即装即用版本”,准确传达其便利性。 196 | 5. **immensely proud:** 译为“非常自豪”,表达了强烈的积极情感。 197 | 6. **open-source and freely available:** 译为“开源且免费提供”,清晰明了。 198 | 7. **technical inclination:** 译为“技术意愿”,指有能力和兴趣去操作。 199 | 8. **incurs no monetary cost:** 译为“不产生任何费用”,比直译“不招致货币成本”更自然。 200 | 201 | **初中部分 (七年级到九年级)** 202 | 203 | * **代数:** 204 | * **乘法公式:** 205 | * 完全平方公式:$(a+b)^2 = a^2 + 2ab + b^2$,$(a-b)^2 = a^2 - 2ab + b^2$ 206 | * 平方差公式:$a^2 - b^2 = (a+b)(a-b)$ 207 | * **一元一次方程** 208 | * **一元二次方程:** 209 | * 求根公式:对于方程 $ax^2 + bx + c = 0$ ($a \neq 0$),根为 $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ 210 | * 判别式:$\Delta = b^2 - 4ac$ 211 | * $\Delta > 0$,方程有两个不相等的实数根 212 | * $\Delta = 0$,方程有两个相等的实数根 213 | * $\Delta < 0$,方程没有实数根 214 | 215 | 三角函数的定理有很多种,以下是几个常见定理及其证明思路的简要说明: 216 | 217 | ### 1. **毕达哥拉斯定理(勾股定理)** 218 | \\( \sin^2 \theta + \cos^2 \theta = 1 \\) 219 | **证明**: 220 | 利用单位圆定义,设角 \\( \theta \\) 的终边与单位圆交于点 \\( (x, y) \\),则 \\( x = \cos \theta \\), \\( y = \sin \theta \\)。根据圆的方程 \\( x^2 + y^2 = 1 \\),直接代入即得。 221 | 222 | ### 2. **和角公式** 223 | \\( \sin(a + b) = \sin a \cos b + \cos a \sin b \\) 224 | **证明**: 225 | - **几何法**:通过构造两个角叠加的三角形,利用面积或投影关系推导。 226 | - **复数法**:用欧拉公式 \\( e^{i(a+b)} = e^{ia} e^{ib} \\) 展开后比较虚部。 227 | 228 | ### 3. **正弦定理** 229 | \\( \frac{a}{\sin A} = \frac{b}{\sin B} = \frac{c}{\sin C} = 2R \\)(\\( R \\) 为外接圆半径) 230 | **证明**: 231 | 通过三角形的高或外接圆性质,将边与角的正弦关系转化为直径的表达式。 232 | 233 | ### 4. **余弦定理** 234 | \\( c^2 = a^2 + b^2 - 2ab \cos C \\) 235 | **证明**: 236 | - **坐标法**:将三角形顶点置于坐标系中,用距离公式展开。 237 | - **向量法**:利用向量点积的性质 \\( \vec{c} \cdot \vec{c} = (\vec{a} - \vec{b})^2 \\)。 238 | 239 | ### 5. **万能公式(万能代换)** 240 | \\( \sin \theta = \frac{2t}{1 + t^2} \\), \\( \cos \theta = \frac{1 - t^2}{1 + t^2} \\)(其中 \\( t = \tan \frac{\theta}{2} \\)) 241 | **证明**: 242 | 通过半角的正切定义,结合三角恒等式推导。 243 | 244 | 如果需要具体某个定理的详细证明步骤或更多定理,可以告诉我,我会进一步展开! 245 | 246 | """### 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MarkdownView 2 | 3 | MIT License 4 | 5 | Copyright (c) 2025 Lakr Aream 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | ## swift-markdown-ui 26 | 27 | The MIT License (MIT) 28 | 29 | Copyright (c) 2020 Guillermo Gonzalez 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MarkdownView", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macCatalyst(.v13), 11 | ], 12 | products: [ 13 | .library(name: "MarkdownView", targets: ["MarkdownView"]), 14 | .library(name: "MarkdownParser", targets: ["MarkdownParser"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/apple/swift-collections", from: "1.2.0"), 18 | .package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.1"), 19 | .package(url: "https://github.com/Lakr233/Splash", from: "0.17.0"), 20 | .package(url: "https://github.com/Lakr233/Litext", from: "0.4.1"), 21 | .package(url: "https://github.com/swiftlang/swift-cmark", from: "0.6.0"), 22 | .package(url: "https://github.com/nicklockwood/LRUCache", from: "1.0.7"), 23 | ], 24 | targets: [ 25 | .target(name: "MarkdownView", dependencies: [ 26 | "Litext", 27 | "Splash", 28 | "MarkdownParser", 29 | "SwiftMath", 30 | "LRUCache", 31 | .product(name: "DequeModule", package: "swift-collections"), 32 | ]), 33 | .target(name: "MarkdownParser", dependencies: [ 34 | .product(name: "cmark-gfm", package: "swift-cmark"), 35 | .product(name: "cmark-gfm-extensions", package: "swift-cmark"), 36 | ]), 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MarkdownView 2 | 3 | A powerful pure UIKit framework for rendering Markdown documents with real-time parsing and rendering capabilities. Battle tested in [FlowDown](https://github.com/Lakr233/FlowDown). 4 | 5 | ## Preview 6 | 7 | ![Preview](./Resources/Simulator%20Screenshot%20-%20iPad%20mini%20(A17%20Pro)%20-%202025-05-27%20at%2003.03.27.png) 8 | 9 | ## Features 10 | 11 | - 🚀 **Real-time Rendering**: Live Markdown parsing and rendering as you type 12 | - 🎨 **Syntax Highlighting**: Beautiful code syntax highlighting with Splash 13 | - 📊 **Math Rendering**: LaTeX math formula rendering with SwiftMath 14 | - 📱 **iOS Optimized**: Native UIKit implementation for optimal performance 15 | 16 | ## Installation 17 | 18 | Add the following to your `Package.swift` file: 19 | 20 | ```swift 21 | dependencies: [ 22 | .package(url: "https://github.com/Lakr233/MarkdownView", from: "0.1.5"), 23 | ] 24 | ``` 25 | 26 | Platform compatibility: 27 | - iOS 13.0+ 28 | - Mac Catalyst 13.0+ 29 | 30 | ## Usage 31 | 32 | ```swift 33 | import MarkdownView 34 | import MarkdownParser 35 | 36 | let markdownTextView = MarkdownTextView() 37 | let parser = MarkdownParser() 38 | let result = parser.parse("# Hello World") 39 | markdownTextView.setMarkdown( 40 | result.document, 41 | theme: .default, 42 | mathContent: result.mathContext 43 | ) 44 | ``` 45 | 46 | ## Example 47 | 48 | Check out the included example project to see MarkdownView in action: 49 | 50 | ```bash 51 | cd Example 52 | open Example.xcodeproj 53 | ``` 54 | 55 | ## License 56 | 57 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 58 | 59 | ### Acknowledgments 60 | 61 | This project includes code adapted from [swift-markdown-ui](https://github.com/gonzalezreal/swift-markdown-ui) by Guillermo Gonzalez, used under the MIT License. 62 | 63 | --- 64 | 65 | Copyright 2025 © Lakr Aream. All rights reserved. -------------------------------------------------------------------------------- /Resources/Simulator Screenshot - iPad mini (A17 Pro) - 2025-05-27 at 03.03.27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lakr233/MarkdownView/c3b321cd66cca62dfe6d393cc34e9cf657b2218c/Resources/Simulator Screenshot - iPad mini (A17 Pro) - 2025-05-27 at 03.03.27.png -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownBlockNode/MarkdownBlockNode+Rewrite.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Sequence { 4 | func rewrite(_ r: (MarkdownBlockNode) throws -> [MarkdownBlockNode]) rethrows -> [MarkdownBlockNode] { 5 | try flatMap { try $0.rewrite(r) } 6 | } 7 | 8 | func rewrite(_ r: (MarkdownInlineNode) throws -> [MarkdownInlineNode]) rethrows -> [MarkdownBlockNode] { 9 | try flatMap { try $0.rewrite(r) } 10 | } 11 | } 12 | 13 | public extension MarkdownBlockNode { 14 | func rewrite(_ r: (MarkdownBlockNode) throws -> [MarkdownBlockNode]) rethrows -> [MarkdownBlockNode] { 15 | switch self { 16 | case let .blockquote(children): 17 | try r(.blockquote(children: children.rewrite(r))) 18 | case let .bulletedList(isTight, items): 19 | try r( 20 | .bulletedList( 21 | isTight: isTight, 22 | items: items.map { 23 | try RawListItem(children: $0.children.rewrite(r)) 24 | } 25 | ) 26 | ) 27 | case let .numberedList(isTight, start, items): 28 | try r( 29 | .numberedList( 30 | isTight: isTight, 31 | start: start, 32 | items: items.map { 33 | try RawListItem(children: $0.children.rewrite(r)) 34 | } 35 | ) 36 | ) 37 | case let .taskList(isTight, items): 38 | try r( 39 | .taskList( 40 | isTight: isTight, 41 | items: items.map { 42 | try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r)) 43 | } 44 | ) 45 | ) 46 | default: 47 | try r(self) 48 | } 49 | } 50 | 51 | func rewrite(_ r: (MarkdownInlineNode) throws -> [MarkdownInlineNode]) rethrows -> [MarkdownBlockNode] { 52 | switch self { 53 | case let .blockquote(children): 54 | try [.blockquote(children: children.rewrite(r))] 55 | case let .bulletedList(isTight, items): 56 | try [ 57 | .bulletedList( 58 | isTight: isTight, 59 | items: items.map { 60 | try RawListItem(children: $0.children.rewrite(r)) 61 | } 62 | ), 63 | ] 64 | case let .numberedList(isTight, start, items): 65 | try [ 66 | .numberedList( 67 | isTight: isTight, 68 | start: start, 69 | items: items.map { 70 | try RawListItem(children: $0.children.rewrite(r)) 71 | } 72 | ), 73 | ] 74 | case let .taskList(isTight, items): 75 | try [ 76 | .taskList( 77 | isTight: isTight, 78 | items: items.map { 79 | try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r)) 80 | } 81 | ), 82 | ] 83 | case let .paragraph(content): 84 | try [.paragraph(content: content.rewrite(r))] 85 | case let .heading(level, content): 86 | try [.heading(level: level, content: content.rewrite(r))] 87 | case let .table(columnAlignments, rows): 88 | try [ 89 | .table( 90 | columnAlignments: columnAlignments, 91 | rows: rows.map { 92 | try RawTableRow( 93 | cells: $0.cells.map { 94 | try RawTableCell(content: $0.content.rewrite(r)) 95 | } 96 | ) 97 | } 98 | ), 99 | ] 100 | default: 101 | [self] 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownBlockNode/MarkdownBlockNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MarkdownBlockNode: Hashable, Equatable, Codable { 4 | case blockquote(children: [MarkdownBlockNode]) 5 | case bulletedList(isTight: Bool, items: [RawListItem]) 6 | case numberedList(isTight: Bool, start: Int, items: [RawListItem]) 7 | case taskList(isTight: Bool, items: [RawTaskListItem]) 8 | case codeBlock(fenceInfo: String?, content: String) 9 | // case htmlBlock(content: String) 10 | case paragraph(content: [MarkdownInlineNode]) 11 | case heading(level: Int, content: [MarkdownInlineNode]) 12 | case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow]) 13 | case thematicBreak 14 | } 15 | 16 | public extension MarkdownBlockNode { 17 | var children: [MarkdownBlockNode] { 18 | switch self { 19 | case let .blockquote(children): 20 | return children 21 | case let .bulletedList(_, items): 22 | return items.map(\.children).flatMap(\.self) 23 | case let .numberedList(_, _, items): 24 | return items.map(\.children).flatMap(\.self) 25 | case let .taskList(_, items): 26 | return items.map(\.children).flatMap(\.self) 27 | default: 28 | print("WARNING: children is not supported for \(self)") 29 | return [] 30 | } 31 | } 32 | 33 | var isParagraph: Bool { 34 | guard case .paragraph = self else { return false } 35 | return true 36 | } 37 | } 38 | 39 | public struct RawListItem: Hashable, Equatable, Codable { 40 | public let children: [MarkdownBlockNode] 41 | 42 | public init(children: [MarkdownBlockNode]) { 43 | self.children = children 44 | } 45 | } 46 | 47 | public struct RawTaskListItem: Hashable, Equatable, Codable { 48 | public let isCompleted: Bool 49 | public let children: [MarkdownBlockNode] 50 | 51 | public init(isCompleted: Bool, children: [MarkdownBlockNode]) { 52 | self.isCompleted = isCompleted 53 | self.children = children 54 | } 55 | } 56 | 57 | public enum RawTableColumnAlignment: Character, Equatable, Codable { 58 | case none = "\0" 59 | case left = "l" 60 | case center = "c" 61 | case right = "r" 62 | } 63 | 64 | public struct RawTableRow: Hashable, Equatable, Codable { 65 | public let cells: [RawTableCell] 66 | 67 | public init(cells: [RawTableCell]) { 68 | self.cells = cells 69 | } 70 | } 71 | 72 | public struct RawTableCell: Hashable, Equatable, Codable { 73 | public let content: [MarkdownInlineNode] 74 | 75 | public init(content: [MarkdownInlineNode]) { 76 | self.content = content 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownInlineNode/MarkdownInlineNode+Collect.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Sequence { 4 | func collect(_ c: (MarkdownInlineNode) throws -> [Result]) rethrows -> [Result] { 5 | try flatMap { try $0.collect(c) } 6 | } 7 | } 8 | 9 | public extension MarkdownInlineNode { 10 | func collect(_ c: (MarkdownInlineNode) throws -> [Result]) rethrows -> [Result] { 11 | try children.collect(c) + c(self) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownInlineNode/MarkdownInlineNode+Rewrite.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Sequence { 4 | func rewrite(_ r: (MarkdownInlineNode) throws -> [MarkdownInlineNode]) rethrows -> [MarkdownInlineNode] { 5 | try flatMap { try $0.rewrite(r) } 6 | } 7 | } 8 | 9 | public extension MarkdownInlineNode { 10 | func rewrite(_ r: (MarkdownInlineNode) throws -> [MarkdownInlineNode]) rethrows -> [MarkdownInlineNode] { 11 | var inline = self 12 | inline.children = try children.rewrite(r) 13 | return try r(inline) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownInlineNode/MarkdownInlineNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MarkdownInlineNode: Hashable, Sendable, Equatable, Codable { 4 | case text(String) 5 | case softBreak 6 | case lineBreak 7 | case code(String) 8 | case html(String) 9 | case emphasis(children: [MarkdownInlineNode]) 10 | case strong(children: [MarkdownInlineNode]) 11 | case strikethrough(children: [MarkdownInlineNode]) 12 | case link(destination: String, children: [MarkdownInlineNode]) 13 | case image(source: String, children: [MarkdownInlineNode]) 14 | } 15 | 16 | public extension MarkdownInlineNode { 17 | var children: [MarkdownInlineNode] { 18 | get { 19 | switch self { 20 | case let .emphasis(children): 21 | children 22 | case let .strong(children): 23 | children 24 | case let .strikethrough(children): 25 | children 26 | case let .link(_, children): 27 | children 28 | case let .image(_, children): 29 | children 30 | default: 31 | [] 32 | } 33 | } 34 | 35 | set { 36 | switch self { 37 | case .emphasis: 38 | self = .emphasis(children: newValue) 39 | case .strong: 40 | self = .strong(children: newValue) 41 | case .strikethrough: 42 | self = .strikethrough(children: newValue) 43 | case let .link(destination, _): 44 | self = .link(destination: destination, children: newValue) 45 | case let .image(source, _): 46 | self = .image(source: source, children: newValue) 47 | default: 48 | break 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownParser/MarkdownParser+MathContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownParser+MathContext.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 6/3/25. 6 | // 7 | 8 | import Foundation 9 | 10 | private let mathPattern: NSRegularExpression? = { 11 | let patterns = [ 12 | ###"\$\$([\s\S]*?)\$\$"###, // 块级公式 $$ ... $$ 13 | ###"\\\\\[([\s\S]*?)\\\\\]"###, // 带转义的块级公式 \\[ ... \\] 14 | ###"\\\\\(([\s\S]*?)\\\\\)"###, // 带转义的行内公式 \\( ... \\) 15 | ###"\\\[[\s\S]*?(\\(\s|\S)+?\n)+?\\\]"###, // 带转义的块级公式 \[ ... \] 且存在多行 16 | ] 17 | let pattern = patterns.joined(separator: "|") 18 | guard let regex = try? NSRegularExpression( 19 | pattern: pattern, 20 | options: [ 21 | .caseInsensitive, 22 | .allowCommentsAndWhitespace, 23 | ] 24 | ) else { 25 | assertionFailure("failed to create regex for math pattern") 26 | return nil 27 | } 28 | return regex 29 | }() 30 | 31 | public extension MarkdownParser { 32 | class MathContext { 33 | let document: String 34 | var indexedContent: String? 35 | 36 | public fileprivate(set) var indexedMathContent: [Int: String] = [:] 37 | 38 | init(preprocessText: String) { 39 | document = preprocessText 40 | } 41 | 42 | func process() { 43 | guard let regex = mathPattern else { 44 | assertionFailure() 45 | return 46 | } 47 | 48 | var document = document 49 | 50 | let matches = regex.matches( 51 | in: document, 52 | options: [], 53 | range: NSRange(location: 0, length: document.count) 54 | ).reversed() 55 | if matches.isEmpty { return } 56 | 57 | var indexer = 0 58 | for match in matches where match.numberOfRanges > 1 { 59 | var mathContentRange: NSRange? 60 | 61 | // find the longest capture group 62 | for rangeIndex in 1 ..< match.numberOfRanges { 63 | let captureRange = match.range(at: rangeIndex) 64 | if captureRange.location != NSNotFound { 65 | mathContentRange = captureRange 66 | break 67 | } 68 | } 69 | 70 | guard let contentRange = mathContentRange else { continue } 71 | 72 | let fullMatchRange = match.range(at: 0) 73 | guard let fullRange = Range(fullMatchRange, in: document) else { continue } 74 | 75 | let mathIndex = indexer 76 | let mathContent = (document as NSString).substring(with: contentRange) 77 | 78 | indexer += 1 79 | 80 | indexedMathContent[mathIndex] = mathContent 81 | 82 | let replacement = "`math://\(mathIndex)`" 83 | document.replaceSubrange(fullRange, with: replacement) 84 | } 85 | 86 | indexedContent = document 87 | } 88 | } 89 | } 90 | 91 | private let mathPatternWithinBlock: NSRegularExpression? = { 92 | let patterns = [ 93 | ###"\\\(([^\r\n]+?)\\\)"###, // 行内公式 \(...\) 94 | // ###"\( ([^\r\n]+?) \)"###, // 行内公式 ( ... ) 95 | ###"\$([^\r\n]+?)\$"###, // 行内公式 $ ... $ 96 | ] 97 | let pattern = patterns.joined(separator: "|") 98 | guard let regex = try? NSRegularExpression( 99 | pattern: pattern, 100 | options: [ 101 | .caseInsensitive, 102 | .allowCommentsAndWhitespace, 103 | ] 104 | ) else { 105 | assertionFailure("failed to create regex for math pattern") 106 | return nil 107 | } 108 | return regex 109 | }() 110 | 111 | extension MarkdownParser { 112 | func processInlineMathBlocks(_ nodes: [MarkdownBlockNode], mathContext: MathContext) -> [MarkdownBlockNode] { 113 | nodes.map { processInlineMathBlock($0, mathContext: mathContext) }.flatMap(\.self) 114 | } 115 | 116 | func processInlineMathBlock(_ node: MarkdownBlockNode, mathContext: MathContext) -> [MarkdownBlockNode] { 117 | switch node { 118 | case let .blockquote(children): 119 | return [.blockquote(children: processInlineMathBlocks(children, mathContext: mathContext))] 120 | case let .bulletedList(isTight, items): 121 | let processedItems = items.map { item in 122 | RawListItem(children: processInlineMathBlocks(item.children, mathContext: mathContext)) 123 | } 124 | return [.bulletedList(isTight: isTight, items: processedItems)] 125 | case let .numberedList(isTight, start, items): 126 | let processedItems = items.map { item in 127 | RawListItem(children: processInlineMathBlocks(item.children, mathContext: mathContext)) 128 | } 129 | return [.numberedList(isTight: isTight, start: start, items: processedItems)] 130 | case let .taskList(isTight, items): 131 | let processedItems = items.map { item in 132 | RawTaskListItem(isCompleted: item.isCompleted, children: processInlineMathBlocks(item.children, mathContext: mathContext)) 133 | } 134 | return [.taskList(isTight: isTight, items: processedItems)] 135 | case let .paragraph(content): 136 | let processedContent = processInlineMathInNodes(content, mathContext: mathContext) 137 | return [.paragraph(content: processedContent)] 138 | case let .table(columnAlignments, rows): 139 | let processedRows = rows.map { row in 140 | let processedCells = row.cells.map { cell in 141 | RawTableCell(content: processInlineMathInNodes(cell.content, mathContext: mathContext)) 142 | } 143 | return RawTableRow(cells: processedCells) 144 | } 145 | return [.table(columnAlignments: columnAlignments, rows: processedRows)] 146 | default: 147 | return [node] 148 | } 149 | } 150 | 151 | private func processInlineMathInNodes(_ nodes: [MarkdownInlineNode], mathContext: MathContext) -> [MarkdownInlineNode] { 152 | var result: [MarkdownInlineNode] = [] 153 | 154 | for node in nodes { 155 | switch node { 156 | case let .text(text): 157 | result.append(contentsOf: processInlineMathInText(text, mathContext: mathContext)) 158 | default: 159 | result.append(node) 160 | } 161 | } 162 | 163 | return result 164 | } 165 | 166 | private func processInlineMathInText(_ text: String, mathContext: MathContext) -> [MarkdownInlineNode] { 167 | guard let regex = mathPatternWithinBlock else { 168 | return [.text(text)] 169 | } 170 | 171 | let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) 172 | 173 | if matches.isEmpty { 174 | return [.text(text)] 175 | } 176 | 177 | var result: [MarkdownInlineNode] = [] 178 | var lastEnd = 0 179 | 180 | for match in matches { 181 | let contentRange = (0 ..< match.numberOfRanges) 182 | .compactMap { match.range(at: $0) } 183 | .sorted { $0.length > $1.length } 184 | .first 185 | guard let fullRange = contentRange, fullRange.location != NSNotFound else { 186 | continue 187 | } 188 | 189 | if fullRange.location > lastEnd { 190 | let beforeText = (text as NSString).substring( 191 | with: NSRange(location: lastEnd, length: fullRange.location - lastEnd) 192 | ) 193 | if !beforeText.isEmpty { result.append(.text(beforeText)) } 194 | } 195 | 196 | let mathContent = (text as NSString).substring(with: fullRange) 197 | let mathIndex = mathContext.indexedMathContent.count 198 | mathContext.indexedMathContent[mathIndex] = mathContent 199 | result.append(.code("math://\(mathIndex)")) 200 | 201 | lastEnd = fullRange.location + fullRange.length 202 | } 203 | 204 | if lastEnd < text.count { 205 | let remainingText = (text as NSString).substring(from: lastEnd) 206 | if !remainingText.isEmpty { 207 | result.append(.text(remainingText)) 208 | } 209 | } 210 | 211 | return result 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownParser/MarkdownParser+Node.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownParser+Node.swift 3 | // FlowMarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/3. 6 | // 7 | 8 | import cmark_gfm 9 | import cmark_gfm_extensions 10 | import Foundation 11 | 12 | extension MarkdownParser { 13 | func dumpBlocks(root: UnsafeNode?) -> [MarkdownBlockNode] { 14 | guard let root else { 15 | assertionFailure() 16 | return [] 17 | } 18 | assert(root.pointee.type == CMARK_NODE_DOCUMENT.rawValue) 19 | let nodeList = root.children.compactMap(MarkdownBlockNode.init(unsafeNode:)) 20 | 21 | let reorderContext = ReorderContext() 22 | for node in nodeList { 23 | reorderContext.append(node) 24 | } 25 | return reorderContext.complete() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownParser/MarkdownParser+ReorderContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownParser+ReorderContext.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 5/27/25. 6 | // 7 | 8 | import cmark_gfm 9 | import cmark_gfm_extensions 10 | import Foundation 11 | 12 | extension MarkdownParser { 13 | class ReorderContext { 14 | private var context: [MarkdownBlockNode] = [] 15 | 16 | init() {} 17 | 18 | func append(_ node: MarkdownBlockNode) { 19 | processNode(node) 20 | } 21 | 22 | func complete() -> [MarkdownBlockNode] { 23 | defer { context.removeAll() } 24 | return context 25 | } 26 | } 27 | } 28 | 29 | private extension MarkdownParser.ReorderContext { 30 | func rawListItemByCherryPick( 31 | _ rawListItem: RawListItem 32 | ) -> (RawListItem, [MarkdownBlockNode]) { 33 | let children = rawListItem.children 34 | var newChildren: [MarkdownBlockNode] = [] 35 | var pickedNodes: [MarkdownBlockNode] = [] 36 | 37 | for child in children { 38 | switch child { 39 | case .codeBlock, .table, .heading, .thematicBreak: 40 | pickedNodes.append(child) 41 | case let .bulletedList(isTight, items): 42 | var resultItems: [RawListItem] = [] 43 | for item in items { 44 | let (newItem, picked) = rawListItemByCherryPick(item) 45 | resultItems.append(newItem) 46 | pickedNodes.append(contentsOf: picked) 47 | } 48 | newChildren.append(.bulletedList(isTight: isTight, items: resultItems)) 49 | case let .numberedList(isTight, start, items): 50 | var resultItems: [RawListItem] = [] 51 | for item in items { 52 | let (newItem, picked) = rawListItemByCherryPick(item) 53 | resultItems.append(newItem) 54 | pickedNodes.append(contentsOf: picked) 55 | } 56 | newChildren.append(.numberedList(isTight: isTight, start: start, items: resultItems)) 57 | case let .taskList(isTight, items): 58 | var resultItems: [RawTaskListItem] = [] 59 | for item in items { 60 | let (newItem, picked) = rawTaskListItemByCherryPick(item) 61 | resultItems.append(newItem) 62 | pickedNodes.append(contentsOf: picked) 63 | } 64 | newChildren.append(.taskList(isTight: isTight, items: resultItems)) 65 | default: 66 | newChildren.append(child) 67 | } 68 | } 69 | 70 | return (RawListItem(children: newChildren), pickedNodes) 71 | } 72 | 73 | func rawTaskListItemByCherryPick( 74 | _ rawTaskListItem: RawTaskListItem 75 | ) -> (RawTaskListItem, [MarkdownBlockNode]) { 76 | let children = rawTaskListItem.children 77 | var newChildren: [MarkdownBlockNode] = [] 78 | var pickedNodes: [MarkdownBlockNode] = [] 79 | 80 | for child in children { 81 | switch child { 82 | case .codeBlock, .table, .heading, .thematicBreak: 83 | pickedNodes.append(child) 84 | case let .bulletedList(isTight, items): 85 | var resultItems: [RawListItem] = [] 86 | for item in items { 87 | let (newItem, picked) = rawListItemByCherryPick(item) 88 | resultItems.append(newItem) 89 | pickedNodes.append(contentsOf: picked) 90 | } 91 | newChildren.append(.bulletedList(isTight: isTight, items: resultItems)) 92 | case let .numberedList(isTight, start, items): 93 | var resultItems: [RawListItem] = [] 94 | for item in items { 95 | let (newItem, picked) = rawListItemByCherryPick(item) 96 | resultItems.append(newItem) 97 | pickedNodes.append(contentsOf: picked) 98 | } 99 | newChildren.append(.numberedList(isTight: isTight, start: start, items: resultItems)) 100 | case let .taskList(isTight, items): 101 | var resultItems: [RawTaskListItem] = [] 102 | for item in items { 103 | let (newItem, picked) = rawTaskListItemByCherryPick(item) 104 | resultItems.append(newItem) 105 | pickedNodes.append(contentsOf: picked) 106 | } 107 | newChildren.append(.taskList(isTight: isTight, items: resultItems)) 108 | default: 109 | newChildren.append(child) 110 | } 111 | } 112 | 113 | return (RawTaskListItem(isCompleted: rawTaskListItem.isCompleted, children: newChildren), pickedNodes) 114 | } 115 | 116 | func processNodeInsideListEnvironment( 117 | _ node: MarkdownBlockNode 118 | ) -> [MarkdownBlockNode] { 119 | switch node { 120 | case let .bulletedList(isTight, items): 121 | return processListItems(items: items) { processedItems in 122 | .bulletedList(isTight: isTight, items: processedItems) 123 | } 124 | case let .numberedList(isTight, start, items): 125 | var containsElementsToExtract = false 126 | for item in items { 127 | let (_, pickedNodes) = rawListItemByCherryPick(item) 128 | if !pickedNodes.isEmpty { 129 | containsElementsToExtract = true 130 | break 131 | } 132 | } 133 | if containsElementsToExtract { 134 | return processListItems(items: items) { processedItems in 135 | .bulletedList(isTight: isTight, items: processedItems) 136 | } 137 | } else { 138 | return processListItems(items: items) { processedItems in 139 | .numberedList(isTight: isTight, start: start, items: processedItems) 140 | } 141 | } 142 | case let .taskList(isTight, items): 143 | return processTaskListItems(items: items) { processedItems in 144 | .taskList(isTight: isTight, items: processedItems) 145 | } 146 | default: 147 | assertionFailure("unsupported node type in list environment") 148 | return [] 149 | } 150 | } 151 | 152 | private func processListItems( 153 | items: [RawListItem], 154 | createList: ([RawListItem]) -> MarkdownBlockNode 155 | ) -> [MarkdownBlockNode] { 156 | var result: [MarkdownBlockNode] = [] 157 | var currentItems: [RawListItem] = [] 158 | 159 | for itemIndex in 0 ..< items.count { 160 | let item = items[itemIndex] 161 | let (processedItem, pickedNodes) = rawListItemByCherryPick(item) 162 | currentItems.append(processedItem) 163 | if !pickedNodes.isEmpty { 164 | if !currentItems.isEmpty { 165 | result.append(createList(currentItems)) 166 | currentItems = [] 167 | } 168 | result.append(contentsOf: pickedNodes) 169 | } else if itemIndex == items.count - 1, !currentItems.isEmpty { 170 | result.append(createList(currentItems)) 171 | } 172 | } 173 | return result 174 | } 175 | 176 | private func processTaskListItems( 177 | items: [RawTaskListItem], 178 | createList: ([RawTaskListItem]) -> MarkdownBlockNode 179 | ) -> [MarkdownBlockNode] { 180 | var result: [MarkdownBlockNode] = [] 181 | var currentItems: [RawTaskListItem] = [] 182 | 183 | for itemIndex in 0 ..< items.count { 184 | let item = items[itemIndex] 185 | let (processedItem, pickedNodes) = rawTaskListItemByCherryPick(item) 186 | currentItems.append(processedItem) 187 | if !pickedNodes.isEmpty { 188 | if !currentItems.isEmpty { 189 | result.append(createList(currentItems)) 190 | currentItems = [] 191 | } 192 | result.append(contentsOf: pickedNodes) 193 | } else if itemIndex == items.count - 1, !currentItems.isEmpty { 194 | result.append(createList(currentItems)) 195 | } 196 | } 197 | return result 198 | } 199 | 200 | func processNode(_ node: MarkdownBlockNode) { 201 | switch node { 202 | case let .blockquote(children): 203 | context.append(.blockquote(children: children)) 204 | case .bulletedList: 205 | let nodes = processNodeInsideListEnvironment(node) 206 | context.append(contentsOf: nodes) 207 | case .numberedList: 208 | let nodes = processNodeInsideListEnvironment(node) 209 | context.append(contentsOf: nodes) 210 | case .taskList: 211 | let nodes = processNodeInsideListEnvironment(node) 212 | context.append(contentsOf: nodes) 213 | case let .codeBlock(fenceInfo, content): 214 | context.append(.codeBlock(fenceInfo: fenceInfo, content: content)) 215 | case let .paragraph(content): 216 | context.append(.paragraph(content: content)) 217 | case let .heading(level, content): 218 | context.append(.heading(level: level, content: content)) 219 | case let .table(columnAlignments, rows): 220 | context.append(.table(columnAlignments: columnAlignments, rows: rows)) 221 | case .thematicBreak: 222 | context.append(.thematicBreak) 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/MarkdownParser/MarkdownParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownParser.swift 3 | // FlowMarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/2. 6 | // 7 | 8 | import cmark_gfm 9 | import cmark_gfm_extensions 10 | import Foundation 11 | 12 | public class MarkdownParser { 13 | public init() {} 14 | 15 | func withParser(_ block: (UnsafeMutablePointer) -> T) -> T { 16 | let parser = cmark_parser_new(CMARK_OPT_DEFAULT)! 17 | cmark_gfm_core_extensions_ensure_registered() 18 | let extensionNames = [ 19 | "autolink", 20 | "strikethrough", 21 | "tagfilter", 22 | "tasklist", 23 | "table", 24 | ] 25 | for extensionName in extensionNames { 26 | guard let syntaxExtension = cmark_find_syntax_extension(extensionName) else { 27 | assertionFailure() 28 | continue 29 | } 30 | cmark_parser_attach_syntax_extension(parser, syntaxExtension) 31 | } 32 | defer { cmark_parser_free(parser) } 33 | return block(parser) 34 | } 35 | 36 | public struct ParseResult { 37 | public let document: [MarkdownBlockNode] 38 | public let mathContext: [Int: String] 39 | } 40 | 41 | public func parse(_ markdown: String) -> ParseResult { 42 | let math = MathContext(preprocessText: markdown) 43 | math.process() 44 | let markdown = math.indexedContent ?? markdown 45 | let nodes = withParser { parser in 46 | markdown.withCString { str in 47 | cmark_parser_feed(parser, str, strlen(str)) 48 | return cmark_parser_finish(parser) 49 | } 50 | } 51 | var blocks = dumpBlocks(root: nodes) 52 | blocks = processInlineMathBlocks(blocks, mathContext: math) 53 | return .init(document: blocks, mathContext: math.indexedMathContent) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/Utils/Converters.swift: -------------------------------------------------------------------------------- 1 | import cmark_gfm 2 | import cmark_gfm_extensions 3 | import Foundation 4 | 5 | typealias UnsafeNode = UnsafeMutablePointer 6 | 7 | extension MarkdownBlockNode { 8 | init?(unsafeNode: UnsafeNode) { 9 | switch unsafeNode.nodeType { 10 | case .blockquote: 11 | self = .blockquote(children: unsafeNode.children.compactMap(MarkdownBlockNode.init(unsafeNode:))) 12 | case .list: 13 | if unsafeNode.children.contains(where: \.isTaskListItem) { 14 | self = .taskList( 15 | isTight: unsafeNode.isTightList, 16 | items: unsafeNode.children.map(RawTaskListItem.init(unsafeNode:)) 17 | ) 18 | } else { 19 | switch unsafeNode.listType { 20 | case CMARK_BULLET_LIST: 21 | self = .bulletedList( 22 | isTight: unsafeNode.isTightList, 23 | items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) 24 | ) 25 | case CMARK_ORDERED_LIST: 26 | self = .numberedList( 27 | isTight: unsafeNode.isTightList, 28 | start: unsafeNode.listStart, 29 | items: unsafeNode.children.map(RawListItem.init(unsafeNode:)) 30 | ) 31 | default: 32 | fatalError("cmark reported a list node without a list type.") 33 | } 34 | } 35 | case .codeBlock: 36 | self = .codeBlock(fenceInfo: unsafeNode.fenceInfo, content: unsafeNode.literal ?? "") 37 | case .htmlBlock: 38 | self = .codeBlock(fenceInfo: "html", content: unsafeNode.literal ?? "") 39 | case .paragraph: 40 | self = .paragraph(content: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:))) 41 | case .heading: 42 | self = .heading( 43 | level: unsafeNode.headingLevel, 44 | content: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:)) 45 | ) 46 | case .table: 47 | self = .table( 48 | columnAlignments: unsafeNode.tableAlignments, 49 | rows: unsafeNode.children.map(RawTableRow.init(unsafeNode:)) 50 | ) 51 | case .thematicBreak: 52 | self = .thematicBreak 53 | default: 54 | assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in BlockNode.") 55 | return nil 56 | } 57 | } 58 | } 59 | 60 | extension RawListItem { 61 | init(unsafeNode: UnsafeNode) { 62 | guard unsafeNode.nodeType == .item else { 63 | fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.") 64 | } 65 | self.init(children: unsafeNode.children.compactMap(MarkdownBlockNode.init(unsafeNode:))) 66 | } 67 | } 68 | 69 | extension RawTaskListItem { 70 | init(unsafeNode: UnsafeNode) { 71 | guard unsafeNode.nodeType == .taskListItem || unsafeNode.nodeType == .item else { 72 | fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.") 73 | } 74 | self.init( 75 | isCompleted: unsafeNode.isTaskListItemChecked, 76 | children: unsafeNode.children.compactMap(MarkdownBlockNode.init(unsafeNode:)) 77 | ) 78 | } 79 | } 80 | 81 | extension RawTableRow { 82 | init(unsafeNode: UnsafeNode) { 83 | guard unsafeNode.nodeType == .tableRow || unsafeNode.nodeType == .tableHead else { 84 | fatalError("Expected a table row but got a '\(unsafeNode.nodeType)' instead.") 85 | } 86 | self.init(cells: unsafeNode.children.map(RawTableCell.init(unsafeNode:))) 87 | } 88 | } 89 | 90 | extension RawTableCell { 91 | init(unsafeNode: UnsafeNode) { 92 | guard unsafeNode.nodeType == .tableCell else { 93 | fatalError("Expected a table cell but got a '\(unsafeNode.nodeType)' instead.") 94 | } 95 | self.init(content: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:))) 96 | } 97 | } 98 | 99 | extension MarkdownInlineNode { 100 | init?(unsafeNode: UnsafeNode) { 101 | switch unsafeNode.nodeType { 102 | case .text: 103 | self = .text(unsafeNode.literal ?? "") 104 | case .softBreak: 105 | self = .softBreak 106 | case .lineBreak: 107 | self = .lineBreak 108 | case .code: 109 | self = .code(unsafeNode.literal ?? "") 110 | case .html: 111 | self = .html(unsafeNode.literal ?? "") 112 | case .emphasis: 113 | self = .emphasis(children: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:))) 114 | case .strong: 115 | self = .strong(children: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:))) 116 | case .strikethrough: 117 | self = .strikethrough(children: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:))) 118 | case .link: 119 | self = .link( 120 | destination: unsafeNode.url ?? "", 121 | children: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:)) 122 | ) 123 | case .image: 124 | self = .image( 125 | source: unsafeNode.url ?? "", 126 | children: unsafeNode.children.compactMap(MarkdownInlineNode.init(unsafeNode:)) 127 | ) 128 | default: 129 | assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in InlineNode.") 130 | return nil 131 | } 132 | } 133 | } 134 | 135 | extension UnsafeNode { 136 | var nodeType: NodeType { 137 | let typeString = String(cString: cmark_node_get_type_string(self)) 138 | guard let nodeType = NodeType(rawValue: typeString) else { 139 | fatalError("Unknown node type '\(typeString)' found.") 140 | } 141 | return nodeType 142 | } 143 | 144 | var children: UnsafeNodeSequence { 145 | .init(cmark_node_first_child(self)) 146 | } 147 | 148 | var literal: String? { 149 | cmark_node_get_literal(self).map(String.init(cString:)) 150 | } 151 | 152 | var url: String? { 153 | cmark_node_get_url(self).map(String.init(cString:)) 154 | } 155 | 156 | var isTaskListItem: Bool { 157 | nodeType == .taskListItem 158 | } 159 | 160 | var listType: cmark_list_type { 161 | cmark_node_get_list_type(self) 162 | } 163 | 164 | var listStart: Int { 165 | Int(cmark_node_get_list_start(self)) 166 | } 167 | 168 | var isTaskListItemChecked: Bool { 169 | cmark_gfm_extensions_get_tasklist_item_checked(self) 170 | } 171 | 172 | var isTightList: Bool { 173 | cmark_node_get_list_tight(self) != 0 174 | } 175 | 176 | var fenceInfo: String? { 177 | cmark_node_get_fence_info(self).map(String.init(cString:)) 178 | } 179 | 180 | var headingLevel: Int { 181 | Int(cmark_node_get_heading_level(self)) 182 | } 183 | 184 | var tableColumns: Int { 185 | Int(cmark_gfm_extensions_get_table_columns(self)) 186 | } 187 | 188 | var tableAlignments: [RawTableColumnAlignment] { 189 | (0 ..< tableColumns).map { column in 190 | let ascii = cmark_gfm_extensions_get_table_alignments(self)[column] 191 | let scalar = UnicodeScalar(ascii) 192 | let character = Character(scalar) 193 | return .init(rawValue: character) ?? .none 194 | } 195 | } 196 | } 197 | 198 | enum NodeType: String { 199 | case document 200 | case blockquote = "block_quote" 201 | case list 202 | case item 203 | case codeBlock = "code_block" 204 | case htmlBlock = "html_block" 205 | case customBlock = "custom_block" 206 | case paragraph 207 | case heading 208 | case thematicBreak = "thematic_break" 209 | case text 210 | case softBreak = "softbreak" 211 | case lineBreak = "linebreak" 212 | case code 213 | case html = "html_inline" 214 | case customInline = "custom_inline" 215 | case emphasis = "emph" 216 | case strong 217 | case link 218 | case image 219 | case inlineAttributes = "attribute" 220 | case none = "NONE" 221 | case unknown = "" 222 | 223 | // Extensions 224 | 225 | case strikethrough 226 | case table 227 | case tableHead = "table_header" 228 | case tableRow = "table_row" 229 | case tableCell = "table_cell" 230 | case taskListItem = "tasklist" 231 | } 232 | 233 | struct UnsafeNodeSequence: Sequence { 234 | struct Iterator: IteratorProtocol { 235 | var node: UnsafeNode? 236 | 237 | init(_ node: UnsafeNode?) { 238 | self.node = node 239 | } 240 | 241 | mutating func next() -> UnsafeNode? { 242 | guard let node else { return nil } 243 | defer { self.node = cmark_node_next(node) } 244 | return node 245 | } 246 | } 247 | 248 | let node: UnsafeNode? 249 | 250 | init(_ node: UnsafeNode?) { 251 | self.node = node 252 | } 253 | 254 | func makeIterator() -> Iterator { 255 | .init(node) 256 | } 257 | } 258 | 259 | // Extension node types are not exported in `cmark_gfm_extensions`, 260 | // so we need to look for them in the symbol table 261 | struct ExtensionNodeTypes { 262 | let CMARK_NODE_TABLE: cmark_node_type 263 | let CMARK_NODE_TABLE_ROW: cmark_node_type 264 | let CMARK_NODE_TABLE_CELL: cmark_node_type 265 | let CMARK_NODE_STRIKETHROUGH: cmark_node_type 266 | 267 | static let shared = ExtensionNodeTypes() 268 | 269 | init() { 270 | func findNodeType(_ name: String, in handle: UnsafeMutableRawPointer!) -> cmark_node_type? { 271 | guard let symbol = dlsym(handle, name) else { 272 | return nil 273 | } 274 | return symbol.assumingMemoryBound(to: cmark_node_type.self).pointee 275 | } 276 | 277 | let handle = dlopen(nil, RTLD_LAZY) 278 | 279 | CMARK_NODE_TABLE = findNodeType("CMARK_NODE_TABLE", in: handle) ?? CMARK_NODE_NONE 280 | CMARK_NODE_TABLE_ROW = findNodeType("CMARK_NODE_TABLE_ROW", in: handle) ?? CMARK_NODE_NONE 281 | CMARK_NODE_TABLE_CELL = 282 | findNodeType("CMARK_NODE_TABLE_CELL", in: handle) ?? CMARK_NODE_NONE 283 | CMARK_NODE_STRIKETHROUGH = 284 | findNodeType("CMARK_NODE_STRIKETHROUGH", in: handle) ?? CMARK_NODE_NONE 285 | 286 | dlclose(handle) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /Sources/MarkdownParser/Utils/Ext+Array.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext+Array.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/3. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | subscript(safe index: Int) -> Element? { 12 | guard index >= 0, index < count else { return nil } 13 | return self[index] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import Splash 8 | import UIKit 9 | 10 | final class CodeHighlighter { 11 | private let queue: DispatchQueue 12 | private var taskVersion: Int64 = 0 13 | private var syntaxFormat: AttributedStringOutputFormat? 14 | 15 | init() { 16 | queue = DispatchQueue.global(qos: .background) 17 | } 18 | 19 | func updateTheme(_ theme: MarkdownTheme) { 20 | let codeTheme = theme.codeTheme(withFont: theme.fonts.code) 21 | syntaxFormat = AttributedStringOutputFormat(theme: codeTheme) 22 | } 23 | 24 | func highlight( 25 | code: String, 26 | language: String, 27 | delays: TimeInterval = 0, 28 | completion: @escaping ([NSRange: UIColor]) -> Void 29 | ) { 30 | taskVersion += 1 31 | let currentTaskVersion = taskVersion 32 | 33 | queue.async { [weak self] in 34 | guard let format = self?.syntaxFormat else { return } 35 | 36 | if delays > 0 { 37 | Thread.sleep(forTimeInterval: delays) 38 | if currentTaskVersion != self?.taskVersion { 39 | return 40 | } 41 | } 42 | 43 | let result = self?.performHighlight(code: code, language: language, format: format) 44 | guard let result else { return } 45 | 46 | let attributes = self?.extractColorAttributes(from: result) 47 | guard let attributes else { return } 48 | 49 | if currentTaskVersion != self?.taskVersion { 50 | return 51 | } 52 | 53 | DispatchQueue.main.async { 54 | completion(attributes) 55 | } 56 | } 57 | } 58 | 59 | private func performHighlight( 60 | code: String, 61 | language: String, 62 | format: AttributedStringOutputFormat 63 | ) -> NSMutableAttributedString? { 64 | switch language.lowercased() { 65 | case "", "plaintext": 66 | return NSMutableAttributedString(string: code) 67 | case "swift": 68 | let splash = SyntaxHighlighter(format: format, grammar: SwiftGrammar()) 69 | return splash.highlight(code).mutableCopy() as? NSMutableAttributedString 70 | default: 71 | let splash = SyntaxHighlighter(format: format) 72 | return splash.highlight(code).mutableCopy() as? NSMutableAttributedString 73 | } 74 | } 75 | 76 | private func extractColorAttributes(from attributedString: NSMutableAttributedString) -> [NSRange: UIColor] { 77 | var attributes: [NSRange: UIColor] = [:] 78 | let nsString = attributedString.string as NSString 79 | 80 | attributedString.enumerateAttribute( 81 | .foregroundColor, 82 | in: NSRange(location: 0, length: attributedString.length) 83 | ) { value, range, _ in 84 | if range.length == 1 { 85 | if let char = nsString.substring(with: range).first, char.isWhitespace { 86 | return 87 | } 88 | } 89 | 90 | guard let color = value as? UIColor else { return } 91 | attributes[range] = color 92 | } 93 | 94 | return attributes 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/CodeView/CodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import Litext 7 | import UIKit 8 | 9 | final class CodeView: UIView { 10 | var theme: MarkdownTheme = .default { 11 | didSet { 12 | languageLabel.font = theme.fonts.code 13 | highlighter.updateTheme(theme) 14 | } 15 | } 16 | 17 | var language: String = "" { 18 | didSet { 19 | languageLabel.text = language 20 | } 21 | } 22 | 23 | var previewAction: ((String?, NSAttributedString) -> Void)? 24 | 25 | private var _content: String? 26 | var content: String? { 27 | set { 28 | if _content != newValue { 29 | let oldValue = _content 30 | _content = newValue 31 | let delays = shouldDelayHighlight(oldValue: oldValue, newValue: newValue) 32 | if delays == 0 { calculatedAttributes.removeAll() } 33 | updateHighlightedContent() 34 | performHighlight(with: newValue, delays: delays) 35 | } 36 | } 37 | get { _content } 38 | } 39 | 40 | private var calculatedAttributes: [NSRange: UIColor] = [:] 41 | private let highlighter = CodeHighlighter() 42 | 43 | lazy var barView: UIView = .init() 44 | lazy var scrollView: UIScrollView = .init() 45 | lazy var languageLabel: UILabel = .init() 46 | lazy var textView: LTXLabel = .init() 47 | lazy var copyButton: UIButton = .init() 48 | lazy var previewButton: UIButton = .init() 49 | 50 | override init(frame: CGRect) { 51 | super.init(frame: frame) 52 | configureSubviews() 53 | } 54 | 55 | @available(*, unavailable) 56 | required init?(coder _: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | static func intrinsicHeight(for content: String, theme: MarkdownTheme = .default) -> CGFloat { 61 | CodeViewConfiguration.intrinsicHeight(for: content, theme: theme) 62 | } 63 | 64 | override func layoutSubviews() { 65 | super.layoutSubviews() 66 | performLayout() 67 | } 68 | 69 | override var intrinsicContentSize: CGSize { 70 | let labelSize = languageLabel.intrinsicContentSize 71 | let barHeight = labelSize.height + CodeViewConfiguration.barPadding * 2 72 | let textSize = textView.intrinsicContentSize 73 | let supposedHeight = Self.intrinsicHeight(for: content ?? "", theme: theme) 74 | 75 | return CGSize( 76 | width: max( 77 | labelSize.width + CodeViewConfiguration.barPadding * 2, 78 | textSize.width + CodeViewConfiguration.codePadding * 2 79 | ), 80 | height: max( 81 | barHeight + textSize.height + CodeViewConfiguration.codePadding * 2, 82 | supposedHeight 83 | ) 84 | ) 85 | } 86 | 87 | @objc func handleCopy(_: UIButton) { 88 | UIPasteboard.general.string = content 89 | UINotificationFeedbackGenerator().notificationOccurred(.success) 90 | } 91 | 92 | @objc func handlePreview(_: UIButton) { 93 | previewAction?(language, textView.attributedText) 94 | } 95 | 96 | // MARK: - Highlight Logic 97 | 98 | private func shouldDelayHighlight(oldValue: String?, newValue: String?) -> TimeInterval { 99 | if let oldValue, !oldValue.isEmpty, newValue?.contains(oldValue) == true { 100 | // Incremental modification, delay the highlight task. 101 | 0.1 102 | } else { 103 | // Non-incremental modification 104 | 0 105 | } 106 | } 107 | 108 | private func performHighlight(with code: String?, delays: TimeInterval) { 109 | guard let code else { return } 110 | 111 | highlighter.highlight( 112 | code: code, 113 | language: language, 114 | delays: delays 115 | ) { [weak self] attributes in 116 | if attributes.count > self?.calculatedAttributes.count ?? 0 { 117 | self?.calculatedAttributes = attributes 118 | self?.updateHighlightedContent() 119 | } 120 | } 121 | } 122 | 123 | private func updateHighlightedContent() { 124 | let paragraphStyle = NSMutableParagraphStyle() 125 | paragraphStyle.lineSpacing = CodeViewConfiguration.codeLineSpacing 126 | 127 | guard let content = _content else { 128 | textView.attributedText = .init() 129 | return 130 | } 131 | 132 | let plainTextColor = theme.colors.code 133 | let attributedContent: NSMutableAttributedString = .init( 134 | string: content, 135 | attributes: [ 136 | .font: theme.fonts.code, 137 | .paragraphStyle: paragraphStyle, 138 | .foregroundColor: plainTextColor, 139 | ] 140 | ) 141 | let length = attributedContent.length 142 | for attribute in calculatedAttributes { 143 | if attribute.key.upperBound >= length || attribute.value == plainTextColor { 144 | continue 145 | } 146 | let part = attributedContent.attributedSubstring(from: attribute.key).string 147 | if part.allSatisfy(\.isWhitespace) { 148 | continue 149 | } 150 | attributedContent.addAttributes([ 151 | .foregroundColor: attribute.value, 152 | ], range: attribute.key) 153 | } 154 | textView.attributedText = attributedContent 155 | } 156 | } 157 | 158 | extension CodeView: LTXAttributeStringRepresentable { 159 | func attributedStringRepresentation() -> NSAttributedString { 160 | textView.attributedText 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | enum CodeViewConfiguration { 9 | static let barPadding: CGFloat = 8 10 | static let codePadding: CGFloat = 8 11 | static let codeLineSpacing: CGFloat = 6 12 | 13 | static func intrinsicHeight( 14 | for content: String, 15 | theme: MarkdownTheme = .default 16 | ) -> CGFloat { 17 | let font = theme.fonts.code 18 | let lineHeight = font.lineHeight 19 | let barHeight = lineHeight + barPadding * 2 20 | let numberOfRows = content.components(separatedBy: .newlines).count 21 | let codeHeight = lineHeight * CGFloat(numberOfRows) 22 | + codePadding * 2 23 | + codeLineSpacing * CGFloat(max(numberOfRows - 1, 0)) 24 | return ceil(barHeight + codeHeight) 25 | } 26 | } 27 | 28 | extension CodeView { 29 | func configureSubviews() { 30 | setupViewAppearance() 31 | setupBarView() 32 | setupButtons() 33 | setupScrollView() 34 | setupTextView() 35 | } 36 | 37 | private func setupViewAppearance() { 38 | layer.cornerRadius = 8 39 | layer.cornerCurve = .continuous 40 | clipsToBounds = true 41 | backgroundColor = .gray.withAlphaComponent(0.05) 42 | } 43 | 44 | private func setupBarView() { 45 | barView.backgroundColor = .gray.withAlphaComponent(0.05) 46 | addSubview(barView) 47 | barView.addSubview(languageLabel) 48 | } 49 | 50 | private func setupButtons() { 51 | setupPreviewButton() 52 | setupCopyButton() 53 | } 54 | 55 | private func setupPreviewButton() { 56 | let previewImage = UIImage( 57 | systemName: "eye", 58 | withConfiguration: UIImage.SymbolConfiguration(scale: .small) 59 | ) 60 | previewButton.setImage(previewImage, for: .normal) 61 | previewButton.addTarget(self, action: #selector(handlePreview(_:)), for: .touchUpInside) 62 | barView.addSubview(previewButton) 63 | 64 | previewButton.translatesAutoresizingMaskIntoConstraints = false 65 | NSLayoutConstraint.activate([ 66 | previewButton.centerYAnchor.constraint(equalTo: barView.centerYAnchor), 67 | previewButton.trailingAnchor.constraint( 68 | equalTo: barView.trailingAnchor, 69 | constant: -CodeViewConfiguration.barPadding 70 | ), 71 | ]) 72 | } 73 | 74 | private func setupCopyButton() { 75 | let copyImage = UIImage( 76 | systemName: "doc.on.doc", 77 | withConfiguration: UIImage.SymbolConfiguration(scale: .small) 78 | ) 79 | copyButton.setImage(copyImage, for: .normal) 80 | copyButton.addTarget(self, action: #selector(handleCopy(_:)), for: .touchUpInside) 81 | barView.addSubview(copyButton) 82 | 83 | copyButton.translatesAutoresizingMaskIntoConstraints = false 84 | NSLayoutConstraint.activate([ 85 | copyButton.centerYAnchor.constraint(equalTo: barView.centerYAnchor), 86 | copyButton.trailingAnchor.constraint( 87 | equalTo: previewButton.leadingAnchor, 88 | constant: -12 89 | ), 90 | ]) 91 | } 92 | 93 | private func setupScrollView() { 94 | scrollView.showsVerticalScrollIndicator = false 95 | scrollView.showsHorizontalScrollIndicator = false 96 | scrollView.alwaysBounceVertical = false 97 | scrollView.alwaysBounceHorizontal = false 98 | addSubview(scrollView) 99 | } 100 | 101 | private func setupTextView() { 102 | textView.backgroundColor = .clear 103 | textView.preferredMaxLayoutWidth = .infinity 104 | textView.isSelectable = true 105 | scrollView.addSubview(textView) 106 | } 107 | 108 | func performLayout() { 109 | let labelSize = languageLabel.intrinsicContentSize 110 | let barHeight = max(languageLabel.font.lineHeight, labelSize.height) + CodeViewConfiguration.barPadding * 2 111 | 112 | layoutBarView(barHeight: barHeight, labelSize: labelSize) 113 | layoutScrollViewAndTextView(barHeight: barHeight) 114 | } 115 | 116 | private func layoutBarView(barHeight: CGFloat, labelSize: CGSize) { 117 | barView.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: barHeight)) 118 | languageLabel.frame = CGRect( 119 | origin: CGPoint(x: CodeViewConfiguration.barPadding, y: CodeViewConfiguration.barPadding), 120 | size: labelSize 121 | ) 122 | } 123 | 124 | private func layoutScrollViewAndTextView(barHeight: CGFloat) { 125 | let textContentSize = textView.intrinsicContentSize 126 | 127 | scrollView.frame = CGRect( 128 | x: 0, 129 | y: barHeight, 130 | width: bounds.width, 131 | height: bounds.height - barHeight 132 | ) 133 | 134 | textView.frame = CGRect( 135 | x: CodeViewConfiguration.codePadding, 136 | y: CodeViewConfiguration.codePadding, 137 | width: max(bounds.width - CodeViewConfiguration.codePadding * 2, textContentSize.width), 138 | height: textContentSize.height 139 | ) 140 | 141 | scrollView.contentSize = CGSize( 142 | width: textView.frame.width + CodeViewConfiguration.codePadding * 2, 143 | height: 0 // disable vertical scrolling to fix rarer bug 144 | ) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/TableView/GridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridView.swift 3 | // MarkdownView 4 | // 5 | // Created by ktiays on 2025/1/27. 6 | // Copyright (c) 2025 ktiays. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class GridView: UIView { 12 | private var widths: [CGFloat] = [] 13 | private var heights: [CGFloat] = [] 14 | private var totalWidth: CGFloat = 0 15 | private var totalHeight: CGFloat = 0 16 | 17 | private lazy var shapeLayer: CAShapeLayer = .init() 18 | var padding: CGFloat = 2 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | setupView() 23 | } 24 | 25 | @available(*, unavailable) 26 | @MainActor 27 | required init?(coder _: NSCoder) { 28 | fatalError("init(coder:) has not been implemented") 29 | } 30 | 31 | private func setupView() { 32 | shapeLayer.lineWidth = 1 33 | shapeLayer.strokeColor = UIColor.label.cgColor 34 | layer.addSublayer(shapeLayer) 35 | 36 | backgroundColor = .clear 37 | isUserInteractionEnabled = false 38 | } 39 | 40 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 41 | super.traitCollectionDidChange(previousTraitCollection) 42 | shapeLayer.strokeColor = UIColor.label.cgColor 43 | } 44 | 45 | override func layoutSubviews() { 46 | super.layoutSubviews() 47 | shapeLayer.frame = bounds 48 | drawGrid() 49 | } 50 | 51 | private func drawGrid() { 52 | let path = UIBezierPath() 53 | 54 | // Draw vertical lines 55 | var x: CGFloat = padding 56 | path.move(to: .init(x: x, y: padding)) 57 | path.addLine(to: .init(x: x, y: totalHeight + padding)) 58 | 59 | for width in widths { 60 | x += width 61 | path.move(to: .init(x: x, y: padding)) 62 | path.addLine(to: .init(x: x, y: totalHeight + padding)) 63 | } 64 | 65 | // Draw horizontal lines 66 | var y: CGFloat = padding 67 | path.move(to: .init(x: padding, y: y)) 68 | path.addLine(to: .init(x: totalWidth + padding, y: y)) 69 | 70 | for height in heights { 71 | y += height 72 | path.move(to: .init(x: padding, y: y)) 73 | path.addLine(to: .init(x: totalWidth + padding, y: y)) 74 | } 75 | 76 | shapeLayer.path = path.cgPath 77 | } 78 | 79 | func update(widths: [CGFloat], heights: [CGFloat]) { 80 | self.widths = widths 81 | self.heights = heights 82 | totalWidth = widths.reduce(0, +) 83 | totalHeight = heights.reduce(0, +) 84 | setNeedsLayout() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/TableView/TableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/27. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import Litext 7 | import UIKit 8 | 9 | final class TableView: UIView { 10 | typealias Rows = [NSAttributedString] 11 | 12 | // MARK: - Constants 13 | 14 | private let tableViewPadding: CGFloat = 2 15 | private let cellPadding: CGFloat = 10 16 | private let maximumCellWidth: CGFloat = 200 17 | 18 | // MARK: - UI Components 19 | 20 | private lazy var scrollView: UIScrollView = .init() 21 | private lazy var gridView: GridView = .init() 22 | 23 | // MARK: - Properties 24 | 25 | var contents: [Rows] = [] { 26 | didSet { 27 | configureCells() 28 | setNeedsLayout() 29 | } 30 | } 31 | 32 | private var cellManager = TableViewCellManager() 33 | private var widths: [CGFloat] = [] 34 | private var heights: [CGFloat] = [] 35 | 36 | // MARK: - Computed Properties 37 | 38 | private var numberOfRows: Int { 39 | contents.count 40 | } 41 | 42 | private var numberOfColumns: Int { 43 | contents.first?.count ?? 0 44 | } 45 | 46 | // MARK: - Initialization 47 | 48 | override init(frame: CGRect) { 49 | super.init(frame: frame) 50 | configureSubviews() 51 | } 52 | 53 | @available(*, unavailable) 54 | required init?(coder _: NSCoder) { 55 | fatalError("init(coder:) has not been implemented") 56 | } 57 | 58 | // MARK: - Setup 59 | 60 | private func configureSubviews() { 61 | scrollView.showsVerticalScrollIndicator = false 62 | scrollView.showsHorizontalScrollIndicator = false 63 | addSubview(scrollView) 64 | scrollView.addSubview(gridView) 65 | } 66 | 67 | // MARK: - Layout 68 | 69 | override func layoutSubviews() { 70 | super.layoutSubviews() 71 | 72 | scrollView.clipsToBounds = false 73 | scrollView.frame = bounds 74 | scrollView.contentSize = intrinsicContentSize 75 | gridView.frame = bounds 76 | 77 | layoutCells() 78 | } 79 | 80 | private func layoutCells() { 81 | guard !cellManager.cellSizes.isEmpty, !cellManager.cells.isEmpty else { 82 | return 83 | } 84 | 85 | var x: CGFloat = 0 86 | var y: CGFloat = 0 87 | 88 | for row in 0 ..< numberOfRows { 89 | for column in 0 ..< numberOfColumns { 90 | let index = row * numberOfColumns + column 91 | let cellSize = cellManager.cellSizes[index] 92 | let cell = cellManager.cells[index] 93 | let idealCellSize = cell.intrinsicContentSize 94 | 95 | cell.frame = .init( 96 | x: x + cellPadding + tableViewPadding, 97 | y: y + (cellSize.height - idealCellSize.height) / 2 + tableViewPadding, 98 | width: ceil(idealCellSize.width), 99 | height: ceil(idealCellSize.height) 100 | ) 101 | 102 | let columnWidth = widths[column] 103 | x += columnWidth 104 | } 105 | x = 0 106 | y += heights[row] 107 | } 108 | } 109 | 110 | // MARK: - Content Size 111 | 112 | var intrinsicContentHeight: CGFloat { 113 | ceil(heights.reduce(0, +)) + tableViewPadding * 2 114 | } 115 | 116 | override var intrinsicContentSize: CGSize { 117 | .init( 118 | width: ceil(widths.reduce(0, +)) + tableViewPadding * 2, 119 | height: intrinsicContentHeight 120 | ) 121 | } 122 | 123 | // MARK: - Cell Configuration 124 | 125 | private func configureCells() { 126 | cellManager.configureCells( 127 | for: contents, 128 | in: scrollView, 129 | cellPadding: cellPadding, 130 | maximumCellWidth: maximumCellWidth 131 | ) 132 | 133 | widths = cellManager.widths 134 | heights = cellManager.heights 135 | 136 | gridView.padding = tableViewPadding 137 | gridView.update(widths: widths, heights: heights) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/TableView/TableViewCellManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableViewCellManager.swift 3 | // MarkdownView 4 | // 5 | // Created by ktiays on 2025/1/27. 6 | // Copyright (c) 2025 ktiays. All rights reserved. 7 | // 8 | 9 | import Litext 10 | import UIKit 11 | 12 | final class TableViewCellManager { 13 | // MARK: - Properties 14 | 15 | private(set) var cells: [LTXLabel] = [] 16 | private(set) var cellSizes: [CGSize] = [] 17 | private(set) var widths: [CGFloat] = [] 18 | private(set) var heights: [CGFloat] = [] 19 | 20 | // MARK: - Cell Configuration 21 | 22 | func configureCells( 23 | for contents: [[NSAttributedString]], 24 | in containerView: UIView, 25 | cellPadding: CGFloat, 26 | maximumCellWidth: CGFloat 27 | ) { 28 | let numberOfRows = contents.count 29 | let numberOfColumns = contents.first?.count ?? 0 30 | 31 | // Reset arrays 32 | cellSizes = Array(repeating: .zero, count: numberOfRows * numberOfColumns) 33 | cells.forEach { $0.removeFromSuperview() } 34 | cells.removeAll() 35 | widths = Array(repeating: 0, count: numberOfColumns) 36 | heights = Array(repeating: 0, count: numberOfRows) 37 | 38 | // Configure cells for each row and column 39 | for (row, rowContent) in contents.enumerated() { 40 | var rowHeight: CGFloat = 0 41 | 42 | for (column, cellString) in rowContent.enumerated() { 43 | let index = row * rowContent.count + column 44 | let cell = createOrUpdateCell( 45 | at: index, 46 | with: cellString, 47 | maximumWidth: maximumCellWidth, 48 | in: containerView 49 | ) 50 | 51 | let cellSize = calculateCellSize(for: cell, cellPadding: cellPadding) 52 | cellSizes[index] = cellSize 53 | 54 | // Update row and column dimensions 55 | rowHeight = max(rowHeight, cellSize.height) 56 | widths[column] = max(widths[column], cellSize.width) 57 | } 58 | 59 | heights[row] = rowHeight 60 | } 61 | } 62 | 63 | // MARK: - Private Methods 64 | 65 | private func createOrUpdateCell( 66 | at index: Int, 67 | with attributedText: NSAttributedString, 68 | maximumWidth: CGFloat, 69 | in containerView: UIView 70 | ) -> LTXLabel { 71 | let cell: LTXLabel 72 | 73 | if index >= cells.count { 74 | cell = LTXLabel() 75 | cell.isSelectable = true 76 | cell.backgroundColor = .clear 77 | cell.preferredMaxLayoutWidth = maximumWidth 78 | containerView.addSubview(cell) 79 | cells.append(cell) 80 | } else { 81 | cell = cells[index] 82 | } 83 | 84 | cell.attributedText = attributedText 85 | return cell 86 | } 87 | 88 | private func calculateCellSize(for cell: LTXLabel, cellPadding: CGFloat) -> CGSize { 89 | let contentSize = cell.intrinsicContentSize 90 | return CGSize( 91 | width: ceil(contentSize.width) + cellPadding * 2, 92 | height: ceil(contentSize.height) + cellPadding * 2 93 | ) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Components/TableView/TableViewExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableViewExtensions.swift 3 | // MarkdownView 4 | // 5 | // Created by ktiays on 2025/1/27. 6 | // Copyright (c) 2025 ktiays. All rights reserved. 7 | // 8 | 9 | import Litext 10 | import UIKit 11 | 12 | // MARK: - LTXAttributeStringRepresentable Extension 13 | 14 | extension TableView: LTXAttributeStringRepresentable { 15 | func attributedStringRepresentation() -> NSAttributedString { 16 | let attributedString = NSMutableAttributedString() 17 | 18 | for row in contents { 19 | let rowString = NSMutableAttributedString() 20 | 21 | for cell in row { 22 | rowString.append(cell) 23 | rowString.append(NSAttributedString(string: "\t")) 24 | } 25 | 26 | attributedString.append(rowString) 27 | 28 | if row != contents.last { 29 | attributedString.append(NSAttributedString(string: "\n")) 30 | } 31 | } 32 | 33 | return attributedString 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownTheme/MarkdownTheme+Code.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownTheme+Code.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 1/23/25. 6 | // 7 | 8 | import Foundation 9 | import MarkdownParser 10 | import Splash 11 | import UIKit 12 | 13 | public extension MarkdownTheme { 14 | func codeTheme(withFont font: UIFont) -> Splash.Theme { 15 | var ret = codeThemeTemplate 16 | ret.font = .init(size: Double(font.pointSize)) 17 | return ret 18 | } 19 | } 20 | 21 | private let codeThemeTemplate: Splash.Theme = .init( 22 | font: .init(size: Double(0)), 23 | plainTextColor: .label, 24 | tokenColors: [ 25 | .keyword: Color( 26 | light: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1), 27 | dark: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1) 28 | ), 29 | .string: Color( 30 | light: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1), 31 | dark: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1) 32 | ), 33 | .type: Color( 34 | light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1), 35 | dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1) 36 | ), 37 | .call: Color( 38 | light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1), 39 | dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1) 40 | ), 41 | .number: Color( 42 | light: Color(red: 0.387, green: 0.317, blue: 0.774, alpha: 1), 43 | dark: Color(red: 0.587, green: 0.517, blue: 0.974, alpha: 1) 44 | ), 45 | .comment: Color( 46 | light: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1), 47 | dark: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1) 48 | ), 49 | .property: Color( 50 | light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1), 51 | dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1) 52 | ), 53 | .dotAccess: Color( 54 | light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1), 55 | dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1) 56 | ), 57 | .preprocessing: Color( 58 | light: Color(red: 0.752, green: 0.326, blue: 0.12, alpha: 19), 59 | dark: Color(red: 0.952, green: 0.526, blue: 0.22, alpha: 19) 60 | ), 61 | ], 62 | backgroundColor: .clear 63 | ) 64 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownTheme/MarkdownTheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownTheme.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/3. 6 | // 7 | 8 | import Foundation 9 | import Splash 10 | import UIKit 11 | 12 | public extension MarkdownTheme { 13 | static var `default`: MarkdownTheme = .init() 14 | static let codeScale = 0.85 15 | } 16 | 17 | public struct MarkdownTheme: Equatable { 18 | public struct Fonts: Equatable { 19 | public var body = UIFont.preferredFont(forTextStyle: .body) 20 | public var codeInline = UIFont.monospacedSystemFont( 21 | ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, 22 | weight: .regular 23 | ) 24 | public var bold = UIFont.preferredFont(forTextStyle: .body).bold 25 | public var italic = UIFont.preferredFont(forTextStyle: .body).italic 26 | public var code = UIFont.monospacedSystemFont( 27 | ofSize: ceil(UIFont.preferredFont(forTextStyle: .body).pointSize * codeScale), 28 | weight: .regular 29 | ) 30 | public var largeTitle = UIFont.preferredFont(forTextStyle: .body).bold 31 | public var title = UIFont.preferredFont(forTextStyle: .body).bold 32 | public var footnote = UIFont.preferredFont(forTextStyle: .footnote) 33 | } 34 | 35 | public var fonts: Fonts = .init() 36 | 37 | public struct Colors: Equatable { 38 | public var body = UIColor.label 39 | public var highlight = UIColor(named: "AccentColor") 40 | ?? UIColor(named: "accentColor") 41 | ?? .systemOrange 42 | public var emphasis = UIColor(named: "AccentColor") 43 | ?? UIColor(named: "accentColor") 44 | ?? .systemOrange 45 | public var code = UIColor.label 46 | public var codeBackground = UIColor.gray.withAlphaComponent(0.25) 47 | } 48 | 49 | public var colors: Colors = .init() 50 | 51 | public struct Spacings: Equatable { 52 | public var final: CGFloat = 16 53 | public var general: CGFloat = 8 54 | public var list: CGFloat = 12 55 | public var cell: CGFloat = 32 56 | } 57 | 58 | public var spacings: Spacings = .init() 59 | 60 | public struct Sizes: Equatable { 61 | public var bullet: CGFloat = 4 62 | } 63 | 64 | public var sizes: Sizes = .init() 65 | 66 | public init() {} 67 | } 68 | 69 | public extension MarkdownTheme { 70 | static var defaultValueFont: Fonts { Fonts() } 71 | static var defaultValueColor: Colors { Colors() } 72 | static var defaultValueSpacing: Spacings { Spacings() } 73 | static var defaultValueSize: Sizes { Sizes() } 74 | } 75 | 76 | public extension MarkdownTheme { 77 | enum FontScale: String, CaseIterable { 78 | case tiny 79 | case small 80 | case middle 81 | case large 82 | case huge 83 | } 84 | } 85 | 86 | public extension MarkdownTheme.FontScale { 87 | var offset: Int { 88 | switch self { 89 | case .tiny: -4 90 | case .small: -2 91 | case .middle: 0 92 | case .large: 2 93 | case .huge: 4 94 | } 95 | } 96 | 97 | func scale(_ font: UIFont) -> UIFont { 98 | let size = max(4, font.pointSize + CGFloat(offset)) 99 | return font.withSize(size) 100 | } 101 | } 102 | 103 | public extension MarkdownTheme { 104 | mutating func scaleFont(by scale: FontScale) { 105 | let defaultFont = Self.defaultValueFont 106 | fonts.body = scale.scale(defaultFont.body) 107 | fonts.codeInline = scale.scale(defaultFont.codeInline) 108 | fonts.bold = scale.scale(defaultFont.bold) 109 | fonts.italic = scale.scale(defaultFont.italic) 110 | fonts.code = scale.scale(defaultFont.code) 111 | fonts.largeTitle = scale.scale(defaultFont.largeTitle) 112 | fonts.title = scale.scale(defaultFont.title) 113 | } 114 | 115 | mutating func align(to pointSize: CGFloat) { 116 | fonts.body = fonts.body.withSize(pointSize) 117 | fonts.codeInline = fonts.codeInline.withSize(pointSize) 118 | fonts.bold = fonts.bold.withSize(pointSize).bold 119 | fonts.italic = fonts.italic.withSize(pointSize) 120 | fonts.code = fonts.code.withSize(pointSize * Self.codeScale) 121 | fonts.largeTitle = fonts.largeTitle.withSize(pointSize).bold 122 | fonts.title = fonts.title.withSize(pointSize).bold 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/BlockProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/20. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Litext 8 | import MarkdownParser 9 | import UIKit 10 | 11 | // MARK: - BlockProcessor 12 | 13 | final class BlockProcessor { 14 | private let theme: MarkdownTheme 15 | private let viewProvider: DrawingViewProvider 16 | private let thematicBreakDrawing: TextBuilder.DrawingCallback? 17 | private let codeDrawing: TextBuilder.DrawingCallback? 18 | private let tableDrawing: TextBuilder.DrawingCallback? 19 | 20 | init( 21 | theme: MarkdownTheme, 22 | viewProvider: DrawingViewProvider, 23 | thematicBreakDrawing: TextBuilder.DrawingCallback?, 24 | codeDrawing: TextBuilder.DrawingCallback?, 25 | tableDrawing: TextBuilder.DrawingCallback? 26 | ) { 27 | self.theme = theme 28 | self.viewProvider = viewProvider 29 | self.thematicBreakDrawing = thematicBreakDrawing 30 | self.codeDrawing = codeDrawing 31 | self.tableDrawing = tableDrawing 32 | } 33 | 34 | func processHeading(level: Int, contents: [MarkdownInlineNode], renderedContext: RenderContext) -> NSAttributedString { 35 | let string = contents.render(theme: theme, renderedContext: renderedContext) 36 | var supposedFont: UIFont = theme.fonts.title 37 | if level <= 1 { 38 | supposedFont = theme.fonts.largeTitle 39 | } 40 | string.addAttributes( 41 | [ 42 | .font: supposedFont, 43 | .foregroundColor: theme.colors.body, 44 | ], 45 | range: .init(location: 0, length: string.length) 46 | ) 47 | return withParagraph { 48 | string 49 | } 50 | } 51 | 52 | func processParagraph(contents: [MarkdownInlineNode], renderedContext: RenderContext) -> NSAttributedString { 53 | withParagraph { 54 | contents.render(theme: theme, renderedContext: renderedContext) 55 | } 56 | } 57 | 58 | func processThematicBreak() -> NSAttributedString { 59 | withParagraph { 60 | let drawingCallback = self.thematicBreakDrawing 61 | return .init(string: LTXReplacementText, attributes: [ 62 | .font: theme.fonts.body, 63 | .ltxAttachment: LTXAttachment.hold(attrString: .init(string: "\n\n")), 64 | .ltxLineDrawingCallback: LTXLineDrawingAction(action: { context, line, lineOrigin in 65 | drawingCallback?(context, line, lineOrigin) 66 | }), 67 | ]) 68 | } 69 | } 70 | 71 | func processCodeBlock(language: String?, content: String) -> NSAttributedString { 72 | let content = content.deletingSuffix(of: .whitespacesAndNewlines) 73 | 74 | return withParagraph { paragraph in 75 | let height = CodeView.intrinsicHeight(for: content, theme: theme) 76 | paragraph.minimumLineHeight = height 77 | } content: { 78 | let codeView = viewProvider.acquireCodeView() 79 | let theme = theme 80 | var lang = language ?? "plaintext" 81 | if lang.isEmpty { lang = "plaintext" } 82 | 83 | codeView.theme = theme 84 | codeView.content = content 85 | codeView.language = lang 86 | 87 | let codeDrawing = self.codeDrawing 88 | return .init(string: LTXReplacementText, attributes: [ 89 | .font: theme.fonts.body, 90 | .ltxAttachment: LTXAttachment.hold(attrString: .init(string: content + "\n")), 91 | .ltxLineDrawingCallback: LTXLineDrawingAction(action: { context, line, lineOrigin in 92 | // avoid data conflict on racing conditions 93 | // TODO: FIND THE ROOT CASE 94 | codeView.theme = theme 95 | codeView.content = content 96 | codeView.language = lang 97 | codeDrawing?(context, line, lineOrigin) 98 | }), 99 | .contextView: codeView, 100 | ]) 101 | } 102 | } 103 | 104 | func processBlockquote(_ children: [MarkdownBlockNode], processor: (MarkdownBlockNode) -> NSAttributedString) -> NSAttributedString { 105 | let result = NSMutableAttributedString() 106 | for child in children { 107 | result.append(processor(child)) 108 | } 109 | return result 110 | } 111 | 112 | func processTable(rows: [RawTableRow], renderedContext: RenderContext) -> NSAttributedString { 113 | let tableView = viewProvider.acquireTableView() 114 | let contents = rows.map { 115 | $0.cells.map { rawCell in 116 | rawCell.content.render(theme: theme, renderedContext: renderedContext) 117 | } 118 | } 119 | tableView.contents = contents 120 | return withParagraph { paragraph in 121 | paragraph.minimumLineHeight = tableView.intrinsicContentHeight 122 | } content: { 123 | let drawingCallback = self.tableDrawing 124 | return .init(string: LTXReplacementText, attributes: [ 125 | .font: theme.fonts.body, 126 | .ltxAttachment: LTXAttachment.hold(attrString: .init(string: contents.map { 127 | $0.map(\.string).joined(separator: "\t") 128 | }.joined(separator: "\n") + "\n")), 129 | .ltxLineDrawingCallback: LTXLineDrawingAction(action: { context, line, lineOrigin in 130 | // avoid data conflict on racing conditions 131 | tableView.contents = contents 132 | drawingCallback?(context, line, lineOrigin) 133 | }), 134 | .contextView: tableView, 135 | ]) 136 | } 137 | } 138 | } 139 | 140 | // MARK: - Paragraph Helper 141 | 142 | extension BlockProcessor { 143 | private func withParagraph( 144 | modifier: (NSMutableParagraphStyle) -> Void = { _ in }, 145 | content: () -> NSMutableAttributedString 146 | ) -> NSMutableAttributedString { 147 | let paragraphStyle: NSMutableParagraphStyle = .init() 148 | paragraphStyle.paragraphSpacing = 16 149 | paragraphStyle.lineSpacing = 4 150 | modifier(paragraphStyle) 151 | 152 | let string = content() 153 | string.addAttributes( 154 | [.paragraphStyle: paragraphStyle], 155 | range: .init(location: 0, length: string.length) 156 | ) 157 | string.append(.init(string: "\n")) 158 | return string 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/DrawingViewProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/31. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import DequeModule 7 | import UIKit 8 | 9 | private class ObjectPool { 10 | private let factory: () -> T 11 | private lazy var objects: Deque = .init() 12 | 13 | public init(_ factory: @escaping () -> T) { 14 | self.factory = factory 15 | } 16 | 17 | open func acquire() -> T { 18 | if let object = objects.popLast() { 19 | object 20 | } else { 21 | factory() 22 | } 23 | } 24 | 25 | open func release(_ object: T) { 26 | objects.append(object) 27 | } 28 | } 29 | 30 | private class ViewBox: ObjectPool { 31 | override func acquire() -> T { 32 | while true { 33 | let item = super.acquire() 34 | if item.superview != nil { 35 | continue 36 | } 37 | return item 38 | } 39 | } 40 | 41 | override func release(_ item: T) { 42 | item.removeFromSuperview() 43 | super.release(item) 44 | } 45 | } 46 | 47 | public final class DrawingViewProvider { 48 | private let codeViewPool: ViewBox = .init { 49 | CodeView() 50 | } 51 | 52 | private let tableViewPool: ViewBox = .init { 53 | TableView() 54 | } 55 | 56 | public init() {} 57 | 58 | func acquireCodeView() -> CodeView { 59 | codeViewPool.acquire() 60 | } 61 | 62 | func releaseCodeView(_ codeView: CodeView) { 63 | codeView.removeFromSuperview() 64 | codeViewPool.release(codeView) 65 | } 66 | 67 | func acquireTableView() -> TableView { 68 | tableViewPool.acquire() 69 | } 70 | 71 | func releaseTableView(_ tableView: TableView) { 72 | tableView.removeFromSuperview() 73 | tableViewPool.release(tableView) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/ListProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/20. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Litext 8 | import MarkdownParser 9 | import UIKit 10 | 11 | // MARK: - ListProcessor 12 | 13 | final class ListProcessor { 14 | private let theme: MarkdownTheme 15 | private let listIndent: CGFloat 16 | private let bulletDrawing: TextBuilder.BulletDrawingCallback? 17 | private let numberedDrawing: TextBuilder.NumberedDrawingCallback? 18 | private let checkboxDrawing: TextBuilder.CheckboxDrawingCallback? 19 | 20 | init( 21 | theme: MarkdownTheme, 22 | listIndent: CGFloat, 23 | bulletDrawing: TextBuilder.BulletDrawingCallback?, 24 | numberedDrawing: TextBuilder.NumberedDrawingCallback?, 25 | checkboxDrawing: TextBuilder.CheckboxDrawingCallback? 26 | ) { 27 | self.theme = theme 28 | self.listIndent = listIndent 29 | self.bulletDrawing = bulletDrawing 30 | self.numberedDrawing = numberedDrawing 31 | self.checkboxDrawing = checkboxDrawing 32 | } 33 | 34 | func processBulletedList(items: [RawListItem], renderedContext: RenderContext) -> NSAttributedString { 35 | let items = flatList(.bulleted(items), currentDepth: 0) 36 | return renderListItems(items, renderedContext: renderedContext) 37 | } 38 | 39 | func processNumberedList(startAt index: Int, items: [RawListItem], renderedContext: RenderContext) -> NSAttributedString { 40 | let items = flatList(.numbered(index, items), currentDepth: 0) 41 | return renderListItems(items, renderedContext: renderedContext) 42 | } 43 | 44 | func processTaskList(items: [RawTaskListItem], renderedContext: RenderContext) -> NSAttributedString { 45 | let items = flatList(.task(items), currentDepth: 0) 46 | return renderListItems(items, renderedContext: renderedContext) 47 | } 48 | 49 | private func renderListItem(_ item: ListItem, reduceLineSpacing: Bool = false, renderedContext: RenderContext) -> NSAttributedString { 50 | let paragraphStyle: NSMutableParagraphStyle = .init() 51 | paragraphStyle.paragraphSpacing = reduceLineSpacing ? 8 : 16 52 | paragraphStyle.lineSpacing = 4 53 | let indent = CGFloat(item.depth + 1) * listIndent 54 | paragraphStyle.firstLineHeadIndent = indent 55 | paragraphStyle.headIndent = indent 56 | 57 | let bulletDrawing = bulletDrawing 58 | let numberedDrawing = numberedDrawing 59 | let checkboxDrawing = checkboxDrawing 60 | let string = NSMutableAttributedString() 61 | string.append(.init(string: LTXReplacementText, attributes: [ 62 | .font: theme.fonts.body, 63 | .ltxLineDrawingCallback: LTXLineDrawingAction(action: { context, line, lineOrigin in 64 | if item.ordered { 65 | numberedDrawing?(context, line, lineOrigin, item.index) 66 | } else if item.isTask { 67 | checkboxDrawing?(context, line, lineOrigin, item.isDone) 68 | } else { 69 | bulletDrawing?(context, line, lineOrigin, item.depth) 70 | } 71 | }), 72 | ])) 73 | string.append(item.paragraph.render(theme: theme, renderedContext: renderedContext)) 74 | 75 | string.addAttributes( 76 | [.paragraphStyle: paragraphStyle], 77 | range: .init(location: 0, length: string.length) 78 | ) 79 | string.append(.init(string: "\n")) 80 | return string 81 | } 82 | 83 | private func renderListItems(_ items: [ListItem], renderedContext: RenderContext) -> NSAttributedString { 84 | let result = NSMutableAttributedString() 85 | for (index, item) in items.enumerated() { 86 | let rendered = renderListItem(item, reduceLineSpacing: index != items.count - 1, renderedContext: renderedContext) 87 | result.append(rendered) 88 | } 89 | return result 90 | } 91 | } 92 | 93 | // MARK: - List Processing Types and Logic 94 | 95 | extension ListProcessor { 96 | private enum List { 97 | case bulleted([RawListItem]) 98 | case numbered(Int, [RawListItem]) 99 | case task([RawTaskListItem]) 100 | } 101 | 102 | private struct ListItem { 103 | let depth: Int 104 | let ordered: Bool 105 | let index: Int 106 | let isTask: Bool 107 | let isDone: Bool 108 | let paragraph: [MarkdownInlineNode] 109 | 110 | init(depth: Int, ordered: Bool, index: Int = 0, isTask: Bool = false, isDone: Bool = false, paragraph: [MarkdownInlineNode]) { 111 | self.depth = depth 112 | self.ordered = ordered 113 | self.index = index 114 | self.isTask = isTask 115 | self.isDone = isDone 116 | self.paragraph = paragraph 117 | } 118 | } 119 | 120 | private func flatList(_ list: List, currentDepth: Int) -> [ListItem] { 121 | var result: [ListItem] = [] 122 | var index = 0 123 | var isOrdered = false 124 | 125 | struct MappedItem { 126 | let isDone: Bool? 127 | let nodes: [MarkdownBlockNode] 128 | } 129 | 130 | func handle(_ items: [MappedItem]) { 131 | for item in items { 132 | for child in item.nodes { 133 | switch child { 134 | case let .paragraph(contents): 135 | let isTask = item.isDone != nil 136 | let isDone = item.isDone ?? false 137 | result.append(.init(depth: currentDepth, ordered: isOrdered, index: index, isTask: isTask, isDone: isDone, paragraph: contents)) 138 | index += 1 139 | case let .bulletedList(_, sublist): 140 | result.append(contentsOf: flatList(.bulleted(sublist), currentDepth: currentDepth + 1)) 141 | case let .numberedList(_, start, sublist): 142 | result.append(contentsOf: flatList(.numbered(start, sublist), currentDepth: currentDepth + 1)) 143 | case let .taskList(_, sublist): 144 | result.append(contentsOf: flatList(.task(sublist), currentDepth: currentDepth + 1)) 145 | default: 146 | print("WARNING: Unhandled list item: \(child)") 147 | } 148 | } 149 | } 150 | } 151 | 152 | switch list { 153 | case let .bulleted(items): 154 | let mapped: [MappedItem] = items.map { 155 | .init(isDone: nil, nodes: $0.children) 156 | } 157 | isOrdered = false 158 | handle(mapped) 159 | case let .numbered(startAt, items): 160 | let mapped: [MappedItem] = items.map { 161 | .init(isDone: nil, nodes: $0.children) 162 | } 163 | isOrdered = true 164 | index = startAt 165 | handle(mapped) 166 | case let .task(items): 167 | let mapped: [MappedItem] = items.map { 168 | .init(isDone: $0.isCompleted, nodes: $0.children) 169 | } 170 | isOrdered = false 171 | handle(mapped) 172 | } 173 | 174 | return result 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/MarkdownTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/20. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Litext 8 | import MarkdownParser 9 | import UIKit 10 | 11 | extension NSAttributedString.Key { 12 | static let contextView: NSAttributedString.Key = .init("contextView") 13 | } 14 | 15 | public final class MarkdownTextView: UIView { 16 | public enum LinkPayload { 17 | case url(URL) 18 | case string(String) 19 | } 20 | 21 | private let viewProvider: DrawingViewProvider 22 | 23 | public private(set) var renderedContexts: RenderContext = .init() 24 | public private(set) var nodes: [MarkdownBlockNode] = [] 25 | 26 | public var linkHandler: ((LinkPayload, NSRange, CGPoint) -> Void)? 27 | public var codePreviewHandler: ((String?, NSAttributedString) -> Void)? 28 | 29 | private var attributedText: NSAttributedString? { 30 | get { textView.attributedText } 31 | set { textView.attributedText = newValue ?? .init() } 32 | } 33 | 34 | private lazy var textView: LTXLabel = .init() 35 | public var theme: MarkdownTheme = .default 36 | 37 | private var drawingViewsDirtyMarks: [UIView: Bool] = [:] 38 | private var isDrawingViewsReady: Bool = false 39 | private var drawingToken: UUID = .init() 40 | 41 | deinit { 42 | releaseDrawingViews() 43 | } 44 | 45 | public convenience init() { 46 | self.init(viewProvider: DrawingViewProvider()) 47 | } 48 | 49 | public init(viewProvider: DrawingViewProvider) { 50 | self.viewProvider = viewProvider 51 | super.init(frame: .zero) 52 | configureSubviews() 53 | } 54 | 55 | @available(*, unavailable) 56 | public required init?(coder _: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | override public func layoutSubviews() { 61 | super.layoutSubviews() 62 | 63 | textView.isSelectable = true 64 | textView.preferredMaxLayoutWidth = bounds.width 65 | textView.frame = bounds 66 | } 67 | 68 | override public func draw(_ rect: CGRect) { 69 | super.draw(rect) 70 | 71 | if isDrawingViewsReady { 72 | // Removes unused drawing views from the superview. 73 | var needsRemove: Set = .init() 74 | for (drawingView, isDirty) in drawingViewsDirtyMarks { 75 | if isDirty, drawingView.superview == self { 76 | needsRemove.insert(drawingView) 77 | } 78 | } 79 | for view in needsRemove { 80 | view.removeFromSuperview() 81 | drawingViewsDirtyMarks.removeValue(forKey: view) 82 | } 83 | } 84 | } 85 | 86 | public func boundingSize(for width: CGFloat) -> CGSize { 87 | textView.preferredMaxLayoutWidth = width 88 | return textView.intrinsicContentSize 89 | } 90 | 91 | public func setMarkdown(_ blocks: [MarkdownBlockNode], mathContent: [Int: String]) { 92 | assert(!Thread.isMainThread) 93 | 94 | let theme = theme 95 | var renderedContexts: [String: RenderedItem] = [:] 96 | 97 | for (key, value) in mathContent { 98 | let image = MathRenderer.renderToImage( 99 | latex: value, 100 | fontSize: theme.fonts.body.pointSize, 101 | textColor: theme.colors.body 102 | )?.withRenderingMode(.alwaysTemplate) 103 | let renderedContext = RenderedItem( 104 | image: image, 105 | text: value 106 | ) 107 | renderedContexts["math://\(key)"] = renderedContext 108 | } 109 | 110 | DispatchQueue.main.async { 111 | self.setMarkdown(blocks, renderedContent: renderedContexts) 112 | } 113 | } 114 | 115 | public func setMarkdown(_ blocks: [MarkdownBlockNode], renderedContent: RenderContext) { 116 | assert(Thread.isMainThread) 117 | renderedContexts = renderedContent 118 | nodes = blocks 119 | // due to a bug in model gemini-flash, there might be a large of unknown empty whitespace inside the table 120 | // thus we hereby call the autoreleasepool to avoid large memory consumption 121 | autoreleasepool { self.updateTextExecute() } 122 | setNeedsLayout() 123 | setNeedsDisplay() 124 | layoutIfNeeded() 125 | } 126 | } 127 | 128 | extension MarkdownTextView { 129 | private func releaseDrawingViews() { 130 | for view in drawingViewsDirtyMarks.keys { 131 | if let codeView = view as? CodeView { 132 | viewProvider.releaseCodeView(codeView) 133 | } 134 | if let tableView = view as? TableView { 135 | viewProvider.releaseTableView(tableView) 136 | } 137 | } 138 | } 139 | 140 | private func updateTextExecute() { 141 | releaseDrawingViews() 142 | // Marks all drawing views as dirty. 143 | for view in drawingViewsDirtyMarks.keys { 144 | drawingViewsDirtyMarks[view] = true 145 | } 146 | 147 | func lineBoundingBox(_ line: CTLine, lineOrigin: CGPoint) -> CGRect { 148 | var ascent: CGFloat = 0 149 | var descent: CGFloat = 0 150 | let width = CTLineGetTypographicBounds(line, &ascent, &descent, nil) 151 | return .init(x: lineOrigin.x, y: lineOrigin.y - descent, width: width, height: ascent + descent) 152 | } 153 | 154 | let newDrawingToken = UUID() 155 | drawingToken = newDrawingToken 156 | 157 | let renderText = TextBuilder(nodes: nodes, renderedContext: renderedContexts, viewProvider: viewProvider) 158 | .withTheme(theme) 159 | .withBulletDrawing { [weak self] context, line, lineOrigin, depth in 160 | guard let self, drawingToken == newDrawingToken else { return } 161 | let radius: CGFloat = 3 162 | let boundingBox = lineBoundingBox(line, lineOrigin: lineOrigin) 163 | 164 | context.setStrokeColor(theme.colors.body.cgColor) 165 | context.setFillColor(theme.colors.body.cgColor) 166 | let rect = CGRect( 167 | x: boundingBox.minX - 16, 168 | y: boundingBox.midY - radius, 169 | width: radius * 2, 170 | height: radius * 2 171 | ) 172 | if depth == 0 { 173 | context.fillEllipse(in: rect) 174 | } else if depth == 1 { 175 | context.strokeEllipse(in: rect) 176 | } else { 177 | context.fill(rect) 178 | } 179 | } 180 | .withNumberedDrawing { [weak self] context, line, lineOrigin, index in 181 | guard let self, drawingToken == newDrawingToken else { return } 182 | let string = NSAttributedString( 183 | string: "\(index).", 184 | attributes: [ 185 | .font: theme.fonts.body, 186 | .foregroundColor: theme.colors.body, 187 | ] 188 | ) 189 | let rect = lineBoundingBox(line, lineOrigin: lineOrigin).offsetBy(dx: -20, dy: 0) 190 | let path = CGPath(rect: rect, transform: nil) 191 | let framesetter = CTFramesetterCreateWithAttributedString(string) 192 | let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, string.length), path, nil) 193 | CTFrameDraw(frame, context) 194 | } 195 | .withCheckboxDrawing { [weak self] context, line, lineOrigin, isChecked in 196 | guard let self, drawingToken == newDrawingToken else { return } 197 | let rect = lineBoundingBox(line, lineOrigin: lineOrigin).offsetBy(dx: -20, dy: 0) 198 | let imageConfiguration = UIImage.SymbolConfiguration(scale: .small) 199 | let image = if isChecked { 200 | UIImage(systemName: "checkmark.square.fill", withConfiguration: imageConfiguration) 201 | } else { 202 | UIImage(systemName: "square", withConfiguration: imageConfiguration) 203 | } 204 | guard let image, let cgImage = image.cgImage else { 205 | assertionFailure("Failed to load symbol image") 206 | return 207 | } 208 | let imageSize = image.size 209 | let targetRect: CGRect = .init( 210 | x: rect.minX, 211 | y: rect.midY - imageSize.height / 2, 212 | width: imageSize.width, 213 | height: imageSize.height 214 | ) 215 | context.clip(to: targetRect, mask: cgImage) 216 | context.setFillColor(theme.colors.body.withAlphaComponent(0.24).cgColor) 217 | context.fill(targetRect) 218 | } 219 | .withThematicBreakDrawing { [weak self] context, line, lineOrigin in 220 | guard let self, drawingToken == newDrawingToken else { return } 221 | let boundingBox = lineBoundingBox(line, lineOrigin: lineOrigin) 222 | 223 | context.setLineWidth(1) 224 | context.setStrokeColor(UIColor.label.withAlphaComponent(0.1).cgColor) 225 | context.move(to: .init(x: boundingBox.minX, y: boundingBox.midY)) 226 | context.addLine(to: .init(x: boundingBox.minX + bounds.width, y: boundingBox.midY)) 227 | context.strokePath() 228 | } 229 | .withCodeDrawing { [weak self] _, line, lineOrigin in 230 | guard let self, drawingToken == newDrawingToken else { return } 231 | guard let firstRun = line.glyphRuns().first else { 232 | assertionFailure() 233 | return 234 | } 235 | let attributes = firstRun.attributes 236 | guard let codeView = attributes[.contextView] as? CodeView else { 237 | assertionFailure() 238 | return 239 | } 240 | 241 | drawingViewsDirtyMarks[codeView] = false 242 | if codeView.superview != self { 243 | addSubview(codeView) 244 | } 245 | let intrinsicContentSize = codeView.intrinsicContentSize 246 | let lineBoundingBox = lineBoundingBox(line, lineOrigin: lineOrigin) 247 | codeView.frame = .init( 248 | origin: .init(x: lineOrigin.x, y: bounds.height - lineBoundingBox.maxY), 249 | size: .init(width: bounds.width, height: intrinsicContentSize.height) 250 | ) 251 | codeView.previewAction = { [weak self] in 252 | guard let self else { return } 253 | codePreviewHandler?($0, $1) 254 | } 255 | 256 | isDrawingViewsReady = true 257 | } 258 | .withTableDrawing { [weak self] _, line, lineOrigin in 259 | guard let self, drawingToken == newDrawingToken else { return } 260 | guard let firstRun = line.glyphRuns().first else { 261 | assertionFailure() 262 | return 263 | } 264 | let attributes = firstRun.attributes 265 | guard let tableView = attributes[.contextView] as? TableView else { 266 | assertionFailure() 267 | return 268 | } 269 | 270 | drawingViewsDirtyMarks[tableView] = false 271 | if tableView.superview != self { 272 | addSubview(tableView) 273 | } 274 | let lineBoundingBox = lineBoundingBox(line, lineOrigin: lineOrigin) 275 | let intrinsicContentSize = tableView.intrinsicContentSize 276 | tableView.frame = .init( 277 | x: lineOrigin.x, 278 | y: bounds.height - lineBoundingBox.maxY, 279 | width: bounds.width, 280 | height: intrinsicContentSize.height 281 | ) 282 | 283 | isDrawingViewsReady = true 284 | } 285 | .build() 286 | attributedText = renderText 287 | } 288 | 289 | private func configureSubviews() { 290 | textView.backgroundColor = .clear 291 | textView.attributedText = attributedText ?? .init() 292 | textView.tapHandler = { [weak self] highlightRegion, touchLocation in 293 | guard let self else { return } 294 | guard let highlightRegion else { 295 | return 296 | } 297 | let link = highlightRegion.attributes[NSAttributedString.Key.link] 298 | let range = highlightRegion.stringRange 299 | if let url = link as? URL { 300 | linkHandler?(.url(url), range, touchLocation) 301 | } else if let string = link as? String { 302 | linkHandler?(.string(string), range, touchLocation) 303 | } 304 | } 305 | if textView.superview != self { 306 | addSubview(textView) 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/TextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/20. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Litext 8 | import MarkdownParser 9 | import UIKit 10 | 11 | final class TextBuilder { 12 | private let nodes: [MarkdownBlockNode] 13 | private let viewProvider: DrawingViewProvider 14 | private var theme: MarkdownTheme 15 | private let text: NSMutableAttributedString = .init() 16 | private let renderedContext: RenderContext 17 | 18 | private var bulletDrawing: BulletDrawingCallback? 19 | private var numberedDrawing: NumberedDrawingCallback? 20 | private var checkboxDrawing: CheckboxDrawingCallback? 21 | private var thematicBreakDrawing: DrawingCallback? 22 | private var codeDrawing: DrawingCallback? 23 | private var tableDrawing: DrawingCallback? 24 | 25 | var listIndent: CGFloat = 20 26 | 27 | init(nodes: [MarkdownBlockNode], renderedContext: RenderContext, viewProvider: DrawingViewProvider) { 28 | self.nodes = nodes 29 | self.renderedContext = renderedContext 30 | self.viewProvider = viewProvider 31 | theme = .default 32 | } 33 | 34 | func withTheme(_ theme: MarkdownTheme) -> TextBuilder { 35 | self.theme = theme 36 | return self 37 | } 38 | 39 | func withBulletDrawing(_ drawing: @escaping BulletDrawingCallback) -> TextBuilder { 40 | bulletDrawing = drawing 41 | return self 42 | } 43 | 44 | func withNumberedDrawing(_ drawing: @escaping NumberedDrawingCallback) -> TextBuilder { 45 | numberedDrawing = drawing 46 | return self 47 | } 48 | 49 | func withCheckboxDrawing(_ drawing: @escaping CheckboxDrawingCallback) -> TextBuilder { 50 | checkboxDrawing = drawing 51 | return self 52 | } 53 | 54 | func withThematicBreakDrawing(_ drawing: @escaping DrawingCallback) -> TextBuilder { 55 | thematicBreakDrawing = drawing 56 | return self 57 | } 58 | 59 | func withCodeDrawing(_ drawing: @escaping DrawingCallback) -> TextBuilder { 60 | codeDrawing = drawing 61 | return self 62 | } 63 | 64 | func withTableDrawing(_ drawing: @escaping DrawingCallback) -> TextBuilder { 65 | tableDrawing = drawing 66 | return self 67 | } 68 | 69 | func build() -> NSAttributedString { 70 | for node in nodes { 71 | text.append(processBlock(node, renderedContext: renderedContext)) 72 | } 73 | return text 74 | } 75 | } 76 | 77 | // MARK: - Block Processing 78 | 79 | extension TextBuilder { 80 | private func processBlock(_ node: MarkdownBlockNode, renderedContext: RenderContext) -> NSAttributedString { 81 | let blockProcessor = BlockProcessor( 82 | theme: theme, 83 | viewProvider: viewProvider, 84 | thematicBreakDrawing: thematicBreakDrawing, 85 | codeDrawing: codeDrawing, 86 | tableDrawing: tableDrawing 87 | ) 88 | 89 | let listProcessor = ListProcessor( 90 | theme: theme, 91 | listIndent: listIndent, 92 | bulletDrawing: bulletDrawing, 93 | numberedDrawing: numberedDrawing, 94 | checkboxDrawing: checkboxDrawing 95 | ) 96 | 97 | switch node { 98 | case let .heading(level, contents): 99 | return blockProcessor.processHeading(level: level, contents: contents, renderedContext: renderedContext) 100 | case let .paragraph(contents): 101 | return blockProcessor.processParagraph(contents: contents, renderedContext: renderedContext) 102 | case let .bulletedList(_, items): 103 | return listProcessor.processBulletedList(items: items, renderedContext: renderedContext) 104 | case let .numberedList(_, index, items): 105 | return listProcessor.processNumberedList(startAt: index, items: items, renderedContext: renderedContext) 106 | case let .taskList(_, items): 107 | return listProcessor.processTaskList(items: items, renderedContext: renderedContext) 108 | case .thematicBreak: 109 | return blockProcessor.processThematicBreak() 110 | case let .codeBlock(language, content): 111 | return blockProcessor.processCodeBlock(language: language, content: content) 112 | case let .blockquote(children): 113 | return blockProcessor.processBlockquote(children) { 114 | self.processBlock($0, renderedContext: renderedContext) 115 | } 116 | case let .table(_, rows): 117 | return blockProcessor.processTable(rows: rows, renderedContext: renderedContext) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/MarkdownView/MarkdownView/TextBuilderTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/20. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Litext 8 | import UIKit 9 | 10 | // MARK: - TextBuilder Callback Types 11 | 12 | extension TextBuilder { 13 | typealias DrawingCallback = (CGContext, CTLine, CGPoint) -> Void 14 | typealias BulletDrawingCallback = (CGContext, CTLine, CGPoint, Int) -> Void 15 | typealias NumberedDrawingCallback = (CGContext, CTLine, CGPoint, Int) -> Void 16 | typealias CheckboxDrawingCallback = (CGContext, CTLine, CGPoint, Bool) -> Void 17 | } 18 | 19 | // MARK: - RenderText 20 | 21 | struct RenderText { 22 | let attributedString: NSAttributedString 23 | let fullWidthAttachments: [LTXAttachment] 24 | } 25 | 26 | // MARK: - String Extension 27 | 28 | extension String { 29 | func deletingSuffix(of characterSet: CharacterSet) -> String { 30 | var result = self 31 | while let lastChar = result.last, characterSet.contains(lastChar.unicodeScalars.first!) { 32 | result.removeLast() 33 | } 34 | return result 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/CFRange+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension CFRange { 9 | var nsRange: NSRange { 10 | NSMakeRange(location == kCFNotFound ? NSNotFound : location, length) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/CTLine+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Foundation 8 | 9 | public extension CTLine { 10 | func glyphRuns() -> [CTRun] { 11 | CTLineGetGlyphRuns(self) as! [CTRun] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/CTRun+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/1/22. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import UIKit 8 | 9 | public extension CTRun { 10 | var attributes: [NSAttributedString.Key: Any] { 11 | (CTRunGetAttributes(self) as NSDictionary as! [String: Any]) 12 | .reduce([:]) { (partialResult: [NSAttributedString.Key: Any], tuple: (key: String, value: Any)) in 13 | var result = partialResult 14 | let attributeName = NSAttributedString.Key(rawValue: tuple.key) 15 | result[attributeName] = tuple.value 16 | return result 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/ImageAttachmentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageAttachmentView.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 5/27/25. 6 | // 7 | 8 | import Litext 9 | import SwiftMath 10 | import UIKit 11 | 12 | class ImageAttachmentView: UIImageView, LTXAttributeStringRepresentable { 13 | let text: String 14 | init(text: String, image: UIImage, theme: MarkdownTheme) { 15 | self.text = text 16 | super.init(frame: .init( 17 | x: 0, 18 | y: 0, 19 | width: image.size.width, 20 | height: image.size.height 21 | )) 22 | self.image = image.withRenderingMode(.alwaysTemplate) 23 | tintColor = theme.colors.body 24 | contentMode = .scaleAspectFit 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder _: NSCoder) { 29 | fatalError() 30 | } 31 | 32 | func attributedStringRepresentation() -> NSAttributedString { 33 | // copy as image 34 | .init(string: "\(text)") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/InlineNode+Render.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineNode+Render.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/3. 6 | // 7 | 8 | import Foundation 9 | import Litext 10 | import MarkdownParser 11 | import SwiftMath 12 | import UIKit 13 | 14 | extension [MarkdownInlineNode] { 15 | func render(theme: MarkdownTheme, renderedContext: RenderContext) -> NSMutableAttributedString { 16 | let result = NSMutableAttributedString() 17 | for node in self { 18 | result.append(node.render(theme: theme, renderedContext: renderedContext)) 19 | } 20 | return result 21 | } 22 | } 23 | 24 | extension MarkdownInlineNode { 25 | func placeImage(theme: MarkdownTheme, image: UIImage, representText: String) -> NSAttributedString { 26 | let attachment: LTXAttachment = .init() 27 | let mathView = ImageAttachmentView(text: representText, image: image, theme: theme) 28 | attachment.view = mathView 29 | attachment.size = mathView.intrinsicContentSize 30 | 31 | return NSAttributedString( 32 | string: LTXReplacementText, 33 | attributes: [ 34 | LTXAttachmentAttributeName: attachment, 35 | kCTRunDelegateAttributeName as NSAttributedString.Key: attachment.runDelegate, 36 | ] 37 | ) 38 | } 39 | 40 | func render(theme: MarkdownTheme, renderedContext: RenderContext) -> NSAttributedString { 41 | assert(Thread.isMainThread) 42 | switch self { 43 | case let .text(string): 44 | return NSMutableAttributedString( 45 | string: string, 46 | attributes: [ 47 | .font: theme.fonts.body, 48 | .foregroundColor: theme.colors.body, 49 | ] 50 | ) 51 | case .softBreak: 52 | return NSAttributedString(string: " ", attributes: [ 53 | .font: theme.fonts.body, 54 | .foregroundColor: theme.colors.body, 55 | ]) 56 | case .lineBreak: 57 | return NSAttributedString(string: "\n", attributes: [ 58 | .font: theme.fonts.body, 59 | .foregroundColor: theme.colors.body, 60 | ]) 61 | case let .code(string): 62 | if let preRendered = renderedContext[string] { 63 | if let image = preRendered.image { 64 | return placeImage(theme: theme, image: image, representText: preRendered.text) 65 | } else { 66 | return NSAttributedString( 67 | string: preRendered.text, 68 | attributes: [ 69 | .font: theme.fonts.codeInline, 70 | .foregroundColor: theme.colors.code, 71 | .backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05), 72 | ] 73 | ) 74 | } 75 | } 76 | return NSAttributedString( 77 | string: "\(string)", 78 | attributes: [ 79 | .font: theme.fonts.codeInline, 80 | .foregroundColor: theme.colors.code, 81 | .backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05), 82 | ] 83 | ) 84 | case let .html(content): 85 | return NSAttributedString( 86 | string: "\(content)", 87 | attributes: [ 88 | .font: theme.fonts.codeInline, 89 | .foregroundColor: theme.colors.code, 90 | .backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05), 91 | ] 92 | ) 93 | case let .emphasis(children): 94 | let ans = NSMutableAttributedString() 95 | children.map { $0.render(theme: theme, renderedContext: renderedContext) }.forEach { ans.append($0) } 96 | ans.addAttributes( 97 | [ 98 | .underlineStyle: NSUnderlineStyle.thick.rawValue, 99 | .underlineColor: theme.colors.emphasis, 100 | ], 101 | range: NSRange(location: 0, length: ans.length) 102 | ) 103 | return ans 104 | case let .strong(children): 105 | let ans = NSMutableAttributedString() 106 | children.map { $0.render(theme: theme, renderedContext: renderedContext) }.forEach { ans.append($0) } 107 | ans.addAttributes( 108 | [.font: theme.fonts.bold], 109 | range: NSRange(location: 0, length: ans.length) 110 | ) 111 | return ans 112 | case let .strikethrough(children): 113 | let ans = NSMutableAttributedString() 114 | children.map { $0.render(theme: theme, renderedContext: renderedContext) }.forEach { ans.append($0) } 115 | ans.addAttributes( 116 | [.strikethroughStyle: NSUnderlineStyle.thick.rawValue], 117 | range: NSRange(location: 0, length: ans.length) 118 | ) 119 | return ans 120 | case let .link(destination, children): 121 | let ans = NSMutableAttributedString() 122 | children.map { $0.render(theme: theme, renderedContext: renderedContext) }.forEach { ans.append($0) } 123 | ans.addAttributes( 124 | [ 125 | .link: destination, 126 | .foregroundColor: theme.colors.highlight, 127 | ], 128 | range: NSRange(location: 0, length: ans.length) 129 | ) 130 | return ans 131 | case let .image(source, _): // children => alternative text can be ignored? 132 | return NSAttributedString( 133 | string: source, 134 | attributes: [ 135 | .link: source, 136 | .font: theme.fonts.body, 137 | .foregroundColor: theme.colors.body, 138 | ] 139 | ) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/LTXAttachment+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXAttachment+Extension.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 3/27/25. 6 | // 7 | 8 | import Foundation 9 | import Litext 10 | 11 | private class LTXHolderAttachment: LTXAttachment { 12 | let attrString: NSAttributedString 13 | init(attrString: NSAttributedString) { 14 | self.attrString = attrString 15 | super.init() 16 | } 17 | 18 | override func attributedStringRepresentation() -> NSAttributedString { 19 | attrString 20 | } 21 | } 22 | 23 | extension LTXAttachment { 24 | static func hold(attrString: NSAttributedString) -> LTXAttachment { 25 | LTXHolderAttachment(attrString: attrString) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/MathRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MathRenderer.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 5/26/25. 6 | // 7 | 8 | import Foundation 9 | import LRUCache 10 | import SwiftMath 11 | import UIKit 12 | 13 | public enum MathRenderer { 14 | static let renderCache = LRUCache(countLimit: 256) 15 | 16 | public static func renderToImage( 17 | latex: String, 18 | fontSize: CGFloat = 16, 19 | textColor: UIColor = .black 20 | ) -> UIImage? { 21 | if let cachedImage = renderCache.value(forKey: latex) { 22 | return cachedImage 23 | } 24 | 25 | let mathImage = MTMathImage( 26 | latex: latex, 27 | fontSize: fontSize, 28 | textColor: textColor, 29 | labelMode: .text 30 | ) 31 | let (error, image) = mathImage.asImage() 32 | guard error == nil, let image else { return nil } 33 | renderCache.setValue(image, forKey: latex) 34 | 35 | return image 36 | } 37 | } 38 | 39 | // MARK: - String Extension 40 | 41 | private extension String { 42 | func substring(with range: NSRange) -> String? { 43 | guard let swiftRange = Range(range, in: self) else { return nil } 44 | return String(self[swiftRange]) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/NSAttributedString+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Extension.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 1/23/25. 6 | // 7 | 8 | import CoreText 9 | import Foundation 10 | import Litext 11 | 12 | public extension NSAttributedString.Key { 13 | @inline(__always) static let coreTextRunDelegate = NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/RenderedItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RenderedItem.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 6/3/25. 6 | // 7 | 8 | import UIKit 9 | 10 | public typealias RenderContext = [String: RenderedItem] 11 | 12 | public struct RenderedItem { 13 | public let image: UIImage? 14 | public let text: String 15 | 16 | public init(image: UIImage?, text: String) { 17 | self.image = image 18 | self.text = text 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/UIColor+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Extension.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/7. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | convenience init(light: UIColor, dark: UIColor) { 12 | if #available(iOS 13.0, tvOS 13.0, *) { 13 | self.init(dynamicProvider: { $0.userInterfaceStyle == .dark ? dark : light }) 14 | } else { 15 | self.init(cgColor: light.cgColor) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/MarkdownView/Supplements/UIFont+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont+Extension.swift 3 | // MarkdownView 4 | // 5 | // Created by 秋星桥 on 2025/1/3. 6 | // 7 | 8 | import UIKit 9 | 10 | public extension UIFont { 11 | var bold: UIFont { 12 | UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitBold)!, size: 0) 13 | } 14 | 15 | var italic: UIFont { 16 | UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitItalic)!, size: 0) 17 | } 18 | 19 | var monospaced: UIFont { 20 | let settings = [[ 21 | UIFontDescriptor.FeatureKey.featureIdentifier: kNumberSpacingType, 22 | UIFontDescriptor.FeatureKey.typeIdentifier: kMonospacedNumbersSelector, 23 | ]] 24 | 25 | let attributes = [UIFontDescriptor.AttributeName.featureSettings: settings] 26 | let newDescriptor = fontDescriptor.addingAttributes(attributes) 27 | return UIFont(descriptor: newDescriptor, size: 0) 28 | } 29 | } 30 | --------------------------------------------------------------------------------