From 0ef12148e016344844ffe33d0f043758bcd9c36b Mon Sep 17 00:00:00 2001 From: stack Date: Mon, 11 May 2026 16:22:02 +0800 Subject: [PATCH 01/27] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 4a185c4..ac278ea 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ [![SPM](https://img.shields.io/badge/SPM-supported-brightgreen?style=flat)](https://github.com/i-stack/STBaseProject) [![iOS](https://img.shields.io/badge/iOS-16.0%2B-blue?style=flat)](https://github.com/i-stack/STBaseProject) [![Xcode](https://img.shields.io/badge/Xcode-15%2B-147EFB?style=flat)](https://developer.apple.com/xcode/) -[![Stars](https://img.shields.io/github/stars/i-stack/STBaseProject?style=flat)](https://github.com/i-stack/STBaseProject/stargazers) STBaseProject 是一个功能强大的 iOS 基础组件库,提供了丰富的 UI 组件和工具类,帮助开发者快速构建高质量的 iOS 应用。 From bc221ea413da89133da50dbbc5452a8e9abe604f Mon Sep 17 00:00:00 2001 From: stack Date: Tue, 12 May 2026 09:30:03 +0800 Subject: [PATCH 02/27] Update .gitignore and README for CocoaPods integration - Added Example/Pods/ to .gitignore to prevent local CocoaPods files from being tracked. - Updated README to include instructions for setting up the local demo with CocoaPods, enhancing clarity for new users. --- .github/workflows/example-tests.yml | 147 + .github/workflows/swift.yml | 20 +- .gitignore | 3 + Example/.gitignore | 15 + Example/Podfile | 36 + Example/Podfile.lock | 3 + .../project.pbxproj | 772 +++++ .../contents.xcworkspacedata | 7 + .../contents.xcworkspacedata | 10 + .../AppDelegate/AppDelegate.swift | 113 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../AppDelegate/Base.lproj/Main.storyboard | 24 + .../AppDelegate/SceneDelegate.swift | 25 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../BaseViewController.swift | 85 + Example/STBaseProjectExample/Info.plist | 25 + .../Models/ViewControllerModel.swift | 15 + .../STBaseProjectExample/Resources/data1.txt | 93 + .../STBaseProjectExample/Resources/data2.txt | 28 + .../STBaseProjectExample/Resources/data3.txt | 46 + .../STBaseProjectExample/ViewController.swift | 73 + .../STBaseProjectExample/ViewController.xib | 47 + .../STBtnTestViewController.swift | 418 +++ .../ViewControllers/STHudViewController.swift | 271 ++ .../ViewControllers/STHudViewController.xib | 29 + .../STLogAndHUDTestViewController.swift | 59 + .../ViewControllers/STLogViewController.swift | 180 ++ ...TMarkdownStreamingTestViewController.swift | 130 + .../STTabBarTestViewController.swift | 64 + .../STTextControlsTestViewController.swift | 102 + .../STTimerTestViewController.swift | 267 ++ .../STToolsManualTestViewController.swift | 213 ++ .../STViewTestViewController.swift | 64 + .../ViewModels/ViewControllerViewModel.swift | 14 + .../Views/STMyScrollableView.swift | 22 + .../STBaseProjectExampleTests.swift | 11 + .../STBaseProjectTests/STBaseModelTests.swift | 692 +++++ .../STBaseViewModelNetworkTests.swift | 510 ++++ .../STContactsTests.swift | 186 ++ .../STDeviceAdapterTests.swift | 384 +++ .../STHTTPDebugLogTests.swift | 52 + .../STLocationManagerTests.swift | 402 +++ ...rkdownASTAndRenderASTExhaustiveTests.swift | 361 +++ .../STMarkdownCoreContractsTests.swift | 284 ++ .../STMarkdownFixTests.swift | 375 +++ ...MarkdownParsingEscapeAndDisplayTests.swift | 1169 ++++++++ .../STMarkdownPipelineTests.swift | 2550 +++++++++++++++++ ...kdownStructureParserASTContractTests.swift | 519 ++++ ...reParserParseAndRenderIntegrityTests.swift | 401 +++ .../STMarkdownUIViewTests.swift | 89 + .../STMediaTests.swift | 592 ++++ .../STNetworkStreamAndResumeTests.swift | 286 ++ .../STSecurityTests.swift | 420 +++ .../STToolsCoreTests.swift | 122 + .../STBaseProjectExampleUITests.swift | 43 + ...BaseProjectExampleUITestsLaunchTests.swift | 35 + README.md | 2 + .../contents.xcworkspacedata | 10 + .../xcshareddata/swiftpm/Package.resolved | 31 + 61 files changed, 13005 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/example-tests.yml create mode 100644 Example/.gitignore create mode 100644 Example/Podfile create mode 100644 Example/Podfile.lock create mode 100644 Example/STBaseProjectExample.xcodeproj/project.pbxproj create mode 100644 Example/STBaseProjectExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/STBaseProjectExample.xcworkspace/contents.xcworkspacedata create mode 100644 Example/STBaseProjectExample/AppDelegate/AppDelegate.swift create mode 100644 Example/STBaseProjectExample/AppDelegate/Base.lproj/LaunchScreen.storyboard create mode 100644 Example/STBaseProjectExample/AppDelegate/Base.lproj/Main.storyboard create mode 100644 Example/STBaseProjectExample/AppDelegate/SceneDelegate.swift create mode 100644 Example/STBaseProjectExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/STBaseProjectExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/STBaseProjectExample/Assets.xcassets/Contents.json create mode 100644 Example/STBaseProjectExample/BaseViewController.swift create mode 100644 Example/STBaseProjectExample/Info.plist create mode 100644 Example/STBaseProjectExample/Models/ViewControllerModel.swift create mode 100644 Example/STBaseProjectExample/Resources/data1.txt create mode 100644 Example/STBaseProjectExample/Resources/data2.txt create mode 100644 Example/STBaseProjectExample/Resources/data3.txt create mode 100644 Example/STBaseProjectExample/ViewController.swift create mode 100644 Example/STBaseProjectExample/ViewController.xib create mode 100644 Example/STBaseProjectExample/ViewControllers/STBtnTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STHudViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STHudViewController.xib create mode 100644 Example/STBaseProjectExample/ViewControllers/STLogAndHUDTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STLogViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STTabBarTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STTextControlsTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STTimerTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STToolsManualTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STViewTestViewController.swift create mode 100644 Example/STBaseProjectExample/ViewModels/ViewControllerViewModel.swift create mode 100644 Example/STBaseProjectExample/Views/STMyScrollableView.swift create mode 100644 Example/STBaseProjectExampleTests/STBaseProjectExampleTests.swift create mode 100644 Example/STBaseProjectExampleTests/STBaseProjectTests/STBaseModelTests.swift create mode 100644 Example/STBaseProjectExampleTests/STBaseViewModelNetworkTests.swift create mode 100644 Example/STBaseProjectExampleTests/STContactsTests.swift create mode 100644 Example/STBaseProjectExampleTests/STDeviceAdapterTests.swift create mode 100644 Example/STBaseProjectExampleTests/STHTTPDebugLogTests.swift create mode 100644 Example/STBaseProjectExampleTests/STLocationTests/STLocationManagerTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownFixTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownStructureParserASTContractTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMediaTests.swift create mode 100644 Example/STBaseProjectExampleTests/STNetworkStreamAndResumeTests.swift create mode 100644 Example/STBaseProjectExampleTests/STSecurityTests.swift create mode 100644 Example/STBaseProjectExampleTests/STToolsCoreTests.swift create mode 100644 Example/STBaseProjectExampleUITests/STBaseProjectExampleUITests.swift create mode 100644 Example/STBaseProjectExampleUITests/STBaseProjectExampleUITestsLaunchTests.swift create mode 100644 STBaseProject.xcworkspace/contents.xcworkspacedata create mode 100644 STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/.github/workflows/example-tests.yml b/.github/workflows/example-tests.yml new file mode 100644 index 0000000..30efc5c --- /dev/null +++ b/.github/workflows/example-tests.yml @@ -0,0 +1,147 @@ +# Example 集成测试:单仓内 Example + 根目录 STBaseProject.xcworkspace(含 Pods)。 +name: Example iOS Tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +concurrency: + group: example-ios-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-15 + timeout-minutes: 45 + + env: + WORKSPACE: STBaseProject.xcworkspace + SCHEME: STBaseProjectExample + RESULT: build/result.xcresult + + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: bash "${GITHUB_WORKSPACE}/.github/select_xcode_ci.sh" + + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: | + Example/Pods + ~/.cocoapods + key: pods-${{ runner.os }}-${{ hashFiles('Example/Podfile.lock') }} + restore-keys: | + pods-${{ runner.os }}- + + - name: Install CocoaPods + working-directory: Example + run: | + gem install cocoapods -v 1.16.2 --no-document --user-install + GEM_USER_BIN="$(gem env user_gemhome)/bin" + export PATH="$GEM_USER_BIN:$PATH" + echo "$GEM_USER_BIN" >> "$GITHUB_PATH" + pod --version + pod install --repo-update + + - name: Pick iOS Simulator + id: sim + shell: bash + run: | + set -euo pipefail + xcrun simctl list devices available -j > /tmp/devices.json + UDID="$(python3 <<'PY' + import json, sys + with open("/tmp/devices.json") as f: + data = json.load(f)["devices"] + for runtime in sorted([k for k in data if "iOS" in k], reverse=True): + for dv in data[runtime]: + if dv.get("isAvailable") and "iPhone" in dv["name"]: + print(dv["udid"]); sys.exit(0) + sys.exit("No available iPhone simulator found") + PY + )" + echo "Picked simulator: $UDID" + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + + - name: Resolve SPM dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" + + - name: Run tests + shell: bash + run: | + set -o pipefail + mkdir -p build + xcodebuild test \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -resultBundlePath "$RESULT" \ + -parallel-testing-enabled YES \ + -test-iterations 2 \ + -retry-tests-on-failure \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=YES \ + DEVELOPMENT_TEAM="" \ + PROVISIONING_PROFILE_SPECIFIER="" \ + | tee build/xcodebuild.log + + - name: Test summary + if: always() + shell: bash + run: | + if [ ! -d "$RESULT" ]; then + echo "::error::No xcresult bundle produced"; exit 1 + fi + xcrun xcresulttool get test-results summary \ + --path "$RESULT" --format json > build/summary.json + python3 - <<'PY' + import json, os + with open("build/summary.json") as f: + d = json.load(f) + total = d["passedTests"] + d["failedTests"] + d["skippedTests"] + print(f"Total: {total}") + print(f"Passed: {d['passedTests']}") + print(f"Failed: {d['failedTests']}") + print(f"Skipped: {d['skippedTests']}") + print(f"Result: {d['result']}") + step = os.environ.get("GITHUB_STEP_SUMMARY") + if step: + with open(step, "a") as f: + f.write("## Example iOS Test Summary\n\n") + f.write(f"| Metric | Value |\n|---|---|\n") + f.write(f"| Total | {total} |\n") + f.write(f"| Passed | {d['passedTests']} |\n") + f.write(f"| Failed | {d['failedTests']} |\n") + f.write(f"| Skipped | {d['skippedTests']} |\n") + f.write(f"| Result | **{d['result']}** |\n") + for tf in d.get("testFailures", [])[:50]: + f.write(f"\n- ❌ `{tf.get('testIdentifierString','?')}` — {tf.get('failureText','')}\n") + PY + + - name: Upload xcresult on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcresult-${{ github.run_id }} + path: build/result.xcresult + retention-days: 14 + + - name: Upload xcodebuild log + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log-${{ github.run_id }} + path: build/xcodebuild.log + retention-days: 7 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 2d48628..81867f3 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,5 +1,5 @@ -# 库仓 CI:只做静态检查 + SPM 构建烟囱测试。 -# 业务测试(466 单测 + UI 测试)在 i-stack/STBaseProjectExample 仓库的 tests.yml 中跑。 +# 库仓 CI:静态检查 + SPM 构建烟囱测试。 +# Example 全量单测 / UI 测在同仓 `.github/workflows/example-tests.yml`(根目录 `STBaseProject.xcworkspace`)。 name: Swift @@ -44,19 +44,3 @@ jobs: -destination 'generic/platform=iOS Simulator' \ build - # 库 main 分支有更新时,触发 STBaseProjectExample 仓重新跑全量测试。 - # 仅在 push 到 main 时触发,避免 PR 阶段把下游测试也拉满。 - # notify-example: - # needs: build - # if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # runs-on: ubuntu-latest - # steps: - # - name: Trigger STBaseProjectExample tests - # uses: peter-evans/repository-dispatch@v3 - # with: - # # 需要在仓库 Settings → Secrets and variables → Actions 中配置 EXAMPLE_REPO_PAT - # # PAT 至少需要 repo 写权限,用于触发 i-stack/STBaseProjectExample 的 repository_dispatch - # token: ${{ secrets.EXAMPLE_REPO_PAT }} - # repository: i-stack/STBaseProjectExample - # event-type: stbaseproject-updated - # client-payload: '{"sha": "${{ github.sha }}", "ref": "${{ github.ref }}"}' diff --git a/.gitignore b/.gitignore index 299ea5f..01470e3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ DerivedData/ # Swift Package Manager local metadata .swiftpm/ +# CocoaPods(Example Demo) +Example/Pods/ + # Artifacts *.hmap *.ipa diff --git a/Example/.gitignore b/Example/.gitignore new file mode 100644 index 0000000..9720387 --- /dev/null +++ b/Example/.gitignore @@ -0,0 +1,15 @@ +# macOS +.DS_Store + +# Xcode user data +**/xcuserdata/ +**/*.xcuserstate +**/xcshareddata/WorkspaceSettings.xcsettings +**/xcshareddata/swiftpm/Package.resolved + +# Build outputs +DerivedData/ +build/ + +# CocoaPods +Pods/ diff --git a/Example/Podfile b/Example/Podfile new file mode 100644 index 0000000..f218d70 --- /dev/null +++ b/Example/Podfile @@ -0,0 +1,36 @@ +# Demo 与 CocoaPods 脚手架:在 `Example/` 执行 `pod install` 后,请打开仓库根目录的 `STBaseProject.xcworkspace`。 +platform :ios, '16.0' + +target 'STBaseProjectExample' do + use_frameworks! + + # Mirrors SPM `Package.swift` (Markdown + SwiftMath); STMarkdown subspec pulls STBaseProject core transitively. +# pod 'STBaseProject/STMarkdown', path: '..' + + target 'STBaseProjectExampleTests' do + inherit! :search_paths + # Pods for testing + end + + target 'STBaseProjectExampleUITests' do + # Pods for testing + end + +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + deployment = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if deployment && deployment.to_f < 12.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + end + if target.name.include?('swift-markdown') + existing = config.build_settings['OTHER_CFLAGS'] || '$(inherited)' + unless existing.to_s.include?('incomplete-umbrella') + config.build_settings['OTHER_CFLAGS'] = "#{existing} -Wno-incomplete-umbrella" + end + end + end + end +end diff --git a/Example/Podfile.lock b/Example/Podfile.lock new file mode 100644 index 0000000..10ebfcf --- /dev/null +++ b/Example/Podfile.lock @@ -0,0 +1,3 @@ +PODFILE CHECKSUM: f8580ce41955d5ec8be9b912244073e2ab2ef02a + +COCOAPODS: 1.16.2 diff --git a/Example/STBaseProjectExample.xcodeproj/project.pbxproj b/Example/STBaseProjectExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6037543 --- /dev/null +++ b/Example/STBaseProjectExample.xcodeproj/project.pbxproj @@ -0,0 +1,772 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 24E42D55FA269510FCC339D8 /* Pods_STBaseProjectExample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9585ED53167BB872ED6FAED0 /* Pods_STBaseProjectExample.framework */; }; + 3BC33DCE6667208FB2C173D7 /* Pods_STBaseProjectExampleTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0283393535FD1ACC2B18EBC8 /* Pods_STBaseProjectExampleTests.framework */; }; + CAAB0100000000000000AA01 /* STContacts in Frameworks */ = {isa = PBXBuildFile; productRef = CAAB0100000000000000AA00 /* STContacts */; }; + CAAB0200000000000000AA01 /* STMedia in Frameworks */ = {isa = PBXBuildFile; productRef = CAAB0200000000000000AA00 /* STMedia */; }; + CAAB0300000000000000AA01 /* STLocation in Frameworks */ = {isa = PBXBuildFile; productRef = CAAB0300000000000000AA00 /* STLocation */; }; + CAAB0400000000000000AA01 /* STBaseProject in Frameworks */ = {isa = PBXBuildFile; productRef = CAAB0400000000000000AA00 /* STBaseProject */; }; + E4EEED8B2FA0B5D300730900 /* STBaseProject in Frameworks */ = {isa = PBXBuildFile; productRef = E4EEED8A2FA0B5D300730900 /* STBaseProject */; }; + F0B670DF2C171EB57F86C428 /* Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2ECB33F317DD5400A18A89B8 /* Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E4EEECEA2FA0B1AD00730900 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E4EEECCB2FA0B1AD00730900 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E4EEECD22FA0B1AD00730900; + remoteInfo = STBaseProjectExample; + }; + E4EEECF42FA0B1AD00730900 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E4EEECCB2FA0B1AD00730900 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E4EEECD22FA0B1AD00730900; + remoteInfo = STBaseProjectExample; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0283393535FD1ACC2B18EBC8 /* Pods_STBaseProjectExampleTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_STBaseProjectExampleTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2ECB33F317DD5400A18A89B8 /* Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 51B6EBC982779829B921D155 /* Pods-STBaseProjectExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExample.release.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExample/Pods-STBaseProjectExample.release.xcconfig"; sourceTree = ""; }; + 71A69D3A328698A5A101D8A2 /* Pods-STBaseProjectExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExampleTests.release.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExampleTests/Pods-STBaseProjectExampleTests.release.xcconfig"; sourceTree = ""; }; + 88AD10DB079D2C69D4489093 /* Pods-STBaseProjectExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExampleTests/Pods-STBaseProjectExampleTests.debug.xcconfig"; sourceTree = ""; }; + 9392E095265ABE853F0112D6 /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExample-STBaseProjectExampleUITests.debug.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExample-STBaseProjectExampleUITests/Pods-STBaseProjectExample-STBaseProjectExampleUITests.debug.xcconfig"; sourceTree = ""; }; + 9585ED53167BB872ED6FAED0 /* Pods_STBaseProjectExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_STBaseProjectExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DC62E94F4EEA49C7CAD4411A /* Pods-STBaseProjectExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExample.debug.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExample/Pods-STBaseProjectExample.debug.xcconfig"; sourceTree = ""; }; + E4EEECD32FA0B1AD00730900 /* STBaseProjectExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = STBaseProjectExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E4EEECE92FA0B1AD00730900 /* STBaseProjectExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = STBaseProjectExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E4EEECF32FA0B1AD00730900 /* STBaseProjectExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = STBaseProjectExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F5ED43C14EFB7931CEAB5F3B /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-STBaseProjectExample-STBaseProjectExampleUITests.release.xcconfig"; path = "Target Support Files/Pods-STBaseProjectExample-STBaseProjectExampleUITests/Pods-STBaseProjectExample-STBaseProjectExampleUITests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E4EEECFB2FA0B1AD00730900 /* Exceptions for "STBaseProjectExample" folder in "STBaseProjectExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E4EEECD22FA0B1AD00730900 /* STBaseProjectExample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E4EEECD52FA0B1AD00730900 /* STBaseProjectExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + E4EEECFB2FA0B1AD00730900 /* Exceptions for "STBaseProjectExample" folder in "STBaseProjectExample" target */, + ); + path = STBaseProjectExample; + sourceTree = ""; + }; + E4EEECEC2FA0B1AD00730900 /* STBaseProjectExampleTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = STBaseProjectExampleTests; + sourceTree = ""; + }; + E4EEECF62FA0B1AD00730900 /* STBaseProjectExampleUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = STBaseProjectExampleUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E4EEECD02FA0B1AD00730900 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E4EEED8B2FA0B5D300730900 /* STBaseProject in Frameworks */, + 24E42D55FA269510FCC339D8 /* Pods_STBaseProjectExample.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECE62FA0B1AD00730900 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAAB0100000000000000AA01 /* STContacts in Frameworks */, + CAAB0200000000000000AA01 /* STMedia in Frameworks */, + CAAB0300000000000000AA01 /* STLocation in Frameworks */, + CAAB0400000000000000AA01 /* STBaseProject in Frameworks */, + 3BC33DCE6667208FB2C173D7 /* Pods_STBaseProjectExampleTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECF02FA0B1AD00730900 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F0B670DF2C171EB57F86C428 /* Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 19FCEE42B9FAD6357AF9B126 /* Pods */ = { + isa = PBXGroup; + children = ( + DC62E94F4EEA49C7CAD4411A /* Pods-STBaseProjectExample.debug.xcconfig */, + 51B6EBC982779829B921D155 /* Pods-STBaseProjectExample.release.xcconfig */, + 9392E095265ABE853F0112D6 /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.debug.xcconfig */, + F5ED43C14EFB7931CEAB5F3B /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.release.xcconfig */, + 88AD10DB079D2C69D4489093 /* Pods-STBaseProjectExampleTests.debug.xcconfig */, + 71A69D3A328698A5A101D8A2 /* Pods-STBaseProjectExampleTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + E4EEECCA2FA0B1AD00730900 = { + isa = PBXGroup; + children = ( + E4EEECD52FA0B1AD00730900 /* STBaseProjectExample */, + E4EEECEC2FA0B1AD00730900 /* STBaseProjectExampleTests */, + E4EEECF62FA0B1AD00730900 /* STBaseProjectExampleUITests */, + E4EEECD42FA0B1AD00730900 /* Products */, + 19FCEE42B9FAD6357AF9B126 /* Pods */, + EF85DB02CCB1E0993DCB46D9 /* Frameworks */, + ); + sourceTree = ""; + }; + E4EEECD42FA0B1AD00730900 /* Products */ = { + isa = PBXGroup; + children = ( + E4EEECD32FA0B1AD00730900 /* STBaseProjectExample.app */, + E4EEECE92FA0B1AD00730900 /* STBaseProjectExampleTests.xctest */, + E4EEECF32FA0B1AD00730900 /* STBaseProjectExampleUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + EF85DB02CCB1E0993DCB46D9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9585ED53167BB872ED6FAED0 /* Pods_STBaseProjectExample.framework */, + 2ECB33F317DD5400A18A89B8 /* Pods_STBaseProjectExample_STBaseProjectExampleUITests.framework */, + 0283393535FD1ACC2B18EBC8 /* Pods_STBaseProjectExampleTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E4EEECD22FA0B1AD00730900 /* STBaseProjectExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = E4EEECFC2FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExample" */; + buildPhases = ( + FC602DE85749453F6C6ED923 /* [CP] Check Pods Manifest.lock */, + E4EEECCF2FA0B1AD00730900 /* Sources */, + E4EEECD02FA0B1AD00730900 /* Frameworks */, + E4EEECD12FA0B1AD00730900 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E4EEECD52FA0B1AD00730900 /* STBaseProjectExample */, + ); + name = STBaseProjectExample; + packageProductDependencies = ( + E4EEED8A2FA0B5D300730900 /* STBaseProject */, + ); + productName = STBaseProjectExample; + productReference = E4EEECD32FA0B1AD00730900 /* STBaseProjectExample.app */; + productType = "com.apple.product-type.application"; + }; + E4EEECE82FA0B1AD00730900 /* STBaseProjectExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E4EEED012FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExampleTests" */; + buildPhases = ( + 3E337D8B1794D92F27AE3BB7 /* [CP] Check Pods Manifest.lock */, + E4EEECE52FA0B1AD00730900 /* Sources */, + E4EEECE62FA0B1AD00730900 /* Frameworks */, + E4EEECE72FA0B1AD00730900 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E4EEECEB2FA0B1AD00730900 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E4EEECEC2FA0B1AD00730900 /* STBaseProjectExampleTests */, + ); + name = STBaseProjectExampleTests; + packageProductDependencies = ( + CAAB0100000000000000AA00 /* STContacts */, + CAAB0200000000000000AA00 /* STMedia */, + CAAB0300000000000000AA00 /* STLocation */, + CAAB0400000000000000AA00 /* STBaseProject */, + ); + productName = STBaseProjectExampleTests; + productReference = E4EEECE92FA0B1AD00730900 /* STBaseProjectExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + E4EEECF22FA0B1AD00730900 /* STBaseProjectExampleUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E4EEED042FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExampleUITests" */; + buildPhases = ( + B71AD6F9190827153D4A40DE /* [CP] Check Pods Manifest.lock */, + E4EEECEF2FA0B1AD00730900 /* Sources */, + E4EEECF02FA0B1AD00730900 /* Frameworks */, + E4EEECF12FA0B1AD00730900 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E4EEECF52FA0B1AD00730900 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E4EEECF62FA0B1AD00730900 /* STBaseProjectExampleUITests */, + ); + name = STBaseProjectExampleUITests; + productName = STBaseProjectExampleUITests; + productReference = E4EEECF32FA0B1AD00730900 /* STBaseProjectExampleUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E4EEECCB2FA0B1AD00730900 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + E4EEECD22FA0B1AD00730900 = { + CreatedOnToolsVersion = 26.4.1; + }; + E4EEECE82FA0B1AD00730900 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E4EEECD22FA0B1AD00730900; + }; + E4EEECF22FA0B1AD00730900 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E4EEECD22FA0B1AD00730900; + }; + }; + }; + buildConfigurationList = E4EEECCE2FA0B1AD00730900 /* Build configuration list for PBXProject "STBaseProjectExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E4EEECCA2FA0B1AD00730900; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = E4EEECD42FA0B1AD00730900 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E4EEECD22FA0B1AD00730900 /* STBaseProjectExample */, + E4EEECE82FA0B1AD00730900 /* STBaseProjectExampleTests */, + E4EEECF22FA0B1AD00730900 /* STBaseProjectExampleUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E4EEECD12FA0B1AD00730900 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECE72FA0B1AD00730900 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECF12FA0B1AD00730900 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3E337D8B1794D92F27AE3BB7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-STBaseProjectExampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B71AD6F9190827153D4A40DE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-STBaseProjectExample-STBaseProjectExampleUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FC602DE85749453F6C6ED923 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-STBaseProjectExample-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E4EEECCF2FA0B1AD00730900 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECE52FA0B1AD00730900 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E4EEECEF2FA0B1AD00730900 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E4EEECEB2FA0B1AD00730900 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E4EEECD22FA0B1AD00730900 /* STBaseProjectExample */; + targetProxy = E4EEECEA2FA0B1AD00730900 /* PBXContainerItemProxy */; + }; + E4EEECF52FA0B1AD00730900 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E4EEECD22FA0B1AD00730900 /* STBaseProjectExample */; + targetProxy = E4EEECF42FA0B1AD00730900 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E4EEECFD2FA0B1AD00730900 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DC62E94F4EEA49C7CAD4411A /* Pods-STBaseProjectExample.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = STBaseProjectExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E4EEECFE2FA0B1AD00730900 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 51B6EBC982779829B921D155 /* Pods-STBaseProjectExample.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = STBaseProjectExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E4EEECFF2FA0B1AD00730900 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E4EEED002FA0B1AD00730900 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E4EEED022FA0B1AD00730900 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 88AD10DB079D2C69D4489093 /* Pods-STBaseProjectExampleTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/STBaseProjectExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/STBaseProjectExample"; + }; + name = Debug; + }; + E4EEED032FA0B1AD00730900 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 71A69D3A328698A5A101D8A2 /* Pods-STBaseProjectExampleTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/STBaseProjectExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/STBaseProjectExample"; + }; + name = Release; + }; + E4EEED052FA0B1AD00730900 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9392E095265ABE853F0112D6 /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExampleUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = STBaseProjectExample; + }; + name = Debug; + }; + E4EEED062FA0B1AD00730900 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F5ED43C14EFB7931CEAB5F3B /* Pods-STBaseProjectExample-STBaseProjectExampleUITests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = S9FFQU5J4Q; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xunhe.STBaseProjectExampleUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = STBaseProjectExample; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E4EEECCE2FA0B1AD00730900 /* Build configuration list for PBXProject "STBaseProjectExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E4EEECFF2FA0B1AD00730900 /* Debug */, + E4EEED002FA0B1AD00730900 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E4EEECFC2FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E4EEECFD2FA0B1AD00730900 /* Debug */, + E4EEECFE2FA0B1AD00730900 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E4EEED012FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E4EEED022FA0B1AD00730900 /* Debug */, + E4EEED032FA0B1AD00730900 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E4EEED042FA0B1AD00730900 /* Build configuration list for PBXNativeTarget "STBaseProjectExampleUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E4EEED052FA0B1AD00730900 /* Debug */, + E4EEED062FA0B1AD00730900 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CAAB0100000000000000AA00 /* STContacts */ = { + isa = XCSwiftPackageProductDependency; + package = E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */; + productName = STContacts; + }; + CAAB0200000000000000AA00 /* STMedia */ = { + isa = XCSwiftPackageProductDependency; + package = E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */; + productName = STMedia; + }; + CAAB0300000000000000AA00 /* STLocation */ = { + isa = XCSwiftPackageProductDependency; + package = E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */; + productName = STLocation; + }; + CAAB0400000000000000AA00 /* STBaseProject */ = { + isa = XCSwiftPackageProductDependency; + package = E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */; + productName = STBaseProject; + }; + E4EEED8A2FA0B5D300730900 /* STBaseProject */ = { + isa = XCSwiftPackageProductDependency; + package = E4EEED892FA0B5D300730900 /* XCLocalSwiftPackageReference ".." */; + productName = STBaseProject; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E4EEECCB2FA0B1AD00730900 /* Project object */; +} diff --git a/Example/STBaseProjectExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/STBaseProjectExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/STBaseProjectExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/STBaseProjectExample.xcworkspace/contents.xcworkspacedata b/Example/STBaseProjectExample.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..0bcbc55 --- /dev/null +++ b/Example/STBaseProjectExample.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Example/STBaseProjectExample/AppDelegate/AppDelegate.swift b/Example/STBaseProjectExample/AppDelegate/AppDelegate.swift new file mode 100644 index 0000000..f90f44a --- /dev/null +++ b/Example/STBaseProjectExample/AppDelegate/AppDelegate.swift @@ -0,0 +1,113 @@ +// +// AppDelegate.swift +// STBaseProject +// +// Created by 寒江孤影 on 05/16/2017. +// + +import UIKit +import STBaseProject + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + STDeviceAdapter.shared.configureNavigationBar(contentHeight: 50) + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + Bundle.st_setCustomLanguage("zh-Hans") + self.configureLogging() + self.configureHUD() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = AppDelegate.makeRootNavigationController() + self.window = window + window.makeKeyAndVisible() + return true + } + + private func configureLogging() { + STLogManager.bootstrap(.init( + minimumLevel: .debug, + persistDefaultLogs: false, + maxFileSize: 2 * 1024 * 1024, + maxArchivedFiles: 5, + retainedLogCountForDisplay: 1500, + cloudTransport: nil, + cloudBatchSize: 20 + )) + + STPersistentLog("日志系统已完成启动", level: .info, metadata: [ + "environment": "example", + "cloudUpload": "disabled" + ]) + + // 需要接入云端上传时,替换为你们自己的接口地址或 requestBuilder。 + // let transport = STURLSessionLogCloudTransport( + // endpoint: URL(string: "https://example.com/api/logs")!, + // headers: ["Authorization": "Bearer "] + // ) + // STLogManager.setCloudTransport(transport) + } + + private func configureHUD() { + STHUD.sharedHUD.defaultIconPosition = .left + var theme = STHUDTheme() +// theme.cornerRadius = 12 +// theme.shadow = .enabled +// theme.backgroundColor = UIColor.black.withAlphaComponent(0.7)//UIColor.color(hex: "#141415").withAlphaComponent(0.7) + theme.textColor = UIColor.white + theme.detailTextColor = UIColor.white.withAlphaComponent(0.7) + theme.labelFont = UIFont.st_systemFont(ofSize: 16, weight: .semibold) + theme.detailLabelFont = UIFont.st_systemFont(ofSize: 16) +// theme.iconSize = CGSize(width: 18, height: 18) +// theme.successIconName = "toastsu" +// theme.successColor = UIColor.white +// theme.errorColor = UIColor.white +// theme.warningColor = UIColor.white + STHUD.sharedHUD.applyTheme(theme) + } + + static func makeRootNavigationController() -> UINavigationController { + let rootViewController = ViewController(nibName: "ViewController", bundle: nil) + return UINavigationController(rootViewController: rootViewController) + } + + @available(iOS 13.0, *) + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + configuration.delegateClass = SceneDelegate.self + return configuration + } + + @available(iOS 13.0, *) + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // No-op for now + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + + } +} diff --git a/Example/STBaseProjectExample/AppDelegate/Base.lproj/LaunchScreen.storyboard b/Example/STBaseProjectExample/AppDelegate/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Example/STBaseProjectExample/AppDelegate/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/STBaseProjectExample/AppDelegate/Base.lproj/Main.storyboard b/Example/STBaseProjectExample/AppDelegate/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/Example/STBaseProjectExample/AppDelegate/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/STBaseProjectExample/AppDelegate/SceneDelegate.swift b/Example/STBaseProjectExample/AppDelegate/SceneDelegate.swift new file mode 100644 index 0000000..2b88d0e --- /dev/null +++ b/Example/STBaseProjectExample/AppDelegate/SceneDelegate.swift @@ -0,0 +1,25 @@ +// +// AppDelegate.swift +// STBaseProject +// +// Created by 寒江孤影 on 05/16/2017. +// Copyright (c) 2019 STBaseProject. All rights reserved. +// + +import UIKit +import STBaseProject + +@available(iOS 13.0, *) +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + window.rootViewController = AppDelegate.makeRootNavigationController() + self.window = window + window.makeKeyAndVisible() + } +} + diff --git a/Example/STBaseProjectExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/STBaseProjectExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/STBaseProjectExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/STBaseProjectExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/STBaseProjectExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Example/STBaseProjectExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/STBaseProjectExample/Assets.xcassets/Contents.json b/Example/STBaseProjectExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/STBaseProjectExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/STBaseProjectExample/BaseViewController.swift b/Example/STBaseProjectExample/BaseViewController.swift new file mode 100644 index 0000000..bb563a5 --- /dev/null +++ b/Example/STBaseProjectExample/BaseViewController.swift @@ -0,0 +1,85 @@ +// +// BaseViewController.swift +// STBaseProjectExample +// +// Created by 寒江孤影 on 2026/4/27. +// + +import UIKit +import STBaseProject + +class BaseViewController: STBaseViewController, UIGestureRecognizerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemGroupedBackground + self.st_setNavigationBarColor(.systemBackground) + self.configureDefaultBackButtonIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.updateInteractivePopGestureState() + } + + @objc override func onLeftBtnTap() { + if let navigationController = self.navigationController, + navigationController.viewControllers.first !== self { + navigationController.popViewController(animated: true) + return + } + + if self.presentingViewController != nil { + self.dismiss(animated: true) + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return self.shouldEnableInteractivePopGesture + } + + private func configureDefaultBackButtonIfNeeded() { + guard self.shouldShowDefaultBackButton else { return } + self.st_showNavBtnType(type: .showLeftBtn) + self.leftBtn.setImage(UIImage(systemName: "chevron.left"), for: .normal) + } + + private func updateInteractivePopGestureState() { + guard let interactivePopGestureRecognizer = self.navigationController?.interactivePopGestureRecognizer else { + return + } + + interactivePopGestureRecognizer.isEnabled = self.shouldEnableInteractivePopGesture + interactivePopGestureRecognizer.delegate = self.shouldEnableInteractivePopGesture ? self : nil + } + + private var shouldShowDefaultBackButton: Bool { + if let navigationController = self.navigationController { + return navigationController.viewControllers.first !== self + } + + return self.presentingViewController != nil + } + + private var shouldEnableInteractivePopGesture: Bool { + guard let navigationController = self.navigationController else { + return false + } + + return navigationController.viewControllers.count > 1 + } + + /// 让滚动视图从导航栏下方穿过,并启用 Liquid Glass 玻璃导航栏。 + /// 调用方需保证 scrollView 顶部钉在 view.topAnchor,而不是 contentTopAnchor。 + func applyLiquidGlassScrollLayout(_ scrollView: UIScrollView) { + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.contentInset.top = self.navBarHeight + scrollView.verticalScrollIndicatorInsets.top = self.navBarHeight + self.view.bringSubviewToFront(self.navigationBarView) + if #available(iOS 26.0, *) { + self.st_enableLiquidGlass() + self.navBarBackgroundColor = .clear + self.st_linkLiquidGlassVisibility(scrollView) + } + } +} diff --git a/Example/STBaseProjectExample/Info.plist b/Example/STBaseProjectExample/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/Example/STBaseProjectExample/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/Example/STBaseProjectExample/Models/ViewControllerModel.swift b/Example/STBaseProjectExample/Models/ViewControllerModel.swift new file mode 100644 index 0000000..c529c9a --- /dev/null +++ b/Example/STBaseProjectExample/Models/ViewControllerModel.swift @@ -0,0 +1,15 @@ +// +// ViewControllerModel.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2023/2/27. +// Copyright © 2023 STBaseProject. All rights reserved. +// + +import UIKit + +struct ViewControllerModel: Codable { + var title: String = "" + var className = "" + var nibName = "" +} diff --git a/Example/STBaseProjectExample/Resources/data1.txt b/Example/STBaseProjectExample/Resources/data1.txt new file mode 100644 index 0000000..4410425 --- /dev/null +++ b/Example/STBaseProjectExample/Resources/data1.txt @@ -0,0 +1,93 @@ +看到你头疼,真的很想帮你缓解这种不适!头疼确实很烦人,但别担心,有很多方法可以尝试缓解。我来分享一些实用建议,希望能帮到你: + +## 🧐 第一步:自我评估(这很重要!) + +1. **疼痛性质:** 是胀痛、跳痛、钝痛、刺痛还是紧箍感?哪个部位最痛(前额、太阳穴、后脑勺、整个头)? +2. **严重程度:** 疼痛有多剧烈?(1-10分,你打几分?) +3. **伴随症状:** 有没有恶心、呕吐、畏光、怕吵、视力模糊、头晕、脖子僵硬、发烧? +4. **诱因:** 最近有没有睡眠不足、压力大、用眼过度(看电脑/手机太久)、没喝水、没吃饭、喝了酒、吃了特定食物、闻到特殊气味、天气变化、女性是否在经期前后? +5. **类型:** 是偶尔一次,还是经常发生?如果是经常性的,有没有规律?(比如[偏头痛]、紧张性头痛等) + +## 🛌 第二步:尝试家庭缓解方法(适用于轻度到中度、没有危险信号的头痛) + +1. **休息:** + * **找个安静、黑暗的环境躺下:** 闭上眼睛,放松身心。光线和噪音往往是头痛的"帮凶",尤其是偏头痛时。 + * **短暂小睡(15-30分钟):** 有时候小睡一会儿就能让紧绷的神经放松下来,但别睡太久,否则醒来可能更难受。 + +2. **冷敷或热敷:** + * **冷敷:** 用冰袋(或包裹的冷冻蔬菜)敷在额头、太阳穴或脖子后面,每次15-20分钟。对偏头痛、发烧引起的头痛效果较好。冷敷能收缩血管,减轻搏动性疼痛。 + * **热敷:** 用热毛巾敷在脖子后部或肩部,对紧张性头痛(感觉头被带子勒紧)效果较好。热敷能放松紧张的肌肉。洗个热水澡或淋浴也有帮助。 + +3. **补充水分:** 脱水是常见诱因。**慢慢喝一大杯水**,看看是否有改善。平时也要记得规律饮水,别等渴了才喝。 + +4. **适量[咖啡因]:** 对于**偶尔**的头痛,一杯茶或咖啡可能有助于收缩血管缓解疼痛(尤其对某些偏头痛有效)。但如果你经常喝咖啡,突然不喝反而可能引起头痛(戒断性头痛)。**不要过量!** + +5. **轻柔按摩:** + * 按摩太阳穴、额头、后颈和肩部肌肉。用指腹轻轻打圈按压,力度适中。 + * 试试按压合谷穴(手背,拇指和食指骨头交汇的凹陷处)和内关穴(手腕内侧,腕横纹上三横指,两筋之间)。 + +6. **放松技巧:** + * **深呼吸:** 用鼻子深吸气4秒,屏住2秒,再用嘴缓慢呼气6秒。重复几次。 + * **渐进式肌肉放松:** 依次绷紧再放松身体各部位肌肉(脚趾、小腿、大腿、腹部、手、手臂、肩膀、面部)。 + * **冥想或正念:** 专注呼吸或当下感受,让思绪平静下来。手机上有不少冥想APP可以引导你。 + +7. **非处方止痛药:** (请务必按说明书服用,注意禁忌症) + * **[对乙酰氨基酚]:** 如泰诺林、必理通等。相对温和,对胃刺激小,但注意不要超量(每天不超过4克),过量伤肝。 + * **[布洛芬]/萘普生:** 如芬必得(布洛芬)、Aleve(萘普生)等。有抗炎作用,对紧张性头痛、炎症相关头痛可能更有效,但可能刺激胃,有胃病者慎用。 + * **阿司匹林:** 也有抗炎作用,但同样可能刺激胃,儿童青少年病毒感染期间禁用(有瑞氏综合征风险)。 + * **注意:** + * 不要频繁或过量使用止痛药(一周超过2-3天),否则可能导致**药物过度使用性头痛**,形成恶性循环。 + * 仔细阅读说明书,了解禁忌症(如[胃溃疡]、肝[肾功能不全]、哮喘、怀孕等)和最大剂量。 + * 如果一种药无效,不要立即叠加另一种,先咨询医生或药师。 + +8. **调整环境:** 确保房间通风良好,温度适宜。太闷热或太冷的环境都可能诱发头痛。 + +9. **吃点东西:** 低血糖也可能导致头痛。如果有一段时间没进食,吃点健康的零食(如坚果、水果、全麦饼干)。 + +## 🚨 第三步:什么情况下必须立即就医? + +如果头痛伴随以下**任何一项**,请**立即**去急诊或拨打急救电话: + +* **突然发生的、极其剧烈的头痛("一生中最严重的头痛"):** 像被雷劈了一样突然剧痛。 +* **头痛伴有:** + * 高烧不退。 + * 颈部僵硬、疼痛。 + * 精神混乱、意识模糊、行为异常、说话不清、理解困难。 + * 视力突然丧失、模糊、重影。 + * 身体一侧麻木、无力(脸、手臂、腿)。 + * 行走困难、失去平衡、协调性差。 + * 抽搐。 +* **头部受伤后出现的头痛,尤其是伴有恶心、呕吐、嗜睡或意识丧失。** +* **50岁后新发的头痛。** +* **头痛模式发生显著改变(如频率、强度、性质突然加剧)。** +* **咳嗽、用力、弯腰或性生活时诱发或加重的剧烈头痛。** +* **癌症患者或免疫系统受损者(如HIV/AIDS)出现新发头痛。** + +## 🩺 第四步:什么情况下建议尽快看医生(非急诊,但需要专业评估) + +* **头痛频繁发作(一周超过一次),影响生活和工作。** +* **家庭处理方法无效或效果越来越差。** +* **需要越来越频繁或越来越大量地服用止痛药才能控制。** +* **怀孕期间出现新发头痛或头痛加重。** +* **怀疑头痛与特定药物有关。** +* **有慢性疾病(如高血压、自身免疫病)且头痛情况有变化。** + +## 🌿 长期管理和预防 + +* **规律作息:** 保证充足且规律的睡眠。每天尽量在同一时间入睡和起床,周末也不要差太多。 +* **管理压力:** 学习并实践压力管理技巧(运动、冥想、瑜伽、兴趣爱好、心理咨询等)。压力是头痛最常见的诱因之一。 +* **健康饮食:** 规律进食,避免可能诱发头痛的食物(常见诱因包括:陈年奶酪、加工肉类、味精、过量咖啡因、酒精尤其是红酒、某些人工甜味剂)。记录饮食日记可能有帮助。 +* **充足饮水:** 保持身体水分充足。 +* **规律运动:** 适度有氧运动(如快走、游泳、骑自行车)有助于预防头痛,但避免在头痛发作时剧烈运动。 +* **注意姿势:** 尤其是长时间伏案工作或使用电脑时,保持良好坐姿,定时活动颈肩。 +* **管理用眼:** 避免长时间盯着屏幕,定时休息(20-20-20法则:每20分钟看20英尺外物体20秒),保证眼镜度数合适。 +* **记录头痛日记:** 记录每次头痛的时间、强度、持续时间、可能的诱因、伴随症状、服用的药物及效果。这有助于你和医生找出规律和诱因。 +* **遵医嘱:** 如果医生诊断为偏头痛、紧张性头痛等并开了预防性或急性期治疗药物,务必遵医嘱服用。 + +**总结一下:** + +* **轻度偶发头痛:** 优先尝试休息、冷/热敷、喝水、按摩、放松、非处方止痛药(谨慎使用)。 +* **频繁或严重头痛、伴随危险信号:** **立即就医!** +* **慢性或困扰生活的头痛:** **及时看医生**查找原因并制定管理方案。 + +**希望这些方法能帮你缓解不适!但如果疼痛持续或加重,千万别硬撑,及时寻求专业帮助才是明智之举。照顾好自己,愿你早日摆脱头痛的困扰!** 🌈 diff --git a/Example/STBaseProjectExample/Resources/data2.txt b/Example/STBaseProjectExample/Resources/data2.txt new file mode 100644 index 0000000..a49d4cd --- /dev/null +++ b/Example/STBaseProjectExample/Resources/data2.txt @@ -0,0 +1,28 @@ +以下是一个科学、安全的减肥方法表格,结合饮食、运动和生活习惯调整,供参考: + +--- + +### **减肥方法一览表** + +| **类别** | **具体建议** | **注意事项** | +|----------------|-----------------------------------------------------------------------------|-----------------------------------------------------------------------------| +| **饮食调整** | 1. **控制热量摄入**:每日减少300-500大卡(如减少1碗米饭或1块炸鸡) | 不要极端节食(男性不低于1500大卡/天,女性不低于1200大卡/天) | +| | 2. **均衡饮食**:每餐包含蛋白质(鸡蛋/鱼/豆类)+蔬菜(占餐盘1/2)+适量碳水 | 避免完全断碳(可能导致脱发、月经失调) | +| | 3. **减少高热量食物**:避免含糖饮料、油炸食品、甜点,用水果替代零食 | 水果每天200-300克(约1个苹果量),避免过量 | +| **运动建议** | 1. **每周150分钟中等强度运动**:如快走、游泳、骑自行车(可分5天,每天30分钟) | 体重基数大者避免跑步、跳绳(伤膝盖),可改为椭圆机、游泳 | +| | 2. **加入力量训练**:每周2次深蹲、平板支撑、哑铃训练(维持肌肉量,提高代谢) | 运动后补充蛋白质(如鸡蛋、牛奶),避免空腹运动 | +| **生活习惯** | 1. **保证睡眠**:每天7-9小时睡眠(睡眠不足会升高饥饿激素) | 睡前2小时避免剧烈运动或大量进食 | +| | 2. **饭前喝1杯水**:减少过量进食可能 | 避免边看视频边吃饭(易无意识吃多) | +| | 3. **规律三餐**:定时定量,避免暴饮暴食 | 可少量多餐(如分4-5餐),但需控制总热量 | +| **心理调节** | 1. **设定合理目标**:每周减0.5-1公斤(每月2-4公斤) | 体重波动时保持耐心,避免因短期变化焦虑 | +| | 2. **记录饮食和运动**:用APP或笔记本追踪,提高执行效率 | 不要因偶尔放纵自责,及时调整后续计划 | + +--- + +### **关键提醒** +1. **循序渐进**:快速减肥易反弹,且可能引发脱发、月经紊乱等问题。 +2. **个体化调整**:如有疾病(如[糖尿病]、甲减)或特殊情况(如哺乳期),建议先咨询医生或营养师。 +3. **平台期应对**:调整运动方式(如增加间歇性训练)或重新计算每日所需热量(随体重下降而减少)。 +4. **关注身体信号**:如出现头晕、乏力、月经异常等,及时就医排查原因。 + +科学减脂需要耐心,健康的生活方式比短期瘦身更重要。如有需要,可到正规医院营养科或内分泌科制定个性化方案。 diff --git a/Example/STBaseProjectExample/Resources/data3.txt b/Example/STBaseProjectExample/Resources/data3.txt new file mode 100644 index 0000000..9e62edb --- /dev/null +++ b/Example/STBaseProjectExample/Resources/data3.txt @@ -0,0 +1,46 @@ +在Markdown或HTML中,有序列表嵌套无序列表的实现方式如下: + +### Markdown实现示例 +```markdown +1. 有序列表第一项 + - 无序子项A + - 无序子项B +2. 有序列表第二项 + * 无序子项C + + 无序子项D +``` +渲染效果为: +1. 有序列表第一项 + - 无序子项A + - 无序子项B +2. 有序列表第二项 + * 无序子项C + + 无序子项D + +**关键点**: +- 无序子项需缩进至少2-4个空格(或1个Tab)[5][8] +- 支持使用`-`、`*`或`+`作为无序列表标记[8] + +### HTML实现示例 +```html +
    +
  1. 有序列表第一项 +
      +
    • 无序子项A
    • +
    • 无序子项B
    • +
    +
  2. +
  3. 有序列表第二项 +
      +
    • 无序子项C
    • +
    • 无序子项D
    • +
    +
  4. +
+``` +**特性**: +- 可通过`type`属性自定义无序列表符号(如`disc`、`circle`、`square`)[2][9] +- 嵌套需确保`
    `或`
      `标签包含在`
    1. `标签内[6] + +### 应用场景 +这种结构适用于需要分步骤说明且包含并列细节的场景,如教程步骤中的注意事项或任务分解[1][7]。 diff --git a/Example/STBaseProjectExample/ViewController.swift b/Example/STBaseProjectExample/ViewController.swift new file mode 100644 index 0000000..7cda16b --- /dev/null +++ b/Example/STBaseProjectExample/ViewController.swift @@ -0,0 +1,73 @@ +// +// ViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2022/8/4. +// + +import UIKit +import STBaseProject + +class ViewController: BaseViewController { + + private var dataSouces: [String: UIViewController] = [:] + + @IBOutlet weak var tableView: UITableView! + @IBOutlet weak var topConstraint: NSLayoutConstraint! + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "导航目录" + self.topConstraint.constant = 0 + self.tableView.tableFooterView = UIView() + self.applyLiquidGlassScrollLayout(self.tableView) + self.configData() + } + + private func configData() { + let hudViewController = STHudViewController(nibName: "STHudViewController", bundle: nil) + self.dataSouces["hud 测试"] = hudViewController + + let logViewController = STLogViewController() + self.dataSouces["log 测试"] = logViewController + + let btnTestViewController = STBtnTestViewController() + self.dataSouces["STBtn 测试"] = btnTestViewController + + self.dataSouces["STView 测试"] = STViewTestViewController() + self.dataSouces["文本控件测试"] = STTextControlsTestViewController() + self.dataSouces["TabBar 测试"] = STTabBarTestViewController() + self.dataSouces["Log/HUD 背景测试"] = STLogAndHUDTestViewController() + self.dataSouces["STTimer 功能测试"] = STTimerTestViewController() + self.dataSouces["STTools 手动测试"] = STToolsManualTestViewController() + self.dataSouces["Markdown 流式渲染测试"] = STMarkdownStreamingTestViewController() + + self.tableView.reloadData() + } +} + +extension ViewController: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.dataSouces.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + var cell = tableView.dequeueReusableCell(withIdentifier: "ViewControllerCell") + if (cell == nil) { + cell = UITableViewCell.init(style: .default, reuseIdentifier: "ViewControllerCell") + cell?.selectionStyle = .none + cell?.backgroundColor = .clear + } + var config = UIListContentConfiguration.cell() + let key = Array(self.dataSouces.keys)[indexPath.row] + config.text = key + cell?.contentConfiguration = config + return cell ?? UITableViewCell() + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let key = Array(self.dataSouces.keys)[indexPath.row] + guard let vc = self.dataSouces[key] else { return } + self.navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/Example/STBaseProjectExample/ViewController.xib b/Example/STBaseProjectExample/ViewController.xib new file mode 100644 index 0000000..aa9779e --- /dev/null +++ b/Example/STBaseProjectExample/ViewController.xib @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/STBaseProjectExample/ViewControllers/STBtnTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STBtnTestViewController.swift new file mode 100644 index 0000000..88dd3b4 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STBtnTestViewController.swift @@ -0,0 +1,418 @@ +// +// STBtnTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2026/4/27. +// + +import UIKit +import STBaseProject + +final class STBtnTestViewController: BaseViewController { + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let verificationButton = STVerificationCodeBtn(type: .custom) + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "STBtn 测试" + self.setupScrollView() + self.setupButtons() + } + + private func setupScrollView() { + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.scrollView) + NSLayoutConstraint.activate([ + self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) + ]) + + self.stackView.axis = .vertical + self.stackView.spacing = 14 + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.scrollView.addSubview(self.stackView) + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: self.scrollView.topAnchor, constant: 20), + self.stackView.leadingAnchor.constraint(equalTo: self.scrollView.leadingAnchor, constant: 20), + self.stackView.trailingAnchor.constraint(equalTo: self.scrollView.trailingAnchor, constant: -20), + self.stackView.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor, constant: -24), + self.stackView.widthAnchor.constraint(equalTo: self.scrollView.widthAnchor, constant: -40) + ]) + + self.applyLiquidGlassScrollLayout(self.scrollView) + } + + private func setupButtons() { + self.addSectionLabel("基础样式") + self.stackView.addArrangedSubview(self.makeNormalButton()) + self.stackView.addArrangedSubview(self.makeRoundedButton()) + self.stackView.addArrangedSubview(self.makeDisabledButton()) + + self.addSectionLabel("内容边距") + self.stackView.addArrangedSubview(self.makeLeftPaddingButton()) + self.stackView.addArrangedSubview(self.makeRightPaddingButton()) + + self.addSectionLabel("背景样式") + self.stackView.addArrangedSubview(self.makeGradientButton()) + self.stackView.addArrangedSubview(self.makeLiquidGlassButton()) + + self.addSectionLabel("阴影与圆角") + self.stackView.addArrangedSubview(self.makeShadowButton()) + + self.addSectionLabel("交互反馈 · 点击变色") + self.stackView.addArrangedSubview(self.makeHighlightColorButton()) + self.stackView.addArrangedSubview(self.makeHighlightColorRoundedButton()) + + self.addSectionLabel("STIconBtn · 图文位置") + self.stackView.addArrangedSubview(self.makeFilledIconButton(position: .left, title: "STIconBtn 左图右文")) + self.stackView.addArrangedSubview(self.makeFilledIconButton(position: .right, title: "STIconBtn 右图左文")) + self.stackView.addArrangedSubview(self.makeFilledIconButton(position: .top, title: "STIconBtn 上图下文")) + + self.addSectionLabel("STIconBtn · 自适应宽度") + self.stackView.addArrangedSubview(self.makeAdaptiveIconButtonsRow()) + + self.addSectionLabel("STIconBtn · 选中态切换") + self.stackView.addArrangedSubview(self.makeSelectableIconButtonsRow()) + + self.addSectionLabel("STVerificationCodeBtn · 倒计时") + self.setupVerificationButton() + self.stackView.addArrangedSubview(self.verificationButton) + } + + private func addSectionLabel(_ title: String) { + if !self.stackView.arrangedSubviews.isEmpty { + let spacer = UIView() + spacer.heightAnchor.constraint(equalToConstant: 8).isActive = true + self.stackView.addArrangedSubview(spacer) + } + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = .secondaryLabel + self.stackView.addArrangedSubview(label) + } + + // MARK: - 基础样式 + private func makeNormalButton() -> STBtn { + let button = self.makeBaseButton(title: "普通 STBtn") + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + return button + } + + private func makeRoundedButton() -> STBtn { + let button = self.makeBaseButton(title: "圆角 + 边框") + button.backgroundColor = .secondarySystemGroupedBackground + button.setTitleColor(.label, for: .normal) + button.st_roundedButton(cornerRadius: 14, borderWidth: 1, borderColor: .systemBlue) + return button + } + + private func makeDisabledButton() -> STBtn { + let button = self.makeBaseButton(title: "禁用态 STBtn") + button.backgroundColor = .systemGray5 + button.setTitleColor(.secondaryLabel, for: .disabled) + button.isEnabled = false + return button + } + + // MARK: - 内容边距 + private func makeLeftPaddingButton() -> STBtn { + let button = self.makeBaseButton(title: "左对齐 + 左侧 24pt 边距") + button.backgroundColor = .systemIndigo.withAlphaComponent(0.14) + button.setTitleColor(.systemIndigo, for: .normal) + button.contentHorizontalAlignment = .left + button.configuration?.contentInsets.leading += 24 + return button + } + + private func makeRightPaddingButton() -> STBtn { + let button = self.makeBaseButton(title: "右对齐 + 右侧 24pt 边距") + button.backgroundColor = .systemTeal.withAlphaComponent(0.14) + button.setTitleColor(.systemTeal, for: .normal) + button.contentHorizontalAlignment = .right + button.configuration?.contentInsets.trailing += 24 + return button + } + + // MARK: - 背景样式 + private func makeGradientButton() -> STBtn { + let button = self.makeBaseButton(title: "渐变背景") + button.cornerRadius = 16 + button.clipsContentToBounds = true + button.setTitleColor(.white, for: .normal) + button.st_setGradientBackground( + colors: [.systemPurple, .systemPink, .systemOrange], + startPoint: CGPoint(x: 0, y: 0.5), + endPoint: CGPoint(x: 1, y: 0.5) + ) + return button + } + + private func makeLiquidGlassButton() -> STBtn { + let button = self.makeBaseButton(title: "Liquid Glass 背景") + button.cornerRadius = 18 + button.setTitleColor(.label, for: .normal) + button.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.12) + button.st_setLiquidGlassBackground( + tintColor: UIColor.white.withAlphaComponent(0.2), + highlightOpacity: 0.5, + borderColor: UIColor.white.withAlphaComponent(0.55) + ) + button.st_setShadow( + color: UIColor.black.withAlphaComponent(0.18), + offset: CGSize(width: 0, height: 8), + radius: 18, + opacity: 1 + ) + return button + } + + // MARK: - 阴影 + private func makeShadowButton() -> STBtn { + let button = self.makeBaseButton(title: "阴影 + 圆角不裁剪") + button.cornerRadius = 16 + button.backgroundColor = .secondarySystemGroupedBackground + button.setTitleColor(.label, for: .normal) + button.st_setShadow( + color: UIColor.black.withAlphaComponent(0.16), + offset: CGSize(width: 0, height: 6), + radius: 14, + opacity: 1 + ) + return button + } + + private func makeBaseButton(title: String) -> STBtn { + let button = STBtn(type: .custom) + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.heightAnchor.constraint(equalToConstant: 52).isActive = true + return button + } + + // MARK: - 交互反馈:点击后背景颜色变化 + /// 直接通过 `st_setBackgroundColor(_:for:)` 按状态声明颜色, + /// `STBtn` 会在 `refineButtonConfiguration` 里自动按当前 `button.state` 命中对应颜色。 + /// 不需要子类化、不需要自定义 `configurationUpdateHandler`。 + private func makeHighlightColorButton() -> STBtn { + let button = self.makeBaseButton(title: "按下查看背景色变化") + button.setTitleColor(.white, for: .normal) + button.setTitleColor(.white, for: .highlighted) + button.st_setBackgroundColor(.systemBlue, for: .normal) + button.st_setBackgroundColor(.systemIndigo, for: .highlighted) + button.st_setBackgroundColor(.systemGray3, for: .disabled) + button.addTarget(self, action: #selector(self.onHighlightButtonTapped(_:)), for: .touchUpInside) + return button + } + + private func makeHighlightColorRoundedButton() -> STBtn { + let button = self.makeBaseButton(title: "按下变色 + 圆角 + 阴影") + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold) + button.setTitleColor(.white, for: .normal) + button.setTitleColor(.white, for: .highlighted) + button.st_setBackgroundColor(.systemGreen, for: .normal) + button.st_setBackgroundColor(.systemTeal, for: .highlighted) + button.st_roundedButton(cornerRadius: 14) + button.st_setShadow( + color: UIColor.black.withAlphaComponent(0.18), + offset: CGSize(width: 0, height: 4), + radius: 10, + opacity: 1 + ) + button.addTarget(self, action: #selector(self.onHighlightButtonTapped(_:)), for: .touchUpInside) + return button + } + + @objc private func onHighlightButtonTapped(_ sender: UIButton) { + sender.isEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + sender.isEnabled = true + } + } + + // MARK: - STIconBtn 图文位置(LiquidGlass 背景) + private func makeFilledIconButton(position: STIconPosition, title: String) -> STIconBtn { + let button = STIconBtn(type: .custom) + button.setTitle(title, for: .normal) + button.setImage(UIImage(systemName: "sparkles"), for: .normal) + button.tintColor = .systemBlue + button.setTitleColor(.label, for: .normal) + button.cornerRadius = 18 + button.st_setLiquidGlassBackground() + button.configure() + .iconPosition(position) + .spacing(10) + .contentInsets(UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)) + .done() + button.heightAnchor.constraint(equalToConstant: position == .top ? 88 : 56).isActive = true + return button + } + + // MARK: - STIconBtn 自适应宽度 + /// 借助 `UIStackView(alignment = .leading)` 让每个 `STIconBtn` 以自身 intrinsicContentSize + /// (`UIButton.Configuration` 原生依据 `contentInsets + imagePlacement + imagePadding` 计算) + /// 横向收缩到贴合内容,不同文案长度下宽度自动伸缩。 + private func makeAdaptiveIconButtonsRow() -> UIView { + let container = UIStackView() + container.axis = .vertical + container.alignment = .leading + container.spacing = 10 + + container.addArrangedSubview(self.makeAdaptiveIconButton( + title: "收藏", + systemIconName: "star.fill", + iconPosition: .left, + tint: .systemOrange + )) + container.addArrangedSubview(self.makeAdaptiveIconButton( + title: "下一步", + systemIconName: "arrow.right.circle.fill", + iconPosition: .right, + tint: .systemBlue + )) + container.addArrangedSubview(self.makeAdaptiveIconButton( + title: "按下可变色 · 含较长文本的自适应测试", + systemIconName: "hand.tap.fill", + iconPosition: .left, + tint: .systemPurple + )) + container.addArrangedSubview(self.makeAdaptiveIconButton( + title: "上传", + systemIconName: "icloud.and.arrow.up.fill", + iconPosition: .top, + tint: .systemTeal + )) + return container + } + + private func makeAdaptiveIconButton( + title: String, + systemIconName: String, + iconPosition: STIconPosition, + tint: UIColor + ) -> STIconBtn { + let button = STIconBtn(type: .custom) + button.setTitle(title, for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + if let icon = UIImage(systemName: systemIconName)?.withRenderingMode(.alwaysTemplate) { + button.setImage(icon, for: .normal) + button.tintColor = .white + } + button.backgroundColor = tint + button.configure() + .iconPosition(iconPosition) + .spacing(8) + .contentInsets(UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)) + .done() + button.st_roundedButton(cornerRadius: 10) + return button + } + + // MARK: - STIconBtn 选中态切换 + /// 验证「点击切换 isSelected,图标 + 标题同步切换,宽度随文本自适应伸缩」。 + /// `UIButton.Configuration` 会在 `isSelected` 变化时自动经由 `configurationUpdateHandler` + /// 读取对应 state 的 title/image,无需手动同步。 + private func makeSelectableIconButtonsRow() -> UIView { + let container = UIStackView() + container.axis = .vertical + container.alignment = .leading + container.spacing = 10 + + container.addArrangedSubview(self.makeThinkingToggleButton()) + container.addArrangedSubview(self.makeFavoriteToggleButton()) + container.addArrangedSubview(self.makeCheckableToggleButton()) + return container + } + + /// 「思考 / 思考中」 —— 点击切换,文字变长时按钮宽度随之扩展。 + private func makeThinkingToggleButton() -> STIconBtn { + let button = STIconBtn(type: .custom) + button.setTitle("思考", for: .normal) + button.setTitle("思考中…", for: .selected) + button.setTitleColor(.secondaryLabel, for: .normal) + button.setTitleColor(.systemGreen, for: .selected) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.setImage(self.symbolImage("brain.head.profile", tint: .secondaryLabel), for: .normal) + button.setImage(self.symbolImage("brain.head.profile.fill", tint: .systemGreen), for: .selected) + button.configure() + .iconPosition(.left) + .spacing(4) + .contentInsets(UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)) + .done() + button.backgroundColor = .secondarySystemBackground + button.st_roundedButton(cornerRadius: 8) + button.addTarget(self, action: #selector(self.onToggleButtonTapped(_:)), for: .touchUpInside) + return button + } + + /// 收藏开关 —— 图标 + 文案 + 颜色全部按 state 切换。 + private func makeFavoriteToggleButton() -> STIconBtn { + let button = STIconBtn(type: .custom) + button.setTitle("收藏", for: .normal) + button.setTitle("已收藏", for: .selected) + button.setTitleColor(.label, for: .normal) + button.setTitleColor(.systemOrange, for: .selected) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + button.setImage(self.symbolImage("star", tint: .label), for: .normal) + button.setImage(self.symbolImage("star.fill", tint: .systemOrange), for: .selected) + button.configure() + .iconPosition(.left) + .spacing(6) + .contentInsets(UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14)) + .done() + button.backgroundColor = .secondarySystemBackground + button.st_roundedButton(cornerRadius: 8, borderWidth: 1, borderColor: .separator) + button.addTarget(self, action: #selector(self.onToggleButtonTapped(_:)), for: .touchUpInside) + return button + } + + /// 勾选开关 —— 右侧图标 + 等长文字,验证 `.right` 位置下 selected 图标替换。 + private func makeCheckableToggleButton() -> STIconBtn { + let button = STIconBtn(type: .custom) + button.setTitle("同意用户协议", for: .normal) + button.setTitle("同意用户协议", for: .selected) + button.setTitleColor(.secondaryLabel, for: .normal) + button.setTitleColor(.systemBlue, for: .selected) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.setImage(self.symbolImage("circle", tint: .tertiaryLabel), for: .normal) + button.setImage(self.symbolImage("checkmark.circle.fill", tint: .systemBlue), for: .selected) + button.configure() + .iconPosition(.right) + .spacing(6) + .contentInsets(UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)) + .done() + button.addTarget(self, action: #selector(self.onToggleButtonTapped(_:)), for: .touchUpInside) + return button + } + + @objc private func onToggleButtonTapped(_ sender: UIButton) { + sender.isSelected.toggle() + } + + private func symbolImage(_ name: String, tint: UIColor) -> UIImage? { + return UIImage(systemName: name)?.withTintColor(tint, renderingMode: .alwaysOriginal) + } + + // MARK: - STVerificationCodeBtn + private func setupVerificationButton() { + self.verificationButton.setTitle("发送验证码", for: .normal) + self.verificationButton.setTitleColor(.white, for: .normal) + self.verificationButton.setTitleColor(.secondaryLabel, for: .disabled) + self.verificationButton.titleSuffix = "s 后重试" + self.verificationButton.timerInterval = 10 + self.verificationButton.cornerRadius = 18 + self.verificationButton.st_setGradientBackground(colors: [.systemBlue, .systemCyan]) + self.verificationButton.heightAnchor.constraint(equalToConstant: 56).isActive = true + self.verificationButton.addTarget(self, action: #selector(self.startVerificationCountdown), for: .touchUpInside) + } + + @objc private func startVerificationCountdown() { + self.verificationButton.beginTimer() + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STHudViewController.swift b/Example/STBaseProjectExample/ViewControllers/STHudViewController.swift new file mode 100644 index 0000000..37da882 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STHudViewController.swift @@ -0,0 +1,271 @@ +// +// STHudViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2022/8/4. +// + +import UIKit +import STBaseProject + +class STHudViewController: BaseViewController { + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + @MainActor deinit { + STLog("STNextViewController dealloc") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "HUD 测试" + self.setupScrollView() + self.setupButtons() + } + + private func setupScrollView() { + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.scrollView) + NSLayoutConstraint.activate([ + self.scrollView.topAnchor.constraint(equalTo: view.topAnchor), + self.scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + self.stackView.axis = .vertical + self.stackView.spacing = 12 + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.scrollView.addSubview(self.stackView) + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: self.scrollView.topAnchor, constant: 20), + self.stackView.leadingAnchor.constraint(equalTo: self.scrollView.leadingAnchor, constant: 20), + self.stackView.trailingAnchor.constraint(equalTo: self.scrollView.trailingAnchor, constant: -20), + self.stackView.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor, constant: -20), + self.stackView.widthAnchor.constraint(equalTo: self.scrollView.widthAnchor, constant: -40) + ]) + + self.applyLiquidGlassScrollLayout(self.scrollView) + } + + private func setupButtons() { + self.addSectionLabel("自动隐藏") + self.addButton("st_showSuccess", action: #selector(testSuccess)) + self.addButton("st_showSuccess(带详细文本)", action: #selector(testSuccessWithDetail)) + self.addButton("st_showError", action: #selector(testError)) + self.addButton("st_showError(带详细文本)", action: #selector(testErrorWithDetail)) + self.addButton("st_showWarning", action: #selector(testWarning)) + self.addButton("st_showInfo", action: #selector(testInfo)) + self.addButton("st_showToast(纯文本)", action: #selector(testToast)) + + self.addSectionLabel("加载中(需手动关闭)") + self.addButton("st_showLoading(全局 window)", action: #selector(testLoadingGlobal)) + self.addButton("st_showLoading(局部视图)", action: #selector(testLoadingLocal)) + + self.addSectionLabel("关闭") + self.addButton("st_dismiss", action: #selector(testDismiss)) + + self.addSectionLabel("自定义配置") + self.addButton("st_showHUD(with: config)", action: #selector(testCustomConfig)) + + self.addSectionLabel("图标位置") + self.addButton("iconPosition: .left(无 detail)", action: #selector(testIconLeft)) + self.addButton("iconPosition: .left(有 detail)", action: #selector(testIconLeftWithDetail)) + self.addButton("iconPosition: .right(无 detail)", action: #selector(testIconRight)) + self.addButton("iconPosition: .right(有 detail)", action: #selector(testIconRightWithDetail)) + self.addButton("设置全局 defaultIconPosition = .left", action: #selector(testSetGlobalIconPosition)) + self.addButton("恢复全局 defaultIconPosition = .top", action: #selector(testResetGlobalIconPosition)) + + self.addSectionLabel("自定义主题(STHUDTheme)") + self.addButton("浅色主题(白底黑字)", action: #selector(testThemeLight)) + self.addButton("品牌色主题(自定义背景+图标色)", action: #selector(testThemeBrand)) + self.addButton("大图标 + 大字体", action: #selector(testThemeLargeIcon)) + self.addButton("无阴影 + 小圆角", action: #selector(testThemeNoShadow)) + self.addButton("设置全局主题", action: #selector(testApplyGlobalTheme)) + self.addButton("恢复默认全局主题", action: #selector(testResetGlobalTheme)) + } + + private func addSectionLabel(_ title: String) { + if !self.stackView.arrangedSubviews.isEmpty { + let spacer = UIView() + spacer.heightAnchor.constraint(equalToConstant: 8).isActive = true + self.stackView.addArrangedSubview(spacer) + } + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = .secondaryLabel + self.stackView.addArrangedSubview(label) + } + + private func addButton(_ title: String, action: Selector) { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15) + button.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.08) + button.layer.cornerRadius = 8 + button.heightAnchor.constraint(equalToConstant: 44).isActive = true + button.addTarget(self, action: action, for: .touchUpInside) + self.stackView.addArrangedSubview(button) + } + + // MARK: - 自动隐藏 + @objc private func testSuccess() { + self.view.st_showSuccess("操作成功") { + self.testToast() + } + } + + @objc private func testSuccessWithDetail() { + self.view.st_showSuccess("操作成功", detailText: "数据已保存到云端") + } + + @objc private func testError() { + self.view.st_showError("加载失败") + } + + @objc private func testErrorWithDetail() { + self.view.st_showError("加载失败", detailText: "请检查网络连接后重试") + } + + @objc private func testWarning() { + self.view.st_showWarning("操作有风险,请谨慎") + } + + @objc private func testInfo() { + self.view.st_showInfo("这是一条提示信息") + } + + @objc private func testToast() { + self.view.st_showText("这是纯文本 Toast,无图标") + } + + // MARK: - 加载中 + @objc private func testLoadingGlobal() { + self.view.st_showLoading("正在加载...") + } + + @objc private func testLoadingLocal() { + self.view.st_showLoading("正在加载...", in: self.view) + } + + // MARK: - 关闭 + @objc private func testDismiss() { + self.view.st_dismiss() + } + + // MARK: - 自定义配置 + @objc private func testCustomConfig() { + let config = STHUDConfig( + type: .success, + title: "自定义配置", + detailText: "通过 STHUDConfig 传入", + location: .bottom, + autoHide: true, + hideDelay: 3.0 + ) + self.view.st_showHUD(with: config) + } + + // MARK: - 图标位置 + @objc private func testIconLeft() { + self.view.st_showSuccess("操作成功", iconPosition: .left) + } + + @objc private func testIconLeftWithDetail() { + self.view.st_showError("加载失败", detailText: "请检查网络连接后重试", iconPosition: .left) + } + + @objc private func testIconRight() { + self.view.st_showWarning("操作有风险", iconPosition: .right) + } + + @objc private func testIconRightWithDetail() { + self.view.st_showInfo("这是提示", detailText: "图标显示在右侧,icon 垂直居中", iconPosition: .right) + } + + /// 全局设置后,st_showSuccess 等便捷方法无需再传 iconPosition + @objc private func testSetGlobalIconPosition() { + STHUD.sharedHUD.defaultIconPosition = .left + self.view.st_showSuccess("全局图标位置已设为 left", detailText: "后续便捷方法默认左侧图标") + } + + @objc private func testResetGlobalIconPosition() { + STHUD.sharedHUD.defaultIconPosition = .top + self.view.st_showSuccess("已恢复默认图标位置(top)") + } + + // MARK: - 自定义主题 + /// 浅色主题:白底黑字,较大圆角 + @objc private func testThemeLight() { + let theme = STHUDTheme( + backgroundColor: UIColor.white.withAlphaComponent(0.95), + textColor: .black, + detailTextColor: .darkGray, + successColor: .systemGreen, + cornerRadius: 14, + shadow: .enabled + ) + let config = STHUDConfig(type: .success, title: "操作成功", detailText: "浅色主题效果", autoHide: true, theme: theme) + self.view.st_showHUD(with: config) + } + + /// 品牌色主题:自定义背景色与图标颜色 + @objc private func testThemeBrand() { + let brandPurple = UIColor(red: 0.42, green: 0.22, blue: 0.80, alpha: 1) + let theme = STHUDTheme( + backgroundColor: brandPurple, + textColor: .white, + detailTextColor: UIColor.white.withAlphaComponent(0.7), + successColor: .white, + cornerRadius: 16, + shadow: .enabled + ) + let config = STHUDConfig(type: .success, title: "支付成功", detailText: "品牌色主题", autoHide: true, theme: theme) + self.view.st_showHUD(with: config) + } + + /// 大图标 + 大字体 + @objc private func testThemeLargeIcon() { + let theme = STHUDTheme( + iconSize: CGSize(width: 48, height: 48), + labelFont: UIFont.systemFont(ofSize: 20, weight: .bold), + detailLabelFont: UIFont.systemFont(ofSize: 15, weight: .regular) + ) + let config = STHUDConfig(type: .warning, title: "注意", detailText: "大图标 + 大字体主题", autoHide: true, theme: theme) + self.view.st_showHUD(with: config) + } + + /// 无阴影 + 小圆角 + @objc private func testThemeNoShadow() { + let theme = STHUDTheme( + cornerRadius: 4, + shadow: .disabled + ) + let config = STHUDConfig(type: .info, title: "无阴影", detailText: "小圆角,无阴影效果", autoHide: true, theme: theme) + self.view.st_showHUD(with: config) + } + + /// 设置全局主题(后续所有 st_showXxx 便捷方法都生效) + @objc private func testApplyGlobalTheme() { + let theme = STHUDTheme( + backgroundColor: UIColor(red: 0.13, green: 0.13, blue: 0.13, alpha: 0.95), + textColor: .white, + detailTextColor: UIColor.white.withAlphaComponent(0.6), + successColor: .systemYellow, + errorColor: .systemPink, + warningColor: .systemOrange, + infoColor: .systemTeal, + cornerRadius: 12 + ) + STHUD.sharedHUD.applyTheme(theme) + self.view.st_showSuccess("全局主题已设置", detailText: "后续 HUD 均使用此主题") + } + + /// 恢复默认全局主题 + @objc private func testResetGlobalTheme() { + STHUD.sharedHUD.applyTheme(STHUDTheme()) + self.view.st_showInfo("已恢复默认主题") + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STHudViewController.xib b/Example/STBaseProjectExample/ViewControllers/STHudViewController.xib new file mode 100644 index 0000000..8de2a66 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STHudViewController.xib @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/STBaseProjectExample/ViewControllers/STLogAndHUDTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STLogAndHUDTestViewController.swift new file mode 100644 index 0000000..3841186 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STLogAndHUDTestViewController.swift @@ -0,0 +1,59 @@ +// +// STLogAndHUDTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2026/4/27. +// + +import UIKit +import STBaseProject + +final class STLogAndHUDTestViewController: BaseViewController { + + private let stackView = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "Log/HUD 背景测试" + self.setupStackView() + self.setupSamples() + } + + private func setupStackView() { + self.stackView.axis = .vertical + self.stackView.spacing = 16 + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.stackView) + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: STDeviceAdapter.navigationBarHeight + 24), + self.stackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 20), + self.stackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -20) + ]) + } + + private func setupSamples() { + let logView = STLogView() + logView.layer.cornerRadius = 20 + logView.clipsToBounds = true + logView.st_setLiquidGlassBackground() + logView.heightAnchor.constraint(equalToConstant: 320).isActive = true + self.stackView.addArrangedSubview(logView) + + let hudBackground = STProgressHUDBackgroundView() + hudBackground.style = .liquidGlass + hudBackground.layer.cornerRadius = 18 + hudBackground.heightAnchor.constraint(equalToConstant: 96).isActive = true + self.stackView.addArrangedSubview(hudBackground) + + let label = UILabel() + label.text = "STProgressHUDBackgroundView(.liquidGlass)" + label.textAlignment = .center + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + hudBackground.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: hudBackground.centerXAnchor), + label.centerYAnchor.constraint(equalTo: hudBackground.centerYAnchor) + ]) + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STLogViewController.swift b/Example/STBaseProjectExample/ViewControllers/STLogViewController.swift new file mode 100644 index 0000000..aa71dd5 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STLogViewController.swift @@ -0,0 +1,180 @@ +// +// STLogViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2022/8/4. +// + +import UIKit +import STBaseProject + +final class STLogViewController: BaseViewController { + + private var timer: Timer? + private let logView = STLogView() + private let logGenerator = STDemoLogGenerator() + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + self.setupNavBarItems() + self.setupLogView() + self.seedInitialLogs() + self.startAutoLogging() + self.logNetworkSample() + } + + private func setupNavBarItems() { + let randomBtn = self.makeNavButton(title: "随机日志", action: #selector(addRandomLog)) + let burstBtn = self.makeNavButton(title: "批量注入", action: #selector(addBurstLogs)) + let stack = UIStackView(arrangedSubviews: [burstBtn, randomBtn]) + stack.axis = .horizontal + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + self.navigationBarItemsView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: self.navigationBarItemsView.centerYAnchor), + stack.trailingAnchor.constraint(equalTo: self.navigationBarItemsView.trailingAnchor, constant: -12) + ]) + } + + private func setupLogView() { + self.logView.translatesAutoresizingMaskIntoConstraints = false + self.logView.mDelegate = self + self.view.addSubview(self.logView) + NSLayoutConstraint.activate([ + self.logView.topAnchor.constraint(equalTo: self.contentTopAnchor), + self.logView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + self.logView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + self.logView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func seedInitialLogs() { + STLogLevel.allCases.forEach { level in + let message = "示例 \(level.rawValue) 级别日志,用于展示过滤效果。" + STPersistentLog(message, level: level, metadata: ["seed": "true"], file: "STLogDemoViewController.swift", function: "viewDidLoad", line: 52 + ) + } + } + + @objc private func addRandomLog() { + self.logGenerator.randomLog() + } + + @objc private func addBurstLogs() { + (0..<10).forEach { _ in + self.logGenerator.randomLog() + } + } + + private func makeNavButton(title: String, action: Selector) -> UIButton { + let button = STIconBtn(type: .system) + button.setTitle(title, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium) + button.iconContentInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) + button.layer.cornerRadius = 14 + button.layer.masksToBounds = true + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.systemBlue.withAlphaComponent(0.3).cgColor + button.setTitleColor(.systemBlue, for: .normal) + button.backgroundColor = .secondarySystemBackground + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.addTarget(self, action: action, for: .touchUpInside) + let heightConstraint = button.heightAnchor.constraint(equalToConstant: 28) + heightConstraint.priority = .defaultHigh + heightConstraint.isActive = true + return button + } + + private func startAutoLogging() { + self.timer?.invalidate() + self.timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.logGenerator.randomLog() + } + } + + private func logNetworkSample() { + let sampleResponse: [String: Any] = [ + "code": 0, + "message": "success", + "data": [ + "user": [ + "id": 123456789, + "name": "StackOverflow", + "roles": ["admin", "editor", "auditor"], + "meta": [ + "lastLogin": "2024-11-28T08:12:45Z", + "preferences": [ + "theme": "dark", + "timezone": "Asia/Shanghai", + "featureFlags": [ + "newDashboard": true, + "betaNetwork": false, + "logStreaming": true + ] + ] + ] + ], + "items": (0..<5).map { index -> [String: Any] in + return [ + "index": index, + "title": "示例请求条目 \(index)", + "values": (0..<10).map { + [ + "value": Int.random(in: 1000...9999), + "timestamp": "2024-11-28T08:\(String(format: "%02d", $0)):00Z" + ] + } + ] + } + ] + ] + + if let jsonData = try? JSONSerialization.data(withJSONObject: sampleResponse, options: [.prettyPrinted]), + let jsonString = String(data: jsonData, encoding: .utf8) { + STPersistentLog("网络请求返回:\n\(jsonString)", level: .info) + } + } + + @MainActor deinit { + self.timer?.invalidate() + } +} + +// MARK: - STLogViewDelegate +extension STLogViewController: STLogViewDelegate { + func logViewBackBtnClick() { + self.onLeftBtnTap() + } + + func logViewDidFilterLogs(with results: [STLogEntry]) { + + } +} + +// MARK: - Demo Log Generator +private struct STDemoLogGenerator { + let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy年MM月dd日 HH:mm:ss" + return formatter + }() + + private let sampleMessages: [STLogLevel: [String]] = [ + .debug: ["缓存命中,返回内存数据", "启动性能统计完成", "收到调试命令,准备执行"], + .info: ["用户完成登录流程", "配置加载成功", "同步完成,共 42 条记录"], + .warning: ["磁盘空间不足 10%", "网络波动重试中", "检测到可能的循环引用"], + .error: ["接口返回 500 错误", "数据库写入失败", "JSON 解析失败,字段缺失"], + .fatal: ["应用即将崩溃,触发 CrashGuard", "严重数据错乱,终止流程", "安全策略失效,阻断操作"] + ] + + @discardableResult + func randomLog() -> String { + guard let level = STLogLevel.allCases.randomElement() else { return "" } + let message = sampleMessages[level]?.randomElement() ?? "未知日志" + STPersistentLog(message, level: level) + return message + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift new file mode 100644 index 0000000..ad2fb45 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift @@ -0,0 +1,130 @@ +// +// STMarkdownStreamingTestViewController.swift +// STBaseProjectExample +// +// Created by Codex on 2026/5/11. +// + +import UIKit +import STBaseProject + +final class STMarkdownStreamingTestViewController: BaseViewController { + + private var typewriterTimer: Timer? + private var fullMarkdownText: String = "" + private var currentIndex: Int = 0 + + private let typingInterval: TimeInterval = 0.02 + private let typingStep: Int = 1 + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "Markdown 流式测试" + self.buildUI() + self.startRendering() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.stopTypewriter() + } + + private func buildUI() { + self.view.addSubview(self.renderView) + self.view.addSubview(self.reloadButton) + + NSLayoutConstraint.activate([ + self.reloadButton.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + self.reloadButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + self.reloadButton.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + self.reloadButton.heightAnchor.constraint(equalToConstant: 44), + + self.renderView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.renderView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + self.renderView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + self.renderView.bottomAnchor.constraint(equalTo: self.reloadButton.topAnchor, constant: -12) + ]) + + self.applyLiquidGlassScrollLayout(self.renderView.contentTextView) + } + + @objc private func restartRenderTapped() { + self.startRendering() + } + + private func startRendering() { + self.stopTypewriter() + self.fullMarkdownText = self.loadAllFixtures() + self.currentIndex = 0 + self.renderView.reset() + + guard !self.fullMarkdownText.isEmpty else { + self.renderView.setMarkdown("资源读取失败,请检查 data1~3.txt 是否已加入主工程 Bundle。", animated: false) + return + } + + self.typewriterTimer = Timer.scheduledTimer( + timeInterval: self.typingInterval, + target: self, + selector: #selector(self.handleTypewriterTick), + userInfo: nil, + repeats: true + ) + if let timer = self.typewriterTimer { + RunLoop.main.add(timer, forMode: .common) + } + } + + private func stopTypewriter() { + self.typewriterTimer?.invalidate() + self.typewriterTimer = nil + } + + @objc private func handleTypewriterTick() { + guard self.currentIndex < self.fullMarkdownText.count else { + self.stopTypewriter() + return + } + let next = min(self.currentIndex + self.typingStep, self.fullMarkdownText.count) + let end = self.fullMarkdownText.index(self.fullMarkdownText.startIndex, offsetBy: next) + let prefix = String(self.fullMarkdownText[.. String { + let names = ["data1", "data2", "data3"] + let texts = names.compactMap { name in + self.readFixture(named: name).map { "## \(name).txt\n\n\($0)" } + } + return texts.joined(separator: "\n\n---\n\n") + } + + private func readFixture(named name: String) -> String? { + guard let path = Bundle.main.path(forResource: name, ofType: "txt") else { + return nil + } + return try? String(contentsOfFile: path, encoding: .utf8) + } + + private lazy var renderView: STMarkdownStreamingTextView = { + let view = STMarkdownStreamingTextView(style: .default, advancedRenderers: .empty, engine: STMarkdownEngine()) + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemBackground + view.layer.cornerRadius = 8 + view.clipsToBounds = true + view.isTextSelectionEnabled = true + view.tokenFadeDuration = 0.1 + view.animateAcrossNewlines = false + return view + }() + + private lazy var reloadButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("重新逐字渲染", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.addTarget(self, action: #selector(self.restartRenderTapped), for: .touchUpInside) + return button + }() +} diff --git a/Example/STBaseProjectExample/ViewControllers/STTabBarTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STTabBarTestViewController.swift new file mode 100644 index 0000000..78cbebf --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STTabBarTestViewController.swift @@ -0,0 +1,64 @@ +// +// STTabBarTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2026/4/27. +// + +import UIKit +import STBaseProject + +final class STTabBarTestViewController: BaseViewController { + + private let customTabBar = STCustomTabBar() + private let statusLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "TabBar 测试" + self.setupViews() + self.setupTabBar() + } + + private func setupViews() { + self.statusLabel.text = "当前选中:首页" + self.statusLabel.textAlignment = .center + self.statusLabel.font = .systemFont(ofSize: 18, weight: .semibold) + self.statusLabel.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.statusLabel) + + self.customTabBar.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.customTabBar) + NSLayoutConstraint.activate([ + self.statusLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.statusLabel.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + self.customTabBar.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 20), + self.customTabBar.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -20), + self.customTabBar.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -24) + ]) + } + + private func setupTabBar() { + let items = [ + STTabBarItemModel(title: "首页", normalImage: UIImage(systemName: "house"), selectedImage: UIImage(systemName: "house.fill")), + STTabBarItemModel(title: "消息", normalImage: UIImage(systemName: "message"), selectedImage: UIImage(systemName: "message.fill"), badge: STTabBarItemBadge(count: 8)), + STTabBarItemModel(title: "我的", normalImage: UIImage(systemName: "person"), selectedImage: UIImage(systemName: "person.fill")) + ] + var config = STTabBarConfig() + config.height = 64 + config.backgroundColor = .clear + config.showShadow = true + self.customTabBar.layer.cornerRadius = 22 + self.customTabBar.configure(items: items, config: config) + self.customTabBar.st_setLiquidGlassBackground() + self.customTabBar.delegate = self + } +} + +extension STTabBarTestViewController: STCustomTabBarDelegate { + func customTabBar(_ tabBar: STCustomTabBar, didSelectItemAt index: Int) { + tabBar.setSelectedIndex(index) + let titles = ["首页", "消息", "我的"] + self.statusLabel.text = "当前选中:\(titles[index])" + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STTextControlsTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STTextControlsTestViewController.swift new file mode 100644 index 0000000..14bd0a2 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STTextControlsTestViewController.swift @@ -0,0 +1,102 @@ +// +// STTextControlsTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2026/4/27. +// + +import UIKit +import STBaseProject + +final class STTextControlsTestViewController: BaseViewController { + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "文本控件测试" + self.setupScrollView() + self.setupSamples() + } + + private func setupScrollView() { + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.scrollView) + NSLayoutConstraint.activate([ + self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) + ]) + self.stackView.axis = .vertical + self.stackView.spacing = 16 + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.scrollView.addSubview(self.stackView) + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: self.scrollView.topAnchor, constant: 20), + self.stackView.leadingAnchor.constraint(equalTo: self.scrollView.leadingAnchor, constant: 20), + self.stackView.trailingAnchor.constraint(equalTo: self.scrollView.trailingAnchor, constant: -20), + self.stackView.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor, constant: -24), + self.stackView.widthAnchor.constraint(equalTo: self.scrollView.widthAnchor, constant: -40) + ]) + + self.applyLiquidGlassScrollLayout(self.scrollView) + } + + private func setupSamples() { + self.stackView.addArrangedSubview(self.makeLabel()) + self.stackView.addArrangedSubview(self.makeTextField()) + self.stackView.addArrangedSubview(self.makePlaceholderTextView()) + self.stackView.addArrangedSubview(self.makeTextView()) + } + + private func makeLabel() -> STLabel { + let label = STLabel() + label.text = "STLabel:内容边距 + Liquid Glass" + label.numberOfLines = 0 + label.contentEdgeInsets = UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16) + label.cornerRadius = 16 + label.isLiquidGlassEnabled = true + return label + } + + private func makeTextField() -> STTextField { + let textField = STTextField() + textField.placeholder = "STTextField:请输入内容" + textField.textInsetLeft = 16 + textField.textInsetRight = 16 + textField.cornerRadius = 16 + textField.borderWidth = 1 + textField.borderColor = UIColor.white.withAlphaComponent(0.5) + textField.isLiquidGlassEnabled = true + textField.heightAnchor.constraint(equalToConstant: 52).isActive = true + textField.st_enablePasswordToggle() + return textField + } + + private func makePlaceholderTextView() -> STPlaceholderTextView { + let textView = STPlaceholderTextView() + textView.placeholder = "STPlaceholderTextView:placeholder 与 glass 背景" + textView.font = .systemFont(ofSize: 16) + textView.contentInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + textView.layer.cornerRadius = 16 + textView.isLiquidGlassEnabled = true + textView.heightAnchor.constraint(equalToConstant: 100).isActive = true + return textView + } + + private func makeTextView() -> STTextView { + let textView = STTextView() + textView.placeholder = "STTextView:自适应高度、字数限制" + textView.font = .systemFont(ofSize: 16) + textView.cornerRadius = 16 + textView.borderWidth = 1 + textView.borderColor = UIColor.white.withAlphaComponent(0.5) + textView.isLiquidGlassEnabled = true + textView.maxTextCount = 80 + textView.minimumNumberOfLines = 3 + textView.maximumNumberOfLines = 5 + return textView + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STTimerTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STTimerTestViewController.swift new file mode 100644 index 0000000..e5405a1 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STTimerTestViewController.swift @@ -0,0 +1,267 @@ +// +// STTimerTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2022/8/4. +// + +import UIKit +import STBaseProject + +class STTimerTestViewController: BaseViewController { + + private var timer: STTimer? + private var logLines: [String] = [] + private var countdownTimer: STCountdownTimer? + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "STTimer 功能测试" + self.buildUI() + } + + private func buildUI() { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(scrollView) + + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = 12 + scrollView.addSubview(stackView) + + self.addSection(title: "STTimer", actions: [ + ("创建 1s 定时器", #selector(self.createTimer)), + ("start(firstFire: .immediate)", #selector(self.startTimer)), + ("pause()", #selector(self.pauseTimer)), + ("resume()", #selector(self.resumeTimer)), + ("stop()", #selector(self.stopTimer)), + ("读取状态", #selector(self.inspectTimerState)) + ], to: stackView) + + self.addSection(title: "STCountdownTimer", actions: [ + ("创建 10s 倒计时", #selector(self.createCountdown)), + ("start()", #selector(self.startCountdown)), + ("pause()", #selector(self.pauseCountdown)), + ("resume()", #selector(self.resumeCountdown)), + ("stop()", #selector(self.stopCountdown)), + ("reset()", #selector(self.resetCountdown)), + ("读取剩余/进度", #selector(self.inspectCountdownState)) + ], to: stackView) + + self.addSection(title: "STTimeProfiler", actions: [ + ("st_start + st_logElapsed", #selector(self.profileStartAndLog)), + ("st_end", #selector(self.profileEnd)), + ("st_measure (同步)", #selector(self.profileSyncMeasure)), + ("st_measureAsync (异步)", #selector(self.profileAsyncMeasure)), + ("st_clear(tag:)", #selector(self.profileClearTag)), + ("st_clearAll()", #selector(self.profileClearAll)) + ], to: stackView) + + stackView.addArrangedSubview(self.logTextView) + NSLayoutConstraint.activate([ + self.logTextView.heightAnchor.constraint(equalToConstant: 240) + ]) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + scrollView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 12), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + + self.applyLiquidGlassScrollLayout(scrollView) + } + + private func addSection(title: String, actions: [(String, Selector)], to parent: UIStackView) { + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .boldSystemFont(ofSize: 17) + parent.addArrangedSubview(titleLabel) + + for action in actions { + let button = UIButton(type: .system) + button.contentHorizontalAlignment = .left + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.setTitle(action.0, for: .normal) + button.addTarget(self, action: action.1, for: .touchUpInside) + parent.addArrangedSubview(button) + } + } + + private func appendLog(_ message: String) { + let line = "[\(Self.timeString())] \(message)" + self.logLines.append(line) + if self.logLines.count > 120 { + self.logLines.removeFirst(self.logLines.count - 120) + } + self.logTextView.text = self.logLines.joined(separator: "\n") + let range = NSRange(location: max(self.logTextView.text.count - 1, 0), length: 1) + self.logTextView.scrollRangeToVisible(range) + STLog(message) + } + + private static func timeString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: Date()) + } + + private lazy var logTextView: UITextView = { + let textView = UITextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = false + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.backgroundColor = UIColor.secondarySystemBackground + textView.layer.cornerRadius = 8 + return textView + }() +} + +private extension STTimerTestViewController { + + @objc func createTimer() { + self.timer?.stop() + self.timer = STTimer(interval: 1.0) + self.appendLog("STTimer 已创建,间隔 1 秒") + } + + @objc func startTimer() { + guard let timer = self.timer else { + self.appendLog("请先创建 STTimer") + return + } + timer.start(firstFire: .immediate) { [weak self] timer in + self?.appendLog("STTimer 触发: fireCount=\(timer.fireCount), isRunning=\(timer.isRunning), isPaused=\(timer.isPaused)") + } + self.appendLog("调用 STTimer.start(firstFire: .immediate)") + } + + @objc func pauseTimer() { + self.timer?.pause() + self.appendLog("调用 STTimer.pause()") + } + + @objc func resumeTimer() { + self.timer?.resume() + self.appendLog("调用 STTimer.resume()") + } + + @objc func stopTimer() { + self.timer?.stop() + self.appendLog("调用 STTimer.stop()") + } + + @objc func inspectTimerState() { + guard let timer = self.timer else { + self.appendLog("STTimer 不存在") + return + } + self.appendLog("STTimer 状态: isRunning=\(timer.isRunning), isPaused=\(timer.isPaused), fireCount=\(timer.fireCount)") + } + + @objc func createCountdown() { + self.countdownTimer?.stop() + do { + self.countdownTimer = try STCountdownTimer(duration: 10) + self.appendLog("STCountdownTimer 已创建,时长 10 秒") + } catch { + self.appendLog("创建 STCountdownTimer 失败: \(error.localizedDescription)") + } + } + + @objc func startCountdown() { + guard let timer = self.countdownTimer else { + self.appendLog("请先创建 STCountdownTimer") + return + } + timer.start(progress: { [weak self] remaining in + self?.appendLog("倒计时 progress: remaining=\(String(format: "%.2f", remaining))s") + }, completion: { [weak self] in + self?.appendLog("倒计时 completion 回调") + }, error: { [weak self] error in + self?.appendLog("倒计时 error: \(error.localizedDescription)") + }) + self.appendLog("调用 STCountdownTimer.start()") + } + + @objc func pauseCountdown() { + self.countdownTimer?.pause() + self.appendLog("调用 STCountdownTimer.pause()") + } + + @objc func resumeCountdown() { + self.countdownTimer?.resume() + self.appendLog("调用 STCountdownTimer.resume()") + } + + @objc func stopCountdown() { + self.countdownTimer?.stop() + self.appendLog("调用 STCountdownTimer.stop()") + } + + @objc func resetCountdown() { + self.countdownTimer?.reset() + self.appendLog("调用 STCountdownTimer.reset()") + } + + @objc func inspectCountdownState() { + guard let timer = self.countdownTimer else { + self.appendLog("STCountdownTimer 不存在") + return + } + let remaining = timer.getRemainingTime() + let progress = timer.getProgress() + self.appendLog("Countdown 状态: isRunning=\(timer.isRunning), isPaused=\(timer.isPaused), remaining=\(String(format: "%.2f", remaining))s, progress=\(String(format: "%.2f", progress))") + } + + @objc func profileStartAndLog() { + STTimeProfiler.st_start(tag: "manual") + self.appendLog("调用 STTimeProfiler.st_start(tag: manual)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + STTimeProfiler.st_logElapsed(tag: "manual", message: "手动检查耗时") + if let elapsed = STTimeProfiler.st_elapsedTime(tag: "manual") { + self?.appendLog("st_elapsedTime(manual)=\(String(format: "%.4f", elapsed))s") + } + } + } + + @objc func profileEnd() { + STTimeProfiler.st_end(tag: "manual", message: "手动结束") + self.appendLog("调用 STTimeProfiler.st_end(tag: manual)") + } + + @objc func profileSyncMeasure() { + let result = STTimeProfiler.st_measure(tag: "syncMeasure", message: "同步计算") { () -> Int in + (0..<100_000).reduce(0, +) + } + self.appendLog("st_measure 同步完成,结果=\(result)") + } + + @objc func profileAsyncMeasure() { + STTimeProfiler.st_measureAsync(tag: "asyncMeasure", message: "异步等待 300ms") { [weak self] in + try await Task.sleep(nanoseconds: 300_000_000) + await MainActor.run { + self?.appendLog("st_measureAsync block 执行完成") + } + } + self.appendLog("调用 STTimeProfiler.st_measureAsync(tag: asyncMeasure)") + } + + @objc func profileClearTag() { + STTimeProfiler.st_clear(tag: "manual") + self.appendLog("调用 STTimeProfiler.st_clear(tag: manual)") + } + + @objc func profileClearAll() { + STTimeProfiler.st_clearAll() + self.appendLog("调用 STTimeProfiler.st_clearAll()") + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STToolsManualTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STToolsManualTestViewController.swift new file mode 100644 index 0000000..ec7c64c --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STToolsManualTestViewController.swift @@ -0,0 +1,213 @@ +// +// STToolsManualTestViewController.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2022/8/4. +// + +import UIKit +import STBaseProject + +final class STToolsManualTestViewController: BaseViewController { + + private var markdownTypewriterTimer: Timer? + private var markdownTypewriterText: String = "" + private var markdownTypewriterIndex: Int = 0 + private let markdownTypewriterInterval: TimeInterval = 0.02 + private let markdownTypewriterStep: Int = 1 + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "STTools 手动测试" + self.buildUI() + self.appendLine("进入页面后可逐项触发依赖系统能力的测试") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.stopMarkdownTypewriter() + } + + private func buildUI() { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(scrollView) + + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 12 + scrollView.addSubview(stackView) + + let actions: [(String, Selector)] = [ + ("读取 DeviceInfo", #selector(self.readDeviceInfo)), + ("读取 DeviceAdapter", #selector(self.readDeviceAdapter)), + ("CrashDetector 标记/检查", #selector(self.testCrashDetector)), + ("字体/颜色示例", #selector(self.testFontAndColor)), + ("性能测量示例", #selector(self.testScrollPerfDiagnostics)), + ("Markdown 资源逐字输出", #selector(self.showMarkdownResourcesVerbatim)) + ] + + for action in actions { + let button = UIButton(type: .system) + button.contentHorizontalAlignment = .left + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.setTitle(action.0, for: .normal) + button.addTarget(self, action: action.1, for: .touchUpInside) + stackView.addArrangedSubview(button) + } + + stackView.addArrangedSubview(self.markdownStreamingView) + stackView.addArrangedSubview(self.outputTextView) + NSLayoutConstraint.activate([ + self.markdownStreamingView.heightAnchor.constraint(equalToConstant: 360), + self.outputTextView.heightAnchor.constraint(equalToConstant: 260), + scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + scrollView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 12), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + + self.applyLiquidGlassScrollLayout(scrollView) + } + + private func appendLine(_ message: String) { + let time = Date().formatted("HH:mm:ss") + let line = "[\(time)] \(message)" + let current = self.outputTextView.text ?? "" + self.outputTextView.text = current.isEmpty ? line : current + "\n" + line + STLog(message) + } + + private lazy var outputTextView: UITextView = { + let textView = UITextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = false + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.backgroundColor = UIColor.secondarySystemBackground + textView.layer.cornerRadius = 8 + return textView + }() + + private lazy var markdownStreamingView: STMarkdownStreamingTextView = { + let view = STMarkdownStreamingTextView( + style: .default, + advancedRenderers: .empty, + engine: STMarkdownEngine() + ) + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor.secondarySystemBackground + view.layer.cornerRadius = 8 + view.clipsToBounds = true + view.isTextSelectionEnabled = true + view.tokenFadeDuration = 0.1 + view.animateAcrossNewlines = false + return view + }() +} + +private extension STToolsManualTestViewController { + + @objc func readDeviceInfo() { + let info = STDeviceInfo.appInfo + self.appendLine("App: \(info.displayName) \(info.version)(\(info.buildVersion))") + self.appendLine("System: \(STDeviceInfo.systemName) \(STDeviceInfo.systemVersion), model: \(STDeviceInfo.deviceModelName)") + self.appendLine("Battery: \(STDeviceInfo.batteryInfo.percentage)%, charging=\(STDeviceInfo.isCharging)") + self.appendLine("Storage used: \(STDeviceInfo.usedStorage), RAM used: \(STDeviceInfo.usedRAM)") + } + + @objc func readDeviceAdapter() { + self.appendLine("Screen: \(STDeviceAdapter.screenSize), scale: \(UIScreen.main.scale)") + self.appendLine("NavBar: \(STDeviceAdapter.navigationBarHeight), TabBar: \(STDeviceAdapter.tabBarHeight)") + self.appendLine("SafeInsets: \(STDeviceAdapter.safeAreaInsets), isNotch=\(STDeviceAdapter.isNotchScreen)") + } + + @objc func testCrashDetector() { + let detector = STCrashDetector.shared + detector.markAppLaunch() + detector.markAppBackgroundEntry() + let detected = detector.detectCrash() + self.appendLine("CrashDetector.detectCrash = \(detected), info = \(detector.crashInfo())") + detector.markAppTermination() + detector.clearCrashData() + } + + @objc func testFontAndColor() { + let font = UIFont.st_systemFont(ofSize: 14, weight: .medium) + let color = UIColor.color(hex: "#FF8800CC") + let components = color.cgColor.components ?? [] + self.appendLine("Font: \(font.fontName) \(font.pointSize)") + self.appendLine("Color components: \(components)") + } + + @objc func testScrollPerfDiagnostics() { + let value = STScrollPerfDiagnostics.measure(name: "ManualCalc") { () -> Int in + (0..<200_000).reduce(0, +) + } + self.appendLine("STScrollPerfDiagnostics measure result = \(value)") + } + + @objc func showMarkdownResourcesVerbatim() { + self.stopMarkdownTypewriter() + let resourceNames = ["data1", "data2", "data3"] + var sections: [String] = [] + + for name in resourceNames { + guard let path = Bundle.main.path(forResource: name, ofType: "txt") else { + self.appendLine("未找到资源文件:\(name).txt") + continue + } + do { + let content = try String(contentsOfFile: path, encoding: .utf8) + sections.append("## \(name).txt\n\n\(content)") + } catch { + self.appendLine("读取 \(name).txt 失败:\(error.localizedDescription)") + } + } + + if sections.isEmpty == false { + self.markdownTypewriterText = sections.joined(separator: "\n\n---\n\n") + self.markdownTypewriterIndex = 0 + self.markdownStreamingView.reset() + self.startMarkdownTypewriter() + self.appendLine("已开始通过 STMarkdown 逐字渲染 3 份资源") + } + } + + private func startMarkdownTypewriter() { + guard !self.markdownTypewriterText.isEmpty else { return } + self.markdownTypewriterTimer = Timer.scheduledTimer( + timeInterval: self.markdownTypewriterInterval, + target: self, + selector: #selector(self.handleMarkdownTypewriterTick), + userInfo: nil, + repeats: true + ) + if let timer = self.markdownTypewriterTimer { + RunLoop.main.add(timer, forMode: .common) + } + } + + private func stopMarkdownTypewriter() { + self.markdownTypewriterTimer?.invalidate() + self.markdownTypewriterTimer = nil + } + + @objc private func handleMarkdownTypewriterTick() { + guard self.markdownTypewriterIndex < self.markdownTypewriterText.count else { + self.stopMarkdownTypewriter() + self.appendLine("逐字渲染完成") + return + } + let nextIndex = min(self.markdownTypewriterIndex + self.markdownTypewriterStep, self.markdownTypewriterText.count) + let end = self.markdownTypewriterText.index(self.markdownTypewriterText.startIndex, offsetBy: nextIndex) + let current = String(self.markdownTypewriterText[.. STView { + let card = STView() + card.heightAnchor.constraint(equalToConstant: 96).isActive = true + card.cornerRadius = 18 + card.borderWidth = 1 + card.borderColor = UIColor.white.withAlphaComponent(0.5) + card.backgroundColor = glass ? .clear : .secondarySystemGroupedBackground + if glass { + card.st_enableLiquidGlassBackground() + card.st_setShadow(color: UIColor.black.withAlphaComponent(0.12), offset: CGSize(width: 0, height: 8), radius: 18, opacity: 1) + } + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: card.centerXAnchor), + label.centerYAnchor.constraint(equalTo: card.centerYAnchor) + ]) + return card + } +} diff --git a/Example/STBaseProjectExample/ViewModels/ViewControllerViewModel.swift b/Example/STBaseProjectExample/ViewModels/ViewControllerViewModel.swift new file mode 100644 index 0000000..796be6e --- /dev/null +++ b/Example/STBaseProjectExample/ViewModels/ViewControllerViewModel.swift @@ -0,0 +1,14 @@ +// +// ViewControllerViewModel.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2023/2/27. +// Copyright © 2023 STBaseProject. All rights reserved. +// + +import UIKit + +class ViewControllerViewModel: NSObject { + + +} diff --git a/Example/STBaseProjectExample/Views/STMyScrollableView.swift b/Example/STBaseProjectExample/Views/STMyScrollableView.swift new file mode 100644 index 0000000..2578397 --- /dev/null +++ b/Example/STBaseProjectExample/Views/STMyScrollableView.swift @@ -0,0 +1,22 @@ +// +// STMyScrollableView.swift +// STBaseProject_Example +// +// Created by 寒江孤影 on 2025/9/23. +// Copyright © 2025 STBaseProject. All rights reserved. +// + +import UIKit +import STBaseProject + +class STMyScrollableView: STBaseView { + + /* + // Only override draw() if you perform custom drawing. + // An empty implementation adversely affects performance during animation. + override func draw(_ rect: CGRect) { + // Drawing code + } + */ + +} diff --git a/Example/STBaseProjectExampleTests/STBaseProjectExampleTests.swift b/Example/STBaseProjectExampleTests/STBaseProjectExampleTests.swift new file mode 100644 index 0000000..8070253 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STBaseProjectExampleTests.swift @@ -0,0 +1,11 @@ +import Testing +@testable import STBaseProjectExample + +struct STBaseProjectExampleTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } +} diff --git a/Example/STBaseProjectExampleTests/STBaseProjectTests/STBaseModelTests.swift b/Example/STBaseProjectExampleTests/STBaseProjectTests/STBaseModelTests.swift new file mode 100644 index 0000000..87a1b39 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STBaseProjectTests/STBaseModelTests.swift @@ -0,0 +1,692 @@ +// +// STBaseModelTests.swift +// STBaseProjectExampleTests +// +// 覆盖 STBaseModel 关键修复: +// - 嵌套模型 Codable 解码 / 编码 +// - st_update(from:) 嵌套对象 / 嵌套数组 / NSNull +// - STJSONValue(_:) 对 NSNumber 桥接 Bool / Int / Double 的判别 +// - KVC 类型不匹配时的安全降级(不应崩溃) +// - 多层继承属性枚举 +// - hash / isEqual 的一致性契约 +// - st_copy() 的深拷贝语义 +// - 灵活模式 st_getXxx +// + +import XCTest +import Foundation +@testable import STBaseProject + +// MARK: - Test Models + +final class STAddressModel: STBaseModel { + @objc var city: String = "" + @objc var zip: String = "" +} + +final class STContactModel: STBaseModel { + @objc var name: String = "" + @objc var phone: String = "" +} + +class STUserBaseModel: STBaseModel { + @objc var userId: Int = 0 + @objc var userName: String = "" +} + +/// 多层继承 + 嵌套对象 + 嵌套数组 + 蛇形键映射。 +final class STUserModel: STUserBaseModel { + @objc var age: Int = 0 + @objc var isVip: Bool = false + @objc var score: Double = 0.0 + @objc var address: STAddressModel? + @objc var contacts: [STContactModel] = [] + @objc var nickname: String = "" + + override class func st_keyMapping() -> [String: String] { + return [ + "user_id": "userId", + "user_name": "userName", + "is_vip": "isVip", + "nick_name": "nickname" + ] + } + + override class func st_nestedModelTypes() -> [String: STBaseModel.Type] { + return [ + "address": STAddressModel.self, + "contacts": STContactModel.self + ] + } +} + +// MARK: - Tests + +final class STBaseModelTests: XCTestCase { + + // MARK: 嵌套对象 / 嵌套数组 / 字典初始化 + + func test_st_update_handles_nested_object_and_array() { + let dict: [String: Any] = [ + "user_id": 42, + "user_name": "alice", + "is_vip": true, + "score": 98.5, + "age": 30, + "address": ["city": "BJ", "zip": "100000"], + "contacts": [ + ["name": "bob", "phone": "12345"], + ["name": "carol", "phone": "67890"] + ] + ] + let user = STUserModel(from: dict) + + XCTAssertEqual(user.userId, 42) + XCTAssertEqual(user.userName, "alice") + XCTAssertTrue(user.isVip) + XCTAssertEqual(user.score, 98.5, accuracy: 0.0001) + XCTAssertEqual(user.age, 30) + + XCTAssertNotNil(user.address) + XCTAssertEqual(user.address?.city, "BJ") + XCTAssertEqual(user.address?.zip, "100000") + + XCTAssertEqual(user.contacts.count, 2) + XCTAssertEqual(user.contacts[0].name, "bob") + XCTAssertEqual(user.contacts[1].phone, "67890") + } + + func test_st_update_filters_invalid_array_elements_via_compactMap() { + let dict: [String: Any] = [ + "contacts": [ + ["name": "bob", "phone": "1"], + "garbage", // 非字典,应被过滤 + NSNull(), // 同上 + ["name": "carol", "phone": "2"] + ] + ] + let user = STUserModel(from: dict) + XCTAssertEqual(user.contacts.count, 2) + XCTAssertEqual(user.contacts.map { $0.name }, ["bob", "carol"]) + } + + func test_st_update_with_NSNull_does_not_crash_on_object_property() { + let user = STUserModel() + user.address = STAddressModel() + user.st_update(from: ["address": NSNull()]) + // address 是对象属性,允许 nil + XCTAssertNil(user.address) + } + + // MARK: 多层继承属性枚举 + + func test_propertyNames_include_super_class_properties() { + let names = STUserModel.st_propertyNames() + // 父类属性 + XCTAssertTrue(names.contains("userId"), "should include super-class property userId") + XCTAssertTrue(names.contains("userName")) + // 子类属性 + XCTAssertTrue(names.contains("age")) + XCTAssertTrue(names.contains("address")) + // 不应泄漏内部属性 + XCTAssertFalse(names.contains("st_isFlexibleMode")) + } + + // MARK: Codable —— 关键修复 #1 / #2 + + func test_codable_round_trip_preserves_nested_objects_and_arrays() throws { + let user = STUserModel(from: [ + "user_id": 7, + "user_name": "ada", + "is_vip": false, + "score": 1.5, + "age": 18, + "nick_name": "lovelace", + "address": ["city": "London", "zip": "SW1"], + "contacts": [ + ["name": "babbage", "phone": "111"] + ] + ]) + + let data = try JSONEncoder().encode(user) + let decoded = try JSONDecoder().decode(STUserModel.self, from: data) + + XCTAssertEqual(decoded.userId, 7) + XCTAssertEqual(decoded.userName, "ada") + XCTAssertFalse(decoded.isVip) + XCTAssertEqual(decoded.score, 1.5, accuracy: 0.0001) + XCTAssertEqual(decoded.age, 18) + XCTAssertEqual(decoded.nickname, "lovelace") + + XCTAssertEqual(decoded.address?.city, "London") + XCTAssertEqual(decoded.address?.zip, "SW1") + + XCTAssertEqual(decoded.contacts.count, 1) + XCTAssertEqual(decoded.contacts[0].name, "babbage") + XCTAssertEqual(decoded.contacts[0].phone, "111") + } + + func test_codable_uses_json_keys_from_st_keyMapping() throws { + let user = STUserModel() + user.userId = 1 + user.userName = "n" + user.isVip = true + user.nickname = "nn" + + let data = try JSONEncoder().encode(user) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNotNil(json["user_id"]) + XCTAssertNotNil(json["user_name"]) + XCTAssertNotNil(json["is_vip"]) + XCTAssertNotNil(json["nick_name"]) + // 不应同时出现属性原名 + XCTAssertNil(json["userId"]) + XCTAssertNil(json["userName"]) + } + + // MARK: STJSONValue NSNumber 桥接 —— 关键修复 #3 + + func test_STJSONValue_init_distinguishes_NSNumber_bool_from_int() { + // 直接 Swift Bool / Int 字面量 + if case .bool(let b) = STJSONValue(true) { XCTAssertTrue(b) } else { + XCTFail("Bool literal should map to .bool") + } + if case .int(let i) = STJSONValue(42) { XCTAssertEqual(i, 42) } else { + XCTFail("Int literal should map to .int") + } + + // NSNumber 桥接(最容易踩坑) + let nsTrue: Any = NSNumber(value: true) + let nsInt: Any = NSNumber(value: 7) + let nsDouble: Any = NSNumber(value: 1.5) + + switch STJSONValue(nsTrue) { + case .bool(let b): XCTAssertTrue(b) + default: XCTFail("NSNumber(true) should map to .bool, got \(STJSONValue(nsTrue))") + } + switch STJSONValue(nsInt) { + case .int(let i): XCTAssertEqual(i, 7) + default: XCTFail("NSNumber(7) should map to .int") + } + switch STJSONValue(nsDouble) { + case .double(let d): XCTAssertEqual(d, 1.5, accuracy: 0.0001) + default: XCTFail("NSNumber(1.5) should map to .double") + } + } + + func test_STJSONValue_init_handles_JSONSerialization_bridged_bool() throws { + let raw = #"{"flag": true, "count": 3, "ratio": 2.5}"#.data(using: .utf8)! + let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: raw) as? [String: Any]) + + switch STJSONValue(dict["flag"]!) { + case .bool(let b): XCTAssertTrue(b) + default: XCTFail("flag from JSONSerialization should map to .bool") + } + switch STJSONValue(dict["count"]!) { + case .int(let i): XCTAssertEqual(i, 3) + default: XCTFail("count from JSONSerialization should map to .int") + } + switch STJSONValue(dict["ratio"]!) { + case .double(let d): XCTAssertEqual(d, 2.5, accuracy: 0.0001) + default: XCTFail("ratio from JSONSerialization should map to .double") + } + } + + // MARK: KVC 类型安全 —— 不再因类型不匹配崩溃 + + func test_safeSetValue_does_not_crash_on_type_mismatch() { + let user = STUserModel() + // 把一个 String 喂给 Int 属性 userId:旧实现会抛 NSInvalidArgumentException 而崩溃。 + XCTAssertNoThrow(user.st_update(from: ["user_id": "not-a-number"])) + // 行为约定:无法转换的值不写入,userId 保持默认 0。 + XCTAssertEqual(user.userId, 0) + } + + func test_safeSetValue_coerces_string_number_to_int() { + let user = STUserModel() + user.st_update(from: ["user_id": "123"]) + XCTAssertEqual(user.userId, 123) + } + + func test_safeSetValue_coerces_NSNumber_to_bool() { + let user = STUserModel() + user.st_update(from: ["is_vip": NSNumber(value: 1)]) + XCTAssertTrue(user.isVip) + } + + // MARK: hash / isEqual 一致性 —— 关键修复 #12 + + func test_isEqual_and_hash_consistency() { + let dict: [String: Any] = [ + "user_id": 1, + "user_name": "a", + "address": ["city": "X", "zip": "0"] + ] + let a = STUserModel(from: dict) + let b = STUserModel(from: dict) + XCTAssertTrue(a.isEqual(b)) + XCTAssertEqual(a.hash, b.hash, "isEqual ⇒ hash equality") + + let set = NSMutableSet() + set.add(a) + XCTAssertTrue(set.contains(b), "two equal STBaseModel instances must collide in NSSet") + } + + func test_isEqual_returns_false_for_different_types() { + let user = STUserModel() + let address = STAddressModel() + XCTAssertFalse(user.isEqual(address)) + } + + // MARK: st_copy() 深拷贝语义 + + func test_st_copy_deep_copies_nested_object() { + let original = STUserModel(from: [ + "user_id": 1, + "user_name": "a", + "address": ["city": "BJ", "zip": "100"] + ]) + + let copy = original.st_copy() as! STUserModel + XCTAssertNotNil(copy.address) + XCTAssertEqual(copy.address?.city, "BJ") + + // 不应共享同一个 address 实例 + XCTAssertFalse(copy.address === original.address, + "address should be a new instance after st_copy()") + + // 修改 copy 的 address 不影响原对象 + copy.address?.city = "SH" + XCTAssertEqual(original.address?.city, "BJ") + XCTAssertEqual(copy.address?.city, "SH") + } + + func test_st_copy_deep_copies_nested_array_elements() { + let original = STUserModel(from: [ + "contacts": [ + ["name": "bob", "phone": "1"], + ["name": "carol", "phone": "2"] + ] + ]) + + let copy = original.st_copy() as! STUserModel + XCTAssertEqual(copy.contacts.count, 2) + // 元素也必须是新实例 + XCTAssertFalse(copy.contacts[0] === original.contacts[0]) + + copy.contacts[0].name = "BOB" + XCTAssertEqual(original.contacts[0].name, "bob") + } + + func test_st_copy_returns_same_subclass_type() { + let original: STUserBaseModel = STUserModel() + let copy = original.st_copy() + XCTAssertTrue(copy is STUserModel, + "st_copy must return the runtime subclass, not STBaseModel") + } + + // MARK: 灵活模式 + + func test_flexible_mode_get_xxx_returns_typed_values() { + let model = STBaseModel() + model.st_isFlexibleMode = true + model.st_update(from: [ + "name": "alice", + "age": 30, + "isVip": true, + "score": 1.5, + "extra": NSNull() + ]) + + XCTAssertEqual(model.st_getString(forKey: "name"), "alice") + XCTAssertEqual(model.st_getInt(forKey: "age"), 30) + XCTAssertTrue(model.st_getBool(forKey: "isVip")) + XCTAssertEqual(model.st_getDouble(forKey: "score"), 1.5, accuracy: 0.0001) + + XCTAssertEqual(model.st_valueKind(forKey: "name"), .string) + XCTAssertEqual(model.st_valueKind(forKey: "age"), .int) + XCTAssertEqual(model.st_valueKind(forKey: "isVip"), .bool) + XCTAssertEqual(model.st_valueKind(forKey: "score"), .double) + XCTAssertEqual(model.st_valueKind(forKey: "extra"), .null) + XCTAssertEqual(model.st_valueKind(forKey: "missing"), .undefined) + + XCTAssertTrue(model.st_containsKey("name")) + XCTAssertFalse(model.st_containsKey("missing")) + } + + func test_flexible_mode_st_copy_does_not_share_storage() { + let original = STBaseModel() + original.st_isFlexibleMode = true + original.st_update(from: ["name": "alice", "age": 30]) + + let copy = original.st_copy() + copy.st_update(from: ["name": "bob"]) + + XCTAssertEqual(original.st_getString(forKey: "name"), "alice") + XCTAssertEqual(copy.st_getString(forKey: "name"), "bob") + } + + // MARK: nil 写入非 Optional 引用属性的保护 + + func test_st_update_with_NSNull_skips_non_optional_reference_property() { + // 非 Optional 引用属性:NSNull 写入会被跳过,保留原值,避免后续访问崩溃。 + let model = STNonOptionalRefModel() + let original = model.payload + model.st_update(from: ["payload": NSNull()]) + XCTAssertTrue(model.payload === original, "non-optional reference should not be nilled by NSNull") + } + + func test_st_update_with_NSNull_still_clears_optional_reference() { + // 回归测试:Optional 引用属性仍然可以被 NSNull 清空。 + let user = STUserModel() + user.address = STAddressModel() + user.st_update(from: ["address": NSNull()]) + XCTAssertNil(user.address) + } + + // MARK: NSMutableArray / NSMutableDictionary 属性的可变性 + + func test_st_update_preserves_NSMutableArray_mutability() { + let model = STMutableContainerModel() + model.st_update(from: ["items": [1, 2, 3]]) + XCTAssertEqual(model.items.count, 3) + // 直接调用 mutable API:若内部存的是 Swift 桥接不可变数组,这里会崩。 + model.items.add(4) + XCTAssertEqual(model.items.count, 4) + XCTAssertEqual(model.items.lastObject as? Int, 4) + } + + func test_st_update_preserves_NSMutableDictionary_mutability() { + let model = STMutableContainerModel() + model.st_update(from: ["mapping": ["a": 1, "b": 2]]) + XCTAssertEqual(model.mapping.count, 2) + model.mapping.setObject(3, forKey: "c" as NSString) + XCTAssertEqual(model.mapping.count, 3) + XCTAssertEqual(model.mapping["c"] as? Int, 3) + } + + // MARK: - st_toDictionary (standard mode) + + func test_st_toDictionary_standard_mode_applies_key_mapping_and_skips_internal_properties() { + func toInt(_ value: Any?) -> Int? { + if let i = value as? Int { return i } + if let n = value as? NSNumber { return n.intValue } + return nil + } + + func toDouble(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let n = value as? NSNumber { return n.doubleValue } + return nil + } + + func toBool(_ value: Any?) -> Bool? { + if let b = value as? Bool { return b } + if let n = value as? NSNumber { return n.boolValue } + return nil + } + + func toString(_ value: Any?) -> String? { + if let s = value as? String { return s } + if let ns = value as? NSString { return String(ns) } + return nil + } + + let user = STUserModel() + user.userId = 1 + user.userName = "n" + user.isVip = true + user.score = 2.5 + user.age = 3 + user.nickname = "nn" + user.address = STAddressModel() + user.address?.city = "BJ" + user.address?.zip = "100000" + + let dict = user.st_toDictionary() + + XCTAssertEqual(toInt(dict["user_id"]) ?? -1, 1) + XCTAssertEqual(toString(dict["user_name"]) ?? "", "n") + XCTAssertEqual(toBool(dict["is_vip"]) ?? false, true) + XCTAssertEqual(toDouble(dict["score"]) ?? 0.0, 2.5, accuracy: 0.0001) + XCTAssertEqual(toInt(dict["age"]) ?? -1, 3) + XCTAssertEqual(toString(dict["nick_name"]) ?? "", "nn") + + // key mapping 之后不应泄漏属性名 + XCTAssertNil(dict["userId"]) + XCTAssertNil(dict["userName"]) + XCTAssertNil(dict["isVip"]) + XCTAssertNil(dict["nickname"]) + + // 内部 reserved 属性名不应出现在序列化结果中 + XCTAssertNil(dict["st_isFlexibleMode"]) + } + + // MARK: - st_getArray / st_getDictionary / st_toRawDictionary (flexible mode) + + func test_flexible_mode_st_getArray_st_getDictionary_and_toRawDictionary() { + let model = STBaseModel() + model.st_isFlexibleMode = true + model.st_update(from: [ + "name": "alice", + "age": 30, + "arr": [1, "2", NSNull(), true], + "dict": ["k1": "v1", "k2": 2], + "extra": NSNull() + ]) + + XCTAssertEqual(model.st_getString(forKey: "name"), "alice") + XCTAssertEqual(model.st_getInt(forKey: "age"), 30) + XCTAssertEqual(model.st_valueKind(forKey: "arr"), .array) + XCTAssertEqual(model.st_valueKind(forKey: "dict"), .dictionary) + XCTAssertEqual(model.st_valueKind(forKey: "extra"), .null) + + let raw = model.st_toRawDictionary() + XCTAssertEqual((raw["name"]?.stringValue), "alice") + + // Array: [Int, String, Null, Bool] + let arr = model.st_getArray(forKey: "arr") + XCTAssertEqual(arr.count, 4) + XCTAssertEqual(arr[0].intValue, 1) + XCTAssertEqual(arr[1].stringValue, "2") + XCTAssertEqual(arr[2].isNull, true) + XCTAssertEqual(arr[3].boolValue, true) + + // Dictionary: {"k1":"v1","k2":2} + let dict = model.st_getDictionary(forKey: "dict") + XCTAssertEqual(dict.count, 2) + XCTAssertEqual(dict["k1"]?.stringValue, "v1") + XCTAssertEqual(dict["k2"]?.intValue, 2) + + // processedData: should return concrete Any values + let processedArr = model.st_getValue(forKey: "arr") as? [Any] + XCTAssertEqual(processedArr?.count, 4) + XCTAssertEqual(processedArr?[0] as? Int, 1) + XCTAssertEqual(processedArr?[1] as? String, "2") + + let processedExtra = model.st_getValue(forKey: "extra") + XCTAssertTrue(processedExtra is NSNull) + } + + func test_flexible_mode_st_getAllKeys_and_st_containsKey() { + let model = STBaseModel() + model.st_isFlexibleMode = true + model.st_update(from: ["name": "alice", "age": 30]) + + let keys = Set(model.st_getAllKeys()) + XCTAssertTrue(keys.contains("name")) + XCTAssertTrue(keys.contains("age")) + XCTAssertFalse(model.st_containsKey("missing")) + } + + func test_flexible_mode_st_getValueType_string_compat() { + let model = STBaseModel() + model.st_isFlexibleMode = true + model.st_update(from: [ + "name": "alice", + "age": 30, + "isVip": true, + "score": 1.5, + "arr": [1, 2], + "dict": ["k": "v"], + "extra": NSNull() + ]) + + XCTAssertEqual(model.st_getValueType(forKey: "name"), "String") + XCTAssertEqual(model.st_getValueType(forKey: "age"), "Int") + XCTAssertEqual(model.st_getValueType(forKey: "isVip"), "Bool") + XCTAssertEqual(model.st_getValueType(forKey: "score"), "Double") + XCTAssertEqual(model.st_getValueType(forKey: "arr"), "Array") + XCTAssertEqual(model.st_getValueType(forKey: "dict"), "Dictionary") + XCTAssertEqual(model.st_getValueType(forKey: "extra"), "Null") + XCTAssertEqual(model.st_getValueType(forKey: "missing"), "undefined") + } + + // MARK: - Codable encode (flexible mode) + + func test_codable_encode_flexible_mode_uses_rawData_keys_and_values() throws { + let model = STBaseModel() + model.st_isFlexibleMode = true + model.st_update(from: [ + "name": "alice", + "age": 30, + "isVip": true, + "score": 1.5, + "extra": NSNull(), + "arr": [1, 2], + "dict": ["k": "v"] + ]) + + let data = try JSONEncoder().encode(model) + let decoded = try JSONDecoder().decode([String: STJSONValue].self, from: data) + + XCTAssertEqual(decoded["name"]?.stringValue, "alice") + XCTAssertEqual(decoded["age"]?.intValue, 30) + XCTAssertEqual(decoded["isVip"]?.boolValue, true) + XCTAssertEqual(decoded["score"]?.doubleValue ?? 0.0, 1.5, accuracy: 0.0001) + XCTAssertEqual(decoded["extra"]?.isNull, true) + XCTAssertEqual(decoded["arr"]?.arrayValue?.count, 2) + XCTAssertEqual(decoded["dict"]?.objectValue?["k"]?.stringValue, "v") + } + + // MARK: - st_fromArray / st_fromJSONArray + + func test_st_fromArray_returns_subclass_instances() { + let models = STUserModel.st_fromArray([ + ["user_id": 1, "user_name": "alice", "is_vip": false, "score": 1.0, "age": 18, "nick_name": "n1"], + ["user_id": 2, "user_name": "bob", "is_vip": true, "score": 2.0, "age": 20, "nick_name": "n2"] + ]) + + XCTAssertEqual(models.count, 2) + XCTAssertEqual((models[0] as? STUserModel)?.userId, 1) + XCTAssertEqual((models[1] as? STUserModel)?.userId, 2) + XCTAssertTrue(models.allSatisfy { $0 is STUserModel }) + } + + func test_st_fromJSONArray_parses_json_array_into_models() throws { + let rawArray: [[String: Any]] = [ + ["user_id": 7, "user_name": "ada", "is_vip": false, "score": 1.5, "age": 18, "nick_name": "lovelace"] + ] + let data = try JSONSerialization.data(withJSONObject: rawArray, options: []) + + let models = try XCTUnwrap(STUserModel.st_fromJSONArray(data)) + XCTAssertEqual(models.count, 1) + + let user = try XCTUnwrap(models.first as? STUserModel) + XCTAssertEqual(user.userId, 7) + XCTAssertEqual(user.userName, "ada") + XCTAssertFalse(user.isVip) + XCTAssertEqual(user.score, 1.5, accuracy: 0.0001) + XCTAssertEqual(user.age, 18) + XCTAssertEqual(user.nickname, "lovelace") + } + + // MARK: - st_copy deep copy of mutable containers with model values + + func test_st_copy_deep_copies_mutable_array_elements_when_elements_are_models() { + let container = STArrayModelContainer() + let a = STAddressModel() + a.city = "BJ" + a.zip = "100" + + container.addressList = NSMutableArray(array: [a]) + let copy = container.st_copy() as! STArrayModelContainer + + XCTAssertFalse(copy.addressList === container.addressList) + let copyA = copy.addressList.firstObject as? STAddressModel + let originalA = container.addressList.firstObject as? STAddressModel + XCTAssertNotNil(copyA) + XCTAssertNotNil(originalA) + XCTAssertFalse(copyA === originalA) + XCTAssertEqual(copyA?.city, "BJ") + + copyA?.city = "SH" + XCTAssertEqual(originalA?.city, "BJ") + XCTAssertEqual(copyA?.city, "SH") + } + + func test_st_copy_deep_copies_mutable_dictionary_values_when_values_are_models() { + let container = STDictionaryModelContainer() + let a = STAddressModel() + a.city = "BJ" + a.zip = "100" + + container.addressByKey["k1"] = a + let copy = container.st_copy() as! STDictionaryModelContainer + + XCTAssertFalse(copy.addressByKey === container.addressByKey) + let copyA = copy.addressByKey["k1"] as? STAddressModel + let originalA = container.addressByKey["k1"] as? STAddressModel + XCTAssertNotNil(copyA) + XCTAssertNotNil(originalA) + XCTAssertFalse(copyA === originalA) + XCTAssertEqual(copyA?.city, "BJ") + + copyA?.city = "SH" + XCTAssertEqual(originalA?.city, "BJ") + XCTAssertEqual(copyA?.city, "SH") + } + + // MARK: - description / debugDescription (smoke) + + func test_description_contains_key_information_in_standard_and_flexible_modes() { + let standard = STUserModel() + standard.userId = 1 + standard.userName = "alice" + standard.isVip = true + let standardDesc = standard.description + XCTAssertTrue(standardDesc.contains("userId")) + XCTAssertTrue(standardDesc.contains("userName")) + XCTAssertTrue(standardDesc.contains("isVip")) + + let flexible = STBaseModel() + flexible.st_isFlexibleMode = true + flexible.st_update(from: ["name": "alice", "age": 30]) + let flexDesc = flexible.description + XCTAssertTrue(flexDesc.contains("name")) + XCTAssertTrue(flexDesc.contains("alice")) + XCTAssertTrue(flexDesc.contains("age")) + XCTAssertTrue(flexDesc.contains("30")) + } +} + +// MARK: - Additional Test Models + +/// 非 Optional 引用属性:用来验证 NSNull 不会把它写成无效状态。 +final class STNonOptionalRefModel: STBaseModel { + @objc var payload: STAddressModel = STAddressModel() +} + +/// 可变容器属性:用来验证 st_update 写入后仍保持 NSMutable* 类型。 +final class STMutableContainerModel: STBaseModel { + @objc var items: NSMutableArray = NSMutableArray() + @objc var mapping: NSMutableDictionary = NSMutableDictionary() +} + +final class STArrayModelContainer: STBaseModel { + @objc var addressList: NSMutableArray = NSMutableArray() +} + +final class STDictionaryModelContainer: STBaseModel { + @objc var addressByKey: NSMutableDictionary = NSMutableDictionary() +} diff --git a/Example/STBaseProjectExampleTests/STBaseViewModelNetworkTests.swift b/Example/STBaseProjectExampleTests/STBaseViewModelNetworkTests.swift new file mode 100644 index 0000000..d36c54a --- /dev/null +++ b/Example/STBaseProjectExampleTests/STBaseViewModelNetworkTests.swift @@ -0,0 +1,510 @@ +import XCTest +import Combine +import STBaseProject +@testable import STBaseProjectExample + +private struct MockUserDTO: Codable, Equatable { + let id: Int + let name: String +} + +private final class MockRequestingViewModel: STBaseViewModel { + struct CapturedRequest { + let url: String + let method: STHTTPMethod + let parameters: [String: Any]? + let encodingType: STParameterEncoder.EncodingType + } + + var capturedRequest: CapturedRequest? + var capturedURLRequest: URLRequest? + var mockedResponse: STHTTPResponse? + var responseSubjects: [PassthroughSubject] = [] + var dispatchCancelCount = 0 + + override func st_dispatchRequestPublisher( + url: String, + method: STHTTPMethod, + parameters: [String: Any]?, + encodingType: STParameterEncoder.EncodingType + ) -> AnyPublisher { + self.capturedRequest = CapturedRequest( + url: url, + method: method, + parameters: parameters, + encodingType: encodingType + ) + if !self.responseSubjects.isEmpty { + let subject = self.responseSubjects.removeFirst() + return subject + .handleEvents(receiveCancel: { [weak self] in + self?.dispatchCancelCount += 1 + }) + .eraseToAnyPublisher() + } + guard let mockedResponse = self.mockedResponse else { + return Empty().eraseToAnyPublisher() + } + return Just(mockedResponse).eraseToAnyPublisher() + } + + override func st_dispatchRequestPublisher(_ request: URLRequest) -> AnyPublisher { + self.capturedURLRequest = request + guard let mockedResponse = self.mockedResponse else { + return Empty().eraseToAnyPublisher() + } + return Just(mockedResponse).eraseToAnyPublisher() + } +} + +final class STBaseViewModelNetworkTests: XCTestCase { + private var cancellables: Set = [] + + override func tearDown() { + self.cancellables.removeAll() + super.tearDown() + } + + private func makeSuccessHTTPResponse(urlString: String = "https://example.com/user") throws -> STHTTPResponse { + let responseBody = MockUserDTO(id: 7, name: "Song") + let responseData = try JSONEncoder().encode(responseBody) + let url = URL(string: urlString)! + let urlResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + return STHTTPResponse(data: responseData, response: urlResponse, error: nil) + } + + func testRequestSuccessDecodesModelAndPublishesLoadedState() throws { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + + let responseBody = MockUserDTO(id: 7, name: "Song") + let responseData = try JSONEncoder().encode(responseBody) + let url = URL(string: "https://example.com/user")! + let urlResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + viewModel.mockedResponse = STHTTPResponse(data: responseData, response: urlResponse, error: nil) + + var loadingStates: [STLoadingState] = [] + let stateExpectation = expectation(description: "loading state reaches loaded") + viewModel.loadingState + .sink { state in + loadingStates.append(state) + if case .loaded = state { + stateExpectation.fulfill() + } + } + .store(in: &self.cancellables) + + let completionExpectation = expectation(description: "request publisher emits value") + var receivedValue: MockUserDTO? + viewModel.st_requestPublisher( + url: url.absoluteString, + method: .get, + parameters: ["id": 7], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("Expected success but got error: \(error)") + } + }, + receiveValue: { value in + receivedValue = value + completionExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation, stateExpectation], timeout: 1.0) + + XCTAssertEqual(viewModel.capturedRequest?.url, url.absoluteString) + XCTAssertEqual(viewModel.capturedRequest?.method, .get) + XCTAssertEqual(viewModel.capturedRequest?.parameters?["id"] as? Int, 7) + XCTAssertEqual(viewModel.capturedRequest?.encodingType, .json) + + XCTAssertTrue(loadingStates.contains { state in + if case .loading = state { return true } + return false + }) + XCTAssertTrue(loadingStates.contains { state in + if case .loaded = state { return true } + return false + }) + + guard let value = receivedValue else { + return XCTFail("Expected success result") + } + XCTAssertEqual(value, responseBody) + } + + func testRequestPublisherIsLazyUntilSubscribed() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + + let publisher = viewModel.st_requestPublisher( + url: "https://example.com/lazy", + responseType: MockUserDTO.self + ) + XCTAssertNil(viewModel.capturedRequest) + + let completionExpectation = expectation(description: "lazy publisher emits after subscription") + publisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in completionExpectation.fulfill() } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.url, "https://example.com/lazy") + } + + func testRequestPublisherCancellationReturnsLoadingStateToIdleAndCancelsUpstream() { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + viewModel.responseSubjects = [PassthroughSubject()] + + var states: [STLoadingState] = [] + viewModel.loadingState + .sink { states.append($0) } + .store(in: &self.cancellables) + + let token = viewModel.st_requestPublisher( + url: "https://example.com/cancel", + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in XCTFail("Expected cancellation before response") } + ) + + XCTAssertEqual(viewModel.capturedRequest?.url, "https://example.com/cancel") + token.cancel() + + XCTAssertEqual(viewModel.dispatchCancelCount, 1) + XCTAssertTrue(states.contains { state in + if case .loading = state { return true } + return false + }) + XCTAssertEqual(viewModel.loadingState.value, .idle) + } + + func testConcurrentRequestsKeepLoadingUntilAllRequestsFinish() throws { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + let firstSubject = PassthroughSubject() + let secondSubject = PassthroughSubject() + viewModel.responseSubjects = [firstSubject, secondSubject] + + viewModel.st_requestPublisher( + url: "https://example.com/first", + responseType: MockUserDTO.self + ) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &self.cancellables) + + viewModel.st_requestPublisher( + url: "https://example.com/second", + responseType: MockUserDTO.self + ) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &self.cancellables) + + firstSubject.send(try self.makeSuccessHTTPResponse(urlString: "https://example.com/first")) + firstSubject.send(completion: .finished) + XCTAssertEqual(viewModel.loadingState.value, .loading) + + secondSubject.send(try self.makeSuccessHTTPResponse(urlString: "https://example.com/second")) + secondSubject.send(completion: .finished) + XCTAssertEqual(viewModel.loadingState.value, .loaded) + } + + func testURLRequestOverloadPreservesOriginalRequest() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + + var request = URLRequest(url: URL(string: "https://example.com/raw")!) + request.httpMethod = "POST" + request.setValue("application/custom", forHTTPHeaderField: "Content-Type") + request.setValue("signature-value", forHTTPHeaderField: "X-Signature") + request.httpBody = Data("raw-body=1¬-json=true".utf8) + request.timeoutInterval = 7 + request.cachePolicy = .reloadIgnoringLocalCacheData + + let completionExpectation = expectation(description: "URLRequest overload emits value") + viewModel.st_request(request, responseType: MockUserDTO.self) { result in + guard case .success = result else { + return XCTFail("Expected URLRequest overload success") + } + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedURLRequest?.url, request.url) + XCTAssertEqual(viewModel.capturedURLRequest?.httpMethod, "POST") + XCTAssertEqual(viewModel.capturedURLRequest?.value(forHTTPHeaderField: "Content-Type"), "application/custom") + XCTAssertEqual(viewModel.capturedURLRequest?.value(forHTTPHeaderField: "X-Signature"), "signature-value") + XCTAssertEqual(viewModel.capturedURLRequest?.httpBody, request.httpBody) + XCTAssertEqual(viewModel.capturedURLRequest?.timeoutInterval, 7) + XCTAssertEqual(viewModel.capturedURLRequest?.cachePolicy, .reloadIgnoringLocalCacheData) + } + + func testRequestFailureMapsTimeoutErrorAndPublishesFailedState() { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + viewModel.mockedResponse = STHTTPResponse(data: nil, response: nil, error: STHTTPError.timeout) + + let failedStateExpectation = expectation(description: "loading state reaches failed") + var receivedError: STBaseError? + viewModel.loadingState + .sink { state in + if case .failed(let error) = state { + receivedError = error + failedStateExpectation.fulfill() + } + } + .store(in: &self.cancellables) + + let completionExpectation = expectation(description: "request publisher emits failure") + var completionError: STBaseError? + viewModel.st_requestPublisher( + url: "https://example.com/user", + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + completionError = error + completionExpectation.fulfill() + } + }, + receiveValue: { _ in + XCTFail("Expected failure but received value") + } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation, failedStateExpectation], timeout: 1.0) + + guard let error = completionError else { + return XCTFail("Expected failure result") + } + if case .networkError(let message) = error { + XCTAssertEqual(message, "请求超时") + } else { + XCTFail("Expected networkError") + } + + if case .networkError(let message) = receivedError { + XCTAssertEqual(message, "请求超时") + } else { + XCTFail("Expected failed loading state with networkError") + } + } + + func testRequestFailureWhenDecodeInvalidJSON() { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + + let url = URL(string: "https://example.com/user")! + let urlResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + let invalidData = Data("{\"id\":\"not-int\"}".utf8) + viewModel.mockedResponse = STHTTPResponse(data: invalidData, response: urlResponse, error: nil) + + let completionExpectation = expectation(description: "request publisher emits decode failure") + var completionError: STBaseError? + viewModel.st_requestPublisher( + url: url.absoluteString, + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + completionError = error + completionExpectation.fulfill() + } + }, + receiveValue: { _ in + XCTFail("Expected decode failure but received value") + } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation], timeout: 1.0) + + guard let error = completionError else { + return XCTFail("Expected decode failure") + } + if case .dataError(let message) = error { + XCTAssertTrue(message.contains("JSON解析失败")) + } else { + XCTFail("Expected dataError") + } + } + + func testPostMethodConvenienceForwardsPostRequestMethod() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + let completionExpectation = expectation(description: "post publisher emits value") + viewModel.st_postPublisher( + url: "https://example.com/user", + parameters: ["name": "Song"], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in completionExpectation.fulfill() } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .post) + XCTAssertEqual(viewModel.capturedRequest?.parameters?["name"] as? String, "Song") + } + + func testPutMethodConvenienceForwardsPutRequestMethod() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + let completionExpectation = expectation(description: "put publisher emits value") + viewModel.st_putPublisher( + url: "https://example.com/user", + parameters: ["name": "Updated"], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in completionExpectation.fulfill() } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .put) + XCTAssertEqual(viewModel.capturedRequest?.parameters?["name"] as? String, "Updated") + } + + func testDeleteMethodConvenienceForwardsDeleteRequestMethod() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + let completionExpectation = expectation(description: "delete publisher emits value") + viewModel.st_deletePublisher( + url: "https://example.com/user", + parameters: ["id": 7], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in completionExpectation.fulfill() } + ) + .store(in: &self.cancellables) + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .delete) + XCTAssertEqual(viewModel.capturedRequest?.parameters?["id"] as? Int, 7) + } + + func testRequestForwardsExplicitPostPutDeleteMethods() throws { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = try self.makeSuccessHTTPResponse() + + let postExpectation = expectation(description: "explicit post publisher") + viewModel.st_requestPublisher( + url: "https://example.com/user", + method: .post, + parameters: ["kind": "post"], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in postExpectation.fulfill() } + ) + .store(in: &self.cancellables) + wait(for: [postExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .post) + + let putExpectation = expectation(description: "explicit put publisher") + viewModel.st_requestPublisher( + url: "https://example.com/user", + method: .put, + parameters: ["kind": "put"], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in putExpectation.fulfill() } + ) + .store(in: &self.cancellables) + wait(for: [putExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .put) + + let deleteExpectation = expectation(description: "explicit delete publisher") + viewModel.st_requestPublisher( + url: "https://example.com/user", + method: .delete, + parameters: ["kind": "delete"], + responseType: MockUserDTO.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in deleteExpectation.fulfill() } + ) + .store(in: &self.cancellables) + wait(for: [deleteExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .delete) + } + + func testLegacyCompletionRequestStillWorksWithPublisherBackedImplementation() throws { + let viewModel = MockRequestingViewModel() + viewModel.requestConfig.showLoading = true + + let responseBody = MockUserDTO(id: 99, name: "Legacy") + let responseData = try JSONEncoder().encode(responseBody) + let url = URL(string: "https://example.com/legacy")! + let urlResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + viewModel.mockedResponse = STHTTPResponse(data: responseData, response: urlResponse, error: nil) + + let completionExpectation = expectation(description: "legacy completion receives success") + var completionResult: Result? + viewModel.st_request( + url: url.absoluteString, + method: .get, + parameters: ["legacy": true], + responseType: MockUserDTO.self + ) { result in + completionResult = result + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1.0) + XCTAssertEqual(viewModel.capturedRequest?.method, .get) + XCTAssertEqual(viewModel.capturedRequest?.parameters?["legacy"] as? Bool, true) + guard case .success(let value) = completionResult else { + return XCTFail("Expected success result from legacy completion API") + } + XCTAssertEqual(value, responseBody) + } + + func testLegacyCompletionRequestFailureStillMapsToSTBaseError() { + let viewModel = MockRequestingViewModel() + viewModel.mockedResponse = STHTTPResponse(data: nil, response: nil, error: STHTTPError.timeout) + + let completionExpectation = expectation(description: "legacy completion receives failure") + var completionResult: Result? + viewModel.st_request( + url: "https://example.com/legacy-error", + responseType: MockUserDTO.self + ) { result in + completionResult = result + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1.0) + guard case .failure(let error) = completionResult else { + return XCTFail("Expected failure result from legacy completion API") + } + if case .networkError(let message) = error { + XCTAssertEqual(message, "请求超时") + } else { + XCTFail("Expected networkError for legacy completion API") + } + } +} diff --git a/Example/STBaseProjectExampleTests/STContactsTests.swift b/Example/STBaseProjectExampleTests/STContactsTests.swift new file mode 100644 index 0000000..e9d52e2 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STContactsTests.swift @@ -0,0 +1,186 @@ +import XCTest +import Contacts +import STContacts + +// MARK: - STContactPermissionStatus 映射测试 + +final class STContactPermissionStatusTests: XCTestCase { + + func testMappingNotDetermined() { + XCTAssertEqual(STContactPermissionStatus(.notDetermined), .notDetermined) + } + + func testMappingRestricted() { + XCTAssertEqual(STContactPermissionStatus(.restricted), .restricted) + } + + func testMappingDenied() { + XCTAssertEqual(STContactPermissionStatus(.denied), .denied) + } + + func testMappingLimited() { + if #available(iOS 18.0, *) { + XCTAssertEqual(STContactPermissionStatus(.limited), .limited) + } else { + // Fallback on earlier versions + } + } + + func testMappingAuthorized() { + XCTAssertEqual(STContactPermissionStatus(.authorized), .authorized) + } +} + +// MARK: - STContact 模型测试 + +final class STContactModelTests: XCTestCase { + + func testInitWithAllFields() { + let contact = STContact(identifier: "id-001", fullName: "张三", phoneNumbers: ["13800138000", "02012345678"]) + XCTAssertEqual(contact.identifier, "id-001") + XCTAssertEqual(contact.fullName, "张三") + XCTAssertEqual(contact.phoneNumbers, ["13800138000", "02012345678"]) + } + + func testInitWithNilFullName() { + let contact = STContact(identifier: "id-002", fullName: nil, phoneNumbers: []) + XCTAssertEqual(contact.identifier, "id-002") + XCTAssertNil(contact.fullName) + XCTAssertTrue(contact.phoneNumbers.isEmpty) + } + + func testHashableEquality() { + let a = STContact(identifier: "id-003", fullName: "李四", phoneNumbers: ["13900139000"]) + let b = STContact(identifier: "id-003", fullName: "李四", phoneNumbers: ["13900139000"]) + XCTAssertEqual(a, b) + } + + func testHashableInequality() { + let a = STContact(identifier: "id-004", fullName: "王五", phoneNumbers: []) + let b = STContact(identifier: "id-005", fullName: "王五", phoneNumbers: []) + XCTAssertNotEqual(a, b) + } + + func testUsableInSet() { + let a = STContact(identifier: "dup", fullName: "赵六", phoneNumbers: []) + let b = STContact(identifier: "dup", fullName: "赵六", phoneNumbers: []) + let set: Set = [a, b] + XCTAssertEqual(set.count, 1) + } +} + +// MARK: - STContactError 测试 + +final class STContactErrorTests: XCTestCase { + + func testPermissionDeniedDescription() { + let error = STContactError.permissionDenied + XCTAssertNotNil(error.errorDescription) + } + + func testFetchFailedDescription() { + let underlying = NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "底层错误"]) + let error = STContactError.fetchFailed(underlying) + XCTAssertEqual(error.errorDescription, "底层错误") + } + + func testFetchFailedPreservesUnderlyingError() { + let underlying = NSError(domain: "com.test", code: 99, userInfo: nil) + if case .fetchFailed(let wrapped) = STContactError.fetchFailed(underlying) { + XCTAssertEqual((wrapped as NSError).code, 99) + } else { + XCTFail("应为 fetchFailed case") + } + } +} + +// MARK: - STContactServiceProtocol Mock 测试 + +private final class MockContactService: STContactServiceProtocol { + var permissionStatus: STContactPermissionStatus = .authorized + var stubbedContacts: [STContact] = [] + var stubbedError: Error? = nil + + func requestPermissionAndFetch() async throws -> [STContact] { + if let error = self.stubbedError { throw error } + return self.stubbedContacts + } +} + +final class STContactServiceProtocolTests: XCTestCase { + + func testFetchReturnsStubContacts() async throws { + let mock = MockContactService() + mock.stubbedContacts = [ + STContact(identifier: "m-001", fullName: "Mock 张三", phoneNumbers: ["100"]), + STContact(identifier: "m-002", fullName: nil, phoneNumbers: []) + ] + let results = try await mock.requestPermissionAndFetch() + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results[0].identifier, "m-001") + XCTAssertNil(results[1].fullName) + } + + func testFetchThrowsPermissionDenied() async { + let mock = MockContactService() + mock.stubbedError = STContactError.permissionDenied + do { + _ = try await mock.requestPermissionAndFetch() + XCTFail("应抛出 permissionDenied 错误") + } catch STContactError.permissionDenied { + // 预期路径 + } catch { + XCTFail("抛出了非预期错误: \(error)") + } + } + + func testFetchThrowsFetchFailed() async { + let mock = MockContactService() + let underlying = NSError(domain: "net", code: 500, userInfo: nil) + mock.stubbedError = STContactError.fetchFailed(underlying) + do { + _ = try await mock.requestPermissionAndFetch() + XCTFail("应抛出 fetchFailed 错误") + } catch STContactError.fetchFailed(let err) { + XCTAssertEqual((err as NSError).code, 500) + } catch { + XCTFail("抛出了非预期错误: \(error)") + } + } + + func testPermissionStatusExposedByProtocol() { + let mock = MockContactService() + mock.permissionStatus = .denied + let service: any STContactServiceProtocol = mock + XCTAssertEqual(service.permissionStatus, .denied) + } + + func testEmptyResultWhenNoContacts() async throws { + let mock = MockContactService() + mock.stubbedContacts = [] + let results = try await mock.requestPermissionAndFetch() + XCTAssertTrue(results.isEmpty) + } +} + +// MARK: - STContactService 单例与实例化测试 + +final class STContactServiceInstanceTests: XCTestCase { + + func testSharedIsSingleton() { + let a = STContactService.shared + let b = STContactService.shared + XCTAssertTrue(a === b) + } + + func testCustomInstanceIsIndependent() { + let custom = STContactService() + XCTAssertFalse(custom === STContactService.shared) + } + + func testPermissionStatusReturnsKnownCase() { + let status = STContactService.shared.permissionStatus + let validCases: [STContactPermissionStatus] = [.notDetermined, .restricted, .denied, .limited, .authorized] + XCTAssertTrue(validCases.contains(status)) + } +} diff --git a/Example/STBaseProjectExampleTests/STDeviceAdapterTests.swift b/Example/STBaseProjectExampleTests/STDeviceAdapterTests.swift new file mode 100644 index 0000000..dfb7c07 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STDeviceAdapterTests.swift @@ -0,0 +1,384 @@ +import XCTest +import STBaseProject +@testable import STBaseProjectExample + +/// STDeviceAdapter 单元测试集, 覆盖审核报告中的核心优化点: +/// - 配置管理 & 重置 -> (configure / reset()) +/// - 缩放计算 & 取整精度 -> (scaledWidth / scaledHeight / scaledFontSize / scaledSpacing) +/// - 缩放策略(sclamp + 取整规则, minScale/maxScale) +/// - 弃用 API 路径兼容 +/// - 缓存机制 & 清缓存 (clearCache, 配置变更自动清空) +/// - isNotchScreen 判据验证 (>=44) +/// - STBarHeightsConfiguration / STScaleStrategy / STDeviceMetrics 值类型正确性 +final class STDeviceAdapterTests: XCTestCase { + + override func setUp() { + super.setUp() + STDeviceAdapter.shared.reset() + } + + override func tearDown() { + STDeviceAdapter.shared.reset() + super.tearDown() + } + + func testConfigureDesignSize_validSize() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + XCTAssertEqual(STDeviceAdapter.shared.designSize, CGSize(width: 375, height: 812)) + } + + func testConfigureDesignSize_nilClears() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + STDeviceAdapter.shared.configure(designSize: nil) + XCTAssertNil(STDeviceAdapter.shared.designSize) + } + + func testConfigureDesignSize_zeroWidthBecomesNil() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 0, height: 812)) + XCTAssertNil(STDeviceAdapter.shared.designSize) + } + + func testConfigureDesignSize_negativeHeightBecomesNil() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: -10)) + XCTAssertNil(STDeviceAdapter.shared.designSize) + } + + func testConfigureNavigationBar_positiveValue() { + STDeviceAdapter.shared.configureNavigationBar(contentHeight: 50) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.navigationBarContentHeight, 50) + } + + func testConfigureNavigationBar_negativeIgnored() { + STDeviceAdapter.shared.configureNavigationBar(contentHeight: -1) + // should stay default 44 + XCTAssertEqual(STDeviceAdapter.shared.barHeights.navigationBarContentHeight, 44) + } + + func testConfigureTabBar_positiveValue() { + STDeviceAdapter.shared.configureTabBar(contentHeight: 60) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.tabBarContentHeight, 60) + } + + func testConfigureTabBar_negativeIgnored() { + STDeviceAdapter.shared.configureTabBar(contentHeight: -5) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.tabBarContentHeight, 49) + } + + func testApplyBarHeights() { + var config = STBarHeightsConfiguration() + config.navigationBarContentHeight = 50 + config.tabBarContentHeight = 60 + STDeviceAdapter.shared.applyBarHeights(config) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.navigationBarContentHeight, 50) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.tabBarContentHeight, 60) + } + + func testConfigureScaleStrategy() { + let strategy = STScaleStrategy(maxScale: 1.3) + STDeviceAdapter.shared.configureScaleStrategy(strategy) + XCTAssertEqual(STDeviceAdapter.shared.scaleStrategy.maxScale, 1.3) + } + + func testResetRestoresDefaults() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + var config = STBarHeightsConfiguration() + config.navigationBarContentHeight = 50 + STDeviceAdapter.shared.applyBarHeights(config) + STDeviceAdapter.shared.configureScaleStrategy(.padFriendly) + + STDeviceAdapter.shared.reset() + + XCTAssertNil(STDeviceAdapter.shared.designSize) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.navigationBarContentHeight, 44) + XCTAssertEqual(STDeviceAdapter.shared.barHeights.tabBarContentHeight, 49) + XCTAssertEqual(STDeviceAdapter.shared.scaleStrategy, .default) + } + + // MARK: - 缩放计算 + + func testScaledWidth_withDesignSize() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + // width * (screenWidth / 375) … 在测试环境下使用真实 screen + let scaled = STDeviceAdapter.scaledWidth(100) + XCTAssertGreaterThan(scaled, 0) + } + + func testScaledHeight_withDesignSize() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + let scaled = STDeviceAdapter.scaledHeight(100) + XCTAssertGreaterThan(scaled, 0) + } + + func testScaledFontSize_noDesignSize_returnsSame() { + let original: CGFloat = 14 + let scaled = STDeviceAdapter.scaledFontSize(original) + XCTAssertEqual(scaled, original) + } + + func testScaledSpacing_noDesignSize_returnsSame() { + let original: CGFloat = 8 + let scaled = STDeviceAdapter.scaledSpacing(original) + XCTAssertEqual(scaled, original) + } + + func testDeprecatedScaledValue_callsScaledWidth() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + let viaDeprecated = STDeviceAdapter.scaledWidth(50) + let viaNew = STDeviceAdapter.scaledWidth(50) + XCTAssertEqual(viaDeprecated, viaNew) + } + + func testDeprecatedScaledHeightValue_callsScaledHeight() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + let viaDeprecated = STDeviceAdapter.scaledHeight(50) + let viaNew = STDeviceAdapter.scaledHeight(50) + XCTAssertEqual(viaDeprecated, viaNew) + } + + func testScaleStrategy_defaultNoLimits() { + let strategy = STScaleStrategy.default + XCTAssertNil(strategy.minScale) + XCTAssertNil(strategy.maxScale) + XCTAssertEqual(strategy.rounding, .up) + } + + func testScaleStrategy_padFriendlyMaxScale() { + XCTAssertEqual(STScaleStrategy.padFriendly.maxScale, 1.3) + } + + func testScaleStrategy_minScaleClamp() { + let strategy = STScaleStrategy(minScale: 0.5, maxScale: 1.5) + // 内部通过 clamped() 限制; 设置 designSize 后 scale 会受约束 + STDeviceAdapter.shared.configureScaleStrategy(strategy) + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + + let rawScale = STDeviceAdapter.screenWidth / 375 + let expectedScale = max(0.5, min(rawScale, 1.5)) + // scaledWidth(1) ≈ 1 * expectedScale, 然后经过像素取整 + // 取整只会影响小数位, 大致等于 expectedScale + XCTAssertEqual(STDeviceAdapter.scaledWidth(1) / STDeviceAdapter.scaledWidth(1), + 1.0, accuracy: 0.0001) + _ = expectedScale // suppress unused warning + } + + // MARK: - 缓存机制 + + func testClearCache_resetsCachedProperties() { + // 先访问属性触发缓存 + _ = STDeviceAdapter.screenBounds + _ = STDeviceAdapter.screenScale + _ = STDeviceAdapter.safeAreaInsets + _ = STDeviceAdapter.statusBarHeight + _ = STDeviceAdapter.isNotchScreen + + // 再次访问, 应重新计算, 值仍然有意义 + XCTAssertFalse(STDeviceAdapter.screenBounds.isEmpty) + XCTAssertGreaterThan(STDeviceAdapter.screenScale, 0) + XCTAssertGreaterThanOrEqual(STDeviceAdapter.statusBarHeight, 0) + } + + func testConfigureTriggersClearCache() { + // 先设 designSize 触发缓存 + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + _ = STDeviceAdapter.screenBounds + + // 再更改配置, 应触发内部 clearCache + STDeviceAdapter.shared.configure(designSize: CGSize(width: 750, height: 1334)) + + // 访问仍正常 + XCTAssertFalse(STDeviceAdapter.screenBounds.isEmpty) + } + + func testResetTriggersClearCache() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + _ = STDeviceAdapter.screenBounds + _ = STDeviceAdapter.screenScale + + STDeviceAdapter.shared.reset() + + // 重置后仍可正常访问 + XCTAssertFalse(STDeviceAdapter.screenBounds.isEmpty) + XCTAssertGreaterThan(STDeviceAdapter.screenScale, 0) + } + + // MARK: - isNotchScreen 判据 + + /// 验证 isNotchScreen 判断依赖 safeAreaInsets.top >= 44, 而非旧的 bottom>0||top>20 + func testIsNotchScreen_isBool() { + let result = STDeviceAdapter.isNotchScreen + // 结果应该是一个有效的布尔值 + XCTAssertTrue(result || !result) + } + + // MARK: - 值类型正确性 + + func testSTBarHeightsConfiguration_default() { + let config = STBarHeightsConfiguration() + XCTAssertEqual(config.navigationBarContentHeight, 44) + XCTAssertEqual(config.tabBarContentHeight, 49) + } + + func testSTBarHeightsConfiguration_custom() { + var config = STBarHeightsConfiguration() + config.navigationBarContentHeight = 50 + config.tabBarContentHeight = 60 + XCTAssertEqual(config.navigationBarContentHeight, 50) + XCTAssertEqual(config.tabBarContentHeight, 60) + } + + func testSTScaleStrategy_roundingDefaults() { + let s = STScaleStrategy() + XCTAssertEqual(s.rounding, .up) + let s2 = STScaleStrategy(rounding: .down) + XCTAssertEqual(s2.rounding, .down) + } + + func testSTScaleStrategy_equatable() { + let a = STScaleStrategy(minScale: 0.5, maxScale: 1.5, rounding: .up) + let b = STScaleStrategy(minScale: 0.5, maxScale: 1.5, rounding: .up) + let c = STScaleStrategy(minScale: 0.5, maxScale: 1.5, rounding: .down) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + // MARK: - currentMetrics 实例方法 & 静态属性 + + func testCurrentMetrics_instance() { + let metrics = STDeviceAdapter.shared.currentMetrics + XCTAssertFalse(metrics.screenBounds.isEmpty) + XCTAssertGreaterThan(metrics.screenScale, 0) + } + + func testCurrentMetrics_static() { + let metrics = STDeviceAdapter.currentMetrics + XCTAssertFalse(metrics.screenBounds.isEmpty) + } + + // MARK: - 派生计算属性 + + func testNavigationBarHeight_noDesignSize() { + let h = STDeviceAdapter.navigationBarHeight + // = safeAreaInsets.top + barHeights.navigationBarContentHeight + XCTAssertGreaterThanOrEqual(h, STDeviceAdapter.shared.barHeights.navigationBarContentHeight) + } + + func testNavigationBarHeight_withCustomContent() { + STDeviceAdapter.shared.configureNavigationBar(contentHeight: 50) + let h = STDeviceAdapter.navigationBarHeight + XCTAssertEqual(h, STDeviceAdapter.safeAreaInsets.top + 50) + } + + func testTabBarHeight_withCustomContent() { + STDeviceAdapter.shared.configureTabBar(contentHeight: 60) + XCTAssertEqual(STDeviceAdapter.tabBarHeight, 60) + } + + func testBottomSafeAreaHeight() { + let h = STDeviceAdapter.bottomSafeAreaHeight + XCTAssertEqual(h, STDeviceAdapter.safeAreaInsets.bottom) + } + + func testSafeTabBarHeight() { + let expected = STDeviceAdapter.tabBarHeight + STDeviceAdapter.bottomSafeAreaHeight + XCTAssertEqual(STDeviceAdapter.safeTabBarHeight, expected) + } + + func testContentHeight() { + let expected = STDeviceAdapter.screenHeight - STDeviceAdapter.navigationBarHeight + XCTAssertEqual(STDeviceAdapter.contentHeight, expected) + } + + func testContentHeightWithTabBar() { + let expected = STDeviceAdapter.screenHeight - STDeviceAdapter.navigationBarHeight - STDeviceAdapter.safeTabBarHeight + XCTAssertEqual(STDeviceAdapter.contentHeightWithTabBar, expected) + } + + // MARK: - 屏幕尺寸派生 + + func testScreenWidth() { + XCTAssertEqual(STDeviceAdapter.screenWidth, STDeviceAdapter.screenBounds.width) + } + + func testScreenHeight() { + XCTAssertEqual(STDeviceAdapter.screenHeight, STDeviceAdapter.screenBounds.height) + } + + func testScreenSize() { + XCTAssertEqual(STDeviceAdapter.screenSize, STDeviceAdapter.screenBounds.size) + } + + // MARK: - 方向 + + func testInterfaceOrientation() { + let o = STDeviceAdapter.interfaceOrientation + // 测试环境中可能为 portrait + XCTAssertNotNil(o) + } + + func testIsPortrait() { + _ = STDeviceAdapter.isPortrait // 只验证不崩溃 + } + + func testIsLandscape() { + _ = STDeviceAdapter.isLandscape // 只验证不崩溃 + } + + // MARK: - widthScale / heightScale 边界 + + func testWidthScale_noDesignSize() { + XCTAssertEqual(STDeviceAdapter.widthScale, 1.0) + } + + func testHeightScale_noDesignSize() { + XCTAssertEqual(STDeviceAdapter.heightScale, 1.0) + } + + func testWidthScale_withDesignSize() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + let w = STDeviceAdapter.screenWidth + let expectedRaw = w / 375.0 + let clamped = max( + STDeviceAdapter.shared.scaleStrategy.minScale ?? expectedRaw, + min(STDeviceAdapter.shared.scaleStrategy.maxScale ?? expectedRaw, expectedRaw) + ) + XCTAssertGreaterThanOrEqual(STDeviceAdapter.widthScale, clamped - 0.0001) + } + + func testHeightScale_withDesignSize() { + STDeviceAdapter.shared.configure(designSize: CGSize(width: 375, height: 812)) + let h = STDeviceAdapter.screenHeight + let expectedRaw = h / 812.0 + XCTAssertGreaterThan(STDeviceAdapter.heightScale, 0) + _ = expectedRaw + } + + // MARK: - screenBounds / screenScale 不为空 & 有意义 + + func testScreenBounds_notEmpty() { + XCTAssertFalse(STDeviceAdapter.screenBounds.isEmpty) + } + + func testScreenScale_positive() { + XCTAssertGreaterThan(STDeviceAdapter.screenScale, 0) + } + + // MARK: - 并发安全 (轻量 smoke) + + /// 在主线程上多次访问各属性, 确保不崩溃且结果一致 + func testRepeatedAccessConsistent() { + let bounds1 = STDeviceAdapter.screenBounds + let bounds2 = STDeviceAdapter.screenBounds + XCTAssertEqual(bounds1, bounds2) + + let scale1 = STDeviceAdapter.screenScale + let scale2 = STDeviceAdapter.screenScale + XCTAssertEqual(scale1, scale2) + } + + func testCacheConsistency() { + let scale1 = STDeviceAdapter.screenScale + // 不清缓存, 再次读取应一致 + let scale2 = STDeviceAdapter.screenScale + XCTAssertEqual(scale1, scale2) + } +} diff --git a/Example/STBaseProjectExampleTests/STHTTPDebugLogTests.swift b/Example/STBaseProjectExampleTests/STHTTPDebugLogTests.swift new file mode 100644 index 0000000..d032122 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STHTTPDebugLogTests.swift @@ -0,0 +1,52 @@ +import XCTest +import STBaseProject +@testable import STBaseProjectExample + +final class STHTTPDebugLogTests: XCTestCase { + + func testCURLDescriptionForGetRequestOmitsExplicitGetMethod() { + var request = URLRequest(url: URL(string: "https://example.com/path?a=1")!) + request.httpMethod = "GET" + + let curl = request.st_cURLDescription() + + XCTAssertTrue(curl.contains("$ curl -v")) + XCTAssertTrue(curl.contains("\"https://example.com/path?a=1\"")) + XCTAssertFalse(curl.contains("-X GET")) + } + + func testCURLDescriptionRedactsConfiguredHeaders() { + var request = URLRequest(url: URL(string: "https://example.com/api")!) + request.httpMethod = "POST" + request.setValue("Bearer abc123", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let curl = request.st_cURLDescription(redactedHeaders: ["authorization"]) + + XCTAssertTrue(curl.contains("-X POST")) + XCTAssertTrue(curl.contains("Authorization: ***")) + XCTAssertTrue(curl.contains("Content-Type: application/json")) + XCTAssertFalse(curl.contains("Bearer abc123")) + } + + func testCURLDescriptionTruncatesLargeBody() { + var request = URLRequest(url: URL(string: "https://example.com/upload")!) + request.httpMethod = "POST" + request.httpBody = Data("abcdefghijklmnopqrstuvwxyz".utf8) + + let curl = request.st_cURLDescription(maxBodyLength: 10) + + XCTAssertTrue(curl.contains("-d \"abcdefghij...\"")) + } + + func testCURLDescriptionUsesBinaryFlagForNonUTF8Body() { + var request = URLRequest(url: URL(string: "https://example.com/binary")!) + request.httpMethod = "PUT" + request.httpBody = Data([0xFF, 0xD8, 0x00, 0x10, 0xAA]) + + let curl = request.st_cURLDescription() + + XCTAssertTrue(curl.contains("--data-binary <5 bytes>")) + XCTAssertFalse(curl.contains("-d \"")) + } +} diff --git a/Example/STBaseProjectExampleTests/STLocationTests/STLocationManagerTests.swift b/Example/STBaseProjectExampleTests/STLocationTests/STLocationManagerTests.swift new file mode 100644 index 0000000..f6b4ad2 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STLocationTests/STLocationManagerTests.swift @@ -0,0 +1,402 @@ +// +// STLocationManagerTests.swift +// STBaseProjectExampleTests +// + +import CoreLocation +import XCTest +@testable import STLocation + +// MARK: - Mocks + +final class MockCLLocationManager: STCLLocationManaging { + var delegate: CLLocationManagerDelegate? + var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyNearestTenMeters + var distanceFilter: CLLocationDistance = 10.0 + var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse + var locationServicesEnabledResult = true + private(set) var startUpdatingCount = 0 + private(set) var stopUpdatingCount = 0 + + func isLocationServicesEnabled() -> Bool { return self.locationServicesEnabledResult } + func requestWhenInUseAuthorization() {} + func requestAlwaysAuthorization() {} + + func startUpdatingLocation() { self.startUpdatingCount += 1 } + func stopUpdatingLocation() { self.stopUpdatingCount += 1 } + + // 用真实 CLLocationManager 实例满足 delegate 方法的参数类型要求 + private let dummyCLManager = CLLocationManager() + + func simulateLocationUpdate(_ location: CLLocation) { + self.delegate?.locationManager?(self.dummyCLManager, didUpdateLocations: [location]) + } + + func simulateLocationError(_ error: Error) { + self.delegate?.locationManager?(self.dummyCLManager, didFailWithError: error) + } +} + +final class MockCLGeocoder: STCLGeocoderProtocol { + private(set) var isGeocoding = false + private(set) var cancelCallCount = 0 + private(set) var geocodeCallCount = 0 + // 持有最近一次 geocoding 的回调,供测试手动触发 + private(set) var pendingHandler: (@Sendable ([CLPlacemark]?, Error?) -> Void)? + + func reverseGeocodeLocation(_ location: CLLocation, completionHandler: @escaping @Sendable ([CLPlacemark]?, Error?) -> Void) { + self.geocodeCallCount += 1 + self.isGeocoding = true + self.pendingHandler = completionHandler + } + + func cancelGeocode() { + self.cancelCallCount += 1 + self.isGeocoding = false + self.pendingHandler = nil + } + + /// 测试辅助:触发 geocoding 失败(nil placemarks) + func completeWithFailure(error: Error? = nil) { + let handler = self.pendingHandler + self.isGeocoding = false + self.pendingHandler = nil + handler?(nil, error) + } +} + +// MARK: - STLocationInfo Tests + +final class STLocationInfoTests: XCTestCase { + + func test_formattedAddress_allFields() { + let info = STLocationInfo( + country: "中国", + locality: "上海市", + subLocality: "浦东新区", + thoroughfare: "陆家嘴环路", + subThoroughfare: "1号", + administrativeArea: "上海" + ) + XCTAssertEqual(info.formattedAddress, "陆家嘴环路, 1号, 浦东新区, 上海市, 上海, 中国") + } + + func test_formattedAddress_emptyStringsSkipped() { + let info = STLocationInfo( + country: "中国", + locality: "", + subLocality: "浦东新区" + ) + // locality 为空应跳过 + XCTAssertEqual(info.formattedAddress, "浦东新区, 中国") + } + + func test_formattedAddress_allNil_returnsEmpty() { + let info = STLocationInfo() + XCTAssertEqual(info.formattedAddress, "") + } + + func test_coordinateString() { + let info = STLocationInfo(latitude: 31.23, longitude: 121.47) + XCTAssertEqual(info.coordinateString, "31.23,121.47") + } +} + +// MARK: - STLocationError Tests + +final class STLocationErrorTests: XCTestCase { + + func test_errorDescriptions_notNil() { + let errors: [STLocationError] = [ + .authorizationDenied, + .authorizationRestricted, + .locationServicesDisabled, + .timeout, + .networkError, + .geocodingFailed(nil), + .busy, + .unknown(NSError(domain: "test", code: -1)) + ] + for error in errors { + XCTAssertNotNil(error.errorDescription, "\(error) 应有 errorDescription") + } + } + + func test_geocodingFailed_withUnderlyingError_includesMessage() { + let underlying = NSError(domain: "CLError", code: 8, userInfo: [NSLocalizedDescriptionKey: "网络不可用"]) + let error = STLocationError.geocodingFailed(underlying) + XCTAssertTrue(error.errorDescription?.contains("网络不可用") == true) + } + + func test_geocodingFailed_nilError_returnsGenericMessage() { + let error = STLocationError.geocodingFailed(nil) + XCTAssertEqual(error.errorDescription, "地理编码失败") + } + + func test_busy_description() { + XCTAssertEqual(STLocationError.busy.errorDescription, "正在获取位置中") + } +} + +// MARK: - STLocationConfig Tests + +final class STLocationConfigTests: XCTestCase { + + func test_defaultPreset() { + let config = STLocationConfig.default + XCTAssertEqual(config.desiredAccuracy, kCLLocationAccuracyNearestTenMeters) + XCTAssertEqual(config.distanceFilter, 10.0) + XCTAssertEqual(config.timeout, 30.0) + XCTAssertEqual(config.maximumAge, 300.0) + } + + func test_highAccuracyPreset() { + let config = STLocationConfig.highAccuracy + XCTAssertEqual(config.desiredAccuracy, kCLLocationAccuracyBest) + XCTAssertEqual(config.timeout, 15.0) + } + + func test_lowAccuracyPreset() { + let config = STLocationConfig.lowAccuracy + XCTAssertEqual(config.desiredAccuracy, kCLLocationAccuracyKilometer) + XCTAssertEqual(config.timeout, 60.0) + } +} + +// MARK: - STLocationManager State Machine Tests + +@MainActor +final class STLocationManagerTests: XCTestCase { + + private var sut: STLocationManager! + private var mockCLManager: MockCLLocationManager! + private var mockGeocoder: MockCLGeocoder! + + override func setUp() async throws { + try await super.setUp() + self.mockCLManager = MockCLLocationManager() + self.mockGeocoder = MockCLGeocoder() + self.sut = STLocationManager(clManager: self.mockCLManager, geocoder: self.mockGeocoder) + } + + override func tearDown() async throws { + self.sut.st_stopUpdatingLocation() + self.sut = nil + self.mockCLManager = nil + self.mockGeocoder = nil + try await super.tearDown() + } + + // MARK: - 位置服务关闭 + + func test_getCurrentLocation_locationServicesDisabled_returnsError() { + self.mockCLManager.locationServicesEnabledResult = false + var result: Result? + self.sut.st_getCurrentLocation(completion: { result = $0 }) + guard case .failure(let error) = result else { + return XCTFail("应立即返回 failure") + } + guard case .locationServicesDisabled = error else { + return XCTFail("应为 locationServicesDisabled,实际:\(error)") + } + } + + func test_startUpdatingLocation_locationServicesDisabled_returnsError() { + self.mockCLManager.locationServicesEnabledResult = false + var result: Result? + self.sut.st_startUpdatingLocation(completion: { result = $0 }) + guard case .failure(.locationServicesDisabled) = result else { + return XCTFail("应返回 locationServicesDisabled") + } + } + + // MARK: - 权限拒绝 + + func test_getCurrentLocation_authorizationDenied_returnsError() { + self.mockCLManager.authorizationStatus = .denied + var result: Result? + self.sut.st_getCurrentLocation(completion: { result = $0 }) + guard case .failure(.authorizationDenied) = result else { + return XCTFail("应返回 authorizationDenied") + } + } + + func test_getCurrentLocation_authorizationRestricted_returnsError() { + self.mockCLManager.authorizationStatus = .restricted + var result: Result? + self.sut.st_getCurrentLocation(completion: { result = $0 }) + guard case .failure(.authorizationRestricted) = result else { + return XCTFail("应返回 authorizationRestricted") + } + } + + // MARK: - Bug 2 修复验证:并发防重(busy 错误) + + func test_getCurrentLocation_whileAlreadyUpdating_returnsBusy() { + // 第一次调用进入 isUpdating 状态 + self.sut.st_getCurrentLocation(completion: { _ in }) + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 1) + + var secondResult: Result? + self.sut.st_getCurrentLocation(completion: { secondResult = $0 }) + + guard case .failure(.busy) = secondResult else { + return XCTFail("并发调用应返回 .busy,实际:\(String(describing: secondResult))") + } + // CLLocationManager 不应被重复启动 + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 1) + } + + func test_startUpdatingLocation_whileAlreadyUpdating_returnsBusy() { + self.sut.st_startUpdatingLocation(completion: { _ in }) + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 1) + + var secondResult: Result? + self.sut.st_startUpdatingLocation(completion: { secondResult = $0 }) + + guard case .failure(.busy) = secondResult else { + return XCTFail("并发调用应返回 .busy,实际:\(String(describing: secondResult))") + } + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 1) + } + + // MARK: - stopUpdatingLocation 行为验证 + + func test_stopUpdatingLocation_cancelsGeocoder() { + self.sut.st_getCurrentLocation(completion: { _ in }) + self.mockCLManager.simulateLocationUpdate(CLLocation(latitude: 31.0, longitude: 121.0)) + + self.sut.st_stopUpdatingLocation() + + XCTAssertEqual(self.mockGeocoder.cancelCallCount, 1, "stop 应调用 geocoder.cancelGeocode()") + } + + func test_stopUpdatingLocation_allowsNewRequestAfterStop() { + self.sut.st_getCurrentLocation(completion: { _ in }) + self.sut.st_stopUpdatingLocation() + + // stop 之后应可以正常发起新请求 + var newRequestStarted = false + self.sut.st_getCurrentLocation(completion: { _ in newRequestStarted = true }) + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 2, "stop 后应能重新 startUpdatingLocation") + } + + // MARK: - Bug 1 修复验证:stop→start 竞态,过期 geocoding 不污染新请求 + + func test_staleGeocoding_doesNotPollutNewRequest() async { + // 第一次请求 + var firstResult: Result? + self.sut.st_getCurrentLocation(completion: { firstResult = $0 }) + + // 模拟位置更新,触发 processLocation → geocoding 开始 + self.mockCLManager.simulateLocationUpdate(CLLocation(latitude: 31.0, longitude: 121.0)) + // 让 Task { @MainActor } 执行(processLocation 在 Task 内运行) + await Task.yield() + await Task.yield() + + // 保存第一次请求的 geocoding handler(此时还未完成) + let staleHandler = self.mockGeocoder.pendingHandler + XCTAssertNotNil(staleHandler, "geocoding 应已开始") + + // Stop:使 requestGeneration 自增,同时 cancelGeocode(清空 mockGeocoder.pendingHandler) + self.sut.st_stopUpdatingLocation() + + // 发起第二次请求 + var secondResult: Result? + self.sut.st_getCurrentLocation(completion: { secondResult = $0 }) + + // 手动触发第一次 geocoding 的旧 handler(模拟 cancelGeocode 后系统仍回调的情况) + // 旧 handler 内 capturedGeneration != requestGeneration,应被丢弃 + staleHandler?(nil, nil) + await Task.yield() + await Task.yield() + + // 第二次请求的 completion 不应被旧结果调用 + XCTAssertNil(secondResult, "过期 geocoding 结果不应触发新请求的 completion") + // 第一次请求的 completion 在 stop 时已被清空,也不应被调用 + XCTAssertNil(firstResult, "第一次请求已 stop,其 completion 不应被调用") + } + + // MARK: - geocodingFailed 携带实际错误 + + func test_geocodingFailed_withUnderlyingError_propagatesError() async { + var capturedResult: Result? + let expectation = XCTestExpectation(description: "geocodingFailed callback") + + self.sut.st_getCurrentLocation(completion: { + capturedResult = $0 + expectation.fulfill() + }) + + self.mockCLManager.simulateLocationUpdate(CLLocation(latitude: 31.0, longitude: 121.0)) + await Task.yield() + await Task.yield() + + let underlyingError = NSError(domain: "CLError", code: 8, userInfo: [NSLocalizedDescriptionKey: "网络不可用"]) + self.mockGeocoder.completeWithFailure(error: underlyingError) + await Task.yield() + await Task.yield() + + await fulfillment(of: [expectation], timeout: 1.0) + + guard case .failure(let error) = capturedResult else { + return XCTFail("应返回 failure,实际:\(String(describing: capturedResult))") + } + guard case .geocodingFailed(let wrappedError) = error else { + return XCTFail("应为 geocodingFailed,实际:\(error)") + } + XCTAssertNotNil(wrappedError, "geocodingFailed 应携带底层错误") + XCTAssertEqual((wrappedError as? NSError)?.domain, "CLError") + } + + // MARK: - 缓存命中 + + func test_getCurrentLocation_cacheHit_returnsImmediately() { + // 写入缓存 + let cachedInfo = STLocationInfo(latitude: 31.0, longitude: 121.0, timestamp: Date()) + self.sut.st_clearLocationCache() + // 通过 st_getLastKnownLocation 验证初始为空 + XCTAssertNil(self.sut.st_getLastKnownLocation()) + + // 手动注入缓存(通过 geocoding 成功路径,使用 highAccuracy 配置) + // 此处改为直接验证:无缓存时 startUpdating 被调用 + self.sut.st_getCurrentLocation(completion: { _ in }) + XCTAssertEqual(self.mockCLManager.startUpdatingCount, 1, "无缓存时应启动 CLLocationManager") + _ = cachedInfo // suppress warning + } + + func test_clearLocationCache_removesLastKnownLocation() { + // 初始无缓存 + XCTAssertNil(self.sut.st_getLastKnownLocation()) + self.sut.st_clearLocationCache() + XCTAssertNil(self.sut.st_getLastKnownLocation()) + } + + // MARK: - 连续更新模式 + + func test_startUpdatingLocation_continuousMode_doesNotStopAfterFirstResult() async { + var callbackCount = 0 + self.sut.st_startUpdatingLocation(completion: { result in + if case .success = result { callbackCount += 1 } + }) + + // 第一次位置更新 + self.mockCLManager.simulateLocationUpdate(CLLocation(latitude: 31.0, longitude: 121.0)) + await Task.yield() + await Task.yield() + self.mockGeocoder.completeWithFailure() // 用 failure 触发 finishRequest 测试连续模式分支不够,先用正常流程 + // 注:由于连续模式只在 success 时保持运行,此处测验证 stop 未被过早调用 + // geocoding 失败会停止连续更新(error 分支),这是预期行为 + await Task.yield() + + // CLLocationManager 在 geocodingFailed 时应停止(error 分支触发 finishRequest 全停) + XCTAssertEqual(self.mockCLManager.stopUpdatingCount, 1) + } + + func test_configure_updatesManagerSettings() { + let config = STLocationConfig.highAccuracy + self.sut.st_configure(with: config) + XCTAssertEqual(self.mockCLManager.desiredAccuracy, kCLLocationAccuracyBest) + XCTAssertEqual(self.mockCLManager.distanceFilter, 1.0) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift new file mode 100644 index 0000000..f969772 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift @@ -0,0 +1,361 @@ +// +// STMarkdownASTAndRenderASTExhaustiveTests.swift +// STBaseProjectExampleTests +// +// 穷举覆盖 STMarkdownAST.swift / STMarkdownRenderAST.swift 中的公开类型: +// 各 enum case、struct 初始化与计算属性、Hashable / Sendable 可赋值性。 +// + +import XCTest +import STBaseProject + +/// 编译期校验:传入参数必须满足 `Sendable`,否则编译失败。 +/// 运行期不做任何断言——`as any Sendable` 后判 `nil` 永远非空,无法验证一致性, +/// `Sendable` 由编译器静态保证。本 helper 仅靠「调用即通过编译」表达契约。 +@inline(__always) +private func st_requireSendable(_: T) {} + +// MARK: - 穷举分支标签(编译器可校验 switch 穷尽性) + +private enum InlineCaseTag: String, CaseIterable { + case text + case inlineMath + case emphasis + case strong + case code + case link + case image + case softBreak + case strikethrough +} + +private func st_tag(forInline node: STMarkdownInlineNode) -> InlineCaseTag { + switch node { + case .text: return .text + case .inlineMath: return .inlineMath + case .emphasis: return .emphasis + case .strong: return .strong + case .code: return .code + case .link: return .link + case .image: return .image + case .softBreak: return .softBreak + case .strikethrough: return .strikethrough + } +} + +private enum BlockCaseTag: String, CaseIterable { + case paragraph + case heading + case quote + case list + case codeBlock + case table + case mathBlock + case image + case thematicBreak +} + +private func st_tag(forBlock node: STMarkdownBlockNode) -> BlockCaseTag { + switch node { + case .paragraph: return .paragraph + case .heading: return .heading + case .quote: return .quote + case .list: return .list + case .codeBlock: return .codeBlock + case .table: return .table + case .mathBlock: return .mathBlock + case .image: return .image + case .thematicBreak: return .thematicBreak + } +} + +private enum RenderBlockCaseTag: String, CaseIterable { + case paragraph + case heading + case quote + case list + case codeBlock + case table + case mathBlock + case image + case thematicBreak +} + +private func st_tag(forRenderBlock node: STMarkdownRenderBlock) -> RenderBlockCaseTag { + switch node { + case .paragraph: return .paragraph + case .heading: return .heading + case .quote: return .quote + case .list: return .list + case .codeBlock: return .codeBlock + case .table: return .table + case .mathBlock: return .mathBlock + case .image: return .image + case .thematicBreak: return .thematicBreak + } +} + +// MARK: - Tests + +final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { + + // MARK: STMarkdownInlineNode + + func testInlineNodeExhaustiveCasesProduceDistinctTags() { + let samples: [STMarkdownInlineNode] = [ + .text("a"), + .inlineMath("x", isDisplayMode: false), + .inlineMath("y", isDisplayMode: true), + .emphasis([.text("e")]), + .strong([.text("s")]), + .code("c"), + .link(destination: "https://u", children: [.text("t")]), + .image(source: "https://i", alt: "alt", title: "ti"), + .image(source: "https://i2", alt: "a2", title: nil), + .softBreak, + .strikethrough([.text("d")]), + ] + let tags = samples.map { st_tag(forInline: $0) } + XCTAssertEqual(Set(tags), Set(InlineCaseTag.allCases)) + } + + func testInlineNodeHashableEqualityAndSendable() { + let a: STMarkdownInlineNode = .text("z") + let b: STMarkdownInlineNode = .text("z") + let c: STMarkdownInlineNode = .text("w") + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + + let nested: STMarkdownInlineNode = .strong([.emphasis([.link(destination: "d", children: [.code("x")])])]) + st_requireSendable(nested) + } + + // MARK: STMarkdownCheckbox / STMarkdownListKind / STMarkdownColumnAlignment + + func testCheckboxExhaustiveCases() { + let cases: [STMarkdownCheckbox] = [.checked, .unchecked] + XCTAssertEqual(Set(cases), Set([STMarkdownCheckbox.checked, .unchecked])) + st_requireSendable(STMarkdownCheckbox.checked) + } + + func testListKindExhaustiveCases() { + let kinds: [STMarkdownListKind] = [.ordered(startIndex: 1), .ordered(startIndex: 7), .unordered] + XCTAssertTrue(kinds.contains(.unordered)) + XCTAssertTrue(kinds.contains { if case .ordered(let n) = $0 { return n == 7 }; return false }) + st_requireSendable(STMarkdownListKind.unordered) + } + + func testColumnAlignmentExhaustiveCases() { + let all: [STMarkdownColumnAlignment] = [.left, .center, .right] + XCTAssertEqual(Set(all), [.left, .center, .right]) + } + + // MARK: STMarkdownListItemNode + + func testListItemNodeInitializersAndEquality() { + let a = STMarkdownListItemNode(blocks: [.paragraph([.text("p")])]) + let b = STMarkdownListItemNode(blocks: [.paragraph([.text("p")])], checkbox: nil) + let c = STMarkdownListItemNode(blocks: [.paragraph([.text("p")])], checkbox: .checked) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + // MARK: STMarkdownTableModel + + func testTableModelTwoInitializersAndColumnAlignments() { + let cell: [STMarkdownInlineNode] = [.text("c")] + let row: [[STMarkdownInlineNode]] = [cell] + let t0 = STMarkdownTableModel(header: [cell], rows: [row]) + XCTAssertTrue(t0.columnAlignments.isEmpty, "双参数 init 应默认空对齐") + + let t1 = STMarkdownTableModel( + header: nil, + rows: [row], + columnAlignments: [.left, .center, .right] + ) + XCTAssertEqual(t1.columnAlignments, [.left, .center, .right]) + + XCTAssertNotEqual(t0, t1) + } + + /// 不带 alignments 的双参数 init 与带空 alignments 数组的三参数 init 应得到相等模型。 + /// 钉住「默认值漂移」类回归——若实现把双参数 init 默认值改成非空数组,本用例会红字提示。 + func testTableModelDefaultAlignmentsEquivalentToExplicitEmptyAlignments() { + let cell: [STMarkdownInlineNode] = [.text("c")] + let row: [[STMarkdownInlineNode]] = [cell] + let implicit = STMarkdownTableModel(header: [cell], rows: [row]) + let explicit = STMarkdownTableModel(header: [cell], rows: [row], columnAlignments: []) + XCTAssertEqual(implicit, explicit, "双参数 init 与显式空 alignments 三参数 init 应等价") + } + + // MARK: STMarkdownBlockNode + + func testBlockNodeExhaustiveCasesProduceDistinctTags() { + let table = STMarkdownTableModel(header: nil, rows: [[ [.text("a")] ]]) + let samples: [STMarkdownBlockNode] = [ + .paragraph([.text("p")]), + .heading(level: 3, content: [.text("h")]), + .quote([.paragraph([.text("q")])]), + .list(kind: .unordered, items: [STMarkdownListItemNode(blocks: [.paragraph([.text("i")])])]), + .codeBlock(language: "swift", code: "let x = 0"), + .codeBlock(language: nil, code: "plain"), + .table(table), + .mathBlock("E=mc^2"), + .image(url: "https://u", altText: "al", title: "t"), + .image(url: "https://u2", altText: "a2", title: nil), + .thematicBreak, + ] + let tags = samples.map { st_tag(forBlock: $0) } + XCTAssertEqual(Set(tags), Set(BlockCaseTag.allCases)) + } + + func testBlockNodeHashableNestedStructure() { + let inner = STMarkdownBlockNode.paragraph([.strong([.text("x")])]) + let doc = STMarkdownDocument(blocks: [.quote([inner, .thematicBreak])]) + let copy = STMarkdownDocument(blocks: [.quote([inner, .thematicBreak])]) + XCTAssertEqual(doc, copy) + } + + // MARK: STMarkdownDocument + + func testDocumentInitializerAndSendable() { + let d = STMarkdownDocument(blocks: [.paragraph([])]) + st_requireSendable(d) + XCTAssertEqual(d.blocks.count, 1) + if case .paragraph(let inlines)? = d.blocks.first { + XCTAssertTrue(inlines.isEmpty) + } else { + XCTFail("期望单一段落块") + } + } + + // MARK: STMarkdownRenderDocument + + func testRenderDocumentInitializerAndEquality() { + let r = STMarkdownRenderDocument(blocks: [.thematicBreak]) + let same = STMarkdownRenderDocument(blocks: [.thematicBreak]) + XCTAssertEqual(r, same) + st_requireSendable(r) + } + + // MARK: STMarkdownRenderBlock + + func testRenderBlockExhaustiveCasesProduceDistinctTags() { + let table = STMarkdownTableModel(header: [[.text("H")]], rows: [[ [.text("c")] ]]) + let samples: [STMarkdownRenderBlock] = [ + .paragraph([.text("p")]), + .heading(level: 2, content: [.text("h")]), + .quote([.paragraph([.text("q")])]), + .list([ + STMarkdownRenderListItem( + blocks: [.paragraph([.text("li")])], + ordered: false, + level: 0, + orderedIndex: nil + ), + ]), + .codeBlock(language: "js", code: "1"), + .codeBlock(language: nil, code: "2"), + .table(table), + .mathBlock("a+b"), + .image(url: "https://img", altText: "x", title: "y"), + .image(url: "https://img2", altText: "x2", title: nil), + .thematicBreak, + ] + let tags = samples.map { st_tag(forRenderBlock: $0) } + XCTAssertEqual(Set(tags), Set(RenderBlockCaseTag.allCases)) + } + + // MARK: STMarkdownRenderListItem + + func testRenderListItemDirectInitializerPreservesFields() { + let item = STMarkdownRenderListItem( + blocks: [.paragraph([.text("a")]), .codeBlock(language: nil, code: "b")], + ordered: true, + level: 2, + orderedIndex: 9, + checkbox: .unchecked + ) + XCTAssertEqual(item.blocks.count, 2) + XCTAssertTrue(item.ordered) + XCTAssertEqual(item.level, 2) + XCTAssertEqual(item.orderedIndex, 9) + XCTAssertEqual(item.checkbox, .unchecked) + XCTAssertEqual(item.content, [.text("a")]) + XCTAssertEqual(item.childBlocks.count, 1) + if case .codeBlock(_, let code)? = item.childBlocks.first { + XCTAssertEqual(code, "b") + } else { + XCTFail("childBlocks 首项应为 codeBlock") + } + } + + func testRenderListItemContentInitializerBuildsParagraphPrefix() { + let item = STMarkdownRenderListItem( + content: [.text("lead")], + ordered: false, + level: 1, + orderedIndex: nil, + childBlocks: [.quote([.paragraph([.text("nested")])])], + checkbox: .checked + ) + XCTAssertEqual(item.content, [.text("lead")]) + XCTAssertEqual(item.childBlocks.count, 1) + XCTAssertEqual(item.checkbox, .checked) + if case .quote(let inner)? = item.childBlocks.first { + XCTAssertFalse(inner.isEmpty) + } else { + XCTFail("期望 childBlocks 首块为 quote") + } + } + + func testRenderListItemContentInitializerEmptyContentOnlyAppendsChildBlocks() { + // content 为空时不会前置合成 paragraph;若 childBlocks 首块非 paragraph, + // 则 `content` 为空且 `childBlocks` 与 `blocks` 一致。 + let item = STMarkdownRenderListItem( + content: [], + ordered: false, + level: 0, + orderedIndex: nil, + childBlocks: [.codeBlock(language: "swift", code: "only-code")], + checkbox: nil + ) + XCTAssertEqual(item.blocks, [.codeBlock(language: "swift", code: "only-code")]) + XCTAssertEqual(item.blocks.count, 1) + XCTAssertTrue(item.content.isEmpty) + XCTAssertEqual(item.childBlocks, item.blocks) + } + + /// 空 content 不插入额外段;若 childBlocks 仍以 paragraph 开头,则按 API 契约 + /// `content` 取该段 inlines,`childBlocks` 为剩余块(此处为空)。 + func testRenderListItemContentInitializerEmptyContentWithParagraphFirstChildExposesChildInContent() { + let item = STMarkdownRenderListItem( + content: [], + ordered: false, + level: 0, + orderedIndex: nil, + childBlocks: [.paragraph([.text("only-child")])], + checkbox: nil + ) + XCTAssertEqual(item.blocks.count, 1) + XCTAssertEqual(item.content, [.text("only-child")]) + XCTAssertTrue(item.childBlocks.isEmpty) + } + + func testRenderListItemNonParagraphFirstContentAndChildBlocksContract() { + let item = STMarkdownRenderListItem( + blocks: [.codeBlock(language: "swift", code: "x")], + ordered: false, + level: 0, + orderedIndex: nil + ) + XCTAssertTrue(item.content.isEmpty) + XCTAssertEqual(item.childBlocks, item.blocks) + } + + func testRenderListItemHashable() { + let a = STMarkdownRenderListItem(blocks: [.paragraph([.text("z")])], ordered: false, level: 0, orderedIndex: nil) + let b = STMarkdownRenderListItem(blocks: [.paragraph([.text("z")])], ordered: false, level: 0, orderedIndex: nil) + XCTAssertEqual(a, b) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift new file mode 100644 index 0000000..68f3c50 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift @@ -0,0 +1,284 @@ +import XCTest +import UIKit +import STBaseProject + +private struct CoreMockRule: STMarkdownRule { + let name: String + let shouldApplyResult: Bool + let replacement: String + + func shouldApply(to text: String) -> Bool { + self.shouldApplyResult + } + + func apply(to text: String, context: inout STMarkdownPreprocessContext) -> String { + self.replacement + } +} + +private struct CoreMockParser: STMarkdownStructureParsing { + let parseResult: STMarkdownDocument + func parse(_ markdown: String) -> STMarkdownDocument { self.parseResult } +} + +private struct CoreMockRenderAdapter: STMarkdownRenderAdapting { + let adaptResult: STMarkdownRenderDocument + func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument { self.adaptResult } +} + +private struct CoreAppendNormalizer: STMarkdownSemanticNormalizing { + let suffix: String + func normalize(_ document: STMarkdownDocument) -> STMarkdownDocument { + let appended = STMarkdownBlockNode.paragraph([.text(self.suffix)]) + return STMarkdownDocument(blocks: document.blocks + [appended]) + } +} + +@MainActor +private final class CoreInteractionStub: STMarkdownInteractable { + var onLinkTap: ((URL) -> Void)? + var onSelectionChange: ((String) -> Void)? + var isTextSelectionEnabled: Bool = false +} + +final class STMarkdownCoreContractsTests: XCTestCase { + + func testEngineDelegatesToPipelineAndPreservesRawMarkdown() { + let parserOutput = STMarkdownDocument(blocks: [.heading(level: 1, content: [.text("Title")])]) + let renderOutput = STMarkdownRenderDocument(blocks: [.thematicBreak]) + let parser = CoreMockParser(parseResult: parserOutput) + let adapter = CoreMockRenderAdapter(adaptResult: renderOutput) + let engine = STMarkdownEngine( + configuration: .init(enableInputSanitizer: false), + parser: parser, + renderAdapter: adapter + ) + + let result = engine.process("raw markdown") + + XCTAssertEqual(result.rawMarkdown, "raw markdown") + XCTAssertEqual(result.sanitizedMarkdown, "raw markdown") + XCTAssertEqual(result.sourceDocument, parserOutput) + XCTAssertEqual(result.normalizedDocument, parserOutput) + XCTAssertEqual(result.renderDocument, renderOutput) + } + + func testPipelineUsesSemanticNormalizersInOrder() { + let source = STMarkdownDocument(blocks: [.paragraph([.text("seed")])]) + let parser = CoreMockParser(parseResult: source) + let adapter = CoreMockRenderAdapter(adaptResult: .init(blocks: [])) + let pipeline = STMarkdownPipeline( + configuration: .init( + enableInputSanitizer: false, + semanticNormalizers: [ + CoreAppendNormalizer(suffix: "A"), + CoreAppendNormalizer(suffix: "B"), + ] + ), + parser: parser, + renderAdapter: adapter + ) + + let result = pipeline.process("ignored") + + XCTAssertEqual(result.normalizedDocument.blocks.count, 3) + XCTAssertEqual(result.normalizedDocument.blocks[1], .paragraph([.text("A")])) + XCTAssertEqual(result.normalizedDocument.blocks[2], .paragraph([.text("B")])) + } + + func testPipelineConfigurationDefaultsEnableSanitizerAndRules() { + let configuration = STMarkdownPipelineConfiguration() + + XCTAssertTrue(configuration.enableInputSanitizer) + XCTAssertFalse(configuration.debug) + XCTAssertFalse(configuration.sanitizerRules.isEmpty) + XCTAssertTrue(configuration.semanticNormalizers.isEmpty) + } + + func testPreprocessContextMarksAppliedRulesInOrder() { + var context = STMarkdownPreprocessContext(debugMode: .enabled) + let first = CoreMockRule(name: "rule-1", shouldApplyResult: true, replacement: "x") + + context.markApplied(first) + context.markApplied("rule-2") + + XCTAssertTrue(context.debugMode.isEnabled) + XCTAssertEqual(context.appliedRules, ["rule-1", "rule-2"]) + } + + func testRenderAdapterKeepsQuoteListItemLevelWithoutExtraIncrement() { + let adapter = STMarkdownRenderAdapter() + let document = STMarkdownDocument( + blocks: [ + .quote([ + .list( + kind: .ordered(startIndex: 3), + items: [ + STMarkdownListItemNode(blocks: [.paragraph([.text("inside quote")])]), + ] + ) + ]) + ] + ) + + let renderDocument = adapter.adapt(document) + + guard case .quote(let quoteBlocks)? = renderDocument.blocks.first, + case .list(let items)? = quoteBlocks.first + else { + return XCTFail("Expected quote->list render structure") + } + XCTAssertEqual(items.first?.orderedIndex, 3) + XCTAssertEqual(items.first?.level, 0) + } + + func testBodyParagraphStyleUsesConfiguredMetrics() { + let style = STMarkdownStyle.default + let paragraph = STMarkdownTypography.bodyParagraphStyle(style: style) + + XCTAssertEqual(paragraph.minimumLineHeight, style.lineHeight) + XCTAssertEqual(paragraph.maximumLineHeight, style.lineHeight) + XCTAssertEqual(paragraph.lineSpacing, style.bodyLineSpacing) + XCTAssertEqual(paragraph.paragraphSpacing, style.paragraphSpacing) + } + + func testHeadingFontAndInsetsForFallbackLevel() { + let font = STMarkdownTypography.headingFont(for: 6) + let insets = STMarkdownTypography.headingInsets(for: 6) + let expected = UIFont.st_systemFont(ofSize: 16, weight: .medium) + + XCTAssertEqual(font.pointSize, expected.pointSize) + XCTAssertEqual(insets.top, 16) + XCTAssertEqual(insets.bottom, 6) + } + + func testHeadingParagraphStyleNeverUsesNegativeParagraphSpacingBefore() { + var style = STMarkdownStyle.default + style.paragraphSpacing = 100 + let font = UIFont.st_systemFont(ofSize: 22, weight: .bold) + + let paragraph = STMarkdownTypography.headingParagraphStyle(level: 1, font: font, style: style) + + XCTAssertEqual(paragraph.paragraphSpacingBefore, 0) + XCTAssertEqual(paragraph.paragraphSpacing, 10) + } + + func testListStyleResolverOrderedLayoutBuildsMonotonicIndices() { + let style = STMarkdownStyle.default + let baseFont = style.font + let layout = STMarkdownListStyleResolver.makeLayout( + ordered: true, + level: 2, + orderedIndex: 0, + baseFont: baseFont, + style: style + ) + + XCTAssertEqual(layout.markerText, "1.\t") + XCTAssertGreaterThan(layout.contentIndent, layout.markerIndent) + XCTAssertGreaterThan(layout.paragraphStyle.tabStops.count, 1) + } + + func testApplyContinuationIndentAlignsAllParagraphsToContentIndent() { + let attributed = NSMutableAttributedString(string: "line1\nline2\n\nline3") + let style = STMarkdownStyle.default + + STMarkdownListStyleResolver.applyContinuationIndent( + to: attributed, + firstLineIndent: 4, + contentIndent: 28, + style: style + ) + + var location = 0 + let string = attributed.string as NSString + while location < attributed.length { + let range = string.paragraphRange(for: NSRange(location: location, length: 0)) + let paragraph = attributed.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(paragraph?.firstLineHeadIndent, 28) + XCTAssertEqual(paragraph?.headIndent, 28) + location = range.location + range.length + } + } + + func testStyleDefaultAndResolvedDisplayScale() { + let style = STMarkdownStyle.default + XCTAssertEqual(style.lineHeight, 24) + XCTAssertGreaterThan(style.resolvedDisplayScale, 0) + } + + func testStyleHeadingFontProviderCanOverrideLevel() { + var style = STMarkdownStyle.default + style.headingFontProvider = { _ in UIFont.st_systemFont(ofSize: 42, weight: .bold) } + + let resolved = style.headingFontProvider?(3) + let expected = UIFont.st_systemFont(ofSize: 42, weight: .bold) + + XCTAssertEqual(resolved?.pointSize, expected.pointSize) + } + + func testFontResolverReturnsExpectedPointSizeForBoldAndItalic() { + let base = UIFont.st_systemFont(ofSize: 17, weight: .regular) + + let italic = STMarkdownFontResolver.italicFont(from: base) + let bold = STMarkdownFontResolver.boldFont(from: base) + let boldItalic = STMarkdownFontResolver.boldItalicFont(from: base) + + XCTAssertEqual(italic.pointSize, base.pointSize) + XCTAssertEqual(bold.pointSize, base.pointSize) + XCTAssertEqual(boldItalic.pointSize, base.pointSize) + } + + func testPresetsFactoryCreatesFreshRendererInstances() { + let first = STMarkdownPresets.makeDefaultAdvancedRenderers() + let second = STMarkdownPresets.makeDefaultAdvancedRenderers() + + XCTAssertNotNil(first.inlineMathRenderer) + XCTAssertNotNil(first.codeBlockRenderer) + + let firstObject = first.inlineMathRenderer as AnyObject + let secondObject = second.inlineMathRenderer as AnyObject + XCTAssertFalse(firstObject === secondObject) + } + + func testDeprecatedDefaultAdvancedRenderersCompatibilityCreatesFreshInstances() { + let first = STMarkdownPresets.defaultAdvancedRenderers + let second = STMarkdownPresets.defaultAdvancedRenderers + + XCTAssertNotNil(first.inlineMathRenderer) + XCTAssertNotNil(first.blockMathRenderer) + XCTAssertNotNil(first.codeBlockRenderer) + XCTAssertNotNil(first.tableRenderer) + XCTAssertNotNil(first.imageRenderer) + XCTAssertNotNil(first.horizontalRuleRenderer) + + let firstObject = first.inlineMathRenderer as AnyObject + let secondObject = second.inlineMathRenderer as AnyObject + XCTAssertFalse(firstObject === secondObject) + } + + func testPresetsProvideArticleAndCompactWithDifferentTypography() { + XCTAssertGreaterThan(STMarkdownPresets.article.font.pointSize, STMarkdownPresets.compact.font.pointSize) + XCTAssertGreaterThan(STMarkdownPresets.article.lineHeight, STMarkdownPresets.compact.lineHeight) + XCTAssertGreaterThan(STMarkdownPresets.article.horizontalRuleLength, STMarkdownPresets.compact.horizontalRuleLength) + } + + @MainActor + func testInteractionProtocolPropertiesAreUsableOnMainActor() { + let stub = CoreInteractionStub() + var tappedURL: URL? + var selectedText: String? + let url = URL(string: "https://example.com")! + + stub.onLinkTap = { tappedURL = $0 } + stub.onSelectionChange = { selectedText = $0 } + stub.isTextSelectionEnabled = true + + stub.onLinkTap?(url) + stub.onSelectionChange?("selection") + + XCTAssertEqual(tappedURL, url) + XCTAssertEqual(selectedText, "selection") + XCTAssertTrue(stub.isTextSelectionEnabled) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift b/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift new file mode 100644 index 0000000..f9b67b0 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift @@ -0,0 +1,375 @@ +// +// 验证以下三个 Bug 修复: +// 1. STMarkdownMermaidRenderer.cacheKey 使用完整代码字符串,不再用 hashValue,消除碰撞风险 +// 2. STMarkdownTableViewModel 使用静态正则常量,不再在热路径重复编译 +// 3. STMarkdownTableView 添加 UICollectionViewDelegate,使 onCitationTap 回调可正常触发 + +import XCTest +import UIKit +@testable import STBaseProject +@testable import STBaseProjectExample + +// MARK: - 1. Mermaid Cache Key Tests + +/// 验证 cacheKey 使用完整代码字符串,不同代码/主题产生不同键,不会发生碰撞 +@MainActor +final class STMarkdownMermaidCacheKeyTests: XCTestCase { + + func testDifferentCodesProduceDifferentKeys() { + let renderer = STMarkdownMermaidRenderer.shared + let keyA = renderer.cacheKey("graph LR\n A --> B", .light) + let keyB = renderer.cacheKey("graph TD\n X --> Y", .light) + XCTAssertNotEqual(keyA, keyB, "不同代码应产生不同 cacheKey") + } + + func testSameCodeSameThemeProduceEqualKeys() { + let renderer = STMarkdownMermaidRenderer.shared + let code = "pie title Pets\n\"Dogs\": 386" + let key1 = renderer.cacheKey(code, .light) + let key2 = renderer.cacheKey(code, .light) + XCTAssertEqual(key1, key2, "相同代码和主题应产生相同 cacheKey") + } + + func testDarkAndLightThemeProduceDifferentKeys() { + let renderer = STMarkdownMermaidRenderer.shared + let code = "flowchart LR\n A --> B" + let lightKey = renderer.cacheKey(code, .light) + let darkKey = renderer.cacheKey(code, .dark) + XCTAssertNotEqual(lightKey, darkKey, "深色/浅色主题应产生不同 cacheKey") + } + + func testKeyFormatIsCodeBased() { + let renderer = STMarkdownMermaidRenderer.shared + let code = "graph LR\n A --> B" + let key = renderer.cacheKey(code, .light) + XCTAssertTrue(key.hasSuffix(code), "cacheKey 应以完整代码字符串结尾,而非 hashValue") + XCTAssertTrue(key.hasPrefix("0_"), "浅色主题 cacheKey 应以 '0_' 开头") + } + + func testDarkKeyFormatIsCodeBased() { + let renderer = STMarkdownMermaidRenderer.shared + let code = "sequenceDiagram\n A->>B: Hello" + let key = renderer.cacheKey(code, .dark) + XCTAssertTrue(key.hasPrefix("1_"), "深色主题 cacheKey 应以 '1_' 开头") + XCTAssertTrue(key.hasSuffix(code), "cacheKey 应包含完整代码字符串") + } + + /// 回归测试:hashValue 在同进程中偶尔会对不同字符串返回相同值(Swift 有随机化处理,但原实现存在理论碰撞) + /// 当前实现使用完整字符串,可保证唯一性 + func testKnownCollisionCandidatesProduceDifferentKeys() { + let renderer = STMarkdownMermaidRenderer.shared + // 两段意义完全不同的 Mermaid 代码 + let codes: [String] = [ + "graph LR\n A --> B\n B --> C", + "graph RL\n C --> B\n B --> A", + "sequenceDiagram\n Alice->>Bob: Hi", + "sequenceDiagram\n Bob->>Alice: Hi", + "pie title X\n \"a\": 10", + "pie title X\n \"b\": 10", + ] + var keys = Set() + for code in codes { + let key = renderer.cacheKey(code, .light) + XCTAssertTrue(keys.insert(key).inserted, "代码 '\(code)' 产生的 key '\(key)' 与已有 key 碰撞") + } + } +} + +// MARK: - 2. STMarkdownTableViewModel Citation Tests + +/// 验证静态正则常量正确提取 citation、badge 替换,不受热路径重编译影响 +final class STMarkdownTableViewModelCitationTests: XCTestCase { + + private let style = STMarkdownStyle.default + + // MARK: - Citation Extraction + + func testExtractsCitationFromLinkNode() { + let table = STMarkdownTableModel( + header: [[.link(destination: "", children: [.text("Citation:3")])]], + rows: [] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertEqual(viewModel.cells.count, 1) + let cellData = viewModel.cells[0][0] + XCTAssertTrue(cellData.citations.contains("3"), "应提取 link 节点中的 Citation:3") + } + + func testExtractsCitationFromInlineText() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("参见 [Citation:7] 了解详情")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + let cellData = viewModel.cells[0][0] + XCTAssertTrue(cellData.citations.contains("7"), "应从纯文本 [Citation:7] 提取编号") + } + + func testNoCitationInPlainText() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("普通内容,没有引用")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertEqual(viewModel.cells[0][0].citations, [], "无 citation 时应返回空数组") + } + + func testMultipleCitationsInSameCell() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[ + .text("[Citation:1]"), + .link(destination: "", children: [.text("Citation:2")]) + ]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + let citations = viewModel.cells[0][0].citations + XCTAssertTrue(citations.contains("1"), "应包含 Citation:1") + XCTAssertTrue(citations.contains("2"), "应包含 Citation:2") + } + + func testWebpageVariantAlsoExtracted() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("[Webpage:4]")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + // citation badge regex 覆盖 [Webpage:N] 格式,验证不崩溃且处理正常 + XCTAssertNotNil(viewModel.cells[0][0].attributedContent) + } + + // MARK: - Header Detection + + func testFirstRowIsHeaderWhenHeaderProvided() { + let table = STMarkdownTableModel( + header: [[.text("列标题")]], + rows: [[[.text("数据")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertTrue(viewModel.hasHeader) + XCTAssertTrue(viewModel.cells[0][0].role.isHeader, "第一行应标记为 header") + XCTAssertFalse(viewModel.cells[1][0].role.isHeader, "数据行不应标记为 header") + } + + func testNoHeaderWhenHeaderNil() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("数据")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertFalse(viewModel.hasHeader) + XCTAssertFalse(viewModel.cells[0][0].role.isHeader) + } + + // MARK: - Badge Replacement + + func testCitationInlineTextReplacedWithAttachment() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("参见 [Citation:5] 了解详情")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + let attributed = viewModel.cells[0][0].attributedContent + XCTAssertTrue( + self.containsAttachment(attributed), + "Citation 文本应被替换为 NSTextAttachment badge" + ) + } + + func testCitationLinkReplacedWithAttachment() { + let table = STMarkdownTableModel( + header: [[.link(destination: "", children: [.text("Citation:2")])]], + rows: [] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + let attributed = viewModel.cells[0][0].attributedContent + XCTAssertTrue( + self.containsAttachment(attributed), + "Citation link 节点应替换为 NSTextAttachment badge" + ) + } + + func testPlainTextCellHasNoAttachment() { + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("普通文字,无引用")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + let attributed = viewModel.cells[0][0].attributedContent + XCTAssertFalse( + self.containsAttachment(attributed), + "无 citation 的 cell 不应包含 NSTextAttachment" + ) + } + + // MARK: - Column / Row Count + + func testColumnAndRowCount() { + // header: [[STMarkdownInlineNode]] — 3 columns, each with 1 node + // rows: [[[STMarkdownInlineNode]]] — 2 rows × 3 columns × 1 node + let table = STMarkdownTableModel( + header: [[.text("A")], [.text("B")], [.text("C")]], + rows: [ + [[.text("1")], [.text("2")], [.text("3")]], + [[.text("4")], [.text("5")], [.text("6")]] + ] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertEqual(viewModel.columnCount, 3) + XCTAssertEqual(viewModel.rowCount, 3, "1 header + 2 data rows = 3 rows") + } + + func testEmptyTableProducesZeroCounts() { + let table = STMarkdownTableModel(header: nil, rows: []) + let viewModel = STMarkdownTableViewModel(from: table, style: self.style) + + XCTAssertEqual(viewModel.columnCount, 0) + XCTAssertEqual(viewModel.rowCount, 0) + } + + // MARK: - Helpers + + private func containsAttachment(_ attributed: NSAttributedString) -> Bool { + var found = false + attributed.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: attributed.length), + options: [] + ) { value, _, _ in + if value is NSTextAttachment { found = true } + } + return found + } +} + +// MARK: - 3. STMarkdownTableView Citation Tap Tests + +/// 验证添加 UICollectionViewDelegate 后 onCitationTap 回调正常触发 +@MainActor +final class STMarkdownTableViewCitationTapTests: XCTestCase { + + func testCitationTapCallbackFiredWhenCellSelected() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + + let table = STMarkdownTableModel( + header: nil, + rows: [[[.link(destination: "", children: [.text("Citation:9")])]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: style) + tableView.tableData = viewModel + + var tappedCitation: String? + tableView.onCitationTap = { tappedCitation = $0 } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + XCTAssertNotNil(collectionView, "STMarkdownTableView 应包含 UICollectionView 子视图") + + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + + XCTAssertEqual(tappedCitation, "9", "点击含 Citation:9 的 cell 应触发 onCitationTap(\"9\")") + } + + func testCitationTapNotFiredWhenCellHasNoCitations() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("普通文本")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: style) + tableView.tableData = viewModel + + var tappedCitation: String? + tableView.onCitationTap = { tappedCitation = $0 } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + + XCTAssertNil(tappedCitation, "无 citation 的 cell 被点击时不应触发 onCitationTap") + } + + func testCitationTapWithNilTableDataIsSafe() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + tableView.tableData = nil + + var called = false + tableView.onCitationTap = { _ in called = true } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + // tableData 为 nil,点击任意 cell 不应崩溃且不应触发回调 + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + + XCTAssertFalse(called, "tableData 为 nil 时点击不应触发回调") + } + + func testMultipleColumnsCorrectCitationTap() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + + // rows: 1 row × 2 columns; col 0 = plain text, col 1 = citation link + let table = STMarkdownTableModel( + header: nil, + rows: [ + [ + [.text("无引用")], + [.link(destination: "", children: [.text("Citation:42")])] + ] + ] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: style) + tableView.tableData = viewModel + + var tappedCitation: String? + tableView.onCitationTap = { tappedCitation = $0 } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + + // 点击第 0 列(无引用) + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + XCTAssertNil(tappedCitation, "第 0 列无引用,不应触发回调") + + // 点击第 1 列(有 Citation:42) + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 1, section: 0) + ) + XCTAssertEqual(tappedCitation, "42", "第 1 列应触发 Citation:42 回调") + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift new file mode 100644 index 0000000..effa152 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift @@ -0,0 +1,1169 @@ +// +// STMarkdownParsingEscapeAndDisplayTests.swift +// STBaseProjectExampleTests +// +// 针对 STMarkdown/Parsing 目录两条核心契约: +// 1) CommonMark 反斜杠转义(含 `\* \** \# \` \[ \] \_ \! \> \1. \- \| \~ \$ \\` 与行尾 `\`)、 +// 以及预处理器中的 JSON / HTML 风格转义(`\n \" \/` 还原 + `` 转 markdown 链接)能被正确解析; +// 2) 经默认引擎 + `STMarkdownAttributedStringRenderer` 渲染后的「可见文本」不得残留 Markdown 定界片段、 +// 裸 HTML 标签、占位符;同时富文本 trait(粗 / 斜 / 链接 / 等宽)必须真实存在于属性串上。 +// +// 分工说明: +// - sanitizer 表格 / 锚点 / 页码引用 / 多空行等规则的逐条契约由 STMarkdownPipelineTests 覆盖, +// 本文件聚焦「转义解析」与「最终可见串无 markdown 残留 + 富文本 trait 真实存在」两条端到端契约。 +// - AST / RenderAST 的 enum case 穷举与 struct 初始化等价性由 +// STMarkdownASTAndRenderASTExhaustiveTests 覆盖,本文件不重复。 +// + +import XCTest +import UIKit +import STBaseProject + +// MARK: - 渲染为可见纯文本(与 STMarkdownStructureParserParseAndRenderIntegrityTests 对齐) + +private func st_renderPlainString(markdown: String) -> String { + st_renderAttributed(markdown: markdown).string +} + +private func st_renderAttributed(markdown: String) -> NSAttributedString { + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(markdown) + let renderer = STMarkdownAttributedStringRenderer( + style: .default, + advancedRenderers: .empty + ) + return renderer.render(document: result.renderDocument) +} + +private func st_firstParagraphInlinesFromRender(_ document: STMarkdownRenderDocument) -> [STMarkdownInlineNode]? { + for block in document.blocks { + if case .paragraph(let inlines) = block { + return inlines + } + } + return nil +} + +private func st_markdownFixtureText(named name: String, ext: String = "txt") throws -> String { + let testFileURL = URL(fileURLWithPath: #filePath) + let projectRoot = testFileURL + .deletingLastPathComponent() // STBaseProjectExampleTests + .deletingLastPathComponent() // project root + let fixtureURL = projectRoot + .appendingPathComponent("STBaseProjectExample") + .appendingPathComponent("Resources") + .appendingPathComponent(name) + .appendingPathExtension(ext) + return try String(contentsOf: fixtureURL, encoding: .utf8) +} + +/// 归一化空白,便于将「AST 语义拼接」与 `NSAttributedString.string` 对比。 +private func st_normalizeReaderWhitespace(_ s: String) -> String { + s.replacingOccurrences(of: "\u{00a0}", with: " ") + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +/// 深度优先收集段落 / 标题 / 代码块正文的语义文本(不含列表 marker、引用竖线等 UI 装饰)。 +private func st_collectSemanticTextSegments(from blocks: [STMarkdownRenderBlock]) -> [String] { + var segments: [String] = [] + for block in blocks { + switch block { + case .paragraph(let inlines): + let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty == false { segments.append(t) } + case .heading(_, let inlines): + let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty == false { segments.append(t) } + case .quote(let inner): + segments.append(contentsOf: st_collectSemanticTextSegments(from: inner)) + case .list(let items): + for item in items { + segments.append(contentsOf: st_collectSemanticTextSegments(from: item.blocks)) + } + case .codeBlock(_, let code): + let t = code.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty == false { segments.append(t) } + case .table, .mathBlock, .image, .thematicBreak: + break + } + } + return segments +} + +/// 渲染结果中绝不应出现的 Markdown/HTML 定界片段:与转义/未消费定界相关,无论何种用例都禁。 +private let st_baselineForbiddenSubstrings: [(token: String, label: String)] = [ + ("```", "代码围栏"), + ("{{ST_MATH_BLOCK:", "块公式内部占位符泄漏"), + ("![", "图片 Markdown 前缀"), + ("$$", "块公式美元定界符"), + ("\\(", #"行内公式 `\("#), + ("\\)", #"行内公式 `\)`"#), + ("- [ ]", "任务列表未完成原始语法"), + ("- [x]", "任务列表已完成原始语法(小写 x)"), + ("- [X]", "任务列表已完成原始语法(大写 X)"), + ("` 时合法输出 `>`,行首 `> ` 检测仅按需调用。 +private let st_quoteLinePrefix = "> " + +private struct STMarkdownLeakRelaxations: OptionSet { + let rawValue: Int + static let allowLiteralStarRuns = STMarkdownLeakRelaxations(rawValue: 1 << 0) + static let allowLiteralUnderscoreRuns = STMarkdownLeakRelaxations(rawValue: 1 << 1) + static let allowLiteralTildeRuns = STMarkdownLeakRelaxations(rawValue: 1 << 2) +} + +/// 若仍出现在最终可见串里,视为解析/渲染泄漏的 Markdown / 公式 / 裸 HTML 片段。 +/// 仅检测子串型定界符;ATX / quote 行首前缀因可能与字面字符冲突,由独立 helper 按需校验。 +private func st_assertNoMarkdownLeaks( + in output: String, + relaxations: STMarkdownLeakRelaxations = [], + file: StaticString = #filePath, + line: UInt = #line +) { + for (token, label) in st_baselineForbiddenSubstrings { + XCTAssertFalse( + output.contains(token), + "渲染结果不得包含未消费的 Markdown/HTML 定界片段(\(label)):\(token.debugDescription)", + file: file, + line: line + ) + } + for (token, label) in st_pairedEmphasisDelimiters { + if token == "**" && relaxations.contains(.allowLiteralStarRuns) { continue } + if token == "__" && relaxations.contains(.allowLiteralUnderscoreRuns) { continue } + if token == "~~" && relaxations.contains(.allowLiteralTildeRuns) { continue } + XCTAssertFalse( + output.contains(token), + "渲染结果不得包含未消费的 Markdown 强调定界片段(\(label)):\(token.debugDescription)", + file: file, + line: line + ) + } +} + +/// 按需调用:源 markdown 含真 ATX 标题 / 块引用时,验证渲染后该结构不再以 `# / > ` 行首形式出现; +/// 不可在「源含 `\#` / `\>` 转义」的用例上调用——CommonMark §6.1 转义后字面 `#` / `>` 在行首是合法输出。 +private func st_assertNoAtxOrQuoteLineStarts( + in output: String, + file: StaticString = #filePath, + line: UInt = #line +) { + let lines = output.components(separatedBy: CharacterSet.newlines) + for rawLine in lines { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + for prefix in st_atxHeadingLinePrefixes where trimmed.hasPrefix(prefix) { + XCTFail( + "渲染结果某行不得以 ATX 标题前缀开头(\(prefix.debugDescription)):\(rawLine.debugDescription)", + file: file, + line: line + ) + break + } + if trimmed.hasPrefix(st_quoteLinePrefix) { + XCTFail( + "渲染结果某行不得以块引用前缀 `> ` 开头:\(rawLine.debugDescription)", + file: file, + line: line + ) + } + } +} + +/// 兼容旧调用:默认无任何放过项,禁全部 baseline + 强调子串。 +private func st_assertNoRawMarkdownOrTagSyntaxLeaks( + in output: String, + file: StaticString = #filePath, + line: UInt = #line +) { + st_assertNoMarkdownLeaks(in: output, relaxations: [], file: file, line: line) +} + +/// 兼容旧调用:用例已转义 `\*\*` / `\*`,可见串允许保留字面量 `**` / `*`,故放过粗体定界符序列。 +private func st_assertNoRawMarkdownOrTagSyntaxLeaksAllowingLiteralStarRuns( + in output: String, + file: StaticString = #filePath, + line: UInt = #line +) { + st_assertNoMarkdownLeaks( + in: output, + relaxations: [.allowLiteralStarRuns], + file: file, + line: line + ) +} + +// MARK: - AST 文本收集(仅用于转义解析断言) + +private func st_joinInlinePlainText(_ nodes: [STMarkdownInlineNode]) -> String { + nodes.map { st_inlinePlainText($0) }.joined() +} + +private func st_inlinePlainText(_ node: STMarkdownInlineNode) -> String { + switch node { + case .text(let s): + return s + case .softBreak: + return "\n" + case .inlineMath(let f, _): + return f + case .code(let s): + return s + case .emphasis(let c), .strong(let c), .strikethrough(let c): + return st_joinInlinePlainText(c) + case .link(_, let c): + return st_joinInlinePlainText(c) + case .image(_, let alt, _): + return alt + } +} + +private func st_firstParagraphText(_ document: STMarkdownDocument) -> String? { + for block in document.blocks { + if case .paragraph(let inlines) = block { + return st_joinInlinePlainText(inlines) + } + } + return nil +} + +private func st_blockContainsText(_ block: STMarkdownBlockNode, text: String) -> Bool { + switch block { + case .paragraph(let inlines): + return st_joinInlinePlainText(inlines).contains(text) + case .heading(_, let inlines): + return st_joinInlinePlainText(inlines).contains(text) + case .quote(let children): + return children.contains { st_blockContainsText($0, text: text) } + case .list(_, let items): + return items.contains { item in + item.blocks.contains { st_blockContainsText($0, text: text) } + } + case .table(let table): + let header = (table.header ?? []).flatMap { $0 } + let rows = table.rows.flatMap { $0 }.flatMap { $0 } + return st_joinInlinePlainText(header + rows).contains(text) + case .codeBlock(_, let code): + return code.contains(text) + case .mathBlock(let formula): + return formula.contains(text) + case .image(_, let altText, let title): + return altText.contains(text) || (title?.contains(text) == true) + case .thematicBreak: + return false + } +} + +private func st_renderBlockContainsText(_ block: STMarkdownRenderBlock, text: String) -> Bool { + switch block { + case .paragraph(let inlines): + return st_joinInlinePlainText(inlines).contains(text) + case .heading(_, let inlines): + return st_joinInlinePlainText(inlines).contains(text) + case .quote(let children): + return children.contains { st_renderBlockContainsText($0, text: text) } + case .list(let items): + return items.contains { item in + item.blocks.contains { st_renderBlockContainsText($0, text: text) } + } + case .table(let table): + let header = (table.header ?? []).flatMap { $0 } + let rows = table.rows.flatMap { $0 }.flatMap { $0 } + return st_joinInlinePlainText(header + rows).contains(text) + case .codeBlock(_, let code): + return code.contains(text) + case .mathBlock(let formula): + return formula.contains(text) + case .image(_, let altText, let title): + return altText.contains(text) || (title?.contains(text) == true) + case .thematicBreak: + return false + } +} + +// MARK: - Tests + +final class STMarkdownParsingEscapeAndDisplayTests: XCTestCase { + + // MARK: CommonMark 反斜杠转义 → 解析结果 + + func testParserEscapedAsterisksDoNotFormStrongOrEmphasis() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"展示 \*不是斜体\* 与 \*\*不是粗体\*\*"#) + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望首块为段落") + } + XCTAssertTrue(plain.contains("*不是斜体*"), "转义后的星号应作为字面量保留:\(plain)") + XCTAssertTrue(plain.contains("**不是粗体**"), "转义后的双星号应作为字面量保留:\(plain)") + XCTAssertFalse(plain.contains("\\"), "解析后 Text 节点不应再保留反斜杠转义符本身(CommonMark 会消费反斜杠)") + } + + func testParserEscapedHashIsParagraphNotHeading() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("\\# 这不是标题") + + guard case .paragraph = doc.blocks.first else { + return XCTFail("转义的 # 不应被识别为 ATX 标题,期望段落块") + } + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落含文本") + } + XCTAssertTrue(plain.hasPrefix("# 这不是标题") || plain.contains("这不是标题"), + "字面量井号应保留在段落文本中:\(plain)") + } + + func testParserEscapedBackticksDoNotFormInlineCode() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"这里是 \`不是代码\` 片段"#) + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains("`不是代码`"), "反引号应作为字面量:\(plain)") + } + + func testParserEscapedSquareBracketsDoNotFormLinkOrImage() { + let parser = STMarkdownStructureParser() + // 避免裸 URL 自动链接干扰:验证转义后的 `[]` 不会变成 link 节点。 + // 注:swift-markdown / cmark 对 `\[...\]` 的纯文本拼接未必保留 ASCII 方括号字面量, + // 但「可见方括号」等语义与「不得出现 link」应稳定成立。 + let doc = parser.parse("参考 \\[可见方括号\\] 后文") + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains("可见方括号") && plain.contains("参考") && plain.contains("后文"), "语义应保留:\(plain)") + + let inlines = doc.blocks.compactMap { block -> [STMarkdownInlineNode]? in + if case .paragraph(let nodes) = block { return nodes } + return nil + }.first ?? [] + let hasLink = inlines.contains { node in + if case .link = node { return true } + return false + } + XCTAssertFalse(hasLink, "转义后的 [] 不应被解析为 Markdown 链接节点") + let hasInlineImage = inlines.contains { node in + if case .image = node { return true } + return false + } + XCTAssertFalse(hasInlineImage, "转义后的 [] 不应被解析为 inline image 节点") + let hasBlockImage = doc.blocks.contains { block in + if case .image = block { return true } + return false + } + XCTAssertFalse(hasBlockImage, "转义后的 [] 不应被提升为段落级 image 块") + } + + /// `\![alt](url)` 的反斜杠把图片前缀 `!` 转义掉;解析器不得生成 image 节点。 + func testParserEscapedBangBracketDoesNotFormImage() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"开头 \![占位](https://example.com/i.png) 结尾"#) + + let hasBlockImage = doc.blocks.contains { block in + if case .image = block { return true } + return false + } + XCTAssertFalse(hasBlockImage, "转义的 \\! 不应触发段落级 image 提升") + + let inlines = doc.blocks.compactMap { block -> [STMarkdownInlineNode]? in + if case .paragraph(let nodes) = block { return nodes } + return nil + }.first ?? [] + let hasInlineImage = inlines.contains { node in + if case .image = node { return true } + return false + } + XCTAssertFalse(hasInlineImage, "转义的 \\! 不应被解析为 inline image 节点") + } + + func testParserEscapedUnderscoreDoesNotFormEmphasis() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"下划线 \_字面量\_ 测试"#) + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains("_字面量_"), "转义下划线应保留:\(plain)") + } + + /// 行首 `\>` 被转义后,整行视为段落而非 BlockQuote 块。 + func testParserEscapedGreaterThanDoesNotFormBlockQuote() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"\> 不是引用"#) + + XCTAssertEqual(doc.blocks.count, 1) + guard case .paragraph = doc.blocks.first else { + return XCTFail("转义的 \\> 不应被识别为 BlockQuote,期望段落块;实际:\(String(describing: doc.blocks.first))") + } + let hasQuote = doc.blocks.contains { block in + if case .quote = block { return true } + return false + } + XCTAssertFalse(hasQuote) + } + + /// 行首数字后转义的 `\.` 阻止有序列表识别。 + func testParserEscapedOrderedListMarkerDoesNotFormList() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"1\. 不是有序列表"#) + + let hasList = doc.blocks.contains { block in + if case .list = block { return true } + return false + } + XCTAssertFalse(hasList, "转义后的 1\\. 不应被识别为有序列表块") + guard case .paragraph = doc.blocks.first else { + return XCTFail("期望首块为段落,实际:\(String(describing: doc.blocks.first))") + } + } + + /// 行首 `\-` 阻止无序列表识别。 + func testParserEscapedUnorderedListMarkerDoesNotFormList() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"\- 不是无序列表"#) + + let hasList = doc.blocks.contains { block in + if case .list = block { return true } + return false + } + XCTAssertFalse(hasList, "转义后的 \\- 不应被识别为无序列表块") + } + + /// `\~` 阻止 GFM 删除线识别,且字面 `~` 字符应保留。 + func testParserEscapedTildeDoesNotFormStrikethrough() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"\~不是删除线\~ 文本"#) + + let inlines = doc.blocks.compactMap { block -> [STMarkdownInlineNode]? in + if case .paragraph(let nodes) = block { return nodes } + return nil + }.first ?? [] + let hasStrikethrough = inlines.contains { node in + if case .strikethrough = node { return true } + return false + } + XCTAssertFalse(hasStrikethrough, "转义后的 \\~ 不应被解析为 strikethrough 节点") + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains("~不是删除线~"), "转义后的字面 ~ 应保留:\(plain)") + } + + /// `\|` 在表格未成立的上下文里转义为字面 `|`,且不会触发 table 块。 + func testParserEscapedPipeDoesNotFormTableInProseContext() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"列 A \| 列 B 是字面"#) + + let hasTable = doc.blocks.contains { block in + if case .table = block { return true } + return false + } + XCTAssertFalse(hasTable, "无 delimiter row 的散文场景下,转义 \\| 不应触发 table 块") + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains("|"), "转义后的字面 | 应保留:\(plain)") + } + + /// `\$\$` 不应触发 mathBlock;mathNormalizer 仅在行首独占 `$$` 才提取,单行内 `\$\$` 走 cmark。 + func testParserEscapedDollarSignsDoNotFormMathBlock() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"价格 \$9.99 与 \$\$ 不是公式 \$\$"#) + + let hasMathBlock = doc.blocks.contains { block in + if case .mathBlock = block { return true } + return false + } + XCTAssertFalse(hasMathBlock, "单行内的 \\$\\$ 不应被识别为块公式") + guard case .paragraph = doc.blocks.first else { + return XCTFail("期望首块为段落") + } + } + + /// CommonMark §6.1:`\\` 应渲染为字面单反斜杠;解析后的 text 节点至少含一个 `\` 字符。 + func testParserDoubleBackslashRendersAsLiteralSingleBackslash() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"路径 C:\\Users\\song 字面"#) + + guard let plain = st_firstParagraphText(doc) else { + return XCTFail("期望段落") + } + XCTAssertTrue(plain.contains(#"\"#), "双反斜杠应保留为字面单反斜杠:\(plain)") + } + + /// CommonMark §6.6:行尾单 `\` 表示硬换行,解析后段落 inline 节点序列应含 softBreak。 + func testParserTrailingBackslashFormsHardLineBreak() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("第一行\\\n第二行") + + XCTAssertEqual(doc.blocks.count, 1, "硬换行不应分段") + guard case .paragraph(let inlines) = doc.blocks.first else { + return XCTFail("期望首块为段落") + } + let hasBreak = inlines.contains { node in + if case .softBreak = node { return true } + return false + } + XCTAssertTrue(hasBreak, "行尾 \\ 应映射到 softBreak 节点;实际 inlines=\(inlines)") + } + + /// 工程契约(主动偏离 CommonMark):`STMarkdownMathNormalizer` 把 `\(...\)` 视为行内公式定界符; + /// 因此用户在散文中写 `\(对话\)` 会被解析成 inlineMath 节点,而非字面括号。 + /// 本用例钉住该契约——若实现改变(如恢复 CommonMark 转义括号语义),此用例将红字提示需要同步更新文档。 + func testMathNormalizerTreatsBackslashParenthesesAsInlineMathEvenInProseContext() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"开头 \(对话\) 结尾"#) + + guard case .paragraph(let inlines) = doc.blocks.first else { + return XCTFail("期望首块为段落") + } + let mathNode = inlines.first { node in + if case .inlineMath = node { return true } + return false + } + guard case .inlineMath(let formula, let display)? = mathNode else { + return XCTFail("工程契约:\\(...\\) 应被吞成 inlineMath;实际 inlines=\(inlines)") + } + XCTAssertEqual(formula, "对话") + XCTAssertFalse(display, "\\(...\\) 是 inline 模式(非 display)") + } + + func testMathNormalizerDoubleBackslashBeforeParenNormalizesToSingleForInlineMath() { + // 通过公共 API:normalizeDelimiters 在 splitInlineMath 内部执行, + // 将 API/模型输出的 "\\(" 规范为 "\(" 后再识别行内公式。 + let raw = #"\\(a+b\\)"# + let nodes = STMarkdownMathNormalizer.splitInlineMath(in: raw) + XCTAssertEqual(nodes.count, 1) + guard case .inlineMath(let formula, let display) = nodes.first else { + return XCTFail("期望单个 inlineMath 节点") + } + XCTAssertEqual(formula, "a+b") + XCTAssertFalse(display) + } + + // MARK: 预处理器转义(STHtmlNormalizeRule 等)+ 全链路 + + func testSanitizerUnescapesJsonStyleSequencesThenParserSeesRealNewlines() { + let sanitizer = STMarkdownInputSanitizer(rules: [STHtmlNormalizeRule()]) + let result = sanitizer.sanitize("第一行\\n第二行\\n第三行") + XCTAssertTrue( + result.appliedRules.contains("STHtmlNormalizeRule"), + "JSON 风格 \\n 必须由 STHtmlNormalizeRule 还原;appliedRules=\(result.appliedRules)" + ) + XCTAssertNotEqual(result.sanitizedText, result.originalText, "sanitized 文本应不同于原始文本") + + let parser = STMarkdownStructureParser() + let doc = parser.parse(result.sanitizedText) + + // STHtmlNormalizeRule 把 `\n` 还原为单换行;CommonMark 把单换行视为 softBreak(同段落), + // 因此应当形成「单一段落 + 两个 softBreak」结构,而非多段落。 + XCTAssertEqual(doc.blocks.count, 1, "单换行应折叠到同一段落,期望 blocks.count == 1:实际 \(doc.blocks.count)") + guard case .paragraph(let inlines) = doc.blocks.first else { + return XCTFail("首块应为 paragraph,实际:\(String(describing: doc.blocks.first))") + } + let softBreakCount = inlines.reduce(into: 0) { partial, node in + if case .softBreak = node { partial += 1 } + } + XCTAssertEqual(softBreakCount, 2, "两个 \\n 应解析为两个 softBreak 节点;实际 \(softBreakCount)") + let joined = st_joinInlinePlainText(inlines) + XCTAssertTrue(joined.contains("第一行")) + XCTAssertTrue(joined.contains("第二行")) + XCTAssertTrue(joined.contains("第三行")) + } + + func testEndToEndEscapedInlineRichTextRendersWithoutDelimiterOrHtmlLeaks() { + let md = #""" + 行内混合:\*\*不是粗体\*\*、\*不是斜体\*、\`不是代码\`、\[方括\] 后接、~~真删除~~。 + """# + let plain = st_renderPlainString(markdown: md) + // 本用例故意含「已转义」字面量 ** / * / `,可见串允许出现连续 *,不得按「泄漏」判失败 + st_assertNoRawMarkdownOrTagSyntaxLeaksAllowingLiteralStarRuns(in: plain) + // CommonMark §6.1:所有 ASCII 标点的反斜杠转义都应保留字面字符。 + // 单分支严格断言而非 `||` 双向放过,保证「正确解析为字面」与「错误解析为强调」可被区分。 + XCTAssertTrue(plain.contains("**不是粗体**"), "已转义 \\*\\* 应保留字面 ** 字符序列:\(plain)") + XCTAssertTrue(plain.contains("*不是斜体*"), "已转义 \\* 应保留字面 * 字符序列:\(plain)") + XCTAssertTrue(plain.contains("`不是代码`"), "已转义 \\` 应保留字面 ` 字符:\(plain)") + XCTAssertTrue(plain.contains("真删除"), "真删除线正文应渲染:\(plain)") + XCTAssertFalse(plain.contains("~~"), "真删除线经解析后可见串不应残留 ~~") + } + + func testEndToEndEscapedHeadingMarkerRendersAsPlainTextWithoutAtxLeak() { + let md = """ + \\# 这不是标题 + + 普通 **粗** 尾 + """ + let plain = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("这不是标题")) + XCTAssertTrue(plain.contains("粗")) + } + + func testEndToEndHtmlAnchorFromApiSanitizesToMarkdownThenRendersNoRawTags() { + let md = #"点我 与 **粗**"# + + // 先单跑 sanitizer 验证规则确实被应用——避免「sanitizer 静默失效 + cmark 吞掉 raw HTML」类回归静默通过 + let sanitizer = STMarkdownInputSanitizer(rules: STMarkdownInputSanitizer.defaultRules) + let sanitized = sanitizer.sanitize(md) + XCTAssertTrue( + sanitized.appliedRules.contains("STHtmlNormalizeRule"), + "STHtmlNormalizeRule 应将 \\\" 还原为 \";appliedRules=\(sanitized.appliedRules)" + ) + XCTAssertTrue( + sanitized.appliedRules.contains("STHtmlLinkToMarkdownRule"), + "STHtmlLinkToMarkdownRule 应将 转为 markdown 链接;appliedRules=\(sanitized.appliedRules)" + ) + XCTAssertTrue( + sanitized.sanitizedText.contains("[点我](https://example.com)"), + "sanitize 后应得到 markdown 链接形式:\(sanitized.sanitizedText)" + ) + + let attributed = st_renderAttributed(markdown: md) + let visible = attributed.string + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + XCTAssertTrue(visible.contains("点我")) + XCTAssertTrue(visible.contains("粗")) + + let ns = visible as NSString + let linkRange = ns.range(of: "点我") + XCTAssertNotEqual(linkRange.location, NSNotFound) + let linkAttribute = attributed.attribute(.link, at: linkRange.location, effectiveRange: nil) as? URL + XCTAssertEqual( + linkAttribute?.absoluteString, + "https://example.com", + "anchor 转 markdown 链接后,渲染层应在「点我」区段附 .link 属性而非以 [文本](url) 形式出现在可见串" + ) + + let boldRange = ns.range(of: "粗") + XCTAssertNotEqual(boldRange.location, NSNotFound) + let boldFont = attributed.attribute(.font, at: boldRange.location, effectiveRange: nil) as? UIFont + XCTAssertTrue( + boldFont?.fontDescriptor.symbolicTraits.contains(.traitBold) == true, + "**粗** 区段应携带 .traitBold" + ) + } + + /// 界面可见串应等于「解析管线最终语义」下的纯文本拼接;不得残留 Markdown/HTML 定界片段; + /// 且属性串上须能观察到富文本(粗体字体与链接),不能退化成单一默认字体的纯文本块。 + func testRenderedVisibleStringMatchesFinalMarkdownSemanticsAndUsesRichAttributes() { + let md = "请使用 **加粗** 查看 [文档](https://docs.example.com) 与 *斜体*。" + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + guard let inlines = st_firstParagraphInlinesFromRender(result.renderDocument) else { + return XCTFail("期望渲染文档首块为段落") + } + let expectedSemantic = st_joinInlinePlainText(inlines) + + let attributed = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + let visible = st_normalizeReaderWhitespace(attributed.string) + let expectedTrimmed = st_normalizeReaderWhitespace(expectedSemantic) + + XCTAssertEqual( + visible, + expectedTrimmed, + "可见文本应与管线最终 AST 的语义拼接一致(即 Markdown 解析完成后的读者文本)" + ) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + + let ns = visible as NSString + let boldRange = ns.range(of: "加粗") + let italicRange = ns.range(of: "斜体") + let linkRange = ns.range(of: "文档") + XCTAssertNotEqual(boldRange.location, NSNotFound) + XCTAssertNotEqual(italicRange.location, NSNotFound) + XCTAssertNotEqual(linkRange.location, NSNotFound) + + let fontAtBold = attributed.attribute(.font, at: boldRange.location, effectiveRange: nil) as? UIFont + XCTAssertNotNil(fontAtBold) + let boldTraits = fontAtBold?.fontDescriptor.symbolicTraits + XCTAssertTrue( + boldTraits?.contains(.traitBold) == true, + "粗体区段应携带 .traitBold,界面不能只呈现无样式纯文本" + ) + + let fontAtItalic = attributed.attribute(.font, at: italicRange.location, effectiveRange: nil) as? UIFont + XCTAssertNotNil(fontAtItalic) + let italicTraits = fontAtItalic?.fontDescriptor.symbolicTraits + XCTAssertTrue( + italicTraits?.contains(.traitItalic) == true, + "斜体区段应携带 .traitItalic" + ) + + let linkURL = attributed.attribute(.link, at: linkRange.location, effectiveRange: nil) as? URL + XCTAssertEqual(linkURL?.absoluteString, "https://docs.example.com", "链接语义应体现在属性上,而非以 `[文本](url)` 形式出现在可见串中") + } + + // MARK: 多段 / 非首块 paragraph / 列表与引用(补全语义与富文本覆盖) + + /// 两段纯段落:可见串与递归收集的语义段落拼接一致,且两段内粗/斜均有 trait。 + func testMultiParagraphRenderedVisibleMatchesSemanticSegmentsAndRichAttributes() { + let md = """ + 第一段 **粗一**。 + + 第二段 *斜二*。 + """ + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + let segments = st_collectSemanticTextSegments(from: result.renderDocument.blocks) + XCTAssertEqual(segments.count, 2, "期望两个顶层段落语义片段") + + let attributed = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + let visible = st_normalizeReaderWhitespace(attributed.string) + let expected = st_normalizeReaderWhitespace(segments.joined(separator: "\n")) + XCTAssertEqual( + visible, + expected, + "多段纯文本场景下,可见串应与各段语义文本用块间单换行拼接一致(与 STMarkdownAttributedStringRenderer 块分隔一致)" + ) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + + let ns = visible as NSString + let r1 = ns.range(of: "粗一") + let r2 = ns.range(of: "斜二") + XCTAssertNotEqual(r1.location, NSNotFound) + XCTAssertNotEqual(r2.location, NSNotFound) + let boldTraits = (attributed.attribute(.font, at: r1.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits + let italicTraits = (attributed.attribute(.font, at: r2.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits + XCTAssertTrue(boldTraits?.contains(.traitBold) == true) + XCTAssertTrue(italicTraits?.contains(.traitItalic) == true) + } + + /// 首块为标题时,顶层首块不是 paragraph(但文档内嵌套处仍可有 paragraph,故 `st_firstParagraphInlinesFromRender` 可能非 nil)。 + func testHeadingThenParagraphVisibleMatchesSemanticJoinWithoutAtxMarkers() { + let md = """ + ## 章节 **内粗** + + 正文 *斜* 尾。 + """ + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + guard case .heading(let level, _)? = result.renderDocument.blocks.first else { + return XCTFail("期望渲染文档首块为 heading,实际:\(String(describing: result.renderDocument.blocks.first))") + } + XCTAssertEqual(level, 2) + + let segments = st_collectSemanticTextSegments(from: result.renderDocument.blocks) + XCTAssertEqual(segments.count, 2) + + let attributed = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + let visible = st_normalizeReaderWhitespace(attributed.string) + let expected = st_normalizeReaderWhitespace(segments.joined(separator: "\n")) + XCTAssertEqual(visible, expected) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + st_assertNoAtxOrQuoteLineStarts(in: visible) + XCTAssertFalse(visible.contains("##"), "可见串不应残留 ATX ## 定界") + XCTAssertTrue(visible.contains("章节") && visible.contains("内粗")) + + let ns = visible as NSString + let rBold = ns.range(of: "内粗") + let rItalic = ns.range(of: "斜") + XCTAssertTrue( + (attributed.attribute(.font, at: rBold.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitBold) == true + ) + XCTAssertTrue( + (attributed.attribute(.font, at: rItalic.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitItalic) == true + ) + } + + /// 块引用内粗体:可见串含引用正文,无 `> ` 前缀泄漏,粗体区段有 trait。 + func testBlockQuoteWithStrongRendersQuoteBodyWithoutMarkdownPrefixLeaks() { + let md = "> **引用粗** 与 *引用斜*。" + let attributed = st_renderAttributed(markdown: md) + let visible = attributed.string + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + st_assertNoAtxOrQuoteLineStarts(in: visible) + XCTAssertTrue(visible.contains("引用粗") && visible.contains("引用斜")) + XCTAssertFalse(visible.contains("> "), "块引用不应以 Markdown `> ` 前缀出现在可见文本中") + + let ns = visible as NSString + let rB = ns.range(of: "引用粗") + let rI = ns.range(of: "引用斜") + XCTAssertTrue( + (attributed.attribute(.font, at: rB.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitBold) == true + ) + XCTAssertTrue( + (attributed.attribute(.font, at: rI.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitItalic) == true + ) + } + + /// 无序列表项内粗体:首块为 list 非 paragraph;可见串无 `**`,且列表项正文为粗体 trait。 + func testUnorderedListWithStrongItemNoRawMarkdownAndBoldTrait() { + let md = """ + - **列表粗** + - 普通项 + """ + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + guard case .list? = result.renderDocument.blocks.first else { + return XCTFail("期望首块为 list,实际:\(String(describing: result.renderDocument.blocks.first))") + } + + let attributed = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + let visible = attributed.string + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + XCTAssertFalse(visible.contains("**")) + XCTAssertTrue(visible.contains("列表粗") && visible.contains("普通项")) + + let ns = visible as NSString + let rBold = ns.range(of: "列表粗") + XCTAssertNotEqual(rBold.location, NSNotFound) + XCTAssertTrue( + (attributed.attribute(.font, at: rBold.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitBold) == true + ) + } + + /// 有序列表后接独立段落:首块为 list;语义收集含「粗体项」与「跟段落」;可见串无列表 Markdown 定界泄漏。 + func testOrderedListThenParagraphSemanticSegmentsAndNoDelimiterLeaks() { + let md = """ + 1. 有序 **粗体项** + + 跟段落 *斜*。 + """ + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + guard case .list? = result.renderDocument.blocks.first else { + return XCTFail("期望首块为 list,实际:\(String(describing: result.renderDocument.blocks.first))") + } + + let segments = st_collectSemanticTextSegments(from: result.renderDocument.blocks) + XCTAssertEqual(segments.count, 2) + XCTAssertTrue(segments[0].contains("粗体项")) + XCTAssertTrue(segments[1].contains("跟段落")) + + let visible = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + XCTAssertTrue(visible.contains("粗体项") && visible.contains("跟段落")) + XCTAssertFalse(visible.contains("**")) + XCTAssertFalse(visible.contains("1. 有序"), "不应把有序列表源码前缀原样显示为读者文本") + } + + /// 列表项以围栏代码块开头、后接段落:首子块非 paragraph;可见串含代码正文与「说明」,且不得残留 ``` 围栏。 + func testListItemLeadingWithCodeBlockThenParagraphRendersWithoutFenceLeaks() { + let md = """ + - ```swift + let v = 1 + ``` + + 说明文字 + """ + let attributed = st_renderAttributed(markdown: md) + let visible = attributed.string + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + XCTAssertFalse(visible.contains("```"), "围栏代码不应以 ``` 出现在最终可见串中") + XCTAssertTrue(visible.contains("let v = 1"), "代码正文应出现在可见输出中") + XCTAssertTrue(visible.contains("说明文字"), "代码块后的列表项段落应保留") + + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(md) + guard case .list(let items)? = result.renderDocument.blocks.first, + let firstItem = items.first + else { + return XCTFail("期望首块为列表") + } + XCTAssertFalse(firstItem.blocks.isEmpty, "列表项应包含子块") + if case .codeBlock = firstItem.blocks.first {} else { + XCTFail("期望列表项首子块为 codeBlock(首块非 paragraph 场景)") + } + } + + /// 嵌套无序列表:可见串含父子项正文,无 `**` 泄漏。 + func testNestedUnorderedListRendersChildBoldWithoutMarkdownDelimiters() { + let md = """ + - 父项 + - **子粗** + """ + let visible = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: visible) + XCTAssertTrue(visible.contains("父项") && visible.contains("子粗")) + XCTAssertFalse(visible.contains("**")) + + let attributed = st_renderAttributed(markdown: md) + let ns = visible as NSString + let r = ns.range(of: "子粗") + XCTAssertNotEqual(r.location, NSNotFound) + XCTAssertTrue( + (attributed.attribute(.font, at: r.location, effectiveRange: nil) as? UIFont)? + .fontDescriptor.symbolicTraits.contains(.traitBold) == true + ) + } + + @MainActor + func testStreamingViewAccumulatedStringHasNoMarkdownOrHtmlLeaksWhenEscapesPresent() { + // 显式注入带 sanitizer 的 engine:避免默认值漂移导致 chunks 内字面 `\n` 还原行为变化造成假阳性 + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let view = STMarkdownStreamingTextView( + style: .default, + advancedRenderers: .empty, + engine: engine + ) + let chunks = [ + #"字面值 \*"#, + #"*不斜*\n\n"#, + #"[链](https://e.com)"#, + "\n\n**真粗**", + ] + var accumulated = "" + for chunk in chunks { + accumulated += chunk + view.updateStreamingMarkdown(accumulated) + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: view.attributedText.string) + } + XCTAssertTrue(view.attributedText.string.contains("真粗")) + } + + func testSemanticNormalizerPassthroughPreservesEscapedParagraphAST() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"字面 \*星\*"#) + let normalized = STMarkdownSemanticNormalizer.passthrough.normalize(doc) + XCTAssertEqual(normalized, doc) + } + + func testRenderAdapterProducesRenderDocumentWithoutExtraMarkdownDelimiters() { + let parser = STMarkdownStructureParser() + let adapter = STMarkdownRenderAdapter() + let doc = parser.parse("\\# 标题字面 与 **粗**") + let renderDoc = adapter.adapt(doc) + let plain = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: renderDoc) + .string + st_assertNoRawMarkdownOrTagSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("粗")) + } + + func testRenderListItemParagraphInitializerPreservesCheckboxForTaskEscapeScenario() { + let item = STMarkdownRenderListItem( + content: [.text("任务项")], + ordered: false, + level: 0, + orderedIndex: nil, + childBlocks: [], + checkbox: .unchecked + ) + XCTAssertEqual(item.checkbox, .unchecked) + XCTAssertEqual(item.content, [.text("任务项")]) + } + + // MARK: 本地 Resources 数据回归 + + func testResourceData1MarkdownCanBeParsedAndRendered() throws { + let markdown = try st_markdownFixtureText(named: "data1") + let result = STMarkdownEngine().process(markdown) + XCTAssertFalse(result.sourceDocument.blocks.isEmpty, "data1 应至少解析出一个 source block") + XCTAssertFalse(result.renderDocument.blocks.isEmpty, "data1 应至少生成一个 render block") + + let rendered = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + .string + XCTAssertTrue(rendered.contains("第一步")) + XCTAssertTrue(rendered.contains("第三步")) + XCTAssertTrue(rendered.contains("立即")) + } + + func testResourceData2MarkdownCanBeParsedAndRendered() throws { + let markdown = try st_markdownFixtureText(named: "data2") + let result = STMarkdownEngine().process(markdown) + XCTAssertFalse(result.sourceDocument.blocks.isEmpty, "data2 应至少解析出一个 source block") + XCTAssertFalse(result.renderDocument.blocks.isEmpty, "data2 应至少生成一个 render block") + + let rendered = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + .string + XCTAssertTrue(rendered.contains("减肥方法")) + XCTAssertTrue(rendered.contains("关键提醒")) + XCTAssertTrue(rendered.contains("控制热量摄入")) + } + + func testResourceData3MarkdownCanBeParsedAndRenderedWithoutCodeFenceLeaks() throws { + let markdown = try st_markdownFixtureText(named: "data3") + let result = STMarkdownEngine().process(markdown) + XCTAssertFalse(result.sourceDocument.blocks.isEmpty, "data3 应至少解析出一个 source block") + XCTAssertFalse(result.renderDocument.blocks.isEmpty, "data3 应至少生成一个 render block") + + let rendered = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + .string + XCTAssertTrue(rendered.contains("Markdown实现示例")) + XCTAssertTrue(rendered.contains("HTML实现示例")) + XCTAssertTrue(rendered.contains("应用场景")) + XCTAssertFalse(rendered.contains("```"), "代码围栏定界符不应泄漏到最终可见串") + } + + func testResourceData1ASTStructureContracts() throws { + let markdown = try st_markdownFixtureText(named: "data1") + let result = STMarkdownEngine().process(markdown) + let sourceBlocks = result.sourceDocument.blocks + XCTAssertFalse(sourceBlocks.isEmpty) + + let headingBlocks = sourceBlocks.compactMap { block -> (Int, [STMarkdownInlineNode])? in + if case .heading(let level, let content) = block { return (level, content) } + return nil + } + XCTAssertGreaterThanOrEqual(headingBlocks.count, 5, "data1 应含多级标题结构") + XCTAssertTrue(headingBlocks.contains { level, content in + level == 2 && st_joinInlinePlainText(content).contains("第一步") + }, "data1 应包含“第一步”二级标题") + XCTAssertTrue(headingBlocks.contains { level, content in + level == 2 && st_joinInlinePlainText(content).contains("第三步") + }, "data1 应包含“第三步”二级标题") + + let listBlocks = sourceBlocks.compactMap { block -> (STMarkdownListKind, [STMarkdownListItemNode])? in + if case .list(let kind, let items) = block { return (kind, items) } + return nil + } + XCTAssertFalse(listBlocks.isEmpty, "data1 应解析出列表块") + XCTAssertTrue(listBlocks.contains { _, items in + items.contains { item in + item.blocks.contains { st_blockContainsText($0, text: "安静") } + } + }, "data1 列表中应包含“安静”相关条目") + } + + func testResourceData2ASTStructureContracts() throws { + let markdown = try st_markdownFixtureText(named: "data2") + let result = STMarkdownEngine().process(markdown) + let sourceBlocks = result.sourceDocument.blocks + XCTAssertFalse(sourceBlocks.isEmpty) + + let tables = sourceBlocks.compactMap { block -> STMarkdownTableModel? in + if case .table(let model) = block { return model } + return nil + } + XCTAssertEqual(tables.count, 1, "data2 应包含单个主表格") + guard let table = tables.first else { return } + XCTAssertNotNil(table.header, "data2 表格应有 header") + XCTAssertGreaterThanOrEqual(table.header?.count ?? 0, 3, "data2 header 至少 3 列") + XCTAssertGreaterThanOrEqual(table.rows.count, 8, "data2 表格应包含多行建议内容") + + let headerText = st_joinInlinePlainText((table.header ?? []).flatMap { $0 }) + XCTAssertTrue(headerText.contains("类别")) + XCTAssertTrue(headerText.contains("具体建议")) + XCTAssertTrue(headerText.contains("注意事项")) + + let rowText = st_joinInlinePlainText(table.rows.flatMap { $0 }.flatMap { $0 }) + XCTAssertTrue(rowText.contains("饮食调整")) + XCTAssertTrue(rowText.contains("运动建议")) + XCTAssertTrue(rowText.contains("生活习惯")) + } + + func testResourceData3ASTStructureContracts() throws { + let markdown = try st_markdownFixtureText(named: "data3") + let result = STMarkdownEngine().process(markdown) + let sourceBlocks = result.sourceDocument.blocks + XCTAssertFalse(sourceBlocks.isEmpty) + + let codeBlocks = sourceBlocks.compactMap { block -> (String?, String)? in + if case .codeBlock(let language, let code) = block { return (language, code) } + return nil + } + XCTAssertGreaterThanOrEqual(codeBlocks.count, 2, "data3 至少包含 markdown/html 两段代码块") + XCTAssertTrue(codeBlocks.contains { lang, code in + (lang ?? "").lowercased().contains("markdown") && code.contains("1. 有序列表第一项") + }) + XCTAssertTrue(codeBlocks.contains { lang, code in + (lang ?? "").lowercased().contains("html") && code.contains("
        ") + }) + + let renderLists = result.renderDocument.blocks.compactMap { block -> [STMarkdownRenderListItem]? in + if case .list(let items) = block { return items } + return nil + } + XCTAssertFalse(renderLists.isEmpty, "data3 渲染 AST 应至少包含一个列表块") + XCTAssertTrue(renderLists.contains { items in + items.contains { $0.ordered } + }, "data3 应包含有序列表") + XCTAssertTrue(result.renderDocument.blocks.contains { st_renderBlockContainsText($0, text: "关键点") }) + XCTAssertTrue(result.renderDocument.blocks.contains { st_renderBlockContainsText($0, text: "应用场景") }) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift new file mode 100644 index 0000000..311d1cb --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift @@ -0,0 +1,2550 @@ +import XCTest +@testable import STBaseProject +@testable import STBaseProjectExample + +private struct MockCodeBlockRenderer: STMarkdownCodeBlockRendering { + func renderCodeBlock(language: String?, code: String, style: STMarkdownStyle) -> NSAttributedString? { + NSAttributedString(string: "[code:\(language ?? "plain")]\(code)") + } +} + +private struct MockInlineMathRenderer: STMarkdownInlineMathRendering { + func renderInlineMath(formula: String, style: STMarkdownStyle, baseFont: UIFont, textColor: UIColor) -> NSAttributedString? { + NSAttributedString(string: "[math:\(formula)]") + } +} + +private final class MockImageLoader: STMarkdownImageLoading { + private(set) var lastURL: URL? + + func loadImage(from url: URL, completion: @escaping @Sendable (UIImage?) -> Void) { + self.lastURL = url + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)) + let image = renderer.image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 24, height: 24)) + } + completion(image) + } +} + +private final class CancellableMockImageLoader: STMarkdownCancellableImageLoading { + private(set) var cancellable = MockImageCancellable() + private(set) var requestedURL: URL? + + func loadImage(from url: URL, completion: @escaping @Sendable (UIImage?) -> Void) { + _ = self.loadCancellableImage(from: url, completion: completion) + } + + func loadCancellableImage(from url: URL, completion: @escaping @Sendable (UIImage?) -> Void) -> STMarkdownImageLoadCancellable? { + self.requestedURL = url + return self.cancellable + } +} + +private final class DeferredMockImageLoader: STMarkdownImageLoading { + private(set) var requestedURL: URL? + private var completion: (@Sendable (UIImage?) -> Void)? + + func loadImage(from url: URL, completion: @escaping @Sendable (UIImage?) -> Void) { + self.requestedURL = url + self.completion = completion + } + + func complete(with image: UIImage?) { + self.completion?(image) + } +} + +private final class MockImageCancellable: STMarkdownImageLoadCancellable { + private(set) var didCancel = false + + func cancel() { + self.didCancel = true + } +} + +final class STMarkdownPipelineTests: XCTestCase { + + func testInputSanitizerConvertsHtmlLinkToMarkdown() { + let sanitizer = STMarkdownInputSanitizer( + rules: [ + STHtmlNormalizeRule(), + STHtmlLinkToMarkdownRule(), + ] + ) + + let result = sanitizer.sanitize(#"Example"#) + + XCTAssertEqual(result.sanitizedText, "[Example](https://example.com)") + XCTAssertTrue(result.appliedRules.contains("STHtmlLinkToMarkdownRule")) + } + + func testStructureParserPreservesOrderedListStartIndex() { + let parser = STMarkdownStructureParser() + + let document = parser.parse( + """ + 3. 第三项 + 4. 第四项 + """ + ) + + guard case .list(let kind, let items)? = document.blocks.first else { + return XCTFail("Expected first block to be list") + } + + guard case .ordered(let startIndex) = kind else { + return XCTFail("Expected ordered list kind") + } + + XCTAssertEqual(startIndex, 3) + XCTAssertEqual(items.count, 2) + } + + func testRenderAdapterFlattensOrderedListIndices() { + let parser = STMarkdownStructureParser() + let adapter = STMarkdownRenderAdapter() + let document = parser.parse( + """ + 5. 第一项 + 6. 第二项 + """ + ) + + let renderDocument = adapter.adapt(document) + + guard case .list(let items)? = renderDocument.blocks.first else { + return XCTFail("Expected first render block to be list") + } + + XCTAssertEqual(items.map(\.orderedIndex), [5, 6]) + XCTAssertTrue(items.allSatisfy(\.ordered)) + } + + func testMarkdownEngineReturnsSourceAndRenderDocuments() { + let engine = STMarkdownEngine() + + let result = engine.process("**标题**") + + XCTAssertFalse(result.sourceDocument.blocks.isEmpty) + XCTAssertFalse(result.renderDocument.blocks.isEmpty) + XCTAssertEqual(result.rawMarkdown, "**标题**") + } + + func testSoftBreakCollapsingNormalizerRemovesAdjacentSoftBreaks() { + let document = STMarkdownDocument( + blocks: [ + .paragraph([ + .text("A"), + .softBreak, + .softBreak, + .text("B"), + ]) + ] + ) + let normalizer = STMarkdownSoftBreakCollapsingNormalizer() + + let normalized = normalizer.normalize(document) + + guard case .paragraph(let inlines)? = normalized.blocks.first else { + return XCTFail("Expected paragraph block") + } + XCTAssertEqual(inlines, [.text("A"), .softBreak, .text("B")]) + } + + func testSoftBreakCollapsingNormalizerRecursivelyNormalizesNestedChildren() { + let document = STMarkdownDocument( + blocks: [ + .quote([ + .paragraph([ + .text("outer"), + .softBreak, + .softBreak, + .text("tail"), + ]), + .list( + kind: .unordered, + items: [ + STMarkdownListItemNode( + blocks: [ + .paragraph([ + .text("item"), + .softBreak, + .softBreak, + .text("end"), + ]) + ] + ) + ] + ), + ]) + ] + ) + let normalizer = STMarkdownSoftBreakCollapsingNormalizer() + + let normalized = normalizer.normalize(document) + + guard case .quote(let blocks)? = normalized.blocks.first else { + return XCTFail("Expected quote block") + } + guard case .paragraph(let outer)? = blocks.first else { + return XCTFail("Expected first quote child to be paragraph") + } + XCTAssertEqual(outer, [.text("outer"), .softBreak, .text("tail")]) + + guard case .list(_, let items)? = blocks.last, + case .paragraph(let nested)? = items.first?.blocks.first + else { + return XCTFail("Expected list paragraph in quote") + } + XCTAssertEqual(nested, [.text("item"), .softBreak, .text("end")]) + } + + func testAttributedStringRendererUsesDistinctBoldFontForStrongText() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .strong([.text("粗体")]), + .text(" 普通"), + ]) + ] + ) + + let attributed = renderer.render(document: document) + let strongFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + let normalFont = attributed.attribute(.font, at: attributed.length - 1, effectiveRange: nil) as? UIFont + + XCTAssertNotNil(strongFont) + XCTAssertNotNil(normalFont) + XCTAssertNotEqual(strongFont?.fontName, normalFont?.fontName) + } + + func testAttributedStringRendererAppliesItalicBoldItalicInlineCodeAndLinkAttributes() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + linkColor: .systemGreen, + inlineCodeTextColor: .systemPink + ) + let renderer = STMarkdownAttributedStringRenderer(style: style) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .emphasis([.text("斜体")]), + .text(" "), + .strong([.emphasis([.text("粗斜")])]), + .text(" "), + .code("code"), + .text(" "), + .link(destination: "https://example.com", children: [.text("链接")]), + ]) + ] + ) + + let attributed = renderer.render(document: document) + let italicIndex = (attributed.string as NSString).range(of: "斜体").location + let boldItalicIndex = (attributed.string as NSString).range(of: "粗斜").location + let normalIndex = (attributed.string as NSString).range(of: " ").location + let codeIndex = (attributed.string as NSString).range(of: "code").location + let linkIndex = (attributed.string as NSString).range(of: "链接").location + + let italicFont = attributed.attribute(NSAttributedString.Key.font, at: italicIndex, effectiveRange: nil) as? UIFont + let boldItalicFont = attributed.attribute(NSAttributedString.Key.font, at: boldItalicIndex, effectiveRange: nil) as? UIFont + let normalFont = attributed.attribute(NSAttributedString.Key.font, at: normalIndex, effectiveRange: nil) as? UIFont + XCTAssertNotEqual(italicFont?.fontName, normalFont?.fontName) + XCTAssertNotEqual(boldItalicFont?.fontName, normalFont?.fontName) + let codeFont = attributed.attribute(NSAttributedString.Key.font, at: codeIndex, effectiveRange: nil) as? UIFont + let codeColor = attributed.attribute(NSAttributedString.Key.foregroundColor, at: codeIndex, effectiveRange: nil) as? UIColor + XCTAssertTrue(codeFont?.fontName.lowercased().contains("mono") == true) + XCTAssertEqual(codeColor, UIColor.systemPink) + let linkURL = attributed.attribute(NSAttributedString.Key.link, at: linkIndex, effectiveRange: nil) as? URL + let linkColor = attributed.attribute(NSAttributedString.Key.foregroundColor, at: linkIndex, effectiveRange: nil) as? UIColor + XCTAssertEqual(linkURL?.absoluteString, "https://example.com") + XCTAssertEqual(linkColor, UIColor.systemGreen) + } + + func testAttributedStringRendererRenderInlineContentUsesProvidedBaseAttributes() { + let renderer = STMarkdownAttributedStringRenderer() + let baseFont = UIFont.systemFont(ofSize: 21) + let rendered = renderer.renderInlineContent( + nodes: [.text("A"), .strong([.text("B")])], + baseFont: baseFont, + textColor: .systemOrange + ) + + let firstFont = rendered.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) as? UIFont + let firstColor = rendered.attribute(NSAttributedString.Key.foregroundColor, at: 0, effectiveRange: nil) as? UIColor + let secondFont = rendered.attribute(NSAttributedString.Key.font, at: 1, effectiveRange: nil) as? UIFont + + XCTAssertEqual(firstFont?.pointSize, 21) + XCTAssertEqual(firstColor, UIColor.systemOrange) + XCTAssertNotEqual(firstFont?.fontName, secondFont?.fontName) + } + + func testAttributedStringRendererRendersOrderedListMarker() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .list([ + STMarkdownRenderListItem( + content: [.text("第一项")], + ordered: true, + level: 0, + orderedIndex: 3, + childBlocks: [] + ) + ]) + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.hasPrefix("3.\t")) + XCTAssertTrue(attributed.string.contains("第一项")) + } + + func testMarkdownStreamingTextViewRendersMarkdown() { + let view = STMarkdownStreamingTextView() + + view.setMarkdown("**标题**\n\n1. 第一项", animated: false) + + XCTAssertTrue(view.attributedText.string.contains("标题")) + XCTAssertTrue(view.attributedText.string.contains("第一项")) + } + + func testMarkdownStreamingTextViewReplacesTrailingRangeWhenRenderedPrefixMutates() { + let view = STMarkdownStreamingTextView() + + view.setMarkdown("[链接](https://example.com", animated: false) + view.updateStreamingMarkdown("[链接](https://example.com)") + + let range = (view.attributedText.string as NSString).range(of: "链接") + let link = view.attributedText.attribute(.link, at: range.location, effectiveRange: nil) as? URL + + XCTAssertEqual(link?.absoluteString, "https://example.com") + } + + func testMarkdownTextViewRendersMarkdown() { + let view = STMarkdownTextView() + + view.setMarkdown("## 标题\n\n- 列表项") + + XCTAssertTrue(view.attributedText.string.contains("标题")) + XCTAssertTrue(view.attributedText.string.contains("列表项")) + } + + func testMarkdownTextViewResetClearsContent() { + let view = STMarkdownTextView() + view.setMarkdown("普通文本") + + view.reset() + + XCTAssertTrue(view.attributedText.string.isEmpty) + XCTAssertTrue(view.rawMarkdown.isEmpty) + } + + func testRenderAdapterPreservesNestedListLevel() { + let parser = STMarkdownStructureParser() + let adapter = STMarkdownRenderAdapter() + let document = parser.parse( + """ + 1. 第一项 + - 子项 + """ + ) + + let renderDocument = adapter.adapt(document) + + guard + case .list(let items)? = renderDocument.blocks.first, + case .list(let childItems)? = items.first?.childBlocks.first + else { + return XCTFail("Expected nested render list") + } + + XCTAssertEqual(items.first?.level, 0) + XCTAssertEqual(childItems.first?.level, 1) + } + + func testAttributedStringRendererOffsetsLooseListParagraphIndent() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .list([ + STMarkdownRenderListItem( + blocks: [ + .paragraph([.text("第一段")]), + .paragraph([.text("第二段")]), + ], + ordered: true, + level: 0, + orderedIndex: 1 + ) + ]) + ] + ) + + let attributed = renderer.render(document: document) + let secondParagraphLocation = (attributed.string as NSString).range(of: "第二段").location + let paragraphStyle = attributed.attribute(.paragraphStyle, at: secondParagraphLocation, effectiveRange: nil) as? NSParagraphStyle + + XCTAssertNotNil(paragraphStyle) + XCTAssertGreaterThan(paragraphStyle?.headIndent ?? 0, 0) + } + + func testAttributedStringRendererUsesCustomCodeBlockRenderer() { + let renderer = STMarkdownAttributedStringRenderer( + advancedRenderers: STMarkdownAdvancedRenderers( + codeBlockRenderer: MockCodeBlockRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .codeBlock(language: "swift", code: "print(1)") + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertEqual(attributed.string, "[code:swift]print(1)") + } + + func testAttributedStringRendererUsesCustomInlineMathRenderer() { + let renderer = STMarkdownAttributedStringRenderer( + advancedRenderers: STMarkdownAdvancedRenderers( + inlineMathRenderer: MockInlineMathRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .text("结果 "), + .inlineMath("x+y", isDisplayMode: false), + ]) + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.contains("[math:x+y]")) + } + + func testDefaultMathRendererRendersSuperscriptContent() { + let renderer = STMarkdownAttributedStringRenderer( + advancedRenderers: STMarkdownAdvancedRenderers( + inlineMathRenderer: STMarkdownDefaultMathRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .inlineMath("x^2", isDisplayMode: false) + ]) + ] + ) + + let attributed = renderer.render(document: document) + let baselineOffset = attributed.attribute(.baselineOffset, at: 1, effectiveRange: nil) as? CGFloat + + XCTAssertEqual(attributed.string, "x2") + XCTAssertNotNil(baselineOffset) + XCTAssertGreaterThan(baselineOffset ?? 0, 0) + } + + func testDefaultMathRendererRendersSubscriptCommandMapAndBlockParagraphStyle() { + let renderer = STMarkdownDefaultMathRenderer() + let inline = renderer.renderInlineMath( + formula: #"x_{i} + \\alpha \\times y"#, + style: .default, + baseFont: .systemFont(ofSize: 16), + textColor: .label + ) + let subscriptLocation = (inline?.string as NSString?)?.range(of: "i").location ?? NSNotFound + let baselineOffset = inline?.attribute(.baselineOffset, at: subscriptLocation, effectiveRange: nil) as? CGFloat + + XCTAssertEqual(inline?.string, "xi + α × y") + XCTAssertNotNil(baselineOffset) + XCTAssertLessThan(baselineOffset ?? 0, 0) + + let block = renderer.renderBlockMath(formula: "a=b", style: .default) + XCTAssertEqual(block?.string, "\na=b\n") + let paragraphStyle = block?.attribute(.paragraphStyle, at: 1, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(paragraphStyle?.alignment, .center) + } + + func testStructureParserExtractsInlineMathNodes() { + let parser = STMarkdownStructureParser() + let document = parser.parse(#"结果是 \(x^2+y^2\)"#) + + guard case .paragraph(let inlines)? = document.blocks.first else { + return XCTFail("Expected paragraph block") + } + + XCTAssertTrue(inlines.contains { node in + if case .inlineMath(let formula, _) = node { + return formula == "x^2+y^2" + } + return false + }) + } + + func testDefaultCodeBlockRendererIncludesLanguageHeader() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + codeBlockRenderer: STMarkdownDefaultCodeBlockRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .codeBlock(language: "swift", code: "print(\"hi\")") + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.hasPrefix("SWIFT\n")) + XCTAssertTrue(attributed.string.contains("print(\"hi\")")) + } + + func testDefaultCodeBlockRendererUsesMonospacedFont() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + codeBlockRenderer: STMarkdownDefaultCodeBlockRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .codeBlock(language: nil, code: "let value = 1") + ] + ) + + let attributed = renderer.render(document: document) + let font = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + + XCTAssertNotNil(font) + XCTAssertTrue(font?.fontName.lowercased().contains("mono") == true) + } + + func testDefaultTableRendererRendersHeaderAndSeparator() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + tableRenderer: STMarkdownDefaultTableRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .table( + STMarkdownTableModel( + header: [ + [.text("名称")], + [.text("值")], + ], + rows: [ + [ + [.text("速度")], + [.text("快")], + ] + ] + ) + ) + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.contains("名称")) + XCTAssertTrue(attributed.string.contains("值")) + XCTAssertTrue(attributed.string.contains("┼")) + XCTAssertTrue(attributed.string.contains("速度")) + } + + func testDefaultTableRendererUsesMonospacedFont() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + tableRenderer: STMarkdownDefaultTableRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .table( + STMarkdownTableModel( + header: nil, + rows: [ + [ + [.text("A")], + [.text("B")], + ] + ] + ) + ) + ] + ) + + let attributed = renderer.render(document: document) + let font = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + + XCTAssertNotNil(font) + XCTAssertTrue(font?.fontName.lowercased().contains("mono") == true) + } + + func testDefaultTableRendererPlainTextCoversInlineNodeFallbacks() { + let table = STMarkdownTableModel( + header: nil, + rows: [ + [ + [ + .strong([.text("S")]), + .emphasis([.text("E")]), + .link(destination: "https://example.com", children: [.text("L")]), + .image(source: "https://example.com/image.png", alt: "", title: nil), + ], + [ + .inlineMath("x+y", isDisplayMode: false), + .softBreak, + .code("c"), + .strikethrough([.text("D")]), + ], + ] + ] + ) + + let rendered = STMarkdownDefaultTableRenderer().renderTable(table, style: .default) + + XCTAssertTrue(rendered?.string.contains("SEL[image]") == true) + XCTAssertTrue(rendered?.string.contains("x+y cD") == true) + } + + func testDefaultImageRendererUsesAltTextForInlineImage() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + imageRenderer: STMarkdownDefaultImageRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .image(source: "https://example.com/a.png", alt: "示意图", title: nil) + ]) + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.contains("示意图")) + } + + func testDefaultImageRendererRendersBlockCaption() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + imageRenderer: STMarkdownDefaultImageRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .image(url: "https://example.com/a.png", altText: "", title: "图片说明") + ] + ) + + let attributed = renderer.render(document: document) + + XCTAssertTrue(attributed.string.contains("[image] a.png")) + XCTAssertTrue(attributed.string.contains("图片说明")) + } + + func testDefaultImageRendererFallsBackForInvalidURLAndEmptyAlt() { + let renderer = STMarkdownDefaultImageRenderer() + + let inline = renderer.renderImage( + url: "", + altText: "", + title: nil, + style: .default, + placement: .inline + ) + let block = renderer.renderImage( + url: "", + altText: "", + title: nil, + style: .default, + placement: .block + ) + + XCTAssertTrue(inline?.string.contains("[img]") == true) + XCTAssertNotNil(inline?.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) + XCTAssertTrue(block?.string.contains("[image]") == true) + } + + func testDefaultHorizontalRuleRendererUsesConfiguredLength() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16, weight: .regular), + textColor: .label, + lineHeight: 24, + kern: 0.12, + horizontalRuleLength: 10 + ) + let renderer = STMarkdownAttributedStringRenderer( + style: style, + advancedRenderers: STMarkdownAdvancedRenderers( + horizontalRuleRenderer: STMarkdownDefaultHorizontalRuleRenderer() + ) + ) + let document = STMarkdownRenderDocument(blocks: [.thematicBreak]) + + let attributed = renderer.render(document: document) + + XCTAssertEqual(attributed.string, String(repeating: "─", count: 12)) + } + + func testCodeBlockAttachmentRendererProducesAttachment() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + codeBlockRenderer: STMarkdownCodeBlockAttachmentRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .codeBlock(language: "swift", code: "print(\"hi\")") + ] + ) + + let attributed = renderer.render(document: document) + let attachment = attributed.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment + + XCTAssertNotNil(attachment) + XCTAssertNotNil(attachment?.image) + XCTAssertGreaterThan(attachment?.bounds.width ?? 0, 0) + XCTAssertGreaterThan(attachment?.bounds.height ?? 0, 0) + } + + func testCodeBlockAttachmentRendererOmitsHeaderWhenLanguageIsMissing() { + let renderer = STMarkdownCodeBlockAttachmentRenderer() + let style = STMarkdownStyle.default + let withoutLanguage = renderer.renderCodeBlock(language: nil, code: "let value = 1", style: style) + let withLanguage = renderer.renderCodeBlock(language: "swift", code: "let value = 1", style: style) + let withoutAttachment = withoutLanguage?.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment + let withAttachment = withLanguage?.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment + + XCTAssertNotNil(withoutAttachment) + XCTAssertNotNil(withAttachment) + XCTAssertGreaterThan( + withAttachment?.bounds.height ?? 0, + withoutAttachment?.bounds.height ?? 0, + "高级 code block attachment 无 language 时也不应保留标题区域" + ) + } + + func testCodeBlockAttachmentOmitsHeaderWhenLanguageIsMissing() { + let style = STMarkdownStyle.default + let code = "let value = 1" + let withoutLanguage = STMarkdownCodeBlockAttachment(language: nil, code: code, style: style) + let withLanguage = STMarkdownCodeBlockAttachment(language: "swift", code: code, style: style) + + XCTAssertEqual(withoutLanguage.headerHeight, 0, "无 language 时不应保留空 header 高度") + XCTAssertGreaterThan(withLanguage.headerHeight, 0, "有 language 时应保留 header 高度") + XCTAssertGreaterThan( + withLanguage.bounds.height, + withoutLanguage.bounds.height, + "有 language 的代码块高度应包含 header 与分隔线;无 language 不应保留空白标题区域" + ) + } + + func testAsyncImageRendererProducesAttachmentAndCallsLoader() { + let loader = MockImageLoader() + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + imageRenderer: STMarkdownAsyncImageRenderer(loader: loader) + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .image(url: "https://example.com/image.png", altText: "示意图", title: "图片标题") + ] + ) + + let attributed = renderer.render(document: document) + let attachment = attributed.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment + + XCTAssertEqual(loader.lastURL?.absoluteString, "https://example.com/image.png") + XCTAssertNotNil(attachment) + XCTAssertNotNil(attachment?.image) + XCTAssertTrue(attributed.string.contains("图片标题")) + } + + func testAsyncImageRendererRejectsInvalidURL() { + let loader = MockImageLoader() + let renderer = STMarkdownAsyncImageRenderer(loader: loader) + + let rendered = renderer.renderImage(url: "", altText: "bad", title: nil, style: .default, placement: .inline) + + XCTAssertNil(rendered) + XCTAssertNil(loader.lastURL) + } + + func testAsyncImageAttachmentUpdatesBoundsAndNotifiesObserverWhenImageLoads() { + let loader = DeferredMockImageLoader() + let renderer = STMarkdownAsyncImageRenderer(loader: loader) + let attributed = renderer.renderImage( + url: "https://example.com/wide.png", + altText: "wide", + title: nil, + style: .default, + placement: .block + ) + guard let attachment = attributed?.attribute(.attachment, at: 0, effectiveRange: nil) as? STMarkdownAsyncImageAttachment else { + return XCTFail("Expected async image attachment") + } + let placeholderBounds = attachment.bounds + let expectation = self.expectation(description: "image refresh") + let observation = attachment.addDisplayObserver { + expectation.fulfill() + } + let image = UIGraphicsImageRenderer(size: CGSize(width: 560, height: 280)).image { context in + UIColor.systemRed.setFill() + context.fill(CGRect(x: 0, y: 0, width: 560, height: 280)) + } + + loader.complete(with: image) + wait(for: [expectation], timeout: 1) + _ = observation + + XCTAssertEqual(loader.requestedURL?.absoluteString, "https://example.com/wide.png") + XCTAssertNotEqual(attachment.bounds, placeholderBounds) + XCTAssertEqual(attachment.bounds.width, 280, accuracy: 0.5) + XCTAssertEqual(attachment.bounds.height, 140, accuracy: 0.5) + XCTAssertEqual(attachment.image?.accessibilityLabel, "wide") + } + + func testAsyncImageLegacyLoaderCompletionIsIgnoredAfterAttachmentRelease() { + let loader = DeferredMockImageLoader() + weak var weakAttachment: STMarkdownAsyncImageAttachment? + autoreleasepool { + let attributed = STMarkdownAsyncImageRenderer(loader: loader).renderImage( + url: "https://example.com/release.png", + altText: "release", + title: nil, + style: .default, + placement: .inline + ) + weakAttachment = attributed?.attribute(.attachment, at: 0, effectiveRange: nil) as? STMarkdownAsyncImageAttachment + XCTAssertNotNil(weakAttachment) + } + + loader.complete(with: UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in }) + + XCTAssertNil(weakAttachment) + } + + func testTableAttachmentRendererProducesAttachment() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + tableRenderer: STMarkdownTableAttachmentRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .table( + STMarkdownTableModel( + header: [ + [.text("列1")], + [.text("列2")], + ], + rows: [ + [ + [.text("A")], + [.text("B")], + ] + ] + ) + ) + ] + ) + + let attributed = renderer.render(document: document) + // STMarkdownTableViewAttachment 使用 overlay 机制(不走 TextKit 绘制), + // image 始终为 nil,尺寸通过 attachmentBounds 在 layout 时计算。 + let attachment = attributed.attribute(.attachment, at: 0, effectiveRange: nil) as? STMarkdownTableViewAttachment + + XCTAssertNotNil(attachment) + XCTAssertNil(attachment?.image) + XCTAssertNotNil(attachment?.tableViewModel) + XCTAssertGreaterThan(attachment?.containerWidth ?? 0, 0) + } + + // MARK: - Multi-table Tests + + func testTableBlankLineRuleInsertsBlankLineBeforeTableAfterText() { + let rule = STTableBlankLineNormalizationRule() + var context = STMarkdownPreprocessContext() + + let input = "Some text\n| A | B |\n|---|---|" + + let result = rule.apply(to: input, context: &context) + + XCTAssertTrue(result.contains("Some text\n\n| A | B |")) + } + + func testTableBlankLineRuleInsertsBlankLineAfterTableBeforeText() { + let rule = STTableBlankLineNormalizationRule() + var context = STMarkdownPreprocessContext() + + let input = "| A | B |\n|---|---|\nSome text" + + let result = rule.apply(to: input, context: &context) + + XCTAssertTrue(result.contains("|---|---|\n\nSome text")) + } + + func testTableBlankLineRuleSkipsContentInsideCodeFence() { + let rule = STTableBlankLineNormalizationRule() + var context = STMarkdownPreprocessContext() + + let input = "```\nSome text\n| A | B |\n```" + + let result = rule.apply(to: input, context: &context) + + XCTAssertEqual(result, input) + } + + func testTableDelimiterRuleInsertsDelimiterForHeaderWithoutOne() { + let rule = STTableDelimiterNormalizationRule() + var context = STMarkdownPreprocessContext() + + // Second table starts after blank line but has no delimiter row + let input = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n| 3 | 4 |" + + let result = rule.apply(to: input, context: &context) + + XCTAssertTrue(result.contains("| C | D |\n| --- | --- |\n| 3 | 4 |")) + } + + func testTableDelimiterRuleDoesNotDuplicateExistingDelimiter() { + let rule = STTableDelimiterNormalizationRule() + var context = STMarkdownPreprocessContext() + + let input = "| A | B |\n|---|---|\n| 1 | 2 |" + + let result = rule.apply(to: input, context: &context) + + // No extra delimiter should be inserted + let delimiterCount = result.components(separatedBy: "|---|---|").count - 1 + XCTAssertEqual(delimiterCount, 1) + } + + func testTableDelimiterRuleSkipsContentInsideCodeFence() { + let rule = STTableDelimiterNormalizationRule() + var context = STMarkdownPreprocessContext() + + let input = "```\n| A | B |\n| 1 | 2 |\n```" + + let result = rule.apply(to: input, context: &context) + + XCTAssertEqual(result, input) + } + + func testEngineRecognizesSecondTableMissingDelimiter() { + let engine = STMarkdownEngine() + // Second table lacks delimiter row — should be repaired by STTableDelimiterNormalizationRule + let markdown = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n| 3 | 4 |" + + let result = engine.process(markdown) + let tableBlocks = result.renderDocument.blocks.compactMap { block -> STMarkdownTableModel? in + if case .table(let m) = block { return m } + return nil + } + + XCTAssertEqual(tableBlocks.count, 2, "两个表格都应被识别,即使第二个缺少分隔行") + } + + func testEngineRecognizesTwoWellFormedTables() { + let engine = STMarkdownEngine() + let markdown = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n|---|---|\n| 3 | 4 |" + + let result = engine.process(markdown) + let tableBlocks = result.renderDocument.blocks.compactMap { block -> STMarkdownTableModel? in + if case .table(let m) = block { return m } + return nil + } + + XCTAssertEqual(tableBlocks.count, 2) + } + + func testHighFidelityMathRendererProducesInlineAttachment() { + let renderer = STMarkdownAttributedStringRenderer( + style: STMarkdownStyle.default, + advancedRenderers: STMarkdownAdvancedRenderers( + inlineMathRenderer: STMarkdownHighFidelityMathRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .inlineMath(#"\frac{1}{2}"#, isDisplayMode: false) + ]) + ] + ) + + let attributed = renderer.render(document: document) + let attachment = attributed.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment + + XCTAssertNotNil(attachment) + XCTAssertNotNil(attachment?.image) + XCTAssertGreaterThan(attachment?.bounds.width ?? 0, 0) + } + + // MARK: - Strikethrough Tests + + func testStructureParserParsesStrikethrough() { + let parser = STMarkdownStructureParser() + let document = parser.parse("~~删除文本~~") + + guard case .paragraph(let inlines)? = document.blocks.first else { + return XCTFail("Expected paragraph block") + } + + XCTAssertTrue(inlines.contains { node in + if case .strikethrough(let children) = node { + return children.contains(.text("删除文本")) + } + return false + }) + } + + func testAttributedStringRendererAppliesStrikethroughStyle() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .strikethrough([.text("已删除")]) + ]) + ] + ) + + let attributed = renderer.render(document: document) + let style = attributed.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int + + XCTAssertEqual(attributed.string, "已删除") + XCTAssertNotNil(style) + XCTAssertEqual(style, NSUnderlineStyle.single.rawValue) + } + + func testStrikethroughWithCustomColor() { + let markdownStyle = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0.12, + strikethroughColor: .red + ) + let renderer = STMarkdownAttributedStringRenderer(style: markdownStyle) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .strikethrough([.text("红色删除线")]) + ]) + ] + ) + + let attributed = renderer.render(document: document) + let color = attributed.attribute(.strikethroughColor, at: 0, effectiveRange: nil) as? UIColor + + XCTAssertEqual(color, .red) + } + + // MARK: - Task List / Checkbox Tests + + func testStructureParserParsesTaskListCheckbox() { + let parser = STMarkdownStructureParser() + let document = parser.parse("- [x] 已完成\n- [ ] 未完成") + + guard case .list(_, let items)? = document.blocks.first else { + return XCTFail("Expected list block") + } + + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items[0].checkbox, .checked) + XCTAssertEqual(items[1].checkbox, .unchecked) + } + + func testRenderAdapterPreservesCheckbox() { + let parser = STMarkdownStructureParser() + let adapter = STMarkdownRenderAdapter() + let document = parser.parse("- [x] 已完成\n- [ ] 未完成") + + let renderDocument = adapter.adapt(document) + + guard case .list(let items)? = renderDocument.blocks.first else { + return XCTFail("Expected list render block") + } + + XCTAssertEqual(items[0].checkbox, .checked) + XCTAssertEqual(items[1].checkbox, .unchecked) + } + + func testAttributedStringRendererRendersCheckboxMarkers() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .list([ + STMarkdownRenderListItem( + content: [.text("已完成")], + ordered: false, + level: 0, + orderedIndex: nil, + childBlocks: [], + checkbox: .checked + ), + STMarkdownRenderListItem( + content: [.text("未完成")], + ordered: false, + level: 0, + orderedIndex: nil, + childBlocks: [], + checkbox: .unchecked + ), + ]) + ] + ) + + let attributed = renderer.render(document: document) + let text = attributed.string + + XCTAssertTrue(text.contains("☑")) + XCTAssertTrue(text.contains("☐")) + XCTAssertTrue(text.contains("已完成")) + XCTAssertTrue(text.contains("未完成")) + } + + // MARK: - Sanitizer Rule Tests + + func testHtmlNormalizeRuleUnescapesCRLF() { + let rule = STHtmlNormalizeRule() + var context = STMarkdownPreprocessContext() + + let result = rule.apply(to: "第一行\\n第二行", context: &context) + + XCTAssertEqual(result, "第一行\n第二行") + } + + func testAnchorCleanupRuleRemovesFragmentAnchors() { + let rule = STAnchorCleanupRule() + var context = STMarkdownPreprocessContext() + + let input = ##"参考文献1内容"## + let result = rule.apply(to: input, context: &context) + + XCTAssertFalse(result.contains("第二行
        第三行
        第四行", context: &context) + + XCTAssertFalse(result.contains(" 系列标签应全部被替换") + XCTAssertFalse(result.contains("
        ")) + // CommonMark 硬换行是行尾两空格 + \n;这里至少三处换行 + let newlineCount = result.filter { $0 == "\n" }.count + XCTAssertEqual(newlineCount, 3, "三个
        应被替换为三次换行") + } + + func testHtmlNormalizeRuleRewritesEmptyClosingTagToAnchor() { + let rule = STHtmlNormalizeRule() + var context = STMarkdownPreprocessContext() + + let result = rule.apply(to: "链接", context: &context) + + XCTAssertFalse(result.contains(""), "`` 应被改写") + XCTAssertTrue(result.contains(""), "`` 应被改写为 ``") + } + + func testHtmlNormalizeRuleUnescapesCRAndCRLF() { + let rule = STHtmlNormalizeRule() + var context = STMarkdownPreprocessContext() + + // 注意:escapedCR/escapedLF 都带 `(?![A-Za-z])` 负前瞻,避免误吃 `\rest` 这种 LaTeX 命令; + // 因此构造样例时单独的 `\r` 后必须不是字母——这里用空格。 + let input = "A\\r\\nB\\r 尾" + let result = rule.apply(to: input, context: &context) + + XCTAssertFalse(result.contains("\\r"), "`\\r\\n` 与 `\\r `(非字母后续)应被消费") + XCTAssertFalse(result.contains("\\n")) + // `\r\n` → 换行;`\r ` → 换行(保留尾随空格) + XCTAssertTrue(result.contains("A\nB\n"), "应把转义换行序列还原为真实换行") + } + + func testHtmlNormalizeRuleShouldApplyGatesByCheapCheck() { + let rule = STHtmlNormalizeRule() + XCTAssertFalse(rule.shouldApply(to: "纯中文,无任何 HTML/转义")) + XCTAssertTrue(rule.shouldApply(to: "含
        的输入")) + XCTAssertTrue(rule.shouldApply(to: #"含 \" 的输入"#)) + XCTAssertTrue(rule.shouldApply(to: #"含 \/ 的输入"#)) + XCTAssertTrue(rule.shouldApply(to: #"含 \n 的输入"#)) + } + + // MARK: - STHtmlLinkToMarkdownRule 补齐 + + func testHtmlLinkRuleFallsBackToTitleWhenSchemeIsDangerous() { + let rule = STHtmlLinkToMarkdownRule() + var context = STMarkdownPreprocessContext() + + // javascript: 被拒绝 → 只保留可见 title + let input = #"点我"# + let result = rule.apply(to: input, context: &context) + + XCTAssertFalse(result.contains("javascript"), "dangerous scheme 不应泄漏进输出") + XCTAssertFalse(result.contains(" 应被消费") + XCTAssertFalse(result.contains("]("), "不应被转换为 markdown 链接语法") + XCTAssertTrue(result.contains("点我"), "title 必须保留") + } + + func testHtmlLinkRuleFallsBackToTitleWhenUrlHasNoHost() { + let rule = STHtmlLinkToMarkdownRule() + var context = STMarkdownPreprocessContext() + + // 无 host → parsedURL.host == nil → 仅保留 title + let input = #"t"# + let result = rule.apply(to: input, context: &context) + + XCTAssertFalse(result.contains(" + Docs + + """ + + let result = rule.apply(to: input, context: &context) + + XCTAssertEqual(result, "[\nDocs\n](https://example.com/docs)") + } + + func testHtmlLinkRulePreservesNestedTagTitleAsMarkdownText() { + let rule = STHtmlLinkToMarkdownRule() + var context = STMarkdownPreprocessContext() + + let result = rule.apply( + to: #"Example"#, + context: &context + ) + + XCTAssertEqual(result, "[Example](https://example.com)") + } + + // MARK: - STAnchorCleanupRule 补齐 + + func testAnchorCleanupRuleKeepsAnchorWhenFragmentContainsHttp() { + let rule = STAnchorCleanupRule() + var context = STMarkdownPreprocessContext() + + // href="#http..." 的 anchor 是真实引用,不应被清理 + let input = ##"前ref后"## + let result = rule.apply(to: input, context: &context) + + XCTAssertTrue(result.contains("= 2 guard 阻止合成 + let input = "| A |\n| 1 |" + let result = rule.apply(to: input, context: &context) + + XCTAssertFalse( + result.contains("---"), + "单列行不应被改写为表格" + ) + } + + // MARK: - STMarkdownInputSanitizer 短路 / 空输入 + + func testInputSanitizerShortCircuitsOnEmptyInput() { + let sanitizer = STMarkdownInputSanitizer(rules: [STHtmlNormalizeRule()]) + let result = sanitizer.sanitize("") + + XCTAssertEqual(result.originalText, "") + XCTAssertEqual(result.sanitizedText, "") + XCTAssertTrue(result.appliedRules.isEmpty, "空输入应跳过所有规则") + } + + func testInputSanitizerDoesNotRecordRuleWhenApplyIsNoOp() { + // shouldApply 返回 true 但 apply 没有实质修改时,不应记入 appliedRules + let sanitizer = STMarkdownInputSanitizer( + rules: [STDoubleNewlineRule()] + ) + // 输入不含 3 个以上连续换行,规则的 shouldApply 就会 false → appliedRules 为空 + let result = sanitizer.sanitize("A\n\nB") + XCTAssertFalse(result.appliedRules.contains("STDoubleNewlineRule")) + XCTAssertEqual(result.sanitizedText, "A\n\nB") + } + + func testPipelineReusesSanitizerAndProducesStableResultsAcrossCalls() { + let pipeline = STMarkdownPipeline() + let input = """ + Example + + + + Tail + """ + + let first = pipeline.process(input) + let second = pipeline.process(input) + + XCTAssertEqual(first.sanitizedMarkdown, second.sanitizedMarkdown) + XCTAssertEqual(first.appliedRules, second.appliedRules) + XCTAssertEqual(first.renderDocument, second.renderDocument) + XCTAssertEqual(first.sanitizedMarkdown, "[Example](https://example.com)\n\nTail") + XCTAssertTrue(first.appliedRules.contains("STHtmlLinkToMarkdownRule")) + XCTAssertTrue(first.appliedRules.contains("STDoubleNewlineRule")) + } + + // MARK: - STMarkdownMathNormalizer 补齐 + + func testMathNormalizerHandlesSameLineDollarBlock() { + // $$formula$$ 同行开闭 + let input = "前文\n\n$$a+b$$\n\n后文" + let result = STMarkdownMathNormalizer.normalizeBlocks(in: input) + + XCTAssertEqual(result.blockMap.count, 1, "同行 $$...$$ 应被识别为块公式") + XCTAssertEqual(result.blockMap[0], "a+b") + XCTAssertTrue(result.text.contains("{{ST_MATH_BLOCK:0}}")) + } + + func testMathNormalizerHandlesSameLineBracketBlock() { + // \[formula\] 同行开闭 + let input = #"前文\n\n\[x=1\]\n\n后文"# + .replacingOccurrences(of: "\\n", with: "\n") + let result = STMarkdownMathNormalizer.normalizeBlocks(in: input) + + XCTAssertEqual(result.blockMap.count, 1, "同行 \\[...\\] 应被识别为块公式") + XCTAssertEqual(result.blockMap[0], "x=1") + } + + func testMathNormalizerHandlesUnterminatedDollarBlockAsEof() { + // $$ 未闭合 → 到 EOF 也应完成收集,不崩溃 + let input = "前文\n\n$$\nE = mc^2\n继续一行" + let result = STMarkdownMathNormalizer.normalizeBlocks(in: input) + + XCTAssertEqual(result.blockMap.count, 1, "未闭合块应兜底产出一条") + XCTAssertTrue(result.blockMap[0]?.contains("E = mc^2") == true) + } + + func testMathNormalizerRecognizesMultipleMathEnvironments() { + let environments = ["equation", "gather", "cases", "pmatrix"] + for env in environments { + let input = """ + 前文 + + \\begin{\(env)} + x + \\end{\(env)} + + 后文 + """ + let result = STMarkdownMathNormalizer.normalizeBlocks(in: input) + XCTAssertEqual(result.blockMap.count, 1, "环境 \(env) 应被识别") + XCTAssertTrue( + result.blockMap[0]?.contains("\\begin{\(env)}") == true, + "应保留 \\begin{\(env)}" + ) + XCTAssertTrue( + result.blockMap[0]?.contains("\\end{\(env)}") == true, + "应保留 \\end{\(env)}" + ) + } + } + + func testMathNormalizerIgnoresUnsupportedEnvironment() { + // 未注册的环境不应被当作 math block,应当作普通文本保留 + let input = """ + 前文 + + \\begin{foo} + x + \\end{foo} + + 后文 + """ + let result = STMarkdownMathNormalizer.normalizeBlocks(in: input) + XCTAssertTrue(result.blockMap.isEmpty, "未支持的环境不应被抽成 math block") + XCTAssertTrue(result.text.contains("\\begin{foo}")) + XCTAssertTrue(result.text.contains("\\end{foo}")) + } + + func testSplitInlineMathRecognizesBracketDisplayModeInline() { + // 行内 \[x\] 应被识别为 isDisplayMode == true + let nodes = STMarkdownMathNormalizer.splitInlineMath(in: #"前 \[a+b\] 后"#) + + XCTAssertEqual(nodes.count, 3) + XCTAssertEqual(nodes[0], .text("前 ")) + XCTAssertEqual(nodes[1], .inlineMath("a+b", isDisplayMode: true)) + XCTAssertEqual(nodes[2], .text(" 后")) + } + + func testSplitInlineMathReturnsEmptyForEmptyInput() { + let nodes = STMarkdownMathNormalizer.splitInlineMath(in: "") + XCTAssertTrue(nodes.isEmpty, "空输入应返回空数组") + } + + func testSplitInlineMathReturnsSingleTextWhenNoFormula() { + let nodes = STMarkdownMathNormalizer.splitInlineMath(in: "纯文本") + XCTAssertEqual(nodes, [.text("纯文本")]) + } + + // MARK: - STMarkdownSoftBreakCollapsingNormalizer 补齐 + + func testSoftBreakNormalizerCollapsesInsideHeading() { + let document = STMarkdownDocument( + blocks: [ + .heading(level: 2, content: [ + .text("A"), + .softBreak, + .softBreak, + .text("B"), + ]) + ] + ) + let normalized = STMarkdownSoftBreakCollapsingNormalizer().normalize(document) + + guard case .heading(let level, let content)? = normalized.blocks.first else { + return XCTFail("Expected heading") + } + XCTAssertEqual(level, 2) + XCTAssertEqual(content, [.text("A"), .softBreak, .text("B")]) + } + + func testSoftBreakNormalizerRecursesIntoEmphasisStrongLinkStrikethrough() { + let document = STMarkdownDocument( + blocks: [ + .paragraph([ + .emphasis([.text("a"), .softBreak, .softBreak, .text("b")]), + .strong([.text("c"), .softBreak, .softBreak, .text("d")]), + .link(destination: "https://x.com", children: [ + .text("e"), .softBreak, .softBreak, .text("f") + ]), + .strikethrough([.text("g"), .softBreak, .softBreak, .text("h")]), + ]) + ] + ) + let normalized = STMarkdownSoftBreakCollapsingNormalizer().normalize(document) + + guard case .paragraph(let inlines)? = normalized.blocks.first else { + return XCTFail("Expected paragraph") + } + + func softBreakCount(_ nodes: [STMarkdownInlineNode]) -> Int { + nodes.reduce(into: 0) { acc, node in + if case .softBreak = node { acc += 1 } + } + } + + for node in inlines { + switch node { + case .emphasis(let c), .strong(let c), .strikethrough(let c): + XCTAssertEqual(softBreakCount(c), 1, "子节点相邻 softBreak 应被折叠") + case .link(_, let c): + XCTAssertEqual(softBreakCount(c), 1, "link 子节点相邻 softBreak 应被折叠") + default: + break + } + } + } + + func testSemanticNormalizerPassthroughKeepsDocumentIntact() { + let document = STMarkdownDocument( + blocks: [ + .paragraph([.text("A"), .softBreak, .softBreak, .text("B")]) + ] + ) + let normalized = STMarkdownSemanticNormalizer.passthrough.normalize(document) + // passthrough 应原样返回,不折叠相邻 softBreak + XCTAssertEqual(normalized, document) + } + + func testSemanticNormalizerChainsMultipleNormalizersInOrder() { + struct TagNormalizer: STMarkdownSemanticNormalizing { + let tag: String + func normalize(_ document: STMarkdownDocument) -> STMarkdownDocument { + let blocks = document.blocks.map { block -> STMarkdownBlockNode in + if case .paragraph(let inlines) = block { + return .paragraph(inlines + [.text(self.tag)]) + } + return block + } + return STMarkdownDocument(blocks: blocks) + } + } + + let composite = STMarkdownSemanticNormalizer( + normalizers: [TagNormalizer(tag: "_1"), TagNormalizer(tag: "_2")] + ) + let normalized = composite.normalize( + STMarkdownDocument(blocks: [.paragraph([.text("X")])]) + ) + + guard case .paragraph(let inlines)? = normalized.blocks.first else { + return XCTFail("Expected paragraph") + } + XCTAssertEqual(inlines, [.text("X"), .text("_1"), .text("_2")], + "normalizer 应按注册顺序依次应用") + } + + // MARK: - STMarkdownRenderListItem 契约 + + func testRenderListItemContentAndChildBlocksWhenFirstBlockIsNotParagraph() { + // 以 codeBlock 开头 → content 返回 [],childBlocks 返回全部 blocks + let codeFirst = STMarkdownRenderListItem( + blocks: [ + .codeBlock(language: "swift", code: "x"), + .paragraph([.text("尾段")]), + ], + ordered: false, + level: 0, + orderedIndex: nil + ) + XCTAssertTrue(codeFirst.content.isEmpty, "首块非 paragraph 时 content 应为空") + XCTAssertEqual(codeFirst.childBlocks.count, 2, + "首块非 paragraph 时 childBlocks 应返回完整 blocks") + } + + func testRenderListItemContentAndChildBlocksWhenFirstBlockIsParagraph() { + let paraFirst = STMarkdownRenderListItem( + blocks: [ + .paragraph([.text("首段")]), + .codeBlock(language: nil, code: "x"), + ], + ordered: false, + level: 0, + orderedIndex: nil + ) + XCTAssertEqual(paraFirst.content, [.text("首段")]) + XCTAssertEqual(paraFirst.childBlocks.count, 1, "应剥掉首段后仅剩子块") + if case .codeBlock = paraFirst.childBlocks.first {} else { + XCTFail("剩余子块应为 codeBlock") + } + } + + // MARK: - STMarkdownStructureParser 补齐 + + func testParserNormalizesLinkDestinationTrimsWhitespace() { + let parser = STMarkdownStructureParser() + // swift-markdown 不允许 destination 里有未转义空白,这里用 `<…>` 形式构造可解析的空白 destination + let doc = parser.parse("[t](< https://example.com >)") + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("Expected paragraph") + } + var destination: String? + for node in inlines { + if case .link(let d, _) = node { destination = d; break } + } + XCTAssertEqual(destination, "https://example.com", + "normalizeLinkDestination 应去除首尾空白") + } + + // MARK: - Rendering 代码审核回归测试 + + /// 回归:`STMarkdownDefaultMathRenderer.normalize` 早期把 `\\(` 写成 raw-string `#"\\("#`, + /// 该字面量实际去匹配两个反斜杠+括号,永远不会命中真实的 `\(...\)` 分隔符。 + /// 修复后,分隔符应被正确剥离。 + func testDefaultMathRendererStripsLatexDelimiters() { + let renderer = STMarkdownDefaultMathRenderer() + let rendered = renderer.renderInlineMath( + formula: #"\(x+y\)"#, + style: .default, + baseFont: .systemFont(ofSize: 16), + textColor: .label + ) + XCTAssertNotNil(rendered) + XCTAssertFalse(rendered?.string.contains(#"\("#) ?? true, + "inline math 分隔符 `\\(` 应被剥离") + XCTAssertFalse(rendered?.string.contains(#"\)"#) ?? true, + "inline math 分隔符 `\\)` 应被剥离") + XCTAssertTrue(rendered?.string.contains("x") == true) + XCTAssertTrue(rendered?.string.contains("y") == true) + } + + func testDefaultMathRendererStripsBracketBlockDelimiters() { + let renderer = STMarkdownDefaultMathRenderer() + let rendered = renderer.renderBlockMath( + formula: #"\[a=b\]"#, + style: .default + ) + XCTAssertNotNil(rendered) + XCTAssertFalse(rendered?.string.contains(#"\["#) ?? true, + "block math 分隔符 `\\[` 应被剥离") + XCTAssertFalse(rendered?.string.contains(#"\]"#) ?? true, + "block math 分隔符 `\\]` 应被剥离") + } + + func testHighFidelityMathRendererRendersBlockAttachmentAndStripsDelimiters() { + let renderer = STMarkdownHighFidelityMathRenderer() + let rendered = renderer.renderBlockMath(formula: #"\[x+y\]"#, style: .default) + + XCTAssertNotNil(rendered) + XCTAssertNotNil(rendered?.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) + XCTAssertFalse(rendered?.string.contains(#"\["#) ?? true) + XCTAssertFalse(rendered?.string.contains(#"\]"#) ?? true) + let paragraphStyle = rendered?.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(paragraphStyle?.alignment, .center) + } + + /// 回归:列表项若首块是非 paragraph(codeBlock/quote/list/table), + /// 之前 marker 后面不补换行会与块内容挤在同一行。 + /// 修复后 marker 与 trailing 子块之间应存在换行。 + func testAttributedStringRendererSeparatesMarkerFromNonParagraphLeadingBlock() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .list([ + STMarkdownRenderListItem( + blocks: [ + .codeBlock(language: "swift", code: "let x = 1") + ], + ordered: false, + level: 0, + orderedIndex: nil + ) + ]) + ] + ) + let attributed = renderer.render(document: document) + // marker 行应当单独一行,且下一行才是代码块内容 + let lines = attributed.string.components(separatedBy: "\n") + XCTAssertGreaterThanOrEqual(lines.count, 2, + "marker 与 codeBlock 之间应有换行") + XCTAssertTrue(lines[0].contains("●") || lines[0].contains("○"), + "首行应包含 list marker") + } + + /// 回归:引用块(quote)之前只在开头插一个 `┃ ` 前缀,多段引用视觉断裂。 + /// 修复后每个非空段落起点都应插入左竖线。 + func testAttributedStringRendererQuoteAppliesPrefixToEachParagraph() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .quote([ + .paragraph([.text("第一段")]), + .paragraph([.text("第二段")]), + ]) + ] + ) + let attributed = renderer.render(document: document) + // 两段引用 → 竖线至少出现两次 + let prefixChar: Character = "▎" + let count = attributed.string.filter { $0 == prefixChar }.count + XCTAssertGreaterThanOrEqual(count, 2, + "多段引用块应对每一段都补左竖线") + } + + /// 回归:`STMarkdownStyle.blockquoteLineColor` 之前是 dead config,总是硬编码 `UIColor.systemGray`。 + /// 修复后自定义颜色应生效。 + func testAttributedStringRendererQuoteHonorsBlockquoteLineColor() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + blockquoteLineColor: .red + ) + let renderer = STMarkdownAttributedStringRenderer(style: style) + let document = STMarkdownRenderDocument( + blocks: [.quote([.paragraph([.text("引用")])])] + ) + let attributed = renderer.render(document: document) + // 找出竖线字符的位置,读它的前景色 + guard let index = attributed.string.firstIndex(of: "▎") else { + return XCTFail("应存在左竖线字符") + } + let nsIndex = attributed.string.utf16.distance( + from: attributed.string.utf16.startIndex, + to: index.samePosition(in: attributed.string.utf16) ?? attributed.string.utf16.startIndex + ) + let color = attributed.attribute(.foregroundColor, at: nsIndex, effectiveRange: nil) as? UIColor + XCTAssertEqual(color, .red, "竖线颜色应来自 style.blockquoteLineColor") + } + + /// 回归:`STMarkdownAttributedStringRenderer.renderTable` 的内建 fallback 早期只取 + /// `renderInline(...).string`,把粗体/斜体/链接全部丢掉。修复后应复用 + /// `STMarkdownDefaultTableRenderer`,至少保留等宽对齐+表头分隔。 + func testAttributedStringRendererFallbackTableUsesSeparator() { + let renderer = STMarkdownAttributedStringRenderer() + let document = STMarkdownRenderDocument( + blocks: [ + .table( + STMarkdownTableModel( + header: [ + [.text("A")], + [.text("B")], + ], + rows: [ + [[.text("1")], [.text("2")]] + ] + ) + ) + ] + ) + let attributed = renderer.render(document: document) + XCTAssertTrue(attributed.string.contains("┼"), + "fallback table 应包含表头分隔符") + } + + /// 回归:`STMarkdownDefaultTableRenderer.columnWidths` 基于 `String.count`, + /// CJK 字符在等宽字体里占两格会导致列对齐错位。修复后应使用 + /// East Asian Width 近似,中文整列 pad 后宽度一致。 + func testDefaultTableRendererAlignsCJKColumns() { + let table = STMarkdownTableModel( + header: [ + [.text("中文")], + [.text("x")], + ], + rows: [ + [[.text("a")], [.text("中文")]] + ] + ) + let rendered = STMarkdownDefaultTableRenderer().renderTable(table, style: .default) + XCTAssertNotNil(rendered) + // 输出形如 `header\n\nseparator\ndata`,过滤空行后取第一/最后行做对齐校验。 + let lines = rendered!.string + .components(separatedBy: "\n") + .filter { $0.isEmpty == false } + XCTAssertGreaterThanOrEqual(lines.count, 3, + "应包含表头、分隔、数据三行") + func displayWidth(_ s: String) -> Int { + s.unicodeScalars.reduce(0) { acc, scalar in + let v = scalar.value + let isWide = (0x4E00...0x9FFF).contains(v) + || (0x3000...0x303F).contains(v) + || (0xFF00...0xFFEF).contains(v) + return acc + (isWide ? 2 : 1) + } + } + let headerWidth = displayWidth(lines[0]) + let dataWidth = displayWidth(lines[lines.count - 1]) + XCTAssertEqual( + headerWidth, + dataWidth, + "表头行与数据行的显示宽度应相同,CJK 对齐不能错位(header=\(lines[0]), data=\(lines.last ?? ""))" + ) + } + + /// 回归:`STMarkdownCodeBlockSupport.keywordPatterns` 里早期有一行 + /// `.replacingOccurrences(of: "\\(joined)", with: joined)`, + /// 但字符串插值阶段 `\(joined)` 已被替换,这是无意义代码。 + /// 修复后仍能正确匹配 Swift 关键字并高亮。 + func testCodeSyntaxHighlighterAppliesKeywordColorForSwift() { + let style = STMarkdownStyle.default + let paragraphStyle = NSMutableParagraphStyle() + let highlighted = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: "swift", + code: "let x = 1", + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: style.textColor, + paragraphStyle: paragraphStyle + ) + // "let" 起始位置应被染成 keyword 色(systemBlue),非 textColor + let color = highlighted.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor + XCTAssertNotNil(color) + XCTAssertNotEqual(color, style.textColor, + "Swift 关键字应被染色,而不是保持默认 textColor") + } + + func testCodeSyntaxHighlighterCoversStringCommentNumberTypeAndTagBranches() { + let style = STMarkdownStyle.default + let paragraphStyle = NSMutableParagraphStyle() + func color(in attributed: NSAttributedString, for needle: String) -> UIColor? { + let range = (attributed.string as NSString).range(of: needle) + guard range.location != NSNotFound else { return nil } + return attributed.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? UIColor + } + + let js = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: "javascript", + code: #"const name = "Ada"; // comment"#, + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: style.textColor, + paragraphStyle: paragraphStyle + ) + XCTAssertNotEqual(color(in: js, for: "const"), style.textColor) + XCTAssertNotEqual(color(in: js, for: #""Ada""#), style.textColor) + XCTAssertNotEqual(color(in: js, for: "// comment"), style.textColor) + + let python = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: "python", + code: "value = 42 # note", + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: style.textColor, + paragraphStyle: paragraphStyle + ) + XCTAssertNotEqual(color(in: python, for: "42"), style.textColor) + XCTAssertNotEqual(color(in: python, for: "# note"), style.textColor) + + let typed = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: "swift", + code: "let name: String = nil", + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: style.textColor, + paragraphStyle: paragraphStyle + ) + XCTAssertNotEqual(color(in: typed, for: "String"), style.textColor) + + let html = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: "html", + code: #"Link"#, + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: style.textColor, + paragraphStyle: paragraphStyle + ) + XCTAssertNotEqual(color(in: html, for: "B", theme: .light) + // 这里只校验不会崩溃以及类型契约;具体缓存写入需要 WKWebView 异步流程。 + XCTAssertTrue(initial == nil || initial != nil) + } + + /// 回归 #28:嵌在 link 里的 inline image attachment 也应继承 `.link` 属性, + /// 否则点击 attachment glyph 不会被识别为链接。 + func testInlineImageAttachmentInheritsLinkAttributeFromSurroundingContext() { + let renderer = STMarkdownAttributedStringRenderer( + advancedRenderers: STMarkdownAdvancedRenderers( + imageRenderer: STMarkdownDefaultImageRenderer() + ) + ) + let document = STMarkdownRenderDocument( + blocks: [ + .paragraph([ + .link(destination: "https://example.com", children: [ + .image(source: "https://example.com/x.png", alt: "x", title: nil) + ]) + ]) + ] + ) + let attributed = renderer.render(document: document) + let link = attributed.attribute(.link, at: 0, effectiveRange: nil) as? URL + XCTAssertEqual(link?.absoluteString, "https://example.com", + "inline image attachment 应继承外层链接 destination") + } + + /// 回归 #15:`rgbaKey` 早期对动态颜色(dark/light)只能取 `description`, + /// 修复后会 `resolvedColor(with:)` 后再取 RGBA,避免缓存错命中。 + func testCodeBlockCacheKeyRespondsToTraitChanges() { + // 同一 code + 同一 style 但 background 是 dynamic color: + // 缓存命中行为需依赖 trait collection 解析后的真实 RGBA。 + let dynamicColor = UIColor { trait in + trait.userInterfaceStyle == .dark ? .black : .white + } + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + codeBlockBackgroundColor: dynamicColor, + renderWidth: 200 + ) + let attachment1 = STMarkdownCodeBlockAttachment(language: "swift", code: "let x = 1", style: style) + XCTAssertNotNil(attachment1.image) + // 二次构造命中缓存(同 trait,同 style) + let attachment2 = STMarkdownCodeBlockAttachment(language: "swift", code: "let x = 1", style: style) + XCTAssertEqual(attachment1.image?.size, attachment2.image?.size) + } + + // MARK: - 第三轮 Rendering 修复回归 + + /// 回归 #11:`STMarkdownAsyncImageRenderer` 通过 `baseURL` 解析相对路径。 + func testAsyncImageRendererResolvesRelativeURLAgainstBase() { + let loader = MockImageLoader() + let base = URL(string: "https://example.com/articles/")! + let renderer = STMarkdownAsyncImageRenderer(loader: loader, baseURL: base) + + let rendered = renderer.renderImage( + url: "../assets/x.png", + altText: "x", + title: nil, + style: .default, + placement: .inline + ) + XCTAssertNotNil(rendered) + XCTAssertEqual(loader.lastURL?.absoluteString, "https://example.com/assets/x.png", + "相对路径应基于 baseURL 解析") + } + + /// 回归 #11:未提供 baseURL 时,相对路径仍应被拒绝(return nil → 上层走占位文本)。 + func testAsyncImageRendererRejectsRelativeURLWithoutBaseURL() { + let loader = MockImageLoader() + let renderer = STMarkdownAsyncImageRenderer(loader: loader) + let rendered = renderer.renderImage( + url: "./image.png", + altText: "", + title: nil, + style: .default, + placement: .block + ) + XCTAssertNil(rendered) + XCTAssertNil(loader.lastURL) + } + + /// 回归 #30:block 图像最大尺寸可通过 `blockMaxSize` 自定义。 + func testAsyncImageRendererHonorsCustomBlockMaxSize() { + let loader = DeferredMockImageLoader() + let renderer = STMarkdownAsyncImageRenderer( + loader: loader, + blockMaxSize: CGSize(width: 100, height: 80) + ) + let attributed = renderer.renderImage( + url: "https://example.com/wide.png", + altText: "", + title: nil, + style: .default, + placement: .block + ) + guard let attachment = attributed?.attribute(.attachment, at: 0, effectiveRange: nil) as? STMarkdownAsyncImageAttachment else { + return XCTFail("Expected async image attachment") + } + let bigImage = UIGraphicsImageRenderer(size: CGSize(width: 800, height: 400)).image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 800, height: 400)) + } + let expectation = self.expectation(description: "image refresh") + let observation = attachment.addDisplayObserver { expectation.fulfill() } + loader.complete(with: bigImage) + wait(for: [expectation], timeout: 1) + _ = observation + XCTAssertLessThanOrEqual(attachment.bounds.width, 100.5, + "block 图宽度应受 blockMaxSize 约束") + XCTAssertLessThanOrEqual(attachment.bounds.height, 80.5, + "block 图高度应受 blockMaxSize 约束") + } + + /// 回归 #26:`inlineCodeBackgroundColor` 应被 inline code 渲染采用。 + func testInlineCodeAppliesBackgroundColorFromStyle() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + inlineCodeBackgroundColor: .yellow + ) + let renderer = STMarkdownAttributedStringRenderer(style: style) + let document = STMarkdownRenderDocument( + blocks: [.paragraph([.code("inline")])] + ) + let attributed = renderer.render(document: document) + let bg = attributed.attribute(.backgroundColor, at: 0, effectiveRange: nil) as? UIColor + XCTAssertEqual(bg, .yellow, "inline code 背景应取自 style.inlineCodeBackgroundColor") + } + + /// 回归 #5 余项:`STMarkdownDefaultHorizontalRuleRenderer` 在没有 + /// `horizontalRuleColor` 时退回 `dividerColor`(之前是 dead config)。 + func testHorizontalRuleFallsBackToDividerColor() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + dividerColor: .systemGreen + ) + let attributed = STMarkdownDefaultHorizontalRuleRenderer().renderHorizontalRule(style: style) + let color = attributed?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor + XCTAssertEqual(color, .systemGreen, + "horizontalRuleColor 缺省时应使用 dividerColor") + } + + /// 回归 #5 余项:`STMarkdownStyle.blockquoteIndentation` 之前完全是 dead config。 + /// 修复后正向缩进应反映到段落 paragraphStyle 上。 + func testQuoteIndentationAppliesToParagraphStyle() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + blockquoteIndentation: 24 + ) + let renderer = STMarkdownAttributedStringRenderer(style: style) + let document = STMarkdownRenderDocument( + blocks: [.quote([.paragraph([.text("引用")])])] + ) + let attributed = renderer.render(document: document) + guard let textRange = attributed.string.range(of: "引用") else { + return XCTFail("找不到引用文本位置") + } + let nsLocation = attributed.string.utf16.distance( + from: attributed.string.utf16.startIndex, + to: textRange.lowerBound.samePosition(in: attributed.string.utf16) ?? attributed.string.utf16.startIndex + ) + let paragraphStyle = attributed.attribute( + .paragraphStyle, + at: nsLocation, + effectiveRange: nil + ) as? NSParagraphStyle + XCTAssertNotNil(paragraphStyle) + // 缩进生效:headIndent 至少包含 blockquoteIndentation + XCTAssertGreaterThanOrEqual(paragraphStyle?.headIndent ?? 0, 24, + "blockquoteIndentation 应叠加到 headIndent") + } + + func testQuotePrefixCarriesParagraphStyleAfterIndentation() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + blockquoteIndentation: 24 + ) + let renderer = STMarkdownAttributedStringRenderer(style: style) + let document = STMarkdownRenderDocument( + blocks: [.quote([.paragraph([.text("引用")])])] + ) + + let attributed = renderer.render(document: document) + guard let prefixIndex = attributed.string.firstIndex(of: "▎") else { + return XCTFail("应存在引用竖线") + } + let nsLocation = attributed.string.utf16.distance( + from: attributed.string.utf16.startIndex, + to: prefixIndex.samePosition(in: attributed.string.utf16) ?? attributed.string.utf16.startIndex + ) + let paragraphStyle = attributed.attribute( + .paragraphStyle, + at: nsLocation, + effectiveRange: nil + ) as? NSParagraphStyle + + XCTAssertGreaterThanOrEqual(paragraphStyle?.headIndent ?? 0, 24, + "引用竖线应携带与正文一致的 paragraphStyle") + } + + func testHighFidelityMathRendererFallsBackOffMainThread() { + let expectation = self.expectation(description: "background render") + var renderedString: String? + var hasAttachment = false + + DispatchQueue.global(qos: .userInitiated).async { + let renderer = STMarkdownHighFidelityMathRenderer() + let rendered = renderer.renderInlineMath( + formula: "x^2", + style: .default, + baseFont: .systemFont(ofSize: 16), + textColor: .label + ) + renderedString = rendered?.string + if let rendered, rendered.length > 0 { + hasAttachment = rendered.attribute(.attachment, at: 0, effectiveRange: nil) != nil + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(renderedString, "x2") + XCTAssertFalse(hasAttachment, "后台线程应走默认数学文本 fallback,而不是触碰 UIView 渲染 attachment") + } + + func testAsyncImageRendererFallsBackWhenBlockMaxSizeIsInvalid() { + let loader = DeferredMockImageLoader() + let renderer = STMarkdownAsyncImageRenderer( + loader: loader, + blockMaxSize: CGSize(width: 0, height: -1) + ) + let attributed = renderer.renderImage( + url: "https://example.com/wide.png", + altText: "", + title: nil, + style: .default, + placement: .block + ) + guard let attachment = attributed?.attribute(.attachment, at: 0, effectiveRange: nil) as? STMarkdownAsyncImageAttachment else { + return XCTFail("Expected async image attachment") + } + let image = UIGraphicsImageRenderer(size: CGSize(width: 560, height: 280)).image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 560, height: 280)) + } + let expectation = self.expectation(description: "image refresh") + let observation = attachment.addDisplayObserver { expectation.fulfill() } + + loader.complete(with: image) + wait(for: [expectation], timeout: 1) + _ = observation + + XCTAssertEqual(attachment.bounds.width, 280, accuracy: 0.5) + XCTAssertEqual(attachment.bounds.height, 140, accuracy: 0.5) + } + + // MARK: - 第四轮 Rendering 修复回归 + + /// 回归 #25:`STMarkdownCodeBlockAttachment.configureRenderCache(countLimit:)` 可调整上限。 + /// 清空缓存后再次构造应命中新的绘制路径,而不复用历史结果。 + func testCodeBlockAttachmentConfigurableRenderCache() { + addTeardownBlock { + STMarkdownCodeBlockAttachment.configureRenderCache(countLimit: 48) + STMarkdownCodeBlockAttachment.clearRenderCache() + } + STMarkdownCodeBlockAttachment.configureRenderCache(countLimit: 8) + STMarkdownCodeBlockAttachment.clearRenderCache() + let style = STMarkdownStyle.default + let first = STMarkdownCodeBlockAttachment(language: "swift", code: "let x = 1", style: style) + XCTAssertNotNil(first.image) + // 清空后强制重新绘制,图像大小仍应保持一致(配置 countLimit 不影响渲染结果) + STMarkdownCodeBlockAttachment.clearRenderCache() + let second = STMarkdownCodeBlockAttachment(language: "swift", code: "let x = 1", style: style) + XCTAssertEqual(first.image?.size, second.image?.size) + } + + /// 回归 #13:`STMarkdownCodeBlockRenderingPresets` 作为 facade 暴露三个 code block + /// renderer 的 typealias,消除调用方在名字相似的实现间纠结。 + func testCodeBlockRenderingPresetsTypealiasesMapToExistingRenderers() { + _ = STMarkdownCodeBlockRenderingPresets.PlainText() + _ = STMarkdownCodeBlockRenderingPresets.StaticAttachment() + _ = STMarkdownCodeBlockRenderingPresets.RichAttachment() + } + + /// 回归 #31:`boundingRect` 已经把 paragraphStyle.lineSpacing 纳入高度, + /// 修复不应再按估算行数二次叠加 lineSpacing,避免代码块被过早折叠。 + func testCodeBlockAttachmentLineSpacingDoesNotTriggerPrematureCollapse() { + STMarkdownCodeBlockAttachment.clearRenderCache() + let largeSpacingStyle = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + bodyLineSpacing: 12, + renderWidth: 240 + ) + let tightStyle = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 24, + kern: 0, + bodyLineSpacing: 0, + renderWidth: 240 + ) + let multiLineCode = "line1\nline2\nline3\nline4\nline5\nline6" + let wide = STMarkdownCodeBlockAttachment(language: "swift", code: multiLineCode, style: largeSpacingStyle) + let tight = STMarkdownCodeBlockAttachment(language: "swift", code: multiLineCode, style: tightStyle) + + XCTAssertGreaterThan( + wide.renderedBodyHeight, + tight.renderedBodyHeight, + "bodyLineSpacing 增大时,TextKit 测量高度应自然变大" + ) + XCTAssertFalse( + wide.isCollapsed, + "lineSpacing 不应被二次叠加到触发过早折叠" + ) + XCTAssertLessThan( + wide.renderedBodyHeight - tight.renderedBodyHeight, + 80, + "高度差应接近真实行间距增量,不能按错误估算行数过度放大" + ) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownStructureParserASTContractTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStructureParserASTContractTests.swift new file mode 100644 index 0000000..465cd8e --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownStructureParserASTContractTests.swift @@ -0,0 +1,519 @@ +// +// STMarkdownStructureParserASTContractTests.swift +// STBaseProjectExampleTests +// +// AST 级契约测试:钉住 STMarkdownStructureParser 的关键解析路径。 +// 这些用例直接断言 STMarkdownDocument 的结构,不经渲染层, +// 目的是让 parser 行为的回归在 PR/CI 阶段就能被发现。 +// + +import XCTest +import STBaseProject + +private extension STMarkdownDocument { + /// 找到第一个匹配的块节点;找不到则 nil。 + func firstBlock(where predicate: (STMarkdownBlockNode) -> Bool) -> STMarkdownBlockNode? { + self.blocks.first(where: predicate) + } +} + +final class STMarkdownStructureParserASTContractTests: XCTestCase { + + // MARK: - P0 #6: 段落内多占位符 + inline AST 保留 + + func testExtractMathBlocks_preservesInlineAST_whenMixedWithStrong() { + // 构造:"看 **公式一** ${{ST_MATH_BLOCK:0}}$ 与 **公式二** ${{ST_MATH_BLOCK:1}}$ 结束" + // 真实输入用 $$…$$ 写在独立行——STMarkdownMathNormalizer 会把它们替换为占位符并隔行; + // 这里我们手动构造一个"占位符与 strong 在同一段落"的边界场景,验证 extractMathBlocks + // 即使遇到这种病理结构也不丢 inline AST。 + // 走的是:先让 normalizer 产出占位符,然后利用 markdown 的 inline 语法把它们拼到同段。 + let parser = STMarkdownStructureParser() + + // 强制把两个块公式与 inline 强调拼到一段:去掉 $$ 前后的空行。 + let markdown = """ + 看 **公式一** + $$ + a = b + $$ + 与 **公式二** + $$ + c = d + $$ + 结束 + """ + + let doc = parser.parse(markdown) + + // 期望:mathBlock 块至少出现两次,并且 strong("公式一")/strong("公式二") 都被保留在 + // 周围段落里——而不是被扁平成 .text("公式一公式二")。 + var mathBlockCount = 0 + var strongTexts: [String] = [] + for block in doc.blocks { + switch block { + case .mathBlock(let latex): + mathBlockCount += 1 + XCTAssertTrue(latex.contains("="), "math 块应保留 LaTeX 内容: \(latex)") + case .paragraph(let inlines): + self.collectStrongTexts(inlines, into: &strongTexts) + default: + break + } + } + + XCTAssertEqual(mathBlockCount, 2, "应识别两个块公式") + XCTAssertTrue(strongTexts.contains("公式一"), "粗体『公式一』应被保留为 strong inline,而不是被扁平化") + XCTAssertTrue(strongTexts.contains("公式二"), "粗体『公式二』应被保留为 strong inline,而不是被扁平化") + } + + // MARK: - P0 #7: 占位符嵌套在 strong/emphasis 内 → 不可识别,走普通段落 + + func testExtractMathBlocks_returnsNil_whenPlaceholderWrappedInStrong() { + let parser = STMarkdownStructureParser() + // 直接喂 swift-markdown 一段把占位符包进 ** 的字面文本——绕过 normalizer,确保 + // 占位符位于 strong 子节点,而不是顶层 Text。 + // 注意:normalizer 不会主动产出这种结构,这里是给 extractMathBlocks 一个"病理输入" + // 验证它的兜底行为:应返回 nil,让段落走普通渲染——而不是悄悄丢掉外层 strong。 + let markdown = "前缀 **{{ST_MATH_BLOCK:0}}** 后缀" + let doc = parser.parse(markdown) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph,实际为 \(String(describing: doc.blocks.first))") + } + + // 没有 mathBlock,因为 mathMap 里根本没有 0;段落必须保留 strong 结构。 + let strongCount = inlines.reduce(into: 0) { acc, node in + if case .strong = node { acc += 1 } + } + XCTAssertEqual(strongCount, 1, "占位符被包进 strong 时不应丢掉外层 strong 结构") + + // 同时应该没有任何 mathBlock 块被提升出来。 + let hasMathBlock = doc.blocks.contains { if case .mathBlock = $0 { return true }; return false } + XCTAssertFalse(hasMathBlock, "未匹配的占位符不应被升级为 mathBlock 块") + } + + func testExtractMathBlocks_preservesLiteralPlaceholder_whenMathMapMissingAtTopLevel() { + let parser = STMarkdownStructureParser() + let markdown = "前缀 {{ST_MATH_BLOCK:999}} 后缀" + let doc = parser.parse(markdown) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph,实际为 \(String(describing: doc.blocks.first))") + } + + let renderedText = self.flattenText(inlines) + XCTAssertEqual(renderedText, markdown, "mathMap 缺失时,顶层占位符必须按字面文本保留,不能静默丢弃") + + let hasMathBlock = doc.blocks.contains { if case .mathBlock = $0 { return true }; return false } + XCTAssertFalse(hasMathBlock, "mathMap 缺失时不应生成 mathBlock") + } + + func testExtractMathBlocks_preservesTopLevelLiteralPlaceholdersAndAdjacentInlineAST_whenMathMapMissing() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("前 **粗体** {{ST_MATH_BLOCK:0}} 与 *斜体* {{ST_MATH_BLOCK:1}} 后") + + XCTAssertEqual(doc.blocks.count, 1, "缺失 mathMap 时整段应保留为普通 paragraph") + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望缺失占位符场景保留为 paragraph,实际为 \(String(describing: doc.blocks.first))") + } + + XCTAssertTrue(inlines.contains { if case .strong = $0 { return true }; return false }, "相邻 strong inline 不应被扁平化或丢失") + XCTAssertTrue(inlines.contains { if case .emphasis = $0 { return true }; return false }, "相邻 emphasis inline 不应被扁平化或丢失") + + let text = self.flattenText(inlines) + XCTAssertTrue(text.contains("{{ST_MATH_BLOCK:0}}"), "第一个缺失占位符应按字面保留") + XCTAssertTrue(text.contains("{{ST_MATH_BLOCK:1}}"), "第二个缺失占位符应按字面保留") + } + + // MARK: - P0 #17: Table columnAlignments — center / right / left + + func testTableColumnAlignments_center_right_left() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + | 左 | 中 | 右 | + | :--- | :---: | ---: | + | a | b | c | + """ + ) + + guard let table = doc.firstBlock(where: { if case .table = $0 { return true }; return false }), + case .table(let model) = table else { + return XCTFail("期望识别为表格") + } + + XCTAssertEqual(model.columnAlignments.count, 3, "应解析三列对齐信息") + XCTAssertEqual(model.columnAlignments[safe: 0], .left, "第 1 列 :--- 应为 left") + XCTAssertEqual(model.columnAlignments[safe: 1], .center, "第 2 列 :---: 应为 center") + XCTAssertEqual(model.columnAlignments[safe: 2], .right, "第 3 列 ---: 应为 right") + } + + // MARK: - P1 #11: OrderedList.startIndex 非 1 + + func testOrderedList_startIndex_nonOne() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + 5. 第五 + 6. 第六 + 7. 第七 + """ + ) + + guard let list = doc.firstBlock(where: { if case .list = $0 { return true }; return false }), + case .list(let kind, let items) = list else { + return XCTFail("期望识别为有序列表") + } + + guard case .ordered(let startIndex) = kind else { + return XCTFail("期望 ordered list,得到 \(kind)") + } + XCTAssertEqual(startIndex, 5, "ordered list 应保留 startIndex = 5") + XCTAssertEqual(items.count, 3, "应识别 3 个列表项") + } + + // MARK: - P1 #27: Link destination 归一化(`\/` → `/`) + + func testLinkDestination_normalizesEscapedSlash() { + let parser = STMarkdownStructureParser() + // 在 destination 中混入字面 `\/`,预期 normalizeLinkDestination 还原为 `/`。 + // Markdown 不会原生触发这种内容;它来自上游 JSON 转义未清理干净的 LLM 输出。 + let markdown = #"[官网](https:\/\/example.com\/path)"# + let doc = parser.parse(markdown) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph") + } + + var foundDestination: String? + for node in inlines { + if case .link(let destination, _) = node { + foundDestination = destination + break + } + } + + guard let destination = foundDestination else { + return XCTFail("应识别为 link inline") + } + XCTAssertFalse(destination.contains(#"\/"#), "link destination 不应保留字面 \\/") + XCTAssertEqual(destination, "https://example.com/path", "link destination 应被归一化为 /") + } + + // MARK: - P1 #21: `\[…\]` 块级数学 + + func testBracketDisplayMath_isRecognizedAsMathBlock() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + #""" + 前文 + + \[ + x = y + 1 + \] + + 后文 + """# + ) + + let mathBlocks = doc.blocks.compactMap { block -> String? in + if case .mathBlock(let latex) = block { return latex } + return nil + } + XCTAssertEqual(mathBlocks.count, 1, "应识别一个 \\[ \\] 块公式") + XCTAssertTrue( + mathBlocks.first?.contains("x = y + 1") ?? false, + "块公式应保留 LaTeX 内容,实际:\(mathBlocks.first ?? "")" + ) + } + + // MARK: - P1 #22: `\begin{align}` 环境块 + + func testAlignEnvironmentBlock_isRecognizedAsMathBlock() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + #""" + 前文 + + \begin{align} + a &= b \\ + c &= d + \end{align} + + 后文 + """# + ) + + let mathBlocks = doc.blocks.compactMap { block -> String? in + if case .mathBlock(let latex) = block { return latex } + return nil + } + XCTAssertEqual(mathBlocks.count, 1, "应识别一个 align 环境块") + XCTAssertTrue( + mathBlocks.first?.contains(#"\begin{align}"#) ?? false, + "应保留 \\begin{align} 起始标记" + ) + XCTAssertTrue( + mathBlocks.first?.contains(#"\end{align}"#) ?? false, + "应保留 \\end{align} 结束标记" + ) + } + + // MARK: - P1 #4: 段落是纯图片 → 单独成 .image 块 + + func testParagraphContainingOnlyImage_becomesImageBlock() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("![示意](https://example.com/x.png)") + + guard let firstBlock = doc.blocks.first else { + return XCTFail("应至少有一个块") + } + + guard case .image(let url, let altText, _) = firstBlock else { + return XCTFail("段落只含一张图片时应升级为 .image block,实际为 \(firstBlock)") + } + XCTAssertEqual(url, "https://example.com/x.png") + XCTAssertEqual(altText, "示意") + } + + // MARK: - P2 # 边界:parse("") → 空文档 + + func testParse_emptyInput_returnsEmptyDocument() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("") + XCTAssertEqual(doc.blocks.count, 0, "空输入应返回空文档(短路)") + } + + // MARK: - P2 #23: 行内公式 `\(…\)` 内容与 isDisplayMode 断言 + + func testInlineMath_parenStyle_preservesContentAndIsNotDisplayMode() { + let parser = STMarkdownStructureParser() + let doc = parser.parse(#"前文 \(x+y\) 后文"#) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph") + } + + var formula: String? + var displayMode: Bool? + for node in inlines { + if case .inlineMath(let f, let d) = node { + formula = f + displayMode = d + break + } + } + XCTAssertEqual(formula, "x+y", "行内公式应保留 LaTeX 内容(不含定界符)") + XCTAssertEqual(displayMode, false, #"\(...\) 形式应为 isDisplayMode == false"#) + } + + // MARK: - P2 #14: 列表项嵌套块(子列表 / 代码块 / 引用) + + func testListItem_withNestedSublistAndCodeBlock() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + - 父项一 + - 子项一 + - 子项二 + - 父项二 + + ```swift + let x = 1 + ``` + """ + ) + + guard case .list(_, let items)? = doc.blocks.first else { + return XCTFail("期望首块为 list") + } + XCTAssertEqual(items.count, 2, "应有两个父项") + + // 父项一:blocks 里应同时含 paragraph + 嵌套 list + let firstItemBlocks = items[0].blocks + let hasNestedList = firstItemBlocks.contains { if case .list = $0 { return true }; return false } + XCTAssertTrue(hasNestedList, "父项一应包含嵌套的子列表块") + + // 父项二:应含 codeBlock 子块 + let secondItemBlocks = items[1].blocks + let nestedCode = secondItemBlocks.first { if case .codeBlock = $0 { return true }; return false } + guard let nestedCode, case .codeBlock(let lang, let code) = nestedCode else { + return XCTFail("父项二应包含嵌套的 codeBlock 子块") + } + XCTAssertEqual(lang, "swift") + XCTAssertTrue(code.contains("let x = 1")) + } + + // MARK: - P2 #8: BlockQuote 嵌套(quote 里套 list 与 code) + + func testBlockQuote_withNestedListAndCode() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + > 引用首段 + > + > - 引用列表项一 + > - 引用列表项二 + > + > ```swift + > let v = 1 + > ``` + """ + ) + + guard case .quote(let inner)? = doc.blocks.first else { + return XCTFail("期望首块为 quote") + } + + let hasParagraph = inner.contains { if case .paragraph = $0 { return true }; return false } + let hasList = inner.contains { if case .list = $0 { return true }; return false } + let hasCode = inner.contains { if case .codeBlock = $0 { return true }; return false } + XCTAssertTrue(hasParagraph, "quote 内应保留段落子块") + XCTAssertTrue(hasList, "quote 内应保留 list 子块") + XCTAssertTrue(hasCode, "quote 内应保留 codeBlock 子块") + } + + // MARK: - P2 #29: SoftBreak(同一段落跨行) + + func testParagraph_withSoftBreakBetweenLines() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + 第一行 + 第二行 + """ + ) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph") + } + let softBreakCount = inlines.reduce(into: 0) { acc, node in + if case .softBreak = node { acc += 1 } + } + XCTAssertEqual(softBreakCount, 1, "段落内同一段两行之间应有一个 softBreak") + } + + // MARK: - P3 #2: Heading 4–6 + + func testHeading_levelFourFiveSix() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + #### 四级 + ##### 五级 + ###### 六级 + """ + ) + let levels = doc.blocks.compactMap { block -> Int? in + if case .heading(let level, _) = block { return level } + return nil + } + XCTAssertEqual(levels, [4, 5, 6], "应识别 4/5/6 级标题") + } + + // MARK: - P3 #10: 无 language 的围栏代码块 + + func testCodeBlock_withoutLanguage_hasNilLanguage() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + ``` + plain code + ``` + """ + ) + guard case .codeBlock(let language, let code)? = doc.blocks.first else { + return XCTFail("期望首块为 codeBlock") + } + // swift-markdown 在没有标签时 language 为 nil 或空串;两种都接受。 + XCTAssertTrue(language == nil || language?.isEmpty == true, "无标签围栏的 language 应为 nil/空,实际:\(String(describing: language))") + XCTAssertTrue(code.contains("plain code")) + } + + // MARK: - P3 #28: Image inline 的 alt / source / title 完整断言 + + func testInlineImage_preservesAltSourceAndTitle() { + let parser = STMarkdownStructureParser() + // 段落含其它 inline,强制 image 走 inline 分支而非"段落只含图片→升级 .image 块"。 + let doc = parser.parse(#"前文 ![替代](https://example.com/i.png "标题") 后文"#) + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph") + } + + var foundSource: String? + var foundAlt: String? + var foundTitle: String? + for node in inlines { + if case .image(let source, let alt, let title) = node { + foundSource = source + foundAlt = alt + foundTitle = title + break + } + } + XCTAssertEqual(foundSource, "https://example.com/i.png") + XCTAssertEqual(foundAlt, "替代") + XCTAssertEqual(foundTitle, "标题") + } + + // MARK: - P3 #30: Strikethrough 子节点完整保留 + + func testStrikethrough_preservesNestedTextContent() { + let parser = STMarkdownStructureParser() + let doc = parser.parse("前 ~~删除内容~~ 后") + + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("期望首块为 paragraph") + } + + var strikethroughText: String? + for node in inlines { + if case .strikethrough(let children) = node { + strikethroughText = self.flattenText(children) + break + } + } + XCTAssertEqual(strikethroughText, "删除内容", "strikethrough 应保留内部文本") + } + + // MARK: - 私有:递归收集 strong 内的纯文本(用于 P0 #6 的断言) + + private func collectStrongTexts(_ inlines: [STMarkdownInlineNode], into bucket: inout [String]) { + for node in inlines { + switch node { + case .strong(let children): + let text = self.flattenText(children) + if text.isEmpty == false { bucket.append(text) } + self.collectStrongTexts(children, into: &bucket) + case .emphasis(let children), + .strikethrough(let children), + .link(_, let children): + self.collectStrongTexts(children, into: &bucket) + default: + break + } + } + } + + private func flattenText(_ inlines: [STMarkdownInlineNode]) -> String { + var out = "" + for node in inlines { + switch node { + case .text(let raw): out += raw + case .strong(let c), .emphasis(let c), .strikethrough(let c): + out += self.flattenText(c) + case .link(_, let c): + out += self.flattenText(c) + default: + break + } + } + return out + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift new file mode 100644 index 0000000..258716e --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift @@ -0,0 +1,401 @@ +// +// STMarkdownStructureParserParseAndRenderIntegrityTests.swift +// STBaseProjectExampleTests +// +// 验证 STMarkdownStructureParser 对常见 Markdown 结构的识别, +// 以及经默认管线 + STMarkdownAttributedStringRenderer 渲染后的纯文本中 +// 不得残留未解析的 Markdown 定界符(标签/语法糖)。 +// + +import XCTest +import STBaseProject + +/// 默认引擎 + 默认属性串渲染器得到的可见字符串(不含 attribute 中的 URL)。 +private func st_renderPlainString(markdown: String) -> String { + let engine = STMarkdownEngine( + configuration: STMarkdownPipelineConfiguration( + enableInputSanitizer: true, + sanitizerRules: STMarkdownInputSanitizer.defaultRules, + debug: false, + semanticNormalizers: [] + ) + ) + let result = engine.process(markdown) + let renderer = STMarkdownAttributedStringRenderer( + style: .default, + advancedRenderers: .empty + ) + return renderer.render(document: result.renderDocument).string +} + +/// 在「我们构造的样例」中,若仍出现在最终可见串里,可视为解析/渲染泄漏的定界片段。 +private func st_assertNoRawMarkdownSyntaxLeaks( + in output: String, + file: StaticString = #filePath, + line: UInt = #line +) { + let forbidden: [(String, String)] = [ + ("**", "粗体定界符"), + ("__", "下划线强调定界符"), + ("~~", "删除线定界符"), + ("```", "代码围栏"), + ("{{ST_MATH_BLOCK:", "块公式内部占位符泄漏"), + ("![", "图片 Markdown 前缀"), + ("$$", "块公式美元定界符"), + ("\\(", #"行内公式 `\(`"#), + ("\\)", #"行内公式 `\)`"#), + ("- [ ]", "任务列表未完成原始语法"), + ("- [x]", "任务列表已完成原始语法(小写 x)"), + ("- [X]", "任务列表已完成原始语法(大写 X)"), + ] + for (token, label) in forbidden { + XCTAssertFalse( + output.contains(token), + "渲染结果不得包含未消费的 Markdown/公式定界片段(\(label)):\(token.debugDescription)", + file: file, + line: line + ) + } +} + +@MainActor +private func st_assertStreamingNoRawMarkdownSyntaxLeaks( + chunks: [String], + file: StaticString = #filePath, + line: UInt = #line +) { + let view = STMarkdownStreamingTextView() + var accumulated = "" + for chunk in chunks { + accumulated += chunk + view.updateStreamingMarkdown(accumulated) + st_assertNoRawMarkdownSyntaxLeaks( + in: view.attributedText.string, + file: file, + line: line + ) + } +} + +private func st_collectInlineKinds(_ nodes: [STMarkdownInlineNode]) -> Set { + var kinds = Set() + func walk(_ nodes: [STMarkdownInlineNode]) { + for node in nodes { + switch node { + case .text: + kinds.insert("text") + case .inlineMath: + kinds.insert("inlineMath") + case .emphasis(let c): + kinds.insert("emphasis") + walk(c) + case .strong(let c): + kinds.insert("strong") + walk(c) + case .code: + kinds.insert("code") + case .link: + kinds.insert("link") + case .image: + kinds.insert("image") + case .softBreak: + kinds.insert("softBreak") + case .strikethrough(let c): + kinds.insert("strikethrough") + walk(c) + } + } + } + walk(nodes) + return kinds +} + +private func st_firstParagraphInlines(_ document: STMarkdownDocument) -> [STMarkdownInlineNode]? { + for block in document.blocks { + if case .paragraph(let inlines) = block { + return inlines + } + } + return nil +} + +final class STMarkdownStructureParserParseAndRenderIntegrityTests: XCTestCase { + + // MARK: - 解析:结构是否被识别为 AST 节点(而非整段原文) + + func testParserRecognizesHeadingLevelsAndContent() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + # 一级 + ## 二级 + ### 三级 + """ + ) + XCTAssertEqual(doc.blocks.count, 3) + guard case .heading(let l1, let c1) = doc.blocks[0], + case .heading(let l2, let c2) = doc.blocks[1], + case .heading(let l3, let c3) = doc.blocks[2] + else { + return XCTFail("期望三个 heading 块") + } + XCTAssertEqual(l1, 1) + XCTAssertEqual(l2, 2) + XCTAssertEqual(l3, 3) + XCTAssertTrue(c1.contains { if case .text(let t) = $0 { return t.contains("一级") }; return false }) + XCTAssertTrue(c2.contains { if case .text(let t) = $0 { return t.contains("二级") }; return false }) + XCTAssertTrue(c3.contains { if case .text(let t) = $0 { return t.contains("三级") }; return false }) + } + + func testParserRecognizesInlineStrongEmphasisCodeLinkImageStrikeAndMath() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + #""" + 行内 **粗体** *斜体* `代码` [链接](https://example.com) ![图](https://example.com/i.png) ~~删~~ 公式 \(x+y\) + """# + ) + guard let inlines = st_firstParagraphInlines(doc) else { + return XCTFail("期望首块为段落") + } + let kinds = st_collectInlineKinds(inlines) + XCTAssertTrue(kinds.contains("strong"), "应识别 ** 为 strong") + XCTAssertTrue(kinds.contains("emphasis"), "应识别 * 为 emphasis") + XCTAssertTrue(kinds.contains("code"), "应识别行内代码") + XCTAssertTrue(kinds.contains("link"), "应识别链接") + XCTAssertTrue(kinds.contains("image"), "应识别图片") + XCTAssertTrue(kinds.contains("strikethrough"), "应识别删除线") + XCTAssertTrue(kinds.contains("inlineMath"), "应识别行内公式") + } + + func testParserRecognizesBlockQuoteFencedCodeTableThematicBreakAndDisplayMath() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + > 引用一行 + + ```swift + let a = 1 + ``` + + | 列一 | 列二 | + | --- | --- | + | 甲 | 乙 | + + --- + + $$ + E = mc^2 + $$ + """ + ) + + var sawQuote = false + var sawCode = false + var sawTable = false + var sawBreak = false + var sawMath = false + + for block in doc.blocks { + switch block { + case .quote(let inner): + sawQuote = true + XCTAssertFalse(inner.isEmpty) + case .codeBlock(let lang, let code): + sawCode = true + XCTAssertEqual(lang, "swift") + XCTAssertTrue(code.contains("let a")) + case .table(let model): + sawTable = true + XCTAssertFalse(model.rows.isEmpty) + case .thematicBreak: + sawBreak = true + case .mathBlock(let latex): + sawMath = true + XCTAssertTrue(latex.contains("E = mc^2")) + default: + break + } + } + + XCTAssertTrue(sawQuote, "应识别块引用") + XCTAssertTrue(sawCode, "应识别围栏代码块") + XCTAssertTrue(sawTable, "应识别 GFM 表格") + XCTAssertTrue(sawBreak, "应识别主题分隔线") + XCTAssertTrue(sawMath, "应识别块级公式(mathBlock)") + } + + func testParserRecognizesOrderedUnorderedAndTaskLists() { + let parser = STMarkdownStructureParser() + let doc = parser.parse( + """ + 1. 有序一 + 2. 有序二 + + - 无序项 + + - [x] 已完成 + - [ ] 未完成 + """ + ) + + var orderedItems = 0 + var unorderedItems = 0 + var taskChecked = false + var taskUnchecked = false + + for block in doc.blocks { + guard case .list(let kind, let items) = block else { continue } + switch kind { + case .ordered: + orderedItems += items.count + case .unordered: + unorderedItems += items.count + for item in items { + if item.checkbox == .checked { taskChecked = true } + if item.checkbox == .unchecked { taskUnchecked = true } + } + } + } + + XCTAssertEqual(orderedItems, 2, "有序列表两项") + XCTAssertGreaterThanOrEqual(unorderedItems, 3, "无序 + 任务列表项") + XCTAssertTrue(taskChecked, "应识别已完成任务项") + XCTAssertTrue(taskUnchecked, "应识别未完成任务项") + } + + // MARK: - 渲染:可见串中不得残留 Markdown 定界符 + + func testRenderedOutputHasNoMarkdownDelimiterLeaks_inlineRichParagraph() { + let md = #""" + 展示 **粗体**、*斜体*、`mono`、[点我](https://example.com)、![示意](https://example.com/x.png) 与 ~~旧文~~ 以及 \(a+b\)。 + """# + let plain = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("粗体")) + XCTAssertTrue(plain.contains("斜体")) + XCTAssertTrue(plain.contains("mono")) + XCTAssertTrue(plain.contains("点我")) + XCTAssertTrue(plain.contains("示意")) + XCTAssertTrue(plain.contains("旧文")) + } + + func testRenderedOutputHasNoMarkdownDelimiterLeaks_headingsAndBlockStructures() { + let md = """ + # 标题一 + + ## 标题二 + + > 引用内容 + + ```swift + let v = 42 + ``` + + | H1 | H2 | + | --- | --- | + | c1 | c2 | + + --- + + $$ + x^2 + $$ + """ + let plain = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("标题一")) + XCTAssertTrue(plain.contains("标题二")) + XCTAssertTrue(plain.contains("引用内容")) + XCTAssertTrue(plain.contains("let v = 42")) + XCTAssertTrue(plain.contains("c1")) + XCTAssertFalse(plain.contains("# "), "标题不应以 Markdown # 前缀出现在可见文本中") + XCTAssertFalse(plain.contains("> "), "块引用不应以 `> ` 出现在可见文本中") + } + + func testRenderedOutputHasNoMarkdownDelimiterLeaks_listsAndTasks() { + let md = """ + 1. 第一项 + 2. 第二项 + + - 圆点 + + - [x] 做完 + - [ ] 待办 + """ + let plain = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("第一项")) + XCTAssertTrue(plain.contains("第二项")) + XCTAssertTrue(plain.contains("圆点")) + XCTAssertTrue(plain.contains("做完")) + XCTAssertTrue(plain.contains("待办")) + } + + func testRenderedOutputHasNoMarkdownDelimiterLeaks_nestedEmphasisAndLink() { + let md = """ + 外层 **粗里 *斜粗尾* 尾** [链](https://a.org/b) + """ + let plain = st_renderPlainString(markdown: md) + st_assertNoRawMarkdownSyntaxLeaks(in: plain) + XCTAssertTrue(plain.contains("粗里")) + XCTAssertTrue(plain.contains("斜粗尾")) + XCTAssertTrue(plain.contains("链")) + } + + // MARK: - 流式:每个中间态都不允许出现 Markdown 定界符 + + @MainActor + func testStreamingIntermediateStatesHaveNoMarkdownLeaks_strongAndEmphasis() { + st_assertStreamingNoRawMarkdownSyntaxLeaks( + chunks: [ + "展示 ", + "**粗", + "体**", + " 与 *斜", + "体* 结束" + ] + ) + } + + @MainActor + func testStreamingIntermediateStatesHaveNoMarkdownLeaks_linkAndInlineCode() { + st_assertStreamingNoRawMarkdownSyntaxLeaks( + chunks: [ + "点击 ", + "[链", + "接](https://example.com)", + " 与 `co", + "de`" + ] + ) + } + + @MainActor + func testStreamingIntermediateStatesHaveNoMarkdownLeaks_strikethroughAndTaskList() { + st_assertStreamingNoRawMarkdownSyntaxLeaks( + chunks: [ + "~~删", + "除线~~\n\n", + "- [x] 已", + "完成\n", + "- [ ] 待", + "办" + ] + ) + } + + @MainActor + func testStreamingIntermediateStatesHaveNoMarkdownLeaks_mathDelimiters() { + st_assertStreamingNoRawMarkdownSyntaxLeaks( + chunks: [ + "行内公式 ", + #"\(x+"#, + #"y\)"#, + "\n\n", + "$$", + "\nE = mc^2\n", + "$$" + ] + ) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift new file mode 100644 index 0000000..30ef4b5 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift @@ -0,0 +1,89 @@ +import XCTest +import UIKit +@testable import STBaseProject + +@MainActor +final class STMarkdownUIViewTests: XCTestCase { + + func testTextViewLinkTapCallbackReturnsFalseAndForwardsURL() { + let view = STMarkdownTextView() + let url = URL(string: "https://example.com")! + var tappedURL: URL? + view.onLinkTap = { tappedURL = $0 } + + let shouldInteract = view.textView( + view.contentTextView, + shouldInteractWith: url, + in: NSRange(location: 0, length: 0), + interaction: .invokeDefaultAction + ) + + XCTAssertFalse(shouldInteract) + XCTAssertEqual(tappedURL, url) + } + + func testTextViewSelectionChangeCallbackReturnsSelectedText() { + let view = STMarkdownTextView() + view.setMarkdown("Hello World") + var selectedText: String? + view.onSelectionChange = { selectedText = $0 } + + view.contentTextView.selectedRange = NSRange(location: 6, length: 5) + view.textViewDidChangeSelection(view.contentTextView) + + XCTAssertEqual(selectedText, "World") + } + + func testTextViewResetClearsRawMarkdownAndRenderedText() { + let view = STMarkdownTextView() + view.setMarkdown("## Title") + + view.reset() + + XCTAssertTrue(view.rawMarkdown.isEmpty) + XCTAssertTrue(view.attributedText.string.isEmpty) + } + + func testStreamingViewUsesCustomDocumentRendererWhenProvided() { + let view = STMarkdownStreamingTextView() + view.customDocumentRenderer = { _ in + NSAttributedString(string: "custom-rendered") + } + + view.setMarkdown("**ignored**", animated: false) + + XCTAssertEqual(view.attributedText.string, "custom-rendered") + } + + func testStreamingViewAppendEmptyFragmentDoesNotChangeState() { + let view = STMarkdownStreamingTextView() + view.setMarkdown("base", animated: false) + + view.appendMarkdownFragment("", animated: true) + + XCTAssertEqual(view.rawMarkdown, "base") + XCTAssertEqual(view.attributedText.string, "base") + } + + func testStreamingApplyConfigurationUpdatesStyleAndRendersMarkdown() { + let view = STMarkdownStreamingTextView() + let style = STMarkdownStyle( + font: .systemFont(ofSize: 18, weight: .medium), + textColor: .systemRed, + lineHeight: 26, + kern: 0.2 + ) + + view.applyConfiguration( + markdown: "Configured content", + style: style, + advancedRenderers: .empty, + engine: STMarkdownEngine(), + animated: false + ) + + XCTAssertEqual(view.rawMarkdown, "Configured content") + XCTAssertEqual(view.attributedText.string, "Configured content") + XCTAssertEqual(view.contentTextView.textColor, .systemRed) + } +} diff --git a/Example/STBaseProjectExampleTests/STMediaTests.swift b/Example/STBaseProjectExampleTests/STMediaTests.swift new file mode 100644 index 0000000..06dbef0 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMediaTests.swift @@ -0,0 +1,592 @@ +import XCTest +import STMedia + +// MARK: - STImageFormatTests + +final class STImageFormatTests: XCTestCase { + + // MARK: format(from:) 字节头检测 + + func test_format_jpeg() { + let data = Data([0xFF, 0xD8, 0xFF, 0xE0]) + XCTAssertEqual(UIImage.format(from: data), .jpeg) + } + + func test_format_png() { + let data = Data([0x89, 0x50, 0x4E, 0x47]) + XCTAssertEqual(UIImage.format(from: data), .png) + } + + func test_format_gif() { + let data = Data([0x47, 0x49, 0x46, 0x38]) + XCTAssertEqual(UIImage.format(from: data), .gif) + } + + func test_format_tiff_little_endian() { + let data = Data([0x49, 0x49, 0x2A, 0x00]) + XCTAssertEqual(UIImage.format(from: data), .tiff) + } + + func test_format_tiff_big_endian() { + let data = Data([0x4D, 0x4D, 0x00, 0x2A]) + XCTAssertEqual(UIImage.format(from: data), .tiff) + } + + func test_format_webp() { + // RIFF????WEBP header (12 bytes) + var bytes = Array("RIFF".utf8) + [0x00, 0x00, 0x00, 0x00] + Array("WEBP".utf8) + let data = Data(bytes) + XCTAssertEqual(UIImage.format(from: data), .webp) + } + + func test_format_empty_returns_undefined() { + XCTAssertEqual(UIImage.format(from: Data()), .undefined) + } + + func test_format_unknown_returns_undefined() { + let data = Data([0xAB, 0xCD, 0xEF]) + XCTAssertEqual(UIImage.format(from: data), .undefined) + } + + // MARK: STImageFormat 属性 + + func test_mimeType() { + XCTAssertEqual(STImageFormat.jpeg.mimeType, "image/jpeg") + XCTAssertEqual(STImageFormat.png.mimeType, "image/png") + XCTAssertEqual(STImageFormat.gif.mimeType, "image/gif") + XCTAssertEqual(STImageFormat.webp.mimeType, "image/webp") + XCTAssertEqual(STImageFormat.heic.mimeType, "image/heic") + } + + func test_fileExtension() { + XCTAssertEqual(STImageFormat.jpeg.fileExtension, "jpeg") + XCTAssertEqual(STImageFormat.png.fileExtension, "png") + } + + // MARK: getFormat() 实例方法(alpha 启发式) + + func test_getFormat_opaqueImage_returns_jpeg() { + let image = makeOpaqueImage(size: CGSize(width: 4, height: 4), color: .red) + XCTAssertEqual(image.getFormat(), .jpeg) + } + + func test_getFormat_alphaImage_returns_png() { + let image = makeAlphaImage(size: CGSize(width: 4, height: 4)) + XCTAssertEqual(image.getFormat(), .png) + } + + // MARK: toData() 格式选择 + + func test_toData_opaqueImage_decodesAsJPEG() { + let image = makeOpaqueImage(size: CGSize(width: 4, height: 4), color: .blue) + let data = image.toData() + XCTAssertFalse(data.isEmpty) + // 无 alpha → 编码为 JPEG,首字节 0xFF + XCTAssertEqual(UIImage.format(from: data), .jpeg) + } + + func test_toData_alphaImage_decodesAsPNG() { + let image = makeAlphaImage(size: CGSize(width: 4, height: 4)) + let data = image.toData() + XCTAssertFalse(data.isEmpty) + // 有 alpha → 编码为 PNG,首字节 0x89 + XCTAssertEqual(UIImage.format(from: data), .png) + } + + // MARK: Helpers + + private func makeOpaqueImage(size: CGSize, color: UIColor) -> UIImage { + // opaque = true 确保 cgImage.alphaInfo 为 none,与 getFormat() 的 alpha 启发式保持一致 + let format = UIGraphicsImageRendererFormat() + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { ctx in + color.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } + + private func makeAlphaImage(size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIColor.clear.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + UIColor.red.withAlphaComponent(0.5).setFill() + ctx.fill(CGRect(x: 0, y: 0, width: size.width / 2, height: size.height)) + } + } +} + +// MARK: - STImageCompressTests + +final class STImageCompressTests: XCTestCase { + + private func makeColorImage(size: CGSize = CGSize(width: 100, height: 100)) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIColor.blue.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } + + func test_smartCompress_jpeg_withinLimit() throws { + let image = makeColorImage() + let maxKB = 50 + let data = try XCTUnwrap(UIImage.smartCompress(image, maxFileSize: maxKB)) + XCTAssertLessThanOrEqual(data.count, maxKB * 1024) + } + + func test_smartCompress_png_producesValidData() throws { + let image = makeColorImage(size: CGSize(width: 10, height: 10)) + let data = try XCTUnwrap(UIImage.smartCompress(image, maxFileSize: 100, format: .png)) + XCTAssertFalse(data.isEmpty) + XCTAssertEqual(UIImage.format(from: data), .png) + } + + func test_compressToSize_reducesSize() throws { + let image = makeColorImage(size: CGSize(width: 500, height: 500)) + let maxKB = 30 + let data = try XCTUnwrap(UIImage.compressToSize(image, maxFileSize: maxKB)) + XCTAssertLessThanOrEqual(data.count, maxKB * 1024) + } + + func test_resizeImage_outputSize() throws { + let image = makeColorImage(size: CGSize(width: 200, height: 200)) + let target = CGSize(width: 50, height: 50) + let resized = try XCTUnwrap(UIImage.resizeImage(image, to: target)) + XCTAssertEqual(resized.size, target) + } + + func test_aspectFitScale_maintainsRatio() throws { + let image = makeColorImage(size: CGSize(width: 200, height: 100)) + let result = try XCTUnwrap(image.aspectFitScale(to: CGSize(width: 50, height: 50))) + XCTAssertLessThanOrEqual(result.size.width, 50 + 1) + XCTAssertLessThanOrEqual(result.size.height, 50 + 1) + let ratio = result.size.width / result.size.height + XCTAssertEqual(ratio, 2.0, accuracy: 0.01) + } + + func test_aspectFillScale_outputSize() throws { + let image = makeColorImage(size: CGSize(width: 200, height: 100)) + let target = CGSize(width: 50, height: 50) + let result = try XCTUnwrap(image.aspectFillScale(to: target)) + XCTAssertEqual(result.size.width, target.width, accuracy: 1.0) + XCTAssertEqual(result.size.height, target.height, accuracy: 1.0) + } + + func test_toBase64_roundTrip() { + let image = makeColorImage(size: CGSize(width: 4, height: 4)) + let base64 = image.toBase64() + XCTAssertFalse(base64.isEmpty) + let decoded = Data(base64Encoded: base64) + XCTAssertNotNil(decoded) + XCTAssertNotNil(UIImage(data: decoded!)) + } + + func test_isEmpty_emptyImage() { + let empty = UIImage() + XCTAssertTrue(UIImage.isEmpty(empty)) + } + + func test_isEmpty_nonEmptyImage() { + let image = makeColorImage() + XCTAssertFalse(UIImage.isEmpty(image)) + } +} + +// MARK: - STImageTransformTests + +final class STImageTransformTests: XCTestCase { + + private func makeImage(size: CGSize = CGSize(width: 100, height: 60)) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIColor.green.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } + + func test_crop() throws { + let image = makeImage(size: CGSize(width: 100, height: 100)) + let cropRect = CGRect(x: 10, y: 10, width: 40, height: 40) + let cropped = try XCTUnwrap(image.crop(to: cropRect)) + XCTAssertEqual(cropped.size.width, 40, accuracy: 1.0) + XCTAssertEqual(cropped.size.height, 40, accuracy: 1.0) + } + + func test_rotate_90degrees() throws { + let image = makeImage(size: CGSize(width: 100, height: 60)) + let rotated = try XCTUnwrap(image.rotate(by: 90)) + // 90° 旋转后宽高互换(允许浮点误差) + XCTAssertEqual(rotated.size.width, 60, accuracy: 1.0) + XCTAssertEqual(rotated.size.height, 100, accuracy: 1.0) + } + + func test_roundedCorners_returnsImage() throws { + let image = makeImage() + let rounded = try XCTUnwrap(image.roundedCorners(radius: 10)) + XCTAssertEqual(rounded.size, image.size) + } + + func test_addBorder_enlargesImage() throws { + let image = makeImage(size: CGSize(width: 80, height: 80)) + let bordered = try XCTUnwrap(image.addBorder(width: 5, color: .red)) + XCTAssertEqual(bordered.size, image.size) + } + + func test_addWatermark_returnsImage() throws { + let base = makeImage(size: CGSize(width: 100, height: 100)) + let watermark = makeImage(size: CGSize(width: 20, height: 20)) + let result = try XCTUnwrap(base.addWatermark(watermark, position: .center)) + XCTAssertEqual(result.size, base.size) + } + + func test_applyBlur_returnsImage() throws { + let image = makeImage() + let blurred = try XCTUnwrap(image.applyBlur(radius: 5)) + XCTAssertFalse(UIImage.isEmpty(blurred)) + } + + func test_adjustBrightness_returnsImage() throws { + let image = makeImage() + let adjusted = try XCTUnwrap(image.adjustBrightness(0.2)) + XCTAssertFalse(UIImage.isEmpty(adjusted)) + } + + func test_getColor_centerPixel() { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 10, height: 10)) + let image = renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(origin: .zero, size: CGSize(width: 10, height: 10))) + } + let color = image.getColor(at: CGPoint(x: 5, y: 5)) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + XCTAssertGreaterThan(r, 0.5) + XCTAssertLessThan(g, 0.1) + XCTAssertEqual(a, 1.0, accuracy: 0.01) + } + + func test_getColor_outOfBoundsReturnssClear() { + let image = makeImage() + let color = image.getColor(at: CGPoint(x: 9999, y: 9999)) + XCTAssertEqual(color, .clear) + } +} + +// MARK: - STScanManagerTests + +final class STScanManagerTests: XCTestCase { + + // MARK: 空内容/零尺寸边界校验 + + func test_generateQRCode_emptyContent_throws() async { + do { + _ = try await STScanManager.generateQRCode(content: "", size: CGSize(width: 200, height: 200)) + XCTFail("应该抛出 invalidContent 错误") + } catch STScanError.invalidContent { + // 预期 + } catch { + XCTFail("期望 STScanError.invalidContent,实际:\(error)") + } + } + + func test_generateQRCode_zeroSize_throws() async { + do { + _ = try await STScanManager.generateQRCode(content: "test", size: .zero) + XCTFail("应该抛出 invalidSize 错误") + } catch STScanError.invalidSize { + // 预期 + } catch { + XCTFail("期望 STScanError.invalidSize,实际:\(error)") + } + } + + func test_generateBarCode_emptyContent_throws() async { + do { + _ = try await STScanManager.generateBarCode(content: "", size: CGSize(width: 200, height: 80)) + XCTFail("应该抛出 invalidContent 错误") + } catch STScanError.invalidContent { + // 预期 + } catch { + XCTFail("期望 STScanError.invalidContent,实际:\(error)") + } + } + + // MARK: 正常生成 + + func test_generateQRCode_returnsValidImage() async throws { + let size = CGSize(width: 200, height: 200) + let image = try await STScanManager.generateQRCode(content: "https://example.com", size: size) + XCTAssertEqual(image.size.width, size.width, accuracy: 1.0) + XCTAssertEqual(image.size.height, size.height, accuracy: 1.0) + XCTAssertFalse(UIImage.isEmpty(image)) + } + + func test_generateQRCode_customColor_returnsImage() async throws { + let size = CGSize(width: 150, height: 150) + let image = try await STScanManager.generateQRCode( + content: "hello", + size: size, + color: .blue, + background: .yellow + ) + XCTAssertEqual(image.size.width, size.width, accuracy: 1.0) + XCTAssertFalse(UIImage.isEmpty(image)) + } + + func test_generateBarCode_returnsValidImage() async throws { + let size = CGSize(width: 300, height: 100) + let image = try await STScanManager.generateBarCode(content: "1234567890", size: size) + XCTAssertEqual(image.size.width, size.width, accuracy: 1.0) + XCTAssertFalse(UIImage.isEmpty(image)) + } + + // MARK: QR 生成后识别(端到端) + + func test_generateQRCode_thenRecognize_matchesOriginalContent() async throws { + let content = "STMedia-QR-Test-\(UUID().uuidString)" + let qrImage = try await STScanManager.generateQRCode( + content: content, + size: CGSize(width: 300, height: 300) + ) + let recognized = try await STScanManager.recognizeQRCode(in: qrImage) + XCTAssertEqual(recognized, content) + } + + func test_generateQRCode_withWatermark_thenRecognize() async throws { + let content = "watermark-test-\(UUID().uuidString)" + let size = CGSize(width: 400, height: 400) + let watermarkSize = CGSize(width: 60, height: 60) + let watermark = makeColorImage(size: watermarkSize, color: .white) + let qrImage = try await STScanManager.generateQRCode( + content: content, + size: size, + watermark: watermark, + watermarkSize: watermarkSize + ) + let recognized = try await STScanManager.recognizeQRCode(in: qrImage) + XCTAssertEqual(recognized, content) + } + + // MARK: recognizeQRCode 识别失败路径 + + func test_recognizeQRCode_solidColorImage_throws() async { + let image = makeColorImage(size: CGSize(width: 100, height: 100), color: .white) + do { + _ = try await STScanManager.recognizeQRCode(in: image) + XCTFail("纯色图片应该识别失败") + } catch STScanError.noQRCodeFound { + // 预期 + } catch { + XCTFail("期望 STScanError.noQRCodeFound,实际:\(error)") + } + } + + // MARK: Helpers + + private func makeColorImage(size: CGSize, color: UIColor = .white) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + color.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } +} + +// MARK: - STScanViewTests + +final class STScanViewTests: XCTestCase { + + // MARK: 配置更新后 scanLineImage 响应 + + func test_scanLineImage_updatesAfterConfigChange() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + let originalColor = view.configuration.cornerColor + var newConfig = view.configuration + newConfig.cornerColor = .red + view.configuration = newConfig + // 断言 configuration 正确更新(间接验证 makeScanLineImage 被调用) + XCTAssertFalse(view.configuration.cornerColor == originalColor) + XCTAssertEqual(view.configuration.cornerColor, .red) + } + + func test_scanLineHeight_updatesAfterConfigChange() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + var config = view.configuration + config.scanLineHeight = 10.0 + view.configuration = config + XCTAssertEqual(view.configuration.scanLineHeight, 10.0) + } + + // MARK: 遮罩宽度计算(间接通过 getScanAreaRect 验证扫描框尺寸) + + func test_scanAreaRect_centeredInView() { + let frame = CGRect(x: 0, y: 0, width: 320, height: 568) + let view = STScanView(frame: frame) + // 关闭 safe area 适配以得到确定性结果 + view.setSafeAreaAdaptation(.disabled) + let rect = view.getScanAreaRect() + // 扫描框应在视图宽度内 + XCTAssertGreaterThan(rect.minX, 0) + XCTAssertLessThan(rect.maxX, frame.width) + XCTAssertGreaterThan(rect.minY, 0) + XCTAssertLessThan(rect.maxY, frame.height) + } + + func test_scanAreaRect_barCodeType_isWider() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.setSafeAreaAdaptation(.disabled) + view.st_configScanType(scanType: .qrCode) + let qrRect = view.getScanAreaRect() + view.st_configScanType(scanType: .barCode) + let barRect = view.getScanAreaRect() + // barCode 的 heightScale = 3.0,高度应显著小于宽度(扁长条形) + XCTAssertLessThan(barRect.height, barRect.width) + // 条码框高度应小于二维码框高度 + XCTAssertLessThan(barRect.height, qrRect.height) + } + + // MARK: 主题切换 + + func test_theme_light_setsMaskAlpha() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.theme = .light + XCTAssertEqual(view.configuration.maskAlpha, 0.4, accuracy: 0.001) + } + + func test_theme_dark_setsMaskAlpha() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.theme = .dark + XCTAssertEqual(view.configuration.maskAlpha, 0.6, accuracy: 0.001) + } + + func test_theme_custom_appliesConfiguration() { + var config = STScanViewConfiguration() + config.maskAlpha = 0.9 + config.tipText = "自定义提示" + // 直接设置 theme 属性,避免便利初始化路径的时序问题 + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.theme = .custom(config) + XCTAssertEqual(view.configuration.maskAlpha, 0.9, accuracy: 0.001) + XCTAssertEqual(view.configuration.tipText, "自定义提示") + } + + // MARK: resetToDefault + + func test_resetToDefault_restoresDarkTheme() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.theme = .light + view.resetToDefault() + XCTAssertEqual(view.configuration.maskAlpha, 0.6, accuracy: 0.001) + XCTAssertEqual(view.scanType, .qrCode) + } + + // MARK: updateTipText + + func test_updateTipText_updatesConfiguration() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.updateTipText("新提示文字") + XCTAssertEqual(view.configuration.tipText, "新提示文字") + } + + // MARK: isAnimating 状态 + + func test_isAnimating_barCodeType_isFalse() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.st_configScanType(scanType: .barCode) + XCTAssertFalse(view.isAnimating) + } + + func test_stopAnimating_setsIsAnimatingFalse() { + let view = STScanView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + view.st_stopAnimating() + XCTAssertFalse(view.isAnimating) + } +} + +// MARK: - STImageManagerModelTests + +final class STImageManagerModelTests: XCTestCase { + + func test_buildModel_jpeg_usesJpegExtension() throws { + let image = makeOpaqueImage(size: CGSize(width: 100, height: 100)) + var config = STImageManagerConfiguration() + config.maxFileSize = 500 + config.imageFormat = "jpeg" + let model = try STImageManager.buildModel(from: image, source: .camera, config: config) + XCTAssertFalse(model.imageData.isEmpty) + XCTAssertTrue(model.fileName.hasSuffix(".jpeg"), "文件名应以 .jpeg 结尾,实际:\(model.fileName)") + XCTAssertTrue(model.mimeType.hasPrefix("image/"), "mimeType 应以 image/ 开头") + XCTAssertEqual(model.source, .camera) + } + + func test_buildModel_compressedDataWithinLimit() throws { + let image = makeOpaqueImage(size: CGSize(width: 1000, height: 1000)) + var config = STImageManagerConfiguration() + config.maxFileSize = 100 // KB + let model = try STImageManager.buildModel(from: image, source: .photoLibrary, config: config) + XCTAssertLessThanOrEqual(model.imageData.count, 100 * 1024 + 1024) // 允许极小误差 + } + + func test_buildModel_compressionFailed_onZeroMaxSize() { + // 0KB 限制应导致压缩失败 + let image = makeOpaqueImage(size: CGSize(width: 10, height: 10)) + var config = STImageManagerConfiguration() + config.maxFileSize = 0 + XCTAssertThrowsError( + try STImageManager.buildModel(from: image, source: .camera, config: config) + ) { error in + guard case STImageManagerError.compressionFailed = error else { + XCTFail("期望 compressionFailed,实际:\(error)") + return + } + } + } + + func test_buildModel_sourcePreserved() throws { + let image = makeOpaqueImage(size: CGSize(width: 50, height: 50)) + let config = STImageManagerConfiguration() + let modelCamera = try STImageManager.buildModel(from: image, source: .camera, config: config) + let modelLib = try STImageManager.buildModel(from: image, source: .photoLibrary, config: config) + XCTAssertEqual(modelCamera.source, .camera) + XCTAssertEqual(modelLib.source, .photoLibrary) + } + + // MARK: Helpers + + private func makeOpaqueImage(size: CGSize) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { ctx in + UIColor.orange.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } +} + +// MARK: - STScanErrorTests + +final class STScanErrorTests: XCTestCase { + + func test_errorDescriptions_notNil() { + let errors: [STScanError] = [ + .invalidContent, .invalidSize, .recognitionFailed, + .noQRCodeFound, .cameraNotAvailable, .cameraPermissionDenied + ] + for error in errors { + XCTAssertNotNil(error.errorDescription, "\(error) 应有错误描述") + } + } +} + +// MARK: - STImageErrorTests + +final class STImageErrorTests: XCTestCase { + + func test_errorDescriptions_notNil() { + XCTAssertNotNil(STImageError.invalidData.errorDescription) + XCTAssertNotNil(STImageError.photoLibraryPermissionDenied.errorDescription) + } +} diff --git a/Example/STBaseProjectExampleTests/STNetworkStreamAndResumeTests.swift b/Example/STBaseProjectExampleTests/STNetworkStreamAndResumeTests.swift new file mode 100644 index 0000000..db55293 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STNetworkStreamAndResumeTests.swift @@ -0,0 +1,286 @@ +import XCTest +import Combine +import STBaseProject +@testable import STBaseProjectExample + +final class STNetworkStreamAndResumeTests: XCTestCase { + private var cancellables: Set = [] + + override func tearDown() { + self.cancellables.removeAll() + super.tearDown() + } + + func testStreamRequestParsesServerSentEventsAcrossChunks() { + let request = STDataStreamRequest() + let eventExpectation = expectation(description: "receive parsed SSE events") + eventExpectation.expectedFulfillmentCount = 2 + + var receivedEvents: [STServerSentEvent] = [] + request.eventPublisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { event in + receivedEvents.append(event) + eventExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + let part1 = Data("id:1\nevent:update\ndata:hello\n\nid:2\ndata:wo".utf8) + let part2 = Data("rld\n\n".utf8) + request.didReceive(part1) + request.didReceive(part2) + + wait(for: [eventExpectation], timeout: 1.0) + XCTAssertTrue(request.hasReceivedFirstByte) + XCTAssertEqual(receivedEvents.count, 2) + XCTAssertEqual(receivedEvents[0].id, "1") + XCTAssertEqual(receivedEvents[0].event, "update") + XCTAssertEqual(receivedEvents[0].data, "hello") + XCTAssertEqual(receivedEvents[1].id, "2") + XCTAssertEqual(receivedEvents[1].event, nil) + XCTAssertEqual(receivedEvents[1].data, "world") + } + + func testStreamRequestOnCompleteReceivesTerminalError() { + let request = STDataStreamRequest() + let completionExpectation = expectation(description: "stream completion receives terminal error") + let terminalError = STHTTPError.timeout + + var receivedError: Error? + request.eventPublisher + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + receivedError = error + } + completionExpectation.fulfill() + }, + receiveValue: { _ in } + ) + .store(in: &self.cancellables) + + request.didFinish(error: terminalError) + wait(for: [completionExpectation], timeout: 1.0) + + guard let httpError = receivedError as? STHTTPError else { + return XCTFail("Expected STHTTPError.timeout") + } + if case .timeout = httpError { + XCTAssertTrue(true) + } else { + XCTFail("Expected timeout error") + } + } + + func testStreamRequestDataPublisherEmitsChunksInOrder() { + let request = STDataStreamRequest() + let chunkExpectation = expectation(description: "receive two data chunks in order") + chunkExpectation.expectedFulfillmentCount = 2 + + var receivedChunks: [Data] = [] + request.dataPublisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { chunk in + receivedChunks.append(chunk) + chunkExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + let chunk1 = Data("first".utf8) + let chunk2 = Data("second".utf8) + request.didReceive(chunk1) + request.didReceive(chunk2) + + wait(for: [chunkExpectation], timeout: 1.0) + XCTAssertEqual(receivedChunks, [chunk1, chunk2]) + } + + func testStreamRequestParsesMultiLineSSEDataField() { + let request = STDataStreamRequest() + let eventExpectation = expectation(description: "receive one multiline SSE event") + + var receivedEvent: STServerSentEvent? + request.eventPublisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { event in + receivedEvent = event + eventExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + request.didReceive(Data("id:42\nevent:note\ndata:line1\ndata:line2\n\n".utf8)) + + wait(for: [eventExpectation], timeout: 1.0) + XCTAssertEqual(receivedEvent?.id, "42") + XCTAssertEqual(receivedEvent?.event, "note") + XCTAssertEqual(receivedEvent?.data, "line1\nline2") + } + + func testStreamRequestParsesRetryField() { + let request = STDataStreamRequest() + let eventExpectation = expectation(description: "receive one SSE event with retry") + + var receivedEvent: STServerSentEvent? + request.eventPublisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { event in + receivedEvent = event + eventExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + request.didReceive(Data("id:7\nevent:reconnect\ndata:payload\nretry:1500\n\n".utf8)) + + wait(for: [eventExpectation], timeout: 1.0) + XCTAssertEqual(receivedEvent?.id, "7") + XCTAssertEqual(receivedEvent?.event, "reconnect") + XCTAssertEqual(receivedEvent?.data, "payload") + XCTAssertEqual(receivedEvent?.retry, 1500) + } + + func testStreamRequestIgnoresCommentAndUnknownFields() { + let request = STDataStreamRequest() + let eventExpectation = expectation(description: "receive one SSE event ignoring comment and unknown fields") + + var receivedEvent: STServerSentEvent? + request.eventPublisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { event in + receivedEvent = event + eventExpectation.fulfill() + } + ) + .store(in: &self.cancellables) + + request.didReceive( + Data(":this is comment\nfoo:bar\nid:11\nevent:update\ndata:ok\n\n".utf8) + ) + + wait(for: [eventExpectation], timeout: 1.0) + XCTAssertEqual(receivedEvent?.id, "11") + XCTAssertEqual(receivedEvent?.event, "update") + XCTAssertEqual(receivedEvent?.data, "ok") + XCTAssertNil(receivedEvent?.retry) + } + + func testStreamRequestEventsAsyncSequenceYieldsThenFinishes() async throws { + let request = STDataStreamRequest() + let consumedExpectation = expectation(description: "consume two events then finish") + + var consumedEvents: [STServerSentEvent] = [] + let readerTask = Task { + do { + for try await event in request.events() { + consumedEvents.append(event) + } + consumedExpectation.fulfill() + } catch { + XCTFail("Expected finished stream without error, got \(error)") + } + } + + request.didReceive(Data("id:1\ndata:a\n\n".utf8)) + request.didReceive(Data("id:2\ndata:b\n\n".utf8)) + request.didFinish(error: nil) + + await fulfillment(of: [consumedExpectation], timeout: 1.0) + readerTask.cancel() + + XCTAssertEqual(consumedEvents.count, 2) + XCTAssertEqual(consumedEvents[0].id, "1") + XCTAssertEqual(consumedEvents[0].data, "a") + XCTAssertEqual(consumedEvents[1].id, "2") + XCTAssertEqual(consumedEvents[1].data, "b") + } + + func testStreamRequestEventsAsyncSequenceThrowsTerminalError() async throws { + let request = STDataStreamRequest() + let terminalError = STHTTPError.timeout + let completionExpectation = expectation(description: "events async sequence throws terminal error") + + var receivedError: Error? + let readerTask = Task { + do { + for try await _ in request.events() {} + XCTFail("Expected async sequence to throw terminal error") + } catch { + receivedError = error + completionExpectation.fulfill() + } + } + + request.didFinish(error: terminalError) + await fulfillment(of: [completionExpectation], timeout: 1.0) + readerTask.cancel() + + guard let httpError = receivedError as? STHTTPError else { + return XCTFail("Expected STHTTPError.timeout") + } + if case .timeout = httpError { + XCTAssertTrue(true) + } else { + XCTFail("Expected timeout error") + } + } + + func testStreamRequestBytesAsyncSequenceThrowsTerminalError() async throws { + let request = STDataStreamRequest() + let terminalError = STHTTPError.cancelled + let completionExpectation = expectation(description: "bytes async sequence throws terminal error") + + var receivedError: Error? + let readerTask = Task { + do { + for try await _ in request.bytes() {} + XCTFail("Expected async sequence to throw terminal error") + } catch { + receivedError = error + completionExpectation.fulfill() + } + } + + request.didFinish(error: terminalError) + await fulfillment(of: [completionExpectation], timeout: 1.0) + readerTask.cancel() + + guard let httpError = receivedError as? STHTTPError else { + return XCTFail("Expected STHTTPError.cancelled") + } + if case .cancelled = httpError { + XCTAssertTrue(true) + } else { + XCTFail("Expected cancelled error") + } + } + + func testDownloadRequestStoresResumeDataSafely() { + let request = STDownloadRequest() + let resumeData = Data("resume-point".utf8) + request.didReceiveResumeData(resumeData) + + XCTAssertEqual(request.resumeData, resumeData) + } + + func testDownloadRequestCancelByProducingResumeDataReturnsStoredValueWithoutTask() { + let request = STDownloadRequest() + let expected = Data("existing-resume".utf8) + request.didReceiveResumeData(expected) + + let expectation = expectation(description: "cancel callback receives stored resumeData") + _ = request.cancel(byProducingResumeData: { data in + XCTAssertEqual(data, expected) + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/Example/STBaseProjectExampleTests/STSecurityTests.swift b/Example/STBaseProjectExampleTests/STSecurityTests.swift new file mode 100644 index 0000000..b1e2bf2 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STSecurityTests.swift @@ -0,0 +1,420 @@ +import CryptoKit +import XCTest +import STBaseProject +@testable import STBaseProjectExample + +final class STSecurityTests: XCTestCase { + private var keychainTestKeys: [String] = [] + + override func tearDown() { + for key in keychainTestKeys { + try? STKeychainHelper.st_delete(key) + } + keychainTestKeys.removeAll() + try? STKeychainHelper.st_delete("ssl_pinning_config") + try? STKeychainHelper.st_delete("encryption_config") + try? STKeychainHelper.st_delete("anti_debug_config") + super.tearDown() + } + + private func trackKeychainKey(_ key: String) { + keychainTestKeys.append(key) + } + + // MARK: - STKeychainHelper + + func testKeychainSaveLoadStringRoundTrip() throws { + let key = "st_security_tests_string_\(UUID().uuidString)" + trackKeychainKey(key) + let value = "value-测试-\(UUID().uuidString)" + try STKeychainHelper.st_save(key, value: value) + XCTAssertTrue(STKeychainHelper.st_exists(key)) + let loaded = try STKeychainHelper.st_load(key) + XCTAssertEqual(loaded, value) + try STKeychainHelper.st_delete(key) + XCTAssertFalse(STKeychainHelper.st_exists(key)) + keychainTestKeys.removeAll { $0 == key } + } + + func testKeychainBoolIntDoubleAndData() throws { + let base = UUID().uuidString + let kBool = "st_security_tests_bool_\(base)" + let kInt = "st_security_tests_int_\(base)" + let kDouble = "st_security_tests_double_\(base)" + let kData = "st_security_tests_data_\(base)" + [kBool, kInt, kDouble, kData].forEach(trackKeychainKey) + + try STKeychainHelper.st_saveBool(kBool, value: true) + XCTAssertTrue(try STKeychainHelper.st_loadBool(kBool)) + try STKeychainHelper.st_saveInt(kInt, value: -42) + XCTAssertEqual(try STKeychainHelper.st_loadInt(kInt), -42) + try STKeychainHelper.st_saveDouble(kDouble, value: 3.14159) + XCTAssertEqual(try STKeychainHelper.st_loadDouble(kDouble), 3.14159, accuracy: 1e-9) + + let payload = Data([0x00, 0xFF, 0x0A]) + try STKeychainHelper.st_saveData(kData, data: payload) + XCTAssertEqual(try STKeychainHelper.st_loadData(kData), payload) + } + + func testKeychainSaveBatchGetAllKeysDeleteBatch() throws { + let id = UUID().uuidString + let k1 = "st_security_tests_batch_a_\(id)" + let k2 = "st_security_tests_batch_b_\(id)" + trackKeychainKey(k1) + trackKeychainKey(k2) + + try STKeychainHelper.st_saveBatch([ + k1: "one", + k2: 2, + ]) + let all = try STKeychainHelper.st_getAllKeys() + XCTAssertTrue(all.contains(k1)) + XCTAssertTrue(all.contains(k2)) + XCTAssertEqual(try STKeychainHelper.st_load(k1), "one") + XCTAssertEqual(try STKeychainHelper.st_loadInt(k2), 2) + + try STKeychainHelper.st_deleteBatch([k1, k2]) + XCTAssertFalse(STKeychainHelper.st_exists(k1)) + XCTAssertFalse(STKeychainHelper.st_exists(k2)) + keychainTestKeys.removeAll { $0 == k1 || $0 == k2 } + } + + func testKeychainItemCountReflectsOperations() throws { + let key = "st_security_tests_count_\(UUID().uuidString)" + trackKeychainKey(key) + try STKeychainHelper.st_save(key, value: "x") + let afterAdd = try STKeychainHelper.st_getItemCount() + XCTAssertGreaterThanOrEqual(afterAdd, 1) + try STKeychainHelper.st_delete(key) + keychainTestKeys.removeAll { $0 == key } + } + + func testKeychainBiometricAPIsReturnConsistentTypes() { + _ = STKeychainHelper.st_isBiometricAvailable() + _ = STKeychainHelper.st_getBiometricType() + } + + func testSTKeychainErrorDescriptions() { + let e: STKeychainError = .itemNotFound + XCTAssertFalse((e.errorDescription ?? "").isEmpty) + } + + // MARK: - STEncrypt / STEncryptionUtils + + func testStringHashesMatchExpectedFormats() { + XCTAssertEqual("hello".st_md5(), "5d41402abc4b2a76b9719d911017c592") + XCTAssertEqual("hello".st_sha256().count, 64) + XCTAssertEqual("hello".st_sha1().count, 40) + XCTAssertEqual("hello".st_sha384().count, 96) + XCTAssertEqual("hello".st_sha512().count, 128) + } + + func testDataHashDelegatesToSameHexFormat() { + let d = Data("hello".utf8) + XCTAssertEqual(d.st_hash(algorithm: .md5), "hello".st_md5()) + } + + func testHMACSHA256IsStableForSameInputs() { + let msg = "payload" + let key = "secret" + let a = msg.st_hmacSha256(key: key) + let b = msg.st_hmacSha256(key: key) + XCTAssertEqual(a, b) + XCTAssertEqual(a.count, 64) + } + + func testPBKDF2DerivesExpectedLength() throws { + let derived = try "password".st_pbkdf2(salt: "salt", iterations: 1000, keyLength: 32) + XCTAssertEqual(derived.count, 32) + } + + func testPBKDF2InvalidIterationsThrows() { + XCTAssertThrowsError(try Data("p".utf8).st_pbkdf2(salt: Data("s".utf8), iterations: 0, keyLength: 32)) { err in + XCTAssertTrue(err is STCryptoError) + } + } + + func testSTEncryptionUtilsKeyStrengthAndSecureCompare() { + XCTAssertGreaterThan(STEncryptionUtils.st_validateKeyStrength("abcDEF12!"), 0) + XCTAssertTrue(STEncryptionUtils.st_secureCompare("token", "token")) + XCTAssertFalse(STEncryptionUtils.st_secureCompare("token", "tokem")) + } + + func testRandomStringAndHexHelpers() { + let s = String.st_randomString(length: 16) + XCTAssertEqual(s.count, 16) + let hex = String.st_randomHexString(length: 8) + XCTAssertEqual(hex.count, 8) + } + + func testDataAES256GCMRoundTripWithCryptoKitTag() throws { + let key = STEncryptionUtils.st_generateRandomKey(length: 32) + let plain = Data("round-trip".utf8) + let symmetricKey = SymmetricKey(data: key) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(plain, using: symmetricKey, nonce: nonce) + let opened = try AES.GCM.open(sealed, using: symmetricKey) + XCTAssertEqual(opened, plain) + } + + func testSTCryptoErrorDescription() { + let e: STCryptoError = .invalidKey + XCTAssertFalse((e.errorDescription ?? "").isEmpty) + } + + func testStringAES256GCMRoundTripRequiresTag() throws { + let key = "12345678901234567890123456789012" + let encrypted = try "payload".st_encryptAES256GCM(key: key) + let decrypted = try "payload".st_decryptAES256GCM( + ciphertext: encrypted.ciphertext, + key: key, + nonce: encrypted.nonce, + tag: encrypted.tag + ) + XCTAssertEqual(decrypted, "payload") + } + + // MARK: - STCryptoService + + func testSTCryptoServiceGCMRoundTrip() throws { + let key = "unit-test-secret-key-string" + let plain = "你好 NetworkCrypto" + let enc = try STCryptoService.st_encryptString(plain, keyString: key) + let dec = try STCryptoService.st_decryptToString(enc, keyString: key) + XCTAssertEqual(dec, plain) + } + + func testSTCryptoServiceCBCRoundTrip() throws { + let key = "cbc-secret-key-pad-32bytes!!" + let plain = Data("cbc-bytes".utf8) + let enc = try STCryptoService.st_encryptData(plain, keyString: key, config: .aes256CBC) + let dec = try STCryptoService.st_decryptData(enc, keyString: key, config: .aes256CBC) + XCTAssertEqual(dec, plain) + } + + func testSTCryptoServiceChaCha20RoundTrip() throws { + let key = "chacha20-secret-key-string" + let plain = Data("chacha20-bytes".utf8) + let enc = try STCryptoService.st_encryptData(plain, keyString: key, config: .chaCha20Poly1305) + let dec = try STCryptoService.st_decryptData(enc, keyString: key, config: .chaCha20Poly1305) + XCTAssertEqual(dec, plain) + } + + func testSTCryptoServiceSignAndVerify() { + let data = Data("sign-me".utf8) + let secret = "hmac-secret" + let ts: TimeInterval = 1_700_000_000 + let sig = STCryptoService.st_signData(data, secret: secret, timestamp: ts) + XCTAssertTrue(STCryptoService.st_verifySignature(data, signature: sig, secret: secret, timestamp: ts)) + XCTAssertFalse(STCryptoService.st_verifySignature(data, signature: sig, secret: "wrong", timestamp: ts)) + } + + func testSTCryptoServiceDictionaryRoundTrip() throws { + let key = "dict-crypto-key-unique-12345" + let dict: [String: Any] = ["n": 1, "s": "x", "b": true] + let enc = try STCryptoService.st_encryptDictionary(dict, keyString: key) + let out = try STCryptoService.st_decryptToDictionary(enc, keyString: key) + XCTAssertEqual(out["n"] as? Int, 1) + XCTAssertEqual(out["s"] as? String, "x") + XCTAssertEqual(out["b"] as? Bool, true) + } + + func testSTCryptoServiceEmptyInputThrows() { + XCTAssertThrowsError(try STCryptoService.st_encryptData(Data(), keyString: "k")) + XCTAssertThrowsError(try STCryptoService.st_encryptData(Data("a".utf8), keyString: "")) + } + + func testSTCryptoServiceBatchAndIntegrity() throws { + let key = "batch-key-\(UUID().uuidString)" + let parts = [Data("a".utf8), Data("b".utf8)] + let enc = try STCryptoService.st_encryptBatch(parts, keyString: key) + let dec = try STCryptoService.st_decryptBatch(enc, keyString: key) + XCTAssertEqual(dec, parts) + let one = Data("integrity".utf8) + let e = try STCryptoService.st_encryptData(one, keyString: key) + XCTAssertTrue(STCryptoService.st_verifyDataIntegrity(one, encryptedData: e, keyString: key)) + XCTAssertFalse(STCryptoService.st_verifyDataIntegrity(Data("other".utf8), encryptedData: e, keyString: key)) + } + + func testSTCryptoServiceGenerateKeyAndSharedConfig() { + _ = STCryptoService.st_generateRandomKey() + _ = STCryptoService.st_generateKey(from: "hello", config: .aes256GCM) + + let crypto = STCryptoService.shared + crypto.st_setDefaultConfig(.aes256CBC) + XCTAssertEqual(crypto.st_getDefaultConfig().algorithm, .aes256CBC) + crypto.st_setDefaultConfig(.aes256GCM) + crypto.st_clearKeyCache() + } + + func testSTCryptoServiceEncryptAsync() { + let key = "async-key-\(UUID().uuidString)" + let data = Data("async-payload".utf8) + let exp = expectation(description: "encrypt async") + STCryptoService.st_encryptDataAsync(data, keyString: key) { result in + switch result { + case .success(let enc): + XCTAssertFalse(enc.isEmpty) + case .failure: + XCTFail("expected success") + } + exp.fulfill() + } + wait(for: [exp], timeout: 5) + } + + // MARK: - Security Detection APIs + + func testSecurityDetectionAPIsAreCallable() { + _ = STSecurityConfig.st_detectProxy() + _ = STSecurityConfig.st_detectDebugging() + _ = STDeviceInfo.st_detectJailbreak() + _ = STDeviceInfo.st_detectSimulator() + _ = STDeviceInfo.st_detectNetworkConnection() + _ = STSecurityConfig.st_detectSSLPinning() + _ = STSecurityConfig.st_detectAppIntegrity() + } + + func testDetectSSLPinningTracksSessionConfiguration() { + let disabled = STHTTPSession(sslPinningConfig: STSSLPinningConfig(enabled: false)) + XCTAssertFalse(STSecurityConfig.st_detectSSLPinning(session: disabled)) + + let noCertificates = STHTTPSession(sslPinningConfig: STSSLPinningConfig(enabled: true, certificates: [], publicKeyHashes: [], validateHost: true, allowInvalidCertificates: false)) + XCTAssertFalse(STSecurityConfig.st_detectSSLPinning(session: noCertificates)) + + let pinned = STHTTPSession(sslPinningConfig: STSSLPinningConfig(enabled: true, certificates: [Data([0x01])], publicKeyHashes: [], validateHost: true, allowInvalidCertificates: false)) + XCTAssertTrue(STSecurityConfig.st_detectSSLPinning(session: pinned)) + + let publicKeyPinned = STHTTPSession(sslPinningConfig: STSSLPinningConfig(enabled: true, certificates: [], publicKeyHashes: ["abc"], validateHost: true, allowInvalidCertificates: false)) + XCTAssertTrue(STSecurityConfig.st_detectSSLPinning(session: publicKeyPinned)) + } + + func testSSLPinningConfigPublicKeyHashRejectsInvalidCertificateData() { + XCTAssertThrowsError(try STSSLPinningConfig.st_publicKeyHash(from: Data([0x00, 0x01, 0x02]))) + } + + func testSimulatorFlagMatchesCompileTarget() { + #if targetEnvironment(simulator) + XCTAssertTrue(STDeviceInfo.st_detectSimulator()) + #else + XCTAssertFalse(STDeviceInfo.st_detectSimulator()) + #endif + } + + // MARK: - STSecurityConfig & related types + + func testSecurityConfigSaveAndLoadRoundTrip() throws { + let ssl = STSSLPinningConfig(enabled: false, validateHost: false) + let enc = STEncryptionConfig(enabled: true, algorithm: .aes256GCM, keyRotationInterval: 3600, enableRequestSigning: true, enableResponseSigning: false) + let anti = STAntiDebugConfig(enabled: false, checkInterval: 1, enableAntiDebugging: false, enableAntiHooking: true, enableAntiTampering: false) + + let cfg = STSecurityConfig.shared + try cfg.st_saveSSLPinningConfig(ssl) + try cfg.st_saveEncryptionConfig(enc) + try cfg.st_saveAntiDebugConfig(anti) + + XCTAssertEqual(cfg.st_getSSLPinningConfig().enabled, false) + XCTAssertEqual(cfg.st_getEncryptionConfig().enableResponseSigning, false) + XCTAssertEqual(cfg.st_getAntiDebugConfig()?.enabled, false) + } + + func testSecurityConfigAppliesToSharedNetworkRuntime() throws { + let ssl = STSSLPinningConfig(enabled: true, validateHost: false) + let enc = STEncryptionConfig(enabled: true, algorithm: .aes256CBC, keyRotationInterval: 60, enableRequestSigning: true, enableResponseSigning: true) + + let cfg = STSecurityConfig.shared + try cfg.st_saveSSLPinningConfig(ssl) + try cfg.st_saveEncryptionConfig(enc) + cfg.st_applySecurityConfiguration() + + XCTAssertTrue(STHTTPSession.shared.sslPinningConfig.enabled) + XCTAssertEqual(STCryptoService.shared.st_getDefaultConfig().algorithm, .aes256CBC) + } + + func testPerformSecurityCheckReturnsResult() { + let result = STSecurityConfig.shared.st_performSecurityCheck() + XCTAssertEqual(result.isSecure, result.issues.isEmpty) + } + + func testPerformSecurityCheckIncludesSSLPinningFailureWhenConfigEnabledWithoutPins() throws { + let cfg = STSecurityConfig.shared + try cfg.st_saveSSLPinningConfig(STSSLPinningConfig(enabled: true, certificates: [], publicKeyHashes: [], validateHost: true, allowInvalidCertificates: false)) + cfg.st_applySecurityConfiguration() + + let result = cfg.st_performSecurityCheck() + XCTAssertTrue(result.issues.contains(.sslPinningFailed)) + } + + func testSTSecurityIssueMetadata() { + XCTAssertEqual(STSecurityIssue.proxyDetected.rawValue, "proxy_detected") + XCTAssertFalse(STSecurityIssue.jailbreakDetected.description.isEmpty) + XCTAssertEqual(STSecurityIssue.sslPinningFailed.severity, .critical) + XCTAssertEqual(STSecurityIssue.simulatorDetected.severity, .medium) + } + + func testSTSecurityCheckResultInitSetsTimestamp() { + let r = STSecurityCheckResult(issues: [], isSecure: true) + XCTAssertTrue(r.isSecure) + XCTAssertTrue(r.issues.isEmpty) + XCTAssertLessThanOrEqual(r.timestamp.timeIntervalSinceNow, 1) + } + + func testSTAntiDebugMonitorStopIsSafe() { + let m = STAntiDebugMonitor(config: STAntiDebugConfig()) + m.st_stopMonitoring() + m.st_stopMonitoring() + } + + func testSTAntiDebugMonitorCallbackIsInvoked() { + let config = STAntiDebugConfig( + enabled: true, + checkInterval: 0.05, + enableAntiDebugging: true, + enableAntiHooking: false, + enableAntiTampering: false + ) + let issueExp = expectation(description: "issue callback") + issueExp.assertForOverFulfill = false + + let monitor = STAntiDebugMonitor( + config: config, + securityCheck: { + STSecurityCheckResult(issues: [.debuggingDetected], isSecure: false) + } + ) + monitor.onSecurityIssue = { issue in + XCTAssertEqual(issue, .debuggingDetected) + issueExp.fulfill() + } + monitor.st_startMonitoring() + wait(for: [issueExp], timeout: 2) + monitor.st_stopMonitoring() + } + + func testSecurityConfigCanClearOptionalAntiDebugConfig() throws { + let cfg = STSecurityConfig.shared + try cfg.st_saveAntiDebugConfig(STAntiDebugConfig(enabled: false)) + XCTAssertNotNil(cfg.st_getAntiDebugConfig()) + + try cfg.st_clearAntiDebugConfig() + XCTAssertNil(cfg.st_getAntiDebugConfig()) + } + + func testSTSecuritySeverityRawValues() { + XCTAssertEqual(STSecuritySeverity.critical.rawValue, "critical") + } + + func testSTCryptoAlgorithmRawValues() { + XCTAssertEqual(STCryptoAlgorithm.aes256GCM.rawValue, "AES-256-GCM") + XCTAssertEqual(STCryptoAlgorithm.aes256CBC.rawValue, "AES-256-CBC") + XCTAssertEqual(STCryptoAlgorithm.chaCha20Poly1305.rawValue, "ChaCha20-Poly1305") + } + + func testSTEncryptionConfigCodesRoundTripWithEnumAlgorithm() throws { + let original = STEncryptionConfig(enabled: true, algorithm: .chaCha20Poly1305, keyRotationInterval: 120, enableRequestSigning: false, enableResponseSigning: true) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(STEncryptionConfig.self, from: data) + XCTAssertEqual(decoded.algorithm, .chaCha20Poly1305) + XCTAssertEqual(decoded.keyRotationInterval, 120) + } +} diff --git a/Example/STBaseProjectExampleTests/STToolsCoreTests.swift b/Example/STBaseProjectExampleTests/STToolsCoreTests.swift new file mode 100644 index 0000000..2091087 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STToolsCoreTests.swift @@ -0,0 +1,122 @@ +import XCTest +import STBaseProject +@testable import STBaseProjectExample + +final class STToolsCoreTests: XCTestCase { + + func testDataHexAndBase64RoundTrip() { + let data = Data("hello".utf8) + XCTAssertEqual(data.hexEncodedString(), "68656c6c6f") + XCTAssertEqual(Data.hexDecoded("68656c6c6f"), data) + + let base64URLSafe = data.base64URLSafeEncodedString() + XCTAssertEqual(Data.base64URLSafeDecoded(base64URLSafe), data) + } + + func testDataSlicesAndChunks() { + let data = Data([1, 2, 3, 4, 5]) + XCTAssertEqual(data.slice(from: 1, length: 3), Data([2, 3, 4])) + XCTAssertEqual(data.slice(from: -1, length: 2), Data([1, 2])) + XCTAssertEqual(data.chunks(ofSize: 2), [Data([1, 2]), Data([3, 4]), Data([5])]) + } + + func testDataConstantTimeEquals() { + let lhs = Data([1, 2, 3, 4]) + XCTAssertTrue(lhs.constantTimeEquals(to: Data([1, 2, 3, 4]))) + XCTAssertFalse(lhs.constantTimeEquals(to: Data([1, 2, 3, 5]))) + XCTAssertFalse(lhs.constantTimeEquals(to: Data([1, 2, 3]))) + } + + func testDictionaryTypedAccessAndMerge() { + let dict: [String: Any] = ["name": "st", "count": "12", "flag": "yes", "ratio": 2] + XCTAssertEqual(dict.stringValue(for: "name"), "st") + XCTAssertEqual(dict.intValue(for: "count"), 12) + XCTAssertEqual(dict.boolValue(for: "flag"), true) + XCTAssertEqual(dict.doubleValue(for: "ratio"), 2.0) + + let merged = ["a": 1, "b": 2].mergingValues(with: ["b": 3, "c": 4]) + XCTAssertEqual(merged["a"], 1) + XCTAssertEqual(merged["b"], 3) + XCTAssertEqual(merged["c"], 4) + } + + func testGeometryDistanceAndAngle() { + let distance = STGeometry.distance(between: CGPoint(x: 0, y: 0), and: CGPoint(x: 3, y: 4)) + XCTAssertEqual(distance, 5, accuracy: 0.0001) + + let angle = STGeometry.angle( + onCircleWithRadius: 10, + center: CGPoint(x: 0, y: 0), + start: CGPoint(x: 10, y: 0), + end: CGPoint(x: 0, y: 10) + ) + XCTAssertEqual(angle, 90, accuracy: 0.0001) + + let offsetCenterAngle = STGeometry.angle( + onCircleWithRadius: 10, + center: CGPoint(x: 5, y: 5), + start: CGPoint(x: 15, y: 5), + end: CGPoint(x: 5, y: 15) + ) + XCTAssertEqual(offsetCenterAngle, 90, accuracy: 0.0001) + } + + func testJSONValueConversionAndCodable() throws { + let value = STJSONValue(["k": 1, "ok": true, "arr": ["x", 2]] as [String: Any]) + let object = value.object(or: [:]) + XCTAssertEqual(object["k"]?.intValue, 1) + XCTAssertEqual(object["ok"]?.boolValue, true) + XCTAssertEqual(object["arr"]?.arrayValue?.count, 2) + + let encoded = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(STJSONValue.self, from: encoded) + XCTAssertEqual(decoded.object(or: [:])["k"]?.int(or: 0), 1) + } + + func testStringValidationAndMasking() { + XCTAssertTrue(STStringValidator.isValidEmail("a@b.com")) + XCTAssertTrue(STStringValidator.isValidPhoneNumber("13812345678")) + XCTAssertFalse(STStringValidator.isValidPhoneNumber("23812345678")) + XCTAssertEqual("13812345678".maskedPhoneNumber(start: 3, end: 7), "138****5678") + XCTAssertEqual("hello world".snakeCased, "hello world") + } + + func testDateFormattingAndSmartDate() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let text = date.formatted("yyyy-MM-dd") + XCTAssertFalse(text.isEmpty) + XCTAssertEqual(text.date(using: "yyyy-MM-dd")?.formatted("yyyy-MM-dd"), text) + + XCTAssertNotNil("2024-10-01 12:30:00".smartDate) + XCTAssertNotNil("2024-10-01T12:30:00Z".smartDate) + let interval = try XCTUnwrap("1700000000".timestampDate?.timeIntervalSince1970) + XCTAssertEqual(interval, 1_700_000_000, accuracy: 0.001) + } + + func testFileSystemCreateReadWriteRemove() { + let dir = URL(fileURLWithPath: STFileSystem.temporaryDirectoryPath) + .appendingPathComponent("sttools-tests-\(UUID().uuidString)").path + XCTAssertTrue(STFileSystem.createDirectoryIfNeeded(at: dir)) + + let filePath = STFileSystem.createFileIfNeeded(in: dir, fileName: "a.txt") + XCTAssertTrue(STFileSystem.overwriteFile(at: filePath, with: "line1")) + XCTAssertTrue(STFileSystem.appendLine("line2", toFileAt: filePath)) + XCTAssertEqual(STFileSystem.readString(fromFileAt: filePath), "line1\nline2") + XCTAssertTrue(STFileSystem.removeItem(at: dir)) + } + + func testThreadingHelpers() { + let backgroundExpectation = XCTestExpectation(description: "background run") + STThreading.runInBackground { + backgroundExpectation.fulfill() + } + wait(for: [backgroundExpectation], timeout: 1.0) + + let mainExpectation = XCTestExpectation(description: "main run") + STThreading.runOnMain { + XCTAssertTrue(Thread.isMainThread) + mainExpectation.fulfill() + } + wait(for: [mainExpectation], timeout: 1.0) + } +} diff --git a/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITests.swift b/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITests.swift new file mode 100644 index 0000000..fd79f8d --- /dev/null +++ b/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITests.swift @@ -0,0 +1,43 @@ +// +// STBaseProjectExampleUITests.swift +// STBaseProjectExampleUITests +// +// Created by 寒江孤影 on 2026/4/28. +// + +import XCTest + +final class STBaseProjectExampleUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITestsLaunchTests.swift b/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITestsLaunchTests.swift new file mode 100644 index 0000000..ab425d6 --- /dev/null +++ b/Example/STBaseProjectExampleUITests/STBaseProjectExampleUITestsLaunchTests.swift @@ -0,0 +1,35 @@ +// +// STBaseProjectExampleUITestsLaunchTests.swift +// STBaseProjectExampleUITests +// +// Created by 寒江孤影 on 2026/4/28. +// + +import XCTest + +final class STBaseProjectExampleUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/README.md b/README.md index ac278ea..9aacfa4 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ STBaseProject 已随 SPM target 与 CocoaPods subspec 提供 `PrivacyInfo.xcpriv - UIKit 组件:`STUIKit` - 通用工具:`STTools` +**本地 Demo**:克隆后在仓库根目录执行 `cd Example && pod install`,再用 Xcode 打开根目录的 `STBaseProject.xcworkspace`(内含 `Example/STBaseProjectExample.xcodeproj` 与 CocoaPods 生成的 `Pods`)。Demo 通过本地 SPM 引用同仓根目录的 `Package.swift`,与发布到 GitHub 的集成方式一致。 + ## 🎯 主要功能 diff --git a/STBaseProject.xcworkspace/contents.xcworkspacedata b/STBaseProject.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..cb905fa --- /dev/null +++ b/STBaseProject.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved b/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..99b74a3 --- /dev/null +++ b/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,31 @@ +{ + "originHash" : "6bc9e378bfd20724fd6785c7802d9b7dff68219be6e5d67467922d8fada76f0f", + "pins" : [ + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "branch" : "gfm", + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "55d66d9a9e8d4fd3f48d111b0d437e82fe451903" + } + }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mgriebling/SwiftMath.git", + "state" : { + "revision" : "48ff188ba118c37d024551238041113560ab09b9" + } + } + ], + "version" : 3 +} From 11372779160a93eb365e620e94555e69f5472433 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 10:44:28 +0800 Subject: [PATCH 03/27] Enhance STMarkdown functionality with new tests and configuration options - Added tests for `STMarkdownMalformedTableNormalizer` to ensure proper handling of malformed tables. - Introduced `autoFixMalformedTables` configuration option in `STMarkdownPipelineConfiguration` to enable automatic correction of common table issues. - Updated `STMarkdownStyle` to include `linkUnderlineEnabled` and padding options for lists, improving styling flexibility. - Implemented smart streaming capabilities in `STMarkdownStreamingTextView` for more efficient Markdown rendering during live updates. --- ...Markdown-MarkdownDisplayView-Comparison.md | 197 ++++++++++ .../STMarkdownBaseTextViewLayoutTests.swift | 37 ++ .../STMarkdownPipelineTests.swift | 90 +++++ .../STMarkdownStreamBufferTests.swift | 114 ++++++ .../STMarkdown/Core/STMarkdownPipeline.swift | 14 +- .../Core/STMarkdownStreamBuffer.swift | 369 ++++++++++++++++++ Sources/STMarkdown/Core/STMarkdownStyle.swift | 18 +- .../STMarkdownMalformedTableNormalizer.swift | 152 ++++++++ .../STMarkdownAttributedStringRenderer.swift | 43 +- .../UI/STMarkdownBaseTextView.swift | 102 ++++- .../UI/STMarkdownStreamingTextView.swift | 114 +++++- 11 files changed, 1227 insertions(+), 23 deletions(-) create mode 100644 Docs/STMarkdown-MarkdownDisplayView-Comparison.md create mode 100644 Example/STBaseProjectExampleTests/STMarkdownBaseTextViewLayoutTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift create mode 100644 Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md new file mode 100644 index 0000000..5c45c6f --- /dev/null +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -0,0 +1,197 @@ +# STMarkdown 与 MarkdownDisplayView(Vendor)对比说明 + +> 对照基准(Vendor 源码路径): +> `/Users/song/Downloads/MarkdownDisplayView/MarkdownDisplayView/Sources/MarkdownDisplayView` +> 对照对象(本仓库): +> `Sources/STMarkdown/` +> 文档生成日期:2026-05-14 + +本文汇总架构、文件映射、能力差异及在 Cursor 中的对比方式,便于后续按模块对齐或迁移。 + +--- + +## 1. 仓库与路径 + +### 1.1 Vendor 仓库布局 + +- **仓库根**:例如 `MarkdownDisplayView/`(含顶层 `Package.swift`、`Example/` 等)。 +- **SPM 库实现**:`MarkdownDisplayView/Sources/MarkdownDisplayView/` +- **资源**:`MarkdownDisplayView/Sources/MarkdownDisplayView/Resources/`(KaTeX 字体等,约 20 项) + +### 1.2 STBaseProject 布局 + +- **模块根**:`Sources/STMarkdown/` +- **子目录**:`Core/`、`Parsing/`、`Rendering/`、`Table/`、`UI/`、`Attachments/`、`Resources/` + +--- + +## 2. 形态对比 + +| 维度 | MarkdownDisplayView(Vendor) | STMarkdown | +|------|--------------------------------|------------| +| Swift 文件量 | 约 **21** 个 + `Resources/` | **45** 个 + `Resources/` | +| 代码组织 | **少量超大文件**(如 `MarkdownDisplayView.swift`、`MarkdownParser.swift`) | **分层**、职责拆分 | +| 最低 iOS(以各自 Package/podspec 为准) | iOS **15+**(`@available(iOS 15.0, *)` 等) | iOS **16+**(工程配置) | +| 解析依赖 | **swift-markdown**(`import Markdown`) | **swift-markdown**(SPM `Markdown`) | +| CocoaPods 差异(若用 Pod) | 可能经 **AppleSwiftMDWrapper** 等桥接 | **swift-markdown-pod** + CAtomic modulemap | +| 数学公式 | **KaTeX**(字体 + `LaTeXAttachment` / `LatexMathView` 等) | **SwiftMath** + `STMarkdownMathNormalizer` | +| **SPM 产品名** | `MarkdownDisplayView` | 合在 **`STBaseProject`** 的 `STMarkdown` 源码目录(非独立 SwiftPM 产品名) | +| **CocoaPods 产品名** | **`MarkdownDisplayKit`**(与 SPM 名不同) | **`STBaseProject/STMarkdown`** subspec | +| **swift-markdown 版本** | SPM:`from: "0.7.3"`(随解析器升级行为可能变) | 本仓库 `Package.swift` **固定 revision**;与 vendor 的 cmark/扩展差异需升级时单独回归 | + +--- + +## 2.1 对外 API 入口(便于宿主对照) + +| 场景 | Vendor(典型) | STMarkdown(典型) | +|------|----------------|-------------------| +| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 `MarkdownViewTextKit`) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | +| 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | +| 配置对象 | `MarkdownConfiguration`(大结构体,含 `MarkdownLineSpacingConfiguration`、`SyntaxHighlightColors` 等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | +| 流式触感 | `StreamingHapticFeedbackStyle` 等 | **无**同名 API;需宿主自行 `UIImpactFeedbackGenerator` | +| Mermaid / 自定义代码块 | 协议 **`MarkdownCodeBlockRenderer`**(示例工程 `MermaidRenderer`) | **`STMarkdownCodeBlockRendering`**、`STMarkdownMermaidRenderer` 等 | + +--- + +## 3. 文件级映射(Vendor → STMarkdown) + +| Vendor 文件 | 角色 | STMarkdown 对应 / 说明 | +|-------------|------|-------------------------| +| `MarkdownParser.swift` | swift-markdown 遍历、`IncrementalParseResult`、`parseLock`、产出 `MarkdownRenderElement`、TOC、图片附件等 | `STMarkdownStructureParser`、`STMarkdownMathNormalizer`、`STMarkdownPipeline`、`STMarkdownRenderAdapter`、`STMarkdownInputSanitizer`、`STMarkdownMalformedTableNormalizer`。ST 以 **整段管线 `process(_:)`** 为主,**无** vendor 同款「增量 `safePosition` / `replaceCount` / 元素级回溯」公开形态 | +| `MarkdownRenderElement.swift` | 渲染树枚举、`MarkdownConfiguration`、`MarkdownTOCItem`、`MarkdownTypewriterTextMode` 等 | `STMarkdownAST` / `STMarkdownRenderAST`、`STMarkdownStyle`。已核对:ST **有** heading/list/table/math/image 等核心块;**无** vendor 同级的 `details`、`rawHTML`、`footnote`、`TOC item` 块模型 | +| `MarkdownRender.swift` | 元素 → 属性串 / 展示逻辑 | `STMarkdownAttributedStringRenderer` + `Rendering/Default/*`、`Rendering/Advanced/*` | +| `MarkdownDisplayView.swift` | 总装、与 TextKit 视图协作(体量很大) | `STMarkdownBaseTextView`、`STMarkdownTextView`、`STMarkdownStreamingTextView` 等拆分 | +| `MarkdownTextViewTK2.swift` | **TextKit 2**:`NSTextContentStorage`、`NSTextLayoutManager`、附件 Provider、`typewriterTextMode` 等 | `UITextView` / `STShimmerTextView`,**`usingTextLayoutManager: false`**(经典 TextKit 路径) | +| `TypewriterEngine.swift` | 对子视图树(`MarkdownTextViewTK2` / `UILabel` / `UIStackView`)队列动画、`onLayoutChange` | `STShimmerTextView` + `STMarkdownStreamingTextView` 动画/增量更新;**无** vendor 同款整棵 block UI 队列 + watchdog | +| `MarkdownStreamBuffer.swift` | `Int` 型 `lastSafePosition`、`containerWidth`、可选 `onModuleReady`(带预解析元素)、调试日志 | `STMarkdownStreamBuffer`:字符偏移持久化、`streamMinModuleLength`、**纯字符串**模块切分;**无** `containerWidth` / **无**模块内解析回调 | +| `ScrollableMarkdownViewTextKit.swift` | `UIScrollView` 包装、`markdown`/`configuration`、`onTOCItemTap`、`tableOfContents`、`generateTOCView` 等 | **无**同名一体化控件;滚动与 TOC 由宿主或 `STMarkdownSwiftUIView` 等组合实现 | +| `MarkdownTableSupport.swift` | 表格与 TextKit2 / 附件协作 | `Table/STMarkdownTable*.swift`、CollectionView 表格附件;与 CHANGELOG 中 **UILabel 表格 cell + `onLinkTap`** 链路不同 | +| `CodeBlockAttachment.swift` | 代码块附件 | `STMarkdownCodeBlockAttachmentRenderer`、`STMarkdownDefaultCodeBlockRenderer` 等 | +| `LaTeXAttachment.swift`、`LatexMathView.swift`、`LateXParser.swift`、`LateXNodeSets.swift` | KaTeX 渲染链 | `STMarkdownDefaultMathRenderer` + SwiftMath + `STMarkdownMathNormalizer` | +| `FontLoader.swift` | KaTeX 字体注册 | ST 使用 SwiftMath / Bundle 资源,无同一套 `FontLoader` | +| `ImageCacheManager.swift`、`ImageLoader.swift`、`ImageView.swift` | 图片缓存与展示 | `STMarkdownAsyncImageRenderer`、`STMarkdownDefaultImageRenderer` 等 | +| `MarkdownCustomExtension.swift` | 自定义扩展元素 | `STMarkdownAdvancedRenderers`、各类 `*Rendering` 协议 | +| `ArraySafe.swift` | 安全下标等工具 | ST 内散见于各文件,无同名单文件 | + +--- + +## 4. 能力差异摘要 + +1. **渲染引擎**:Vendor 为 **TextKit 2**;ST 主路径为 **`UITextView` + TextKit 1**(`usingTextLayoutManager: false`)。 +2. **解析与并发**:Vendor **`MarkdownParser` 内 `parseLock` 串行化 swift-markdown**,并在视图层配合 `renderQueue`/版本锁做增量渲染保护;ST `STMarkdownEngine` / `STMarkdownPipeline` 已按 `Sendable` 设计,但**没有** vendor 同款 parser 级串行锁与增量回溯保护,是否需要补锁应以并发压测结论为准。 +3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带 `MarkdownRenderElement`**,并与 **Typewriter 视图树** 配合;ST 为 **字符串级 `STMarkdownStreamBuffer`** + **富文本侧 Shimmer/增量 `setMarkdown`**。 +4. **目录 TOC**:Vendor **内置 `MarkdownTOCItem`、生成目录视图、跳转 API**;ST **无对等的一体式 TOC 公共面**(需业务自建或后续扩展)。 +5. **块级模型**:Vendor `MarkdownRenderElement` 含 **`details`、`rawHTML`**,并把 **heading/TOC/footnote** 等信息留在统一块级模型附近;ST 当前 `STMarkdownBlockNode` / `STMarkdownRenderBlock` **未定义** `details`、`rawHTML`、`footnote`、`TOC` 对等节点。 +6. **公式**:Vendor **KaTeX**;ST **SwiftMath**,命令集与排版不必一致。 +7. **表格**:Vendor 与 TextKit2 附件、手势、(文档所述)**表格内链接走 cell 选择 + `onLinkTap`** 等;ST 为 **独立表格 Collection + overlay**,交互模型不同。 +8. **脚注 / 角标**:Vendor 有 **独立脚注模型 + 延迟渲染脚注视图**;ST 侧当前更偏向 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`、表格内 citation 流程),**不能等价视为 footnote 支持**。 + +## 4.3 已从源码核对的结论 + +以下条目是本次直接对照源码后确认的结果,可视为比前文更高置信度的“实现级”结论: + +| 维度 | Vendor 结论 | ST 结论 | 判断 | +|------|-------------|---------|------| +| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | `STMarkdownStreamBuffer` 只负责**字符串模块切分**,真正渲染仍走整段 `engine.process(...)` | ST **弱于** vendor | +| 流式模块回调 | `MarkdownStreamBuffer.onModuleReady` 可回传预解析 `MarkdownRenderElement` | `STMarkdownStreamBuffer` 无模块内预解析回调 | ST **弱于** vendor | +| 块级能力 | `MarkdownRenderElement` 含 `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` | `STMarkdownBlockNode` / `STMarkdownRenderBlock` 仅含 paragraph/heading/quote/list/code/table/math/image/thematicBreak | ST **缺少** `details` / `rawHTML` / `footnote` | +| TOC | 视图层公开 `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | 未检出对等公共 API;heading 仅作为普通 block 渲染 | ST **缺少一体化 TOC 面** | +| 脚注 | 预处理 footnote,缓存并延迟渲染 footnote view | 未检出 footnote 模型/渲染链;存在 citation badge 流程 | ST **缺少 footnote** | +| TextKit 栈 | 核心视图基于 `NSTextLayoutManager` / `NSTextContentStorage` / TK2 attachment provider | `UITextView(usingTextLayoutManager: false)` 明确走 TextKit 1 路线 | 路线不同 | +| HTML | Vendor 存在 `rawHTML(String)` 元素与对应渲染分支 | ST `STHtmlNormalizeRule` 注释明确写明 downstream **no handling for raw HTML** | ST **明确不支持 raw HTML** | +| 交互能力 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | +| 表格交互 | 表格与 TK2 attachment 深度耦合 | 表格为独立 View/Attachment + overlay/citation 区域 | 路线不同 | + +--- + +## 4.1 仓库根目录但未纳入上表的路径(Vendor) + +以下在 **clone 根** 常见,与 `Sources/MarkdownDisplayView` 并列,对比「库能力」时可按需打开: + +| 路径 | 说明 | +|------|------| +| `Example/`、`CocoapodsMDExample/` | 示例 App:调用方式、`startStreaming`、Mermaid 接入等 **集成参考** | +| `Effects/`、`Support/` | 动效/辅助资源等(**非** `Sources` 内核心 Swift 模块;具体以仓库为准) | +| `CHANGELOG.md`、`README_zh.md` | 版本行为、配置项、已知修复的 **文字级** 对照来源 | + +--- + +## 4.2 测试与可观测性 + +| 项目 | Vendor | STMarkdown | +|------|--------|------------| +| 单测位置 | `MarkdownDisplayView/Tests/MarkdownDisplayViewTests/`(Swift `Testing` 等) | `Example/STBaseProjectExampleTests/` 下 `STMarkdown*`、`STMarkdownStreamBufferTests` 等 | +| 调试输出 | `MarkdownStreamBuffer` 等路径存在 **`print`** 日志 | ST 侧一般 **无** 同等控制台噪声;排障依赖宿主或自行埋点 | + +--- + +## 5. 已在 ST 侧做过的对齐方向(会话内实现,供对照) + +以下属于 STMarkdown 演进中与「常见流式 Markdown 组件」接近的行为,**不等同**于 vendor 逐行一致: + +- `STMarkdownStreamBuffer`:围栏闭合处切分、段落模式 EOF 尾段、**字符偏移**持久化 `lastSafeUpperBoundOffset`(避免 `String.Index` 跨 `+=` 失效)。 +- `STMarkdownBaseTextView`:`resolvedMarkdownMeasurementWidth()`、高度回退、`contentLayoutHeightNotificationMinInterval` 等。 +- `STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`:与 vendor 文档中的「坏表修复」类似语义。 + +单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例。 + +--- + +## 6. 在 Cursor 中如何对比阅读 + +1. **将 Vendor 目录加入工作区**:`File → Add Folder to Workspace…` → 选择 + `MarkdownDisplayView/MarkdownDisplayView/Sources/MarkdownDisplayView`。 +2. **分栏**:左侧 `Sources/STMarkdown`,右侧 Vendor `Sources/MarkdownDisplayView`。 +3. **按上表成对打开**:例如 `STMarkdownStreamBuffer.swift` ↔ `MarkdownStreamBuffer.swift`;`STMarkdownStructureParser.swift` ↔ `MarkdownParser.swift`。 +4. **跨仓库搜索**:在两侧分别搜索 `TOC`、`details`、`parseLock`、`NSTextLayoutManager`、`Typewriter`、`onModuleReady`。 + +--- + +## 7. 当前更值得做的优化 + +下面不是“和 vendor 做到一模一样”的愿望清单,而是按 **收益 / 风险 / 落地成本** 排过序的优化方向。 + +| 优先级 | 方向 | 说明 | +|--------|------|------| +| P0 | **流式增量渲染链补强** | 当前 ST 已有 `STMarkdownStreamBuffer`,但渲染仍偏“整段重跑”。最优先补的是 **增量 parse / replaceCount / 安全回溯窗口**,否则长文本流式时 CPU、重排和闪动都不占优。 | +| P0 | **流式专项测试补齐** | 继续补围栏、表格、公式、标题切换、列表/引用未闭合、Unicode chunk 边界、长文多轮 append 的单测。这个成本低,但能直接兜住后续重构。 | +| P1 | **目录 TOC 抽取能力** | ST 已有 heading block,但缺少 heading id、TOC 数据结构、滚动定位 API。若业务里有“长文导航/知识库/AI 报告”场景,这一项收益很高。 | +| P1 | **脚注与引用语义拆分** | 当前 citation badge 更像业务增强,不等于 CommonMark footnote。若要对齐通用 Markdown 能力,应补 `footnote definition/reference` 语义模型,而不是继续堆 UI 角标。 | +| P1 | **并发压测与线程模型定稿** | 不是先机械照搬 `parseLock`,而是先验证 `STMarkdownEngine` 在并发 `process(_:)`、流式 append、异步 attachment 刷新下是否有竞态/崩溃/性能退化,再决定是否引入 parser 级锁或 actor。 | +| P2 | **块级 AST 能力补齐** | 如果产品确实需要折叠块与 HTML 片段,再考虑补 `details` / `rawHTML`。这类能力应先落 AST 和 render block,再落 UI;否则后面会继续把语义写死在 renderer。 | +| P2 | **统一公共组件面** | Vendor 的 `ScrollableMarkdownViewTextKit` 给了宿主一个“整页预览”入口。ST 现在偏散件组合,建议评估是否提供官方容器组件,统一滚动、高度通知、目录、链接、citation、流式入口。 | +| P3 | **TextKit 2 迁移评估** | 这不是当前第一优先级。只有在明确遇到 TextKit 1 的附件布局、选区、超长文档性能或复杂交互瓶颈时,才值得单独立项评估。 | + +### 7.1 我对“当前先做什么”的建议 + +如果只选三件最该做的事,我建议顺序如下: + +1. **先做流式增量渲染链,而不是先迁 TextKit 2。** + 现在真正的能力差距主要在“流式解析与局部更新”,不是渲染后端名字。 +2. **把 TOC/footnote 这类“内容语义能力”单独建模。** + 这些能力一旦继续塞进 renderer 或业务层,后面只会更难补。 +3. **用压测结论决定并发保护形态。** + 若压测未暴露问题,不必急着引入全局锁;若暴露 parser 级竞态,再补最小串行化保护。 + +--- + +## 8. 参考链接 + +- Vendor 仓库: +- Vendor `Package.swift` 中 target path:`MarkdownDisplayView/Sources/MarkdownDisplayView` + +--- + +## 9. 仍可深入补充的维度(未在文中逐条展开) + +若要做「实现级」迁移清单,建议后续按需补小节或链接到具体行号: + +- **`MarkdownConfiguration` 全字段** 与 **`STMarkdownStyle` + Pipeline** 的逐项字段映射表(体量最大)。 +- **`MarkdownViewTextKit` / `MarkdownDisplayView.swift`** 内生命周期、高度通知(vendor `notifyHeightChange` 命名)与 **`STMarkdownBaseTextView.publishContentLayoutHeightNotificationIfNeeded`** 的逐项对照。 +- **增量解析**:`IncrementalParseResult.replaceCount` 回溯策略与 ST **全量重渲染** 的等价性与性能差异。 +- **无障碍**:Vendor TK2 栈与 ST `UITextView` 的 **accessibility** 差异。 +- **许可证**:若从 vendor 复制 **KaTeX 字体文件**,需单独核对字体与 KaTeX 的许可条款;ST 当前以 **SwiftMath** 为主。 + +--- + +*本文描述基于当时仓库快照与目录结构;Vendor 后续版本若变更路径或 API,请以仓库为准更新本节。* diff --git a/Example/STBaseProjectExampleTests/STMarkdownBaseTextViewLayoutTests.swift b/Example/STBaseProjectExampleTests/STMarkdownBaseTextViewLayoutTests.swift new file mode 100644 index 0000000..284a832 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownBaseTextViewLayoutTests.swift @@ -0,0 +1,37 @@ +import XCTest +import UIKit +@testable import STBaseProject + +/// 验证 ``STMarkdownBaseTextView`` 在 Cell 未布局完成时的测量宽度回退与高度回调节流(对齐流式 Cell 稳定性加强)。 +@MainActor +final class STMarkdownBaseTextViewLayoutTests: XCTestCase { + + func testResolvedMeasurementWidthFallsBackToTextViewFrameWhenBoundsZero() { + let view = STMarkdownTextView(frame: CGRect(x: 0, y: 0, width: 0, height: 0)) + view.preferredContentWidth = 0 + view.bounds = .zero + view.textView.frame = CGRect(x: 0, y: 0, width: 320, height: 44) + XCTAssertEqual(view.resolvedMarkdownMeasurementWidth(), 320, accuracy: 0.01) + } + + func testContentLayoutHeightNotificationRespectsMinIntervalUnlessForced() { + let view = STMarkdownTextView(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + view.contentLayoutHeightNotificationThreshold = 0 + view.contentLayoutHeightNotificationMinInterval = 10 + view.setMarkdown("# Hello\n\nSome body text for height.") + + var invocations = 0 + view.onContentLayoutHeightChange = { _ in + invocations += 1 + } + + view.publishContentLayoutHeightNotificationIfNeeded(force: false) + XCTAssertEqual(invocations, 1) + + view.publishContentLayoutHeightNotificationIfNeeded(force: false) + XCTAssertEqual(invocations, 1, "节流期内不应重复触发") + + view.publishContentLayoutHeightNotificationIfNeeded(force: true) + XCTAssertEqual(invocations, 2, "force 应绕过时间节流") + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift index 311d1cb..fbe0dc6 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift @@ -2547,4 +2547,94 @@ final class STMarkdownPipelineTests: XCTestCase { "高度差应接近真实行间距增量,不能按错误估算行数过度放大" ) } + + func testMalformedTableNormalizerRemovesStandalonePipeRow() { + let raw = """ +| a | b | +| --- | --- | +| 1 | 2 | +|| +| 3 | 4 | +""" + let fixed = STMarkdownMalformedTableNormalizer.normalize(raw) + let trimmedLines = fixed.split(separator: "\n").map { + $0.trimmingCharacters(in: .whitespaces) + } + XCTAssertFalse(trimmedLines.contains("||")) + } + + func testMalformedTableNormalizerRemovesSpuriousBlankInsideTable() { + let raw = """ +| a | b | +| --- | --- | +| 1 | 2 | + +| 3 | 4 | +""" + let fixed = STMarkdownMalformedTableNormalizer.normalize(raw) + XCTAssertFalse(fixed.contains("| 1 | 2 |\n\n| 3 |")) + } + + func testPlainTextStreamingHeuristic() { + XCTAssertTrue(STMarkdownStreamingTextView.isLikelyMarkdownFreePlainText("Hello world")) + XCTAssertFalse(STMarkdownStreamingTextView.isLikelyMarkdownFreePlainText("# Title")) + } + + func testStreamBufferDefersUnclosedFence() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + let r1 = buffer.append("```swift\nlet x = 1") + XCTAssertTrue(r1.completeModules.isEmpty) + XCTAssertTrue(r1.hasPendingStructure) + XCTAssertEqual(r1.pendingType, .codeBlock) + XCTAssertTrue(buffer.committedSafePrefix.isEmpty) + } + + func testStreamBufferFlushReleasesTail() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + _ = buffer.append("```\npartial") + XCTAssertTrue(buffer.committedSafePrefix.isEmpty) + let tail = buffer.flush() + XCTAssertFalse(tail.isEmpty) + XCTAssertEqual(buffer.committedSafePrefix, buffer.fullAccumulatedText) + } + + func testStreamBufferSplitsOnSecondH1() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 2) + _ = buffer.append("# A\n\nAlpha paragraph with enough characters.\n\n") + // 前导换行使「未提交起点」严格早于第二个 H1 行首,避免边界与起点重合导致本帧无模块输出。 + let r2 = buffer.append("\n# B\n\nBeta.\n\n") + XCTAssertFalse(r2.completeModules.isEmpty) + XCTAssertTrue(buffer.committedSafePrefix.contains("# A")) + XCTAssertFalse(buffer.committedSafePrefix.contains("# B")) + } + + func testMarkdownStyleStreamMinModuleLengthDefault() { + XCTAssertGreaterThanOrEqual(STMarkdownStyle.default.streamMinModuleLength, 1) + } + + func testLinkUnderlineStyleReflectsStyleFlag() { + var style = STMarkdownStyle.default + style.linkUnderlineEnabled = false + let renderer = STMarkdownAttributedStringRenderer(style: style) + let doc = STMarkdownRenderDocument(blocks: [ + .paragraph([ + .link(destination: "https://example.com", children: [.text("Click")]), + ]), + ]) + let rendered = renderer.render(document: doc) + var foundLink = false + rendered.enumerateAttribute(.link, in: NSRange(location: 0, length: rendered.length)) { value, _, _ in + if value != nil { + foundLink = true + } + } + XCTAssertTrue(foundLink) + let underline = rendered.attribute( + .underlineStyle, + at: 0, + longestEffectiveRange: nil, + in: NSRange(location: 0, length: rendered.length) + ) as? Int + XCTAssertEqual(underline, 0) + } } diff --git a/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift new file mode 100644 index 0000000..4ad6c5c --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import STBaseProject + +final class STMarkdownStreamBufferTests: XCTestCase { + + func testSingleHeadingStreamsParagraphByParagraph() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 10) + let markdown = """ + # Title + + Paragraph one is long enough to stream. + + Paragraph two is also long enough. + + """ + let result = buffer.append(markdown) + XCTAssertEqual(result.completeModules.count, 2) + XCTAssertEqual( + result.completeModules[0].trimmingCharacters(in: .whitespacesAndNewlines), + "# Title\n\nParagraph one is long enough to stream." + ) + XCTAssertEqual( + result.completeModules[1].trimmingCharacters(in: .whitespacesAndNewlines), + "Paragraph two is also long enough." + ) + XCTAssertTrue(result.pendingText.isEmpty) + XCTAssertFalse(result.hasPendingStructure) + } + + func testDoubleNewlinesInsideCodeBlocksDoNotCreateParagraphBoundaries() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 10) + let markdown = """ + # Title + + ```swift + let first = 1 + + let second = 2 + ``` + + Closing paragraph is outside the code block. + + """ + let result = buffer.append(markdown) + XCTAssertEqual(result.completeModules.count, 2) + XCTAssertTrue(result.completeModules[0].contains("let second = 2")) + XCTAssertTrue(result.completeModules[0].contains("```swift")) + XCTAssertEqual( + result.completeModules[1].trimmingCharacters(in: .whitespacesAndNewlines), + "Closing paragraph is outside the code block." + ) + XCTAssertTrue(result.pendingText.isEmpty) + } + + func testUnclosedFenceDefersCommitUntilClosingTripleBacktick() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + _ = buffer.append("Intro\n\n```swift\nlet a = 1\n") + XCTAssertTrue(buffer.committedSafePrefix.isEmpty) + XCTAssertEqual(buffer.fullAccumulatedText, "Intro\n\n```swift\nlet a = 1\n") + + let closed = buffer.append("```\n\nDone.\n") + XCTAssertFalse(closed.hasPendingStructure) + XCTAssertFalse(buffer.committedSafePrefix.isEmpty) + XCTAssertTrue(buffer.committedSafePrefix.contains("let a = 1")) + XCTAssertTrue(buffer.committedSafePrefix.contains("Done.")) + } + + func testOddDollarMathFenceReportsLatexPending() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + let r = buffer.append("Text\n\n$$ E = mc^2 ") + XCTAssertTrue(r.hasPendingStructure) + XCTAssertEqual(r.pendingType, .latexBlock) + XCTAssertTrue(r.completeModules.isEmpty) + } + + func testTableRowWithoutTrailingBlankLineDefersAsTablePending() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + let r = buffer.append("| h1 | h2 |\n| -- | -- |\n| a | b |") + XCTAssertTrue(r.hasPendingStructure) + XCTAssertEqual(r.pendingType, .table) + } + + func testChunkSplitAcrossWordsStillCommitsParagraphs() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 8) + _ = buffer.append("Paragraph one is long") + _ = buffer.append(" enough to meet the minimum.\n\n") + _ = buffer.append("Paragraph two is also long enough.\n\n") + XCTAssertFalse(buffer.committedSafePrefix.isEmpty) + XCTAssertTrue(buffer.committedSafePrefix.contains("Paragraph one")) + XCTAssertTrue(buffer.committedSafePrefix.contains("Paragraph two")) + } + + func testMultiAppendWithoutNewSafePrefixKeepsCommittedStable() { + // 使用 `##` 而非单行 `# `:单 H1 且以 `\n\n` 结尾时会触发 `shouldDeferCommitAwaitingPossibleSecondTopLevelHeading`, + // 后续追加若去掉该后缀,defer 解除并可能把 `lastSafe` 顶到文末,导致 committed 突然变长,与本用例「仅增长尾部」假设冲突。 + let buffer = STMarkdownStreamBuffer(minModuleLength: 10) + let first = buffer.append("## Section\n\nFirst block is long enough.\n\n") + XCTAssertFalse(first.completeModules.isEmpty) + let committedAfterFirst = buffer.committedSafePrefix + _ = buffer.append("still ") + _ = buffer.append("typing ") + _ = buffer.append("pending tail without new paragraph break") + XCTAssertEqual(buffer.committedSafePrefix, committedAfterFirst) + } + + func testFlushExposesFullTextAsCommitted() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 100) + _ = buffer.append("Short") + XCTAssertTrue(buffer.committedSafePrefix.isEmpty) + let tail = buffer.flush() + XCTAssertEqual(tail, "Short") + XCTAssertEqual(buffer.committedSafePrefix, "Short") + } +} diff --git a/Sources/STMarkdown/Core/STMarkdownPipeline.swift b/Sources/STMarkdown/Core/STMarkdownPipeline.swift index 75468ac..8e15d20 100644 --- a/Sources/STMarkdown/Core/STMarkdownPipeline.swift +++ b/Sources/STMarkdown/Core/STMarkdownPipeline.swift @@ -12,17 +12,21 @@ public struct STMarkdownPipelineConfiguration: Sendable { public var sanitizerRules: [any STMarkdownRule] public var debug: Bool public var semanticNormalizers: [any STMarkdownSemanticNormalizing] + /// 解析前修正常见断裂表格(孤立 `|` 行、表内误插空行等),对 LLM 流式输出更友好。 + public var autoFixMalformedTables: Bool public init( enableInputSanitizer: Bool = true, sanitizerRules: [any STMarkdownRule] = STMarkdownInputSanitizer.defaultRules, debug: Bool = false, - semanticNormalizers: [any STMarkdownSemanticNormalizing] = [] + semanticNormalizers: [any STMarkdownSemanticNormalizing] = [], + autoFixMalformedTables: Bool = true ) { self.enableInputSanitizer = enableInputSanitizer self.sanitizerRules = sanitizerRules self.debug = debug self.semanticNormalizers = semanticNormalizers + self.autoFixMalformedTables = autoFixMalformedTables } } @@ -51,6 +55,8 @@ public struct STMarkdownPipelineResult: Sendable { } } +/// 原始 Markdown 处理顺序:可选输入规整 →(可选)断裂表格预修复 → `swift-markdown` 解析 +/// → 语义归一 → 渲染 AST;公式在 ``STMarkdownStructureParser`` 内先于解析做块级抽取。 public final class STMarkdownPipeline: Sendable { public let configuration: STMarkdownPipelineConfiguration public let parser: any STMarkdownStructureParsing @@ -89,7 +95,11 @@ public final class STMarkdownPipeline: Sendable { ) } - let sourceDocument = self.parser.parse(sanitizationResult.sanitizedText) + let parserInput = STMarkdownMalformedTableNormalizer.normalize( + sanitizationResult.sanitizedText, + enabled: self.configuration.autoFixMalformedTables + ) + let sourceDocument = self.parser.parse(parserInput) let normalizedDocument = self.semanticNormalizer.normalize(sourceDocument) let renderDocument = self.renderAdapter.adapt(normalizedDocument) return STMarkdownPipelineResult( diff --git a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift new file mode 100644 index 0000000..bf7180d --- /dev/null +++ b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift @@ -0,0 +1,369 @@ +// +// STMarkdownStreamBuffer.swift +// STBaseProject +// +// 智能流式缓冲:按「安全模块」边界切分累积文本,避免未闭合围栏/公式/表格 +// 在流式中途被解析成错误排版。设计思路对齐常见 Markdown 流式组件的模块检测策略。 +// + +import Foundation + +// MARK: - Stream buffer + +/// 流式场景下用于累积 chunk、检测可独立渲染的 Markdown 模块的缓冲器。 +/// +/// - Note: 使用 Swift 字符串的 `Index` 与 `offsetBy(_:limitedBy:)` 做边界截取,避免流式 Unicode 截断问题。 +public final class STMarkdownStreamBuffer { + + public struct ModuleDetectionResult: Sendable { + public let completeModules: [String] + public let pendingText: String + public let hasPendingStructure: Bool + public let pendingType: PendingStructureType? + } + + public enum PendingStructureType: String, Sendable { + case codeBlock + case latexBlock + case table + } + + private(set) public var accumulatedText: String = "" + + /// 已确认可安全参与「模块级」渲染的前缀在 ``accumulatedText`` 中的上界,用**字符偏移**表示, + /// 避免 `accumulatedText += chunk` 后旧的 `String.Index` 跨变异失效。 + private var lastSafeUpperBoundOffset: Int + + private var minModuleLength: Int + + public init(minModuleLength: Int = 20) { + self.minModuleLength = max(1, minModuleLength) + self.accumulatedText = "" + self.lastSafeUpperBoundOffset = 0 + } + + public func reset() { + accumulatedText = "" + lastSafeUpperBoundOffset = 0 + } + + public func updateMinModuleLength(_ length: Int) { + self.minModuleLength = max(1, length) + } + + /// 当前累积的完整原文(含尚未提交到安全前缀的尾部)。 + public var fullAccumulatedText: String { + accumulatedText + } + + /// 已通过模块检测、可交给渲染管线的前缀子串(不含仍缓冲中的尾部)。 + public var committedSafePrefix: String { + guard lastSafeUpperBoundOffset > 0 else { return "" } + let text = accumulatedText + guard let endIdx = text.index( + text.startIndex, + offsetBy: lastSafeUpperBoundOffset, + limitedBy: text.endIndex + ) else { return "" } + return String(text[.. ModuleDetectionResult { + accumulatedText += text + return detectCompleteModules() + } + + /// 流结束时将剩余内容全部标为已提交,返回此前未纳入安全前缀的尾部字符串。 + @discardableResult + public func flush() -> String { + let text = accumulatedText + let tailStart = indexInCurrentText(offset: lastSafeUpperBoundOffset) + let remaining = tailStart < text.endIndex ? String(text[tailStart...]) : "" + lastSafeUpperBoundOffset = text.distance(from: text.startIndex, to: text.endIndex) + return remaining + } + + // MARK: - Detection + + private func detectCompleteModules() -> ModuleDetectionResult { + let textToAnalyze = accumulatedText + let startPosition = indexInCurrentText(offset: lastSafeUpperBoundOffset) + + if let pending = detectPendingStructure(in: textToAnalyze) { + let pendingSlice = startPosition < textToAnalyze.endIndex + ? String(textToAnalyze[startPosition...]) + : "" + return ModuleDetectionResult( + completeModules: [], + pendingText: pendingSlice, + hasPendingStructure: true, + pendingType: pending + ) + } + + let boundaries = findModuleBoundaries(in: textToAnalyze, from: startPosition) + if boundaries.isEmpty { + let remainingText = startPosition < textToAnalyze.endIndex + ? String(textToAnalyze[startPosition...]) + : "" + let utf16Count = remainingText.utf16.count + if (utf16Count > minModuleLength * 3 && remainingText.hasSuffix("\n\n") + || (isPlainText(remainingText) && utf16Count > minModuleLength && remainingText.hasSuffix("\n"))), + !shouldDeferCommitAwaitingPossibleSecondTopLevelHeading(in: textToAnalyze) { + let completeText = remainingText.trimmingCharacters(in: .whitespacesAndNewlines) + if !completeText.isEmpty { + lastSafeUpperBoundOffset = textToAnalyze.distance(from: textToAnalyze.startIndex, to: textToAnalyze.endIndex) + return ModuleDetectionResult( + completeModules: [completeText], + pendingText: "", + hasPendingStructure: false, + pendingType: nil + ) + } + } + return ModuleDetectionResult( + completeModules: [], + pendingText: remainingText, + hasPendingStructure: false, + pendingType: nil + ) + } + + var completeModules: [String] = [] + var lastBoundary = startPosition + + for boundary in boundaries where boundary > accumulatedText.startIndex { + if boundary > lastBoundary { + let moduleText = extractModule(from: textToAnalyze, start: lastBoundary, end: boundary) + if !moduleText.isEmpty { + completeModules.append(moduleText) + } + } + lastBoundary = boundary + } + + // 首 chunk 已把 `lastSafeUpperBoundOffset` 顶到「第二个 # 」行首时,`startPosition` 与该 H1 边界重合,`>` 分支不会切片,但仍需在本帧产出上一模块供渲染。 + if completeModules.isEmpty, + let firstBoundary = boundaries.first, + firstBoundary == startPosition, + startPosition > textToAnalyze.startIndex { + let moduleText = extractModule(from: textToAnalyze, start: textToAnalyze.startIndex, end: firstBoundary) + if !moduleText.isEmpty { + completeModules.append(moduleText) + } + } + + lastSafeUpperBoundOffset = textToAnalyze.distance(from: textToAnalyze.startIndex, to: lastBoundary) + let pendingText = lastBoundary < textToAnalyze.endIndex + ? String(textToAnalyze[lastBoundary...]) + : "" + return ModuleDetectionResult( + completeModules: completeModules, + pendingText: pendingText, + hasPendingStructure: false, + pendingType: nil + ) + } + + private func detectPendingStructure(in text: String) -> PendingStructureType? { + let trimmedEnd = text.suffix(10) + if trimmedEnd.contains("`") { + let backtickSuffix = String(text.suffix(5)) + if backtickSuffix.hasSuffix("`"), !backtickSuffix.hasSuffix("```") { + let run = backtickSuffix.reversed().prefix(while: { $0 == "`" }) + if run.count == 1 || run.count == 2 { + return .codeBlock + } + } + } + + let nsText = text as NSString + let fence = "```" + var codeBlockCount = 0 + var searchRange = NSRange(location: 0, length: nsText.length) + while searchRange.location < nsText.length { + let foundRange = nsText.range(of: fence, options: [], range: searchRange) + if foundRange.location == NSNotFound { break } + codeBlockCount += 1 + searchRange.location = foundRange.location + foundRange.length + searchRange.length = nsText.length - searchRange.location + } + if codeBlockCount % 2 != 0 { return .codeBlock } + + let latexFence = "$$" + var latexCount = 0 + searchRange = NSRange(location: 0, length: nsText.length) + while searchRange.location < nsText.length { + let foundRange = nsText.range(of: latexFence, options: [], range: searchRange) + if foundRange.location == NSNotFound { break } + latexCount += 1 + searchRange.location = foundRange.location + foundRange.length + searchRange.length = nsText.length - searchRange.location + } + if latexCount % 2 != 0 { return .latexBlock } + + let lines = text.components(separatedBy: .newlines) + if let lastNonEmpty = lines.last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) { + let trimmed = lastNonEmpty.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|"), trimmed.contains("|"), !text.hasSuffix("\n\n") { + return .table + } + } + + return nil + } + + private func isPlainText(_ text: String) -> Bool { + let markers = ["#", "> ", "```", "---", "***", "- ", "* ", "+ ", "| ", "1. ", "2. ", "3. ", "![", "[$"] + for marker in markers where text.contains(marker) { return false } + if text.contains("**") || text.contains("__") || text.contains("`") || text.contains("$$") { + return false + } + return true + } + + /// 返回每个边界对应的 ``accumulatedText`` 中的 `Index`(边界为模块结束位置,即下一模块起点)。 + private func findModuleBoundaries(in text: String, from startPosition: String.Index) -> [String.Index] { + var h1Positions: [String.Index] = [] + var h2Positions: [String.Index] = [] + var paragraphBoundaries: [String.Index] = [] + + var isInsideCodeBlock = false + var paragraphStart = startPosition + var hasRenderableSinceParagraphStart = false + + var lineStart = text.startIndex + while lineStart < text.endIndex { + let lineEnd: String.Index + let afterLine: String.Index + if let nl = text.range(of: "\n", range: lineStart..= startPosition + // 标题位置必须基于全文收集:流式提交第一段后 `startPosition` 会前移,否则只剩「第二个 # 」无法触发多 H1 切分。 + let isH1 = isOutsideCodeBlock + && trimmed.hasPrefix("# ") && !trimmed.hasPrefix("## ") + let isH2 = isOutsideCodeBlock + && trimmed.hasPrefix("## ") && !trimmed.hasPrefix("### ") + + if isH1 { + h1Positions.append(lineStart) + } else if isH2 { + h2Positions.append(lineStart) + } + + if isWithinSearchRange { + if !trimmed.isEmpty && !isH1 && !isH2 { + hasRenderableSinceParagraphStart = true + } + if isOutsideCodeBlock && trimmed.isEmpty && hasRenderableSinceParagraphStart { + let moduleLength = text.distance(from: paragraphStart, to: afterLine) + if moduleLength >= minModuleLength { + paragraphBoundaries.append(afterLine) + paragraphStart = afterLine + hasRenderableSinceParagraphStart = false + } + } + } + + if isFenceMarker { + let wasInsideFence = isInsideCodeBlock + isInsideCodeBlock.toggle() + // 闭合围栏:在此处一切分点结束「标题/前文 + 完整代码块」模块,避免无空行时把后续正文与代码块粘成一块。 + if wasInsideFence, isInsideCodeBlock == false, lineStart >= startPosition { + if paragraphBoundaries.last != afterLine { + paragraphBoundaries.append(afterLine) + } + paragraphStart = afterLine + hasRenderableSinceParagraphStart = false + } + } + + if afterLine >= text.endIndex { + break + } + lineStart = afterLine + } + + var boundaries: [String.Index] = [] + + if h1Positions.count >= 2 { + for boundary in h1Positions.dropFirst() where boundary >= startPosition { + boundaries.append(boundary) + } + } else if h2Positions.count >= 2 { + for boundary in h2Positions.dropFirst() where boundary >= startPosition { + boundaries.append(boundary) + } + } else { + var pb = paragraphBoundaries + // 与常见流式 Markdown 组件一致:最后一个正文段后若无收尾空行,仍按 EOF 提交(不在未闭合围栏内时)。 + if isInsideCodeBlock == false, + hasRenderableSinceParagraphStart, + paragraphStart >= startPosition, + paragraphStart < text.endIndex { + let tailUTF16 = text[paragraphStart...].utf16.count + if tailUTF16 >= minModuleLength, pb.last != text.endIndex, + !shouldDeferCommitAwaitingPossibleSecondTopLevelHeading(in: text) { + pb.append(text.endIndex) + } + } + boundaries = pb + } + + return boundaries + } + + private func extractModule(from text: String, start: String.Index, end: String.Index) -> String { + guard start < end, end <= text.endIndex else { return "" } + return String(text[start..` 条件丢弃。 + private func shouldDeferCommitAwaitingPossibleSecondTopLevelHeading(in text: String) -> Bool { + guard text.hasSuffix("\n\n") else { return false } + var h1Count = 0 + var isInsideCodeBlock = false + var lineStart = text.startIndex + while lineStart < text.endIndex { + let lineEnd: String.Index + let afterLine: String.Index + if let nl = text.range(of: "\n", range: lineStart..= text.endIndex { break } + lineStart = afterLine + } + return h1Count == 1 + } + + private func indexInCurrentText(offset: Int) -> String.Index { + let text = accumulatedText + guard offset > 0 else { return text.startIndex } + return text.index(text.startIndex, offsetBy: offset, limitedBy: text.endIndex) ?? text.endIndex + } +} diff --git a/Sources/STMarkdown/Core/STMarkdownStyle.swift b/Sources/STMarkdown/Core/STMarkdownStyle.swift index f42d896..49f2a5f 100644 --- a/Sources/STMarkdown/Core/STMarkdownStyle.swift +++ b/Sources/STMarkdown/Core/STMarkdownStyle.swift @@ -34,6 +34,8 @@ public struct STMarkdownStyle: @unchecked Sendable { /// 以免样式在跨线程使用时引入 data race。 public var headingFontProvider: (@Sendable (Int) -> UIFont)? public var linkColor: UIColor? + /// 是否为 `[text](url)` 等链接绘制下划线(默认 true,与系统链接观感一致)。 + public var linkUnderlineEnabled: Bool public var inlineCodeTextColor: UIColor? /// 行内代码背景色。nil 时沿用 codeBlockBackgroundColor 或不绘制背景。 public var inlineCodeBackgroundColor: UIColor? @@ -56,6 +58,10 @@ public struct STMarkdownStyle: @unchecked Sendable { public var listHeadIndent: CGFloat public var listMarkerWidth: CGFloat public var listMarkerColor: UIColor? + /// 整块列表顶部额外留白(不改变列表项内部排版)。 + public var listTopPadding: CGFloat + /// 整块列表底部额外留白。 + public var listBottomPadding: CGFloat public var headingLineHeightMultiplier: CGFloat /// 块级元素之间的默认间距 public var blockSpacing: CGFloat @@ -99,6 +105,8 @@ public struct STMarkdownStyle: @unchecked Sendable { public var codeBlockSeparatorSpacing: CGFloat /// 代码块按钮行预留宽度 public var codeBlockButtonRowReservedWidth: CGFloat + /// 智能流式缓冲(``STMarkdownStreamBuffer``)的最小模块长度,过小会导致更频繁的模块切分。 + public var streamMinModuleLength: Int public init( font: UIFont, @@ -114,6 +122,7 @@ public struct STMarkdownStyle: @unchecked Sendable { headingKern: CGFloat? = nil, headingFontProvider: (@Sendable (Int) -> UIFont)? = nil, linkColor: UIColor? = nil, + linkUnderlineEnabled: Bool = true, inlineCodeTextColor: UIColor? = nil, inlineCodeBackgroundColor: UIColor? = nil, codeBlockTextColor: UIColor? = nil, @@ -139,6 +148,8 @@ public struct STMarkdownStyle: @unchecked Sendable { listHeadIndent: CGFloat = 24, listMarkerWidth: CGFloat = 18, listMarkerColor: UIColor? = nil, + listTopPadding: CGFloat = 0, + listBottomPadding: CGFloat = 0, headingLineHeightMultiplier: CGFloat = 1.25, blockSpacing: CGFloat = 16, headingTopSpacing: [CGFloat]? = nil, @@ -156,7 +167,8 @@ public struct STMarkdownStyle: @unchecked Sendable { citationBadgeTextColor: UIColor? = nil, codeBlockHeaderHeight: CGFloat = 0, codeBlockSeparatorSpacing: CGFloat = 8, - codeBlockButtonRowReservedWidth: CGFloat = 120 + codeBlockButtonRowReservedWidth: CGFloat = 120, + streamMinModuleLength: Int = 20 ) { self.font = font self.boldFont = boldFont @@ -171,6 +183,7 @@ public struct STMarkdownStyle: @unchecked Sendable { self.headingKern = headingKern self.headingFontProvider = headingFontProvider self.linkColor = linkColor + self.linkUnderlineEnabled = linkUnderlineEnabled self.inlineCodeTextColor = inlineCodeTextColor self.inlineCodeBackgroundColor = inlineCodeBackgroundColor self.codeBlockTextColor = codeBlockTextColor @@ -196,6 +209,8 @@ public struct STMarkdownStyle: @unchecked Sendable { self.listHeadIndent = listHeadIndent self.listMarkerWidth = listMarkerWidth self.listMarkerColor = listMarkerColor + self.listTopPadding = listTopPadding + self.listBottomPadding = listBottomPadding self.headingLineHeightMultiplier = headingLineHeightMultiplier self.blockSpacing = blockSpacing self.headingTopSpacing = headingTopSpacing @@ -214,6 +229,7 @@ public struct STMarkdownStyle: @unchecked Sendable { self.codeBlockHeaderHeight = codeBlockHeaderHeight self.codeBlockSeparatorSpacing = codeBlockSeparatorSpacing self.codeBlockButtonRowReservedWidth = codeBlockButtonRowReservedWidth + self.streamMinModuleLength = max(1, streamMinModuleLength) } public static let `default` = STMarkdownStyle( diff --git a/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift b/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift new file mode 100644 index 0000000..3189e54 --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift @@ -0,0 +1,152 @@ +// +// STMarkdownMalformedTableNormalizer.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +/// 将常见异常表格 Markdown 规整为更接近标准 GFM 的形态,提升 `swift-markdown` 解析成功率。 +public enum STMarkdownMalformedTableNormalizer: Sendable { + + /// 按开关对输入做表格规整;`enabled == false` 时原样返回。 + public static func normalize(_ markdown: String, enabled: Bool) -> String { + guard enabled, markdown.isEmpty == false else { return markdown } + return self.normalize(markdown) + } + + public static func normalize(_ markdown: String) -> String { + let lines = markdown.components(separatedBy: .newlines) + guard lines.count > 2 else { return markdown } + + var normalized: [String] = [] + var index = 0 + var activeFenceMarker: Character? + + while index < lines.count { + let line = lines[index] + + if let fenceMarker = self.codeFenceMarker(in: line) { + if activeFenceMarker == nil { + activeFenceMarker = fenceMarker + } else if activeFenceMarker == fenceMarker { + activeFenceMarker = nil + } + normalized.append(line) + index += 1 + continue + } + + if activeFenceMarker != nil { + normalized.append(line) + index += 1 + continue + } + + if index + 1 < lines.count, + self.isLikelyTableRow(lines[index]), + self.isTableSeparatorRow(lines[index + 1]) { + normalized.append(lines[index]) + normalized.append(lines[index + 1]) + index += 2 + + while index < lines.count { + let currentLine = lines[index] + let trimmed = currentLine.trimmingCharacters(in: .whitespaces) + + if self.isStandalonePipeLine(trimmed) { + index += 1 + continue + } + + if trimmed.isEmpty { + if let nextNonEmpty = self.nextNonEmptyLineIndex(in: lines, from: index + 1), + self.isLikelyTableRow(lines[nextNonEmpty]) { + // 仅吞掉「表内误插空行」:若空行后是「表头 + 分隔行」起点,说明是新 GFM 表,保留空行以免与上一张表粘成一块。 + let nextLineStartsNewGFMTable = nextNonEmpty + 1 < lines.count + && self.isTableSeparatorRow(lines[nextNonEmpty + 1]) + if !nextLineStartsNewGFMTable { + index += 1 + continue + } + } + normalized.append(currentLine) + index += 1 + break + } + + if self.isLikelyTableRow(currentLine) { + normalized.append(currentLine) + index += 1 + continue + } + + normalized.append(currentLine) + index += 1 + break + } + continue + } + + normalized.append(line) + index += 1 + } + + return normalized.joined(separator: "\n") + } + + // MARK: - Line predicates + + private static func codeFenceMarker(in line: String) -> Character? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first, first == "`" || first == "~" else { return nil } + let fenceCount = trimmed.prefix { $0 == first }.count + return fenceCount >= 3 ? first : nil + } + + private static func isLikelyTableRow(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.isEmpty == false, trimmed.contains("|") else { return false } + + let cells = trimmed + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + let nonEmptyCellCount = cells.filter { $0.isEmpty == false }.count + return nonEmptyCellCount >= 2 + } + + private static func isTableSeparatorRow(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|") else { return false } + + let cells = trimmed + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.isEmpty == false } + + guard cells.count >= 2 else { return false } + return cells.allSatisfy { self.isTableSeparatorCell($0) } + } + + private static func isTableSeparatorCell(_ cell: String) -> Bool { + let stripped = cell.trimmingCharacters(in: CharacterSet(charactersIn: ":")) + guard stripped.count >= 3 else { return false } + return stripped.allSatisfy { $0 == "-" } + } + + private static func isStandalonePipeLine(_ trimmedLine: String) -> Bool { + guard trimmedLine.isEmpty == false, trimmedLine.contains("|") else { return false } + return trimmedLine.allSatisfy { $0 == "|" || $0.isWhitespace } + } + + private static func nextNonEmptyLineIndex(in lines: [String], from start: Int) -> Int? { + guard start < lines.count else { return nil } + for idx in start.. NSAttributedString { - let result = NSMutableAttributedString() + let inner = NSMutableAttributedString() for (index, item) in items.enumerated() { if index > 0 { - result.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) + inner.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) } let layout = self.listLayout(for: item) @@ -378,7 +381,7 @@ private extension STMarkdownAttributedStringRenderer { .paragraphStyle: layout.paragraphStyle, .baselineOffset: layout.baselineOffset, ] - result.append(NSAttributedString(string: layout.markerText, attributes: markerAttributes)) + inner.append(NSAttributedString(string: layout.markerText, attributes: markerAttributes)) let leadingBlocks = self.leadingListBlocks(for: item) if leadingBlocks.isEmpty == false { @@ -389,7 +392,7 @@ private extension STMarkdownAttributedStringRenderer { contentIndent: layout.contentIndent, lineHeight: self.resolveLineHeight(for: leadingBlocks.first) ) - result.append(renderedLeading) + inner.append(renderedLeading) } let trailingBlocks = self.trailingListBlocks(for: item) @@ -397,18 +400,40 @@ private extension STMarkdownAttributedStringRenderer { let child = NSMutableAttributedString(attributedString: self.render(blocks: trailingBlocks)) self.offsetParagraphStyles(in: child, by: layout.contentIndent) if child.length > 0 { - // leading 段落已包含内联文本且后跟 `\n`;这里补一个分隔即可。 - // 当 leading 为空(列表项以 quote / codeBlock / list 等块级元素开头), - // marker 后没有任何换行,直接 append 会让 marker 与块内容挤在同一行。 - result.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) - result.append(child) + inner.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) + inner.append(child) } } } + let result = NSMutableAttributedString() + if let top = self.transparentLineSpacer(minHeight: self.style.listTopPadding) { + result.append(top) + } + result.append(inner) + if let bottom = self.transparentLineSpacer(minHeight: self.style.listBottomPadding) { + result.append(bottom) + } return result } + /// 用透明换行 + 行高模拟垂直留白(与块级分隔策略一致)。 + func transparentLineSpacer(minHeight: CGFloat) -> NSAttributedString? { + guard minHeight > 0 else { return nil } + let separatorStyle = NSMutableParagraphStyle() + separatorStyle.minimumLineHeight = minHeight + separatorStyle.maximumLineHeight = minHeight + separatorStyle.lineBreakMode = .byWordWrapping + return NSAttributedString( + string: "\n", + attributes: [ + .font: self.style.font, + .foregroundColor: UIColor.clear, + .paragraphStyle: separatorStyle, + ] + ) + } + func baseAttributes() -> [NSAttributedString.Key: Any] { [ .font: self.style.font, diff --git a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift index cc03f9f..5efcae5 100644 --- a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift @@ -34,6 +34,17 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { public var engine: STMarkdownEngine public var onLinkTap: ((URL) -> Void)? public var onSelectionChange: ((String) -> Void)? + /// 内容高度变化回调;相对 ``contentLayoutHeightNotificationThreshold`` 防抖,避免频繁抖动。 + /// + /// - Note: 不影响 ``intrinsicContentSize`` 的计算与 Auto Layout intrinsic 更新,仅控制该可选回调的触发频率。 + public var onContentLayoutHeightChange: ((CGFloat) -> Void)? + /// 与 ``onContentLayoutHeightChange`` 搭配:高度变化小于该阈值(pt)时不触发回调。 + public var contentLayoutHeightNotificationThreshold: CGFloat = 9 + /// 已有文本但测得高度为 0 时跳过高度回调(等待后续布局 pass)。 + public var suppressTransientZeroContentLayoutHeightNotification: Bool = true + /// 两次 ``onContentLayoutHeightChange`` 之间的最短间隔(秒);0 表示不节流。用于 TableView Cell 流式输出时压低高度抖动频率。 + public var contentLayoutHeightNotificationMinInterval: TimeInterval = 0 + public var onCitationTap: ((String) -> Void)? { didSet { self.tableOverlayCoordinator.onCitationTap = self.onCitationTap @@ -92,6 +103,8 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { private var attachmentRefreshTokens: [STMarkdownRefreshObservation] = [] private var lastLaidOutSize: CGSize = .zero + private var lastNotifiedContentLayoutHeight: CGFloat = -1 + private var lastContentLayoutHeightNotifyUptime: TimeInterval = -1 internal init( textView: UITextView, @@ -145,18 +158,15 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { } public override var intrinsicContentSize: CGSize { - let w: CGFloat - if self.preferredContentWidth > 0 { - w = self.preferredContentWidth - } else if self.bounds.width > 0 { - w = self.bounds.width - } else { - w = self.window?.bounds.width ?? UIScreen.main.bounds.width - } + let w = self.resolvedMarkdownMeasurementWidth() let fitting = self.textView.sizeThatFits( CGSize(width: w, height: .greatestFiniteMagnitude) ) - return CGSize(width: UIView.noIntrinsicMetric, height: ceil(fitting.height)) + let h = self.resolvedTextViewContentHeight( + width: w, + sizeThatFitsHeight: fitting.height + ) + return CGSize(width: UIView.noIntrinsicMetric, height: h) } public override func sizeThatFits(_ size: CGSize) -> CGSize { @@ -173,6 +183,7 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { ) if sizeChanged && self.bounds.width > 0 { self.invalidateIntrinsicContentSize() + self.publishContentLayoutHeightNotificationIfNeeded(force: false) } } @@ -180,7 +191,8 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { let size = self.textView.sizeThatFits( CGSize(width: width, height: .greatestFiniteMagnitude) ) - return CGSize(width: width, height: ceil(size.height)) + let h = self.resolvedTextViewContentHeight(width: width, sizeThatFitsHeight: size.height) + return CGSize(width: width, height: h) } internal var currentAttributedText: NSAttributedString { @@ -212,6 +224,74 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { self.bindAttachmentRefreshHandlers(in: rendered) self.markTableOverlayDirty() self.invalidateIntrinsicContentSize() + self.publishContentLayoutHeightNotificationIfNeeded(force: false) + } + + internal func publishContentLayoutHeightNotificationIfNeeded(force: Bool = false) { + guard self.onContentLayoutHeightChange != nil else { return } + let height = self.currentMeasuredTextViewContentHeight() + let hasContent = !self.rawMarkdown.isEmpty || (self.textView.attributedText?.length ?? 0) > 0 + if self.suppressTransientZeroContentLayoutHeightNotification && !force && height <= 0 && hasContent { + return + } + let last = self.lastNotifiedContentLayoutHeight + guard force || last < 0 || abs(height - last) >= self.contentLayoutHeightNotificationThreshold else { + return + } + if force == false, + self.contentLayoutHeightNotificationMinInterval > 0 { + let now = ProcessInfo.processInfo.systemUptime + if self.lastContentLayoutHeightNotifyUptime >= 0, + now - self.lastContentLayoutHeightNotifyUptime < self.contentLayoutHeightNotificationMinInterval { + return + } + } + self.lastNotifiedContentLayoutHeight = height + if self.contentLayoutHeightNotificationMinInterval > 0 { + self.lastContentLayoutHeightNotifyUptime = ProcessInfo.processInfo.systemUptime + } + self.onContentLayoutHeightChange?(height) + } + + private func currentMeasuredTextViewContentHeight() -> CGFloat { + let w = self.resolvedMarkdownMeasurementWidth() + let fitting = self.textView.sizeThatFits( + CGSize(width: w, height: .greatestFiniteMagnitude) + ) + return self.resolvedTextViewContentHeight(width: w, sizeThatFitsHeight: fitting.height) + } + + /// Cell 流式场景下 superview 尚未完成布局时,优先用 ``preferredContentWidth``,其次用自身 bounds、再 fallback 到 TextView 的几何宽度。 + internal func resolvedMarkdownMeasurementWidth() -> CGFloat { + if self.preferredContentWidth > 0 { + return self.preferredContentWidth + } + if self.bounds.width > 0 { + return self.bounds.width + } + if self.textView.bounds.width > 0 { + return self.textView.bounds.width + } + if self.textView.frame.width > 0 { + return self.textView.frame.width + } + return self.window?.bounds.width ?? UIScreen.main.bounds.width + } + + /// 对齐常见 Markdown 组件:在 `sizeThatFits` 暂为 0 时回退 `contentSize` / `bounds` 高度,减轻 Cell 初始 pass 的 0↔实际高度跳变。 + private func resolvedTextViewContentHeight(width: CGFloat, sizeThatFitsHeight: CGFloat) -> CGFloat { + var h = ceil(sizeThatFitsHeight) + let hasAttr = (self.textView.attributedText?.length ?? 0) > 0 + if h <= 0, hasAttr, width > 0 { + let contentH = ceil(self.textView.contentSize.height) + if contentH > 0 { + h = contentH + } + } + if h <= 0, hasAttr, self.textView.bounds.height > 0 { + h = ceil(self.textView.bounds.height) + } + return h } internal func resetBaseState() { @@ -222,6 +302,8 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { self.attachmentRefreshTokens.removeAll() self.tableOverlayCoordinator.reset() self.invalidateIntrinsicContentSize() + self.lastNotifiedContentLayoutHeight = -1 + self.lastContentLayoutHeightNotifyUptime = -1 } internal func markTableOverlayDirty() { diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index de1a136..a776d46 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -26,6 +26,23 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { set { self.shimmerTextView.animateAcrossNewlines = newValue } } + private var smartStreamBuffer: STMarkdownStreamBuffer? + private var smartStreamingSessionActive = false + private var isApplyingSmartStreamMarkdownUpdate = false + /// 上一帧已交给 ``setMarkdown(_:animated:)`` 的「安全前缀」展示串(经 ``stripUnclosedTailMarkers``)。 + /// 当缓冲器仅增长尾部、``committedSafePrefix`` 不变时跳过整段重解析,降低流式 CPU 占用(对齐对比文档 P0)。 + private var lastSmartStreamRenderedDisplayMarkdown: String? + + /// 是否处于 ``beginSmartMarkdownStreaming()`` 开启的智能缓冲流式会话中。 + public var isSmartMarkdownStreamingActive: Bool { + self.smartStreamingSessionActive + } + + /// 会话中的完整累积 Markdown(含尚未通过模块检测的尾部);非会话中为 `nil`。 + public var smartStreamingAccumulatedText: String? { + self.smartStreamBuffer?.fullAccumulatedText + } + private var shimmerTextView: STShimmerTextView { guard let textView = self.textView as? STShimmerTextView else { preconditionFailure("STMarkdownStreamingTextView requires STShimmerTextView") @@ -77,6 +94,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } public func reset() { + self.cancelSmartMarkdownStreamingSession() self.shimmerTextView.reset() self.resetBaseState() } @@ -86,6 +104,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } public func setMarkdown(_ markdown: String, animated: Bool = false) { + if self.smartStreamingSessionActive && !self.isApplyingSmartStreamMarkdownUpdate { + self.cancelSmartMarkdownStreamingSession() + } let markdownForRender = animated ? Self.stripUnclosedTailMarkers(in: markdown) : markdown @@ -184,15 +205,86 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { public func appendMarkdownFragment(_ fragment: String, animated: Bool = true) { guard fragment.isEmpty == false else { return } + if self.smartStreamingSessionActive { + self.appendSmartMarkdownStreamingChunk(fragment) + return + } self.setMarkdown(self.rawMarkdown + fragment, animated: animated) } public func updateStreamingMarkdown(_ fullMarkdown: String) { + self.cancelSmartMarkdownStreamingSession() self.setMarkdown(fullMarkdown, animated: true) } + /// 开始智能流式会话:先 ``reset()``,再用 ``STMarkdownStreamBuffer`` 按安全模块边界累积 chunk。 + public func beginSmartMarkdownStreaming() { + self.reset() + self.smartStreamBuffer = STMarkdownStreamBuffer( + minModuleLength: self.markdownStyle.streamMinModuleLength + ) + self.smartStreamingSessionActive = true + } + + /// 向智能流式缓冲追加一段 chunk 并刷新显示(仅显示已通过检测的安全前缀 + 尾部裁剪)。 + public func appendSmartMarkdownStreamingChunk(_ chunk: String) { + guard chunk.isEmpty == false else { return } + guard self.smartStreamingSessionActive, let buffer = self.smartStreamBuffer else { + self.setMarkdown(self.rawMarkdown + chunk, animated: true) + return + } + buffer.updateMinModuleLength(self.markdownStyle.streamMinModuleLength) + _ = buffer.append(chunk) + self.applySmartStreamingPresentation(animated: true) + } + + /// 结束智能流式会话;默认 ``flush`` 后按完整累积文本做一次非动画收敛。 + public func endSmartMarkdownStreaming(flushPending: Bool = true) { + guard self.smartStreamingSessionActive, let buffer = self.smartStreamBuffer else { return } + if flushPending { + _ = buffer.flush() + } + let snapshot = buffer.fullAccumulatedText + self.smartStreamBuffer = nil + self.smartStreamingSessionActive = false + self.lastSmartStreamRenderedDisplayMarkdown = nil + let md = Self.stripUnclosedTailMarkers(in: snapshot) + self.isApplyingSmartStreamMarkdownUpdate = true + defer { self.isApplyingSmartStreamMarkdownUpdate = false } + self.setMarkdown(md, animated: false) + self.rawMarkdown = snapshot + self.publishContentLayoutHeightNotificationIfNeeded(force: true) + } + internal override func configurationDidChangeRerender() { - self.setMarkdown(self.rawMarkdown, animated: false) + if self.smartStreamingSessionActive { + self.smartStreamBuffer?.updateMinModuleLength(self.markdownStyle.streamMinModuleLength) + self.applySmartStreamingPresentation(animated: false, force: true) + } else { + self.setMarkdown(self.rawMarkdown, animated: false) + } + } + + private func cancelSmartMarkdownStreamingSession() { + self.smartStreamBuffer = nil + self.smartStreamingSessionActive = false + self.lastSmartStreamRenderedDisplayMarkdown = nil + } + + private func applySmartStreamingPresentation(animated: Bool, force: Bool = false) { + guard let buffer = self.smartStreamBuffer else { return } + let accumulated = buffer.fullAccumulatedText + let committed = buffer.committedSafePrefix + let md = Self.stripUnclosedTailMarkers(in: committed) + if force == false, md == self.lastSmartStreamRenderedDisplayMarkdown { + self.rawMarkdown = accumulated + return + } + self.isApplyingSmartStreamMarkdownUpdate = true + defer { self.isApplyingSmartStreamMarkdownUpdate = false } + self.setMarkdown(md, animated: animated) + self.lastSmartStreamRenderedDisplayMarkdown = md + self.rawMarkdown = accumulated } private func applyFullReplace(markdown: String, rendered: NSAttributedString) { @@ -429,4 +521,24 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { pattern: #"(?m)(?:^|\n)\t*[●▪]\t"#, options: [] ) + + /// 启发式判断文本是否**不太可能**含有常见 Markdown 块级/行内标记。 + /// + /// 适用于宿主自行缓冲 LLM 输出时决定是否采用更细粒度(例如按单行 `\n`)的提交策略; + /// 与 ``setMarkdown(_:animated:)`` 内置的尾部定界符裁剪正交,可独立使用。 + public static func isLikelyMarkdownFreePlainText(_ text: String) -> Bool { + let blockMarkers = [ + "#", "> ", "```", "---", "***", + "- ", "* ", "+ ", "| ", + "1. ", "2. ", "3. ", + "![", "](", + ] + for marker in blockMarkers where text.contains(marker) { + return false + } + if text.contains("**") || text.contains("__") || text.contains("`") || text.contains("$$") { + return false + } + return true + } } From 164b2356066ac0e88ff5ca670254b9b91e0230fa Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 10:46:48 +0800 Subject: [PATCH 04/27] Enhance documentation and tests for STMarkdown features - Expanded the documentation in `STMarkdown-MarkdownDisplayView-Comparison.md` to detail the implementation of incremental AST, `replaceCount`, and parser-level locks. - Added recommendations for TOC and footnote implementations to align with vendor capabilities. - Updated tests in `STMarkdownStreamBufferTests.swift` to clarify behavior during multi-append scenarios, ensuring stability in committed content. --- ...Markdown-MarkdownDisplayView-Comparison.md | 83 ++++++++++++++++++- .../STMarkdownStreamBufferTests.swift | 11 +-- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index 5c45c6f..a5b1ad0 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -173,6 +173,86 @@ 3. **用压测结论决定并发保护形态。** 若压测未暴露问题,不必急着引入全局锁;若暴露 parser 级竞态,再补最小串行化保护。 +### 7.2 P0:增量 AST、`replaceCount`、parser 级锁(实现语义对照) + +本节把 vendor 源码里的名词落到「ST 若要补齐,大致要做什么」,不等同于逐 API 抄过去。 + +#### 7.2.1 Vendor:`IncrementalParseResult` 在解决什么问题 + +`MarkdownParser.parseIncremental(...)` 大致顺序是:`detectPendingStructure` → `findSafeBreakpoint` 得到 **`safePosition`** → 从 `lastSafePosition` 向前取 **`contextWindowSize`** 字符得到 `parseStart`,对 `[parseStart, safePosition)` 子串 **`parseDocument` + `render`**,得到 **`newElements`**;同一次调用里还会 **`extractHeadings`** 得到 **`tocItems`**。 + +**`replaceCount`** 的含义(见 vendor 注释):因为解析窗口**向前回溯**了 `contextWindowSize`,新解析出的块可能与「上一轮已挂到 UI 上的尾部块」在语义上重叠或已被修正,需要从**元素列表尾部**按个数丢掉/替换旧元素,避免尾部重复或结构纠错失败。实现上由 `estimateReplaceCount(previousElementCount:contextWindowSize:parseStart:lastSafePosition:)` 估算。 + +**`parseLock`**:vendor 在真正走 swift-markdown / cmark 的路径上用 **`NSLock()`** 包一层,注释写明用于避免 **swift-cmark 在多线程并发挂载语法扩展时崩溃**;与视图侧的 `renderQueue`、版本号等是不同层级的保护。 + +#### 7.2.2 ST:当前断点与差距 + +| 概念 | Vendor | ST(当前) | +|------|--------|------------| +| 流式安全切分 | `lastSafePosition` 与 parser 协同 | `STMarkdownStreamBuffer` 的 **`lastSafeUpperBoundOffset`**,仅字符串边界 | +| 解析范围 | 回溯窗口 + 子串 parse | 每次 **`STMarkdownEngine.process` → 整段管线** 为主 | +| 增量产物 | `newElements` + **`replaceCount`** + `tocItems` | 无元素级 **`newBlocks`/`replaceTailCount`** 公开形态 | +| 并发 | **`parseLock`** 串行化 cmark 路径 | `STMarkdownEngine` 文档约定 **Sendable** 组件时可多线程 `process`,**无** vendor 同款 parser 级锁 | + +#### 7.2.3 ST 侧「增量 AST」若要落地,建议拆成两层 + +1. **缓冲层(已有方向)**:继续用 `STMarkdownStreamBuffer` 决定「这一帧可安全提交给渲染器的 markdown 子串」(对齐 vendor 的 safe 边界思想,但 ST 用 UTF-16/字符偏移持久化,避免 `String.Index` 失效)。 +2. **AST / 渲染元素层(待建)**:在管线结果或旁路缓存中维护 **`[STMarkdownRenderBlock]`(或更粗的 `MarkdownRenderElement` 等价物)**,对每一帧: + - 仅对**尾部窗口**(例如最近 K 个 block 或最近 M 个字符对应的 span)做 **重新 parse**; + - 用 **`replaceTailCount`**(语义对齐 vendor `replaceCount`)替换列表尾部,再 **拼接** 已冻结前缀的 attributed 结果,或走 TextKit 的 `replaceCharacters` 等价更新。 + +这样文档里说的「增量 AST」才名副其实:光有字符串 `safe` 切分没有 **元素级 tail replace**,长文流式仍会整段重跑,CPU 与闪动与 vendor 不在同一量级。 + +#### 7.2.4 `parseLock` 是否要在 ST 照搬 + +不必先照搬 **`NSLock` 静态单例**;更合理的顺序是: + +- 先明确 **是否存在多线程同时进入同一条 cmark / swift-markdown 路径**(共享 parser、共享扩展注册、全局缓存等)。 +- 若压测或静态审查确认 **底层非线程安全**,再在 **`STMarkdownStructureParser`(或唯一 `parseDocument` 入口)** 外包一层 **最小临界区**(`NSLock`、`os_unfair_lock`、或 **`actor` 串行 parse**),与 `Sendable` 组合关系写进文档与单测。 + +--- + +### 7.3 P1:TOC、footnote(从「块里有标题」到「可导航产品能力」) + +#### 7.3.1 TOC(目录) + +Vendor 除 `MarkdownTOCItem` 数据结构外,还把 **`tocItems` 放进 `IncrementalParseResult`**,视图层有 **`onTOCItemTap`、`generateTOCView`、`scrollToTOCItem`** 等一体面。 + +ST 若要做到 **P1 可交付**: + +- **模型**:为每个 heading 生成稳定 **锚点 id**(GitHub slug 规则或自定义),输出 **`STMarkdownTOCItem`(level、title、id、可选 sourceRange)**。 +- **抽取**:在 `STMarkdownStructureParser` / AST 遍历中集中收集,避免散落在 `STMarkdownAttributedStringRenderer`。 +- **宿主 API**:与 vendor 对齐的最小面可以是:`tableOfContents: [STMarkdownTOCItem]` + **`scrollToTOCItem(id:)`**(内部映射到 `NSRange` 或 layout fragment,驱动 `UITextView.scrollRangeToVisible` 或外层 `UIScrollView`)。 + +#### 7.3.2 Footnote(脚注) + +Vendor 有 **脚注预处理、缓存、延迟脚注视图** 链路;ST 当前 **citation 角标**(如 `STMarkdownNumberBadgeAttachment`)是另一条产品语义,**不能**当作 CommonMark/GFM 风格 footnote 的完成态。 + +P1 建议: + +- **AST**:增加 `footnoteReference` / `footnoteDefinition`(或扩展 swift-markdown 插件)与 **编号/反向链接** 规则。 +- **渲染**:正文角标 + 文末脚注区(或侧栏)与 **citation** 分流配置,避免两套角标语义混在一个 attachment key 上。 + +--- + +### 7.4 P2:`
        `、rawHTML、TextKit 2 + +#### 7.4.1 `details` / 折叠块 + +Vendor `MarkdownRenderElement` 级有 **`details`** 一类块。ST 若要做: + +- 先 **`STMarkdownBlockNode` / `STMarkdownRenderBlock`** 扩展,再 UI(折叠态可升宿主状态,避免写死在单一 `UITextView`)。 + +#### 7.4.2 `rawHTML` + +Vendor 有 **`rawHTML(String)`** 分支;ST 侧 `STHtmlNormalizeRule` 等已表明 **下游不消费原始 HTML**。若产品强需求,应单独做 **白名单标签 + 安全渲染**(甚至独立 `WKWebView` 沙箱),不宜默认并进 `NSAttributedString` 主路径。 + +#### 7.4.3 TextKit 2 + +Vendor 核心视图走 **`NSTextContentStorage` + `NSTextLayoutManager`**(`MarkdownTextViewTK2.swift`);ST 主路径 **`usingTextLayoutManager: false`**(TextKit 1)。 + +**P2 迁移**适合在出现明确瓶颈时立项,例如:超长文档排版性能、复杂附件 provider、与系统选区/无障碍行为强绑定。与 ST 当前 **表格 Collection + attachment overlay** 的组合是否迁 TK2 需要 **单独架构评估**,不宜与「流式增量 AST」同一迭代混谈。 + --- ## 8. 参考链接 @@ -184,11 +264,10 @@ ## 9. 仍可深入补充的维度(未在文中逐条展开) -若要做「实现级」迁移清单,建议后续按需补小节或链接到具体行号: +若要做「实现级」迁移清单,建议后续按需补小节或链接到具体行号。**增量解析 / replaceCount / parser 锁 / TOC-footnote-TK2** 的落地顺序与语义已集中在 **第 7.2–7.4 节**。 - **`MarkdownConfiguration` 全字段** 与 **`STMarkdownStyle` + Pipeline** 的逐项字段映射表(体量最大)。 - **`MarkdownViewTextKit` / `MarkdownDisplayView.swift`** 内生命周期、高度通知(vendor `notifyHeightChange` 命名)与 **`STMarkdownBaseTextView.publishContentLayoutHeightNotificationIfNeeded`** 的逐项对照。 -- **增量解析**:`IncrementalParseResult.replaceCount` 回溯策略与 ST **全量重渲染** 的等价性与性能差异。 - **无障碍**:Vendor TK2 栈与 ST `UITextView` 的 **accessibility** 差异。 - **许可证**:若从 vendor 复制 **KaTeX 字体文件**,需单独核对字体与 KaTeX 的许可条款;ST 当前以 **SwiftMath** 为主。 diff --git a/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift index 4ad6c5c..a9f8fc3 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownStreamBufferTests.swift @@ -91,15 +91,16 @@ final class STMarkdownStreamBufferTests: XCTestCase { } func testMultiAppendWithoutNewSafePrefixKeepsCommittedStable() { - // 使用 `##` 而非单行 `# `:单 H1 且以 `\n\n` 结尾时会触发 `shouldDeferCommitAwaitingPossibleSecondTopLevelHeading`, - // 后续追加若去掉该后缀,defer 解除并可能把 `lastSafe` 顶到文末,导致 committed 突然变长,与本用例「仅增长尾部」假设冲突。 + // 1) 使用 `##` 避免单 H1 + `\n\n` 结尾触发的 `shouldDeferCommitAwaitingPossibleSecondTopLevelHeading` 与后续追加的交互。 + // 2) `findModuleBoundaries` 在尾部「未闭合段落」上若 `tailUTF16 >= minModuleLength`,会把 `text.endIndex` + // 并入边界,pending 过长时 committed 会合法地扩展;本用例只追加极少字符,使 tail 仍低于阈值。 let buffer = STMarkdownStreamBuffer(minModuleLength: 10) let first = buffer.append("## Section\n\nFirst block is long enough.\n\n") XCTAssertFalse(first.completeModules.isEmpty) let committedAfterFirst = buffer.committedSafePrefix - _ = buffer.append("still ") - _ = buffer.append("typing ") - _ = buffer.append("pending tail without new paragraph break") + _ = buffer.append("a") + _ = buffer.append("b") + _ = buffer.append("c") XCTAssertEqual(buffer.committedSafePrefix, committedAfterFirst) } From 9b67cbd3183c9a66bdc717ffdab3d42df4d8493d Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 11:08:51 +0800 Subject: [PATCH 05/27] Enhance STMarkdown with TOC support and heading anchor IDs - Updated `STMarkdownRenderBlock` to include `anchorId` for headings, facilitating table of contents generation. - Modified `STMarkdownPipeline` to extract TOC items from the rendered document. - Enhanced `STMarkdownBaseTextView` to manage and scroll to heading anchors, improving navigation within rendered content. - Added tests to verify TOC functionality and ensure correct heading anchor behavior across various scenarios. --- ...Markdown-MarkdownDisplayView-Comparison.md | 142 ++++++++++-------- ...rkdownASTAndRenderASTExhaustiveTests.swift | 2 +- .../STMarkdownCoreContractsTests.swift | 1 + ...MarkdownParsingEscapeAndDisplayTests.swift | 6 +- .../STMarkdownPipelineTests.swift | 3 +- .../STMarkdownTOCTests.swift | 39 +++++ .../STMarkdown/Core/STMarkdownPipeline.swift | 10 +- .../Core/STMarkdownRenderAdapter.swift | 23 ++- .../Core/STMarkdownStreamBuffer.swift | 48 ++++-- Sources/STMarkdown/Core/STMarkdownTOC.swift | 131 ++++++++++++++++ .../Parsing/STMarkdownRenderAST.swift | 2 +- .../Parsing/STMarkdownStructureParser.swift | 5 + .../STMarkdownAttributedStringRenderer.swift | 21 ++- .../UI/STMarkdownBaseTextView.swift | 45 ++++++ .../UI/STMarkdownStreamingTextView.swift | 3 +- 15 files changed, 386 insertions(+), 95 deletions(-) create mode 100644 Example/STBaseProjectExampleTests/STMarkdownTOCTests.swift create mode 100644 Sources/STMarkdown/Core/STMarkdownTOC.swift diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index a5b1ad0..e0fc468 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -8,6 +8,15 @@ 本文汇总架构、文件映射、能力差异及在 Cursor 中的对比方式,便于后续按模块对齐或迁移。 +**对齐标注图例**(下文表格「对齐」列或行内标签沿用同一套语义): + +| 标签 | 含义 | +|------|------| +| **【已对齐】** | 该维度上能力或依赖已等价覆盖(实现路径、API 名称可与 Vendor 不同)。 | +| **【部分对齐】** | 双方均有对应能力或同类入口,但栈、交互模型或公开面仍有明显差异。 | +| **【未对齐】** | ST 侧缺失、明确不支持或架构路线与 Vendor 不可直接等同。 | +| (留空或 **—**) | 属仓库形态类对比,不评「能力对齐」。 | + --- ## 1. 仓库与路径 @@ -27,80 +36,81 @@ ## 2. 形态对比 -| 维度 | MarkdownDisplayView(Vendor) | STMarkdown | -|------|--------------------------------|------------| -| Swift 文件量 | 约 **21** 个 + `Resources/` | **45** 个 + `Resources/` | -| 代码组织 | **少量超大文件**(如 `MarkdownDisplayView.swift`、`MarkdownParser.swift`) | **分层**、职责拆分 | -| 最低 iOS(以各自 Package/podspec 为准) | iOS **15+**(`@available(iOS 15.0, *)` 等) | iOS **16+**(工程配置) | -| 解析依赖 | **swift-markdown**(`import Markdown`) | **swift-markdown**(SPM `Markdown`) | -| CocoaPods 差异(若用 Pod) | 可能经 **AppleSwiftMDWrapper** 等桥接 | **swift-markdown-pod** + CAtomic modulemap | -| 数学公式 | **KaTeX**(字体 + `LaTeXAttachment` / `LatexMathView` 等) | **SwiftMath** + `STMarkdownMathNormalizer` | -| **SPM 产品名** | `MarkdownDisplayView` | 合在 **`STBaseProject`** 的 `STMarkdown` 源码目录(非独立 SwiftPM 产品名) | -| **CocoaPods 产品名** | **`MarkdownDisplayKit`**(与 SPM 名不同) | **`STBaseProject/STMarkdown`** subspec | -| **swift-markdown 版本** | SPM:`from: "0.7.3"`(随解析器升级行为可能变) | 本仓库 `Package.swift` **固定 revision**;与 vendor 的 cmark/扩展差异需升级时单独回归 | +| 维度 | MarkdownDisplayView(Vendor) | STMarkdown | 对齐 | +|------|--------------------------------|------------|------| +| Swift 文件量 | 约 **21** 个 + `Resources/` | **45** 个 + `Resources/` | — | +| 代码组织 | **少量超大文件**(如 `MarkdownDisplayView.swift`、`MarkdownParser.swift`) | **分层**、职责拆分 | — | +| 最低 iOS(以各自 Package/podspec 为准) | iOS **15+**(`@available(iOS 15.0, *)` 等) | iOS **16+**(工程配置) | **【未对齐】**(系统版本门槛不同) | +| 解析依赖 | **swift-markdown**(`import Markdown`) | **swift-markdown**(SPM `Markdown`) | **【已对齐】** | +| CocoaPods 差异(若用 Pod) | 可能经 **AppleSwiftMDWrapper** 等桥接 | **swift-markdown-pod** + CAtomic modulemap | **【部分对齐】**(均为 Pod 集成路径,桥接方案不同) | +| 数学公式 | **KaTeX**(字体 + `LaTeXAttachment` / `LatexMathView` 等) | **SwiftMath** + `STMarkdownMathNormalizer` | **【部分对齐】**(均有公式渲染链,引擎与资源不同) | +| **SPM 产品名** | `MarkdownDisplayView` | 合在 **`STBaseProject`** 的 `STMarkdown` 源码目录(非独立 SwiftPM 产品名) | — | +| **CocoaPods 产品名** | **`MarkdownDisplayKit`**(与 SPM 名不同) | **`STBaseProject/STMarkdown`** subspec | — | +| **swift-markdown 版本** | SPM:`from: "0.7.3"`(随解析器升级行为可能变) | 本仓库 `Package.swift` **固定 revision**;与 vendor 的 cmark/扩展差异需升级时单独回归 | **【部分对齐】**(同为 swift-markdown,版本策略不同) | --- ## 2.1 对外 API 入口(便于宿主对照) -| 场景 | Vendor(典型) | STMarkdown(典型) | -|------|----------------|-------------------| -| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 `MarkdownViewTextKit`) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | -| 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | -| 配置对象 | `MarkdownConfiguration`(大结构体,含 `MarkdownLineSpacingConfiguration`、`SyntaxHighlightColors` 等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | -| 流式触感 | `StreamingHapticFeedbackStyle` 等 | **无**同名 API;需宿主自行 `UIImpactFeedbackGenerator` | -| Mermaid / 自定义代码块 | 协议 **`MarkdownCodeBlockRenderer`**(示例工程 `MermaidRenderer`) | **`STMarkdownCodeBlockRendering`**、`STMarkdownMermaidRenderer` 等 | +| 场景 | Vendor(典型) | STMarkdown(典型) | 对齐 | +|------|----------------|-------------------|------| +| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 `MarkdownViewTextKit`) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | **【部分对齐】**(能力可用,无 Vendor 同名一体化控件) | +| 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | **【部分对齐】**(均有流式/动画入口,粒度 API 不同) | +| 配置对象 | `MarkdownConfiguration`(大结构体,含 `MarkdownLineSpacingConfiguration`、`SyntaxHighlightColors` 等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | **【部分对齐】**(均有可配置样式与管线,结构拆分不同) | +| 流式触感 | `StreamingHapticFeedbackStyle` 等 | **无**同名 API;需宿主自行 `UIImpactFeedbackGenerator` | **【未对齐】** | +| Mermaid / 自定义代码块 | 协议 **`MarkdownCodeBlockRenderer`**(示例工程 `MermaidRenderer`) | **`STMarkdownCodeBlockRendering`**、`STMarkdownMermaidRenderer` 等 | **【已对齐】**(均为协议化可插拔代码块渲染) | --- ## 3. 文件级映射(Vendor → STMarkdown) -| Vendor 文件 | 角色 | STMarkdown 对应 / 说明 | -|-------------|------|-------------------------| -| `MarkdownParser.swift` | swift-markdown 遍历、`IncrementalParseResult`、`parseLock`、产出 `MarkdownRenderElement`、TOC、图片附件等 | `STMarkdownStructureParser`、`STMarkdownMathNormalizer`、`STMarkdownPipeline`、`STMarkdownRenderAdapter`、`STMarkdownInputSanitizer`、`STMarkdownMalformedTableNormalizer`。ST 以 **整段管线 `process(_:)`** 为主,**无** vendor 同款「增量 `safePosition` / `replaceCount` / 元素级回溯」公开形态 | -| `MarkdownRenderElement.swift` | 渲染树枚举、`MarkdownConfiguration`、`MarkdownTOCItem`、`MarkdownTypewriterTextMode` 等 | `STMarkdownAST` / `STMarkdownRenderAST`、`STMarkdownStyle`。已核对:ST **有** heading/list/table/math/image 等核心块;**无** vendor 同级的 `details`、`rawHTML`、`footnote`、`TOC item` 块模型 | -| `MarkdownRender.swift` | 元素 → 属性串 / 展示逻辑 | `STMarkdownAttributedStringRenderer` + `Rendering/Default/*`、`Rendering/Advanced/*` | -| `MarkdownDisplayView.swift` | 总装、与 TextKit 视图协作(体量很大) | `STMarkdownBaseTextView`、`STMarkdownTextView`、`STMarkdownStreamingTextView` 等拆分 | -| `MarkdownTextViewTK2.swift` | **TextKit 2**:`NSTextContentStorage`、`NSTextLayoutManager`、附件 Provider、`typewriterTextMode` 等 | `UITextView` / `STShimmerTextView`,**`usingTextLayoutManager: false`**(经典 TextKit 路径) | -| `TypewriterEngine.swift` | 对子视图树(`MarkdownTextViewTK2` / `UILabel` / `UIStackView`)队列动画、`onLayoutChange` | `STShimmerTextView` + `STMarkdownStreamingTextView` 动画/增量更新;**无** vendor 同款整棵 block UI 队列 + watchdog | -| `MarkdownStreamBuffer.swift` | `Int` 型 `lastSafePosition`、`containerWidth`、可选 `onModuleReady`(带预解析元素)、调试日志 | `STMarkdownStreamBuffer`:字符偏移持久化、`streamMinModuleLength`、**纯字符串**模块切分;**无** `containerWidth` / **无**模块内解析回调 | -| `ScrollableMarkdownViewTextKit.swift` | `UIScrollView` 包装、`markdown`/`configuration`、`onTOCItemTap`、`tableOfContents`、`generateTOCView` 等 | **无**同名一体化控件;滚动与 TOC 由宿主或 `STMarkdownSwiftUIView` 等组合实现 | -| `MarkdownTableSupport.swift` | 表格与 TextKit2 / 附件协作 | `Table/STMarkdownTable*.swift`、CollectionView 表格附件;与 CHANGELOG 中 **UILabel 表格 cell + `onLinkTap`** 链路不同 | -| `CodeBlockAttachment.swift` | 代码块附件 | `STMarkdownCodeBlockAttachmentRenderer`、`STMarkdownDefaultCodeBlockRenderer` 等 | -| `LaTeXAttachment.swift`、`LatexMathView.swift`、`LateXParser.swift`、`LateXNodeSets.swift` | KaTeX 渲染链 | `STMarkdownDefaultMathRenderer` + SwiftMath + `STMarkdownMathNormalizer` | -| `FontLoader.swift` | KaTeX 字体注册 | ST 使用 SwiftMath / Bundle 资源,无同一套 `FontLoader` | -| `ImageCacheManager.swift`、`ImageLoader.swift`、`ImageView.swift` | 图片缓存与展示 | `STMarkdownAsyncImageRenderer`、`STMarkdownDefaultImageRenderer` 等 | -| `MarkdownCustomExtension.swift` | 自定义扩展元素 | `STMarkdownAdvancedRenderers`、各类 `*Rendering` 协议 | -| `ArraySafe.swift` | 安全下标等工具 | ST 内散见于各文件,无同名单文件 | +| Vendor 文件 | 角色 | STMarkdown 对应 / 说明 | 对齐 | +|-------------|------|-------------------------|------| +| `MarkdownParser.swift` | swift-markdown 遍历、`IncrementalParseResult`、`parseLock`、产出 `MarkdownRenderElement`、TOC、图片附件等 | `STMarkdownStructureParser`(解析路径 **`parseLock`** 串行化)、`STMarkdownMathNormalizer`、`STMarkdownPipeline`、`STMarkdownRenderAdapter`、`STMarkdownInputSanitizer`、`STMarkdownMalformedTableNormalizer`。ST 仍以 **整段管线 `process(_:)`** 为主,**无** vendor 同款「增量 `safePosition` / `replaceCount` / 元素级回溯」公开形态 | **【部分对齐】**(parser 级锁 **【已对齐】**;元素级增量 **【未对齐】**) | +| `MarkdownRenderElement.swift` | 渲染树枚举、`MarkdownConfiguration`、`MarkdownTOCItem`、`MarkdownTypewriterTextMode` 等 | `STMarkdownAST` / `STMarkdownRenderAST`、`STMarkdownStyle`。`STMarkdownRenderBlock.heading` 含 **`anchorId`**;**`STMarkdownTOCItem`** + 管线 **`tableOfContents`**。仍 **无** vendor 同级的 `details`、`rawHTML`、`footnote` 块模型 | **【部分对齐】**(核心块与 TOC 数据/锚点 **【已对齐】**;`details` / `rawHTML` / `footnote` **【未对齐】**) | +| `MarkdownRender.swift` | 元素 → 属性串 / 展示逻辑 | `STMarkdownAttributedStringRenderer` + `Rendering/Default/*`、`Rendering/Advanced/*` | **【已对齐】** | +| `MarkdownDisplayView.swift` | 总装、与 TextKit 视图协作(体量很大) | `STMarkdownBaseTextView`、`STMarkdownTextView`、`STMarkdownStreamingTextView` 等拆分 | **【部分对齐】**(职责已覆盖,拆分为多类型) | +| `MarkdownTextViewTK2.swift` | **TextKit 2**:`NSTextContentStorage`、`NSTextLayoutManager`、附件 Provider、`typewriterTextMode` 等 | `UITextView` / `STShimmerTextView`,**`usingTextLayoutManager: false`**(经典 TextKit 路径) | **【未对齐】**(TextKit 代际不同) | +| `TypewriterEngine.swift` | 对子视图树(`MarkdownTextViewTK2` / `UILabel` / `UIStackView`)队列动画、`onLayoutChange` | `STShimmerTextView` + `STMarkdownStreamingTextView` 动画/增量更新;**无** vendor 同款整棵 block UI 队列 + watchdog | **【部分对齐】**(均有打字机/流式观感;视图树队列 **【未对齐】**) | +| `MarkdownStreamBuffer.swift` | `Int` 型 `lastSafePosition`、`containerWidth`、可选 `onModuleReady`(带预解析元素)、调试日志 | `STMarkdownStreamBuffer`:字符偏移持久化、`streamMinModuleLength`、**纯字符串**模块切分;可选 **`onCompleteModules`**(仅模块字符串,**无**预解析 AST / **无** `containerWidth`) | **【部分对齐】**(安全切分思想 **【已对齐】**;`onModuleReady` 预解析 / `containerWidth` **【未对齐】**) | +| `ScrollableMarkdownViewTextKit.swift` | `UIScrollView` 包装、`markdown`/`configuration`、`onTOCItemTap`、`tableOfContents`、`generateTOCView` 等 | **无**同名一体化控件;管线产出 **`STMarkdownPipelineResult.tableOfContents`** + ``STMarkdownBaseTextView.tableOfContents`` / ``scrollToHeadingAnchor`` / ``characterRangeForHeadingAnchor``;侧栏目录与 `onTOCItemTap` 仍由宿主组合 | **【部分对齐】**(滚动+渲染可组合;TOC 一体面 **【未对齐】**) | +| `MarkdownTableSupport.swift` | 表格与 TextKit2 / 附件协作 | `Table/STMarkdownTable*.swift`、CollectionView 表格附件;与 CHANGELOG 中 **UILabel 表格 cell + `onLinkTap`** 链路不同 | **【部分对齐】**(均有表格能力;TK2 内嵌 vs Collection **【未对齐】**) | +| `CodeBlockAttachment.swift` | 代码块附件 | `STMarkdownCodeBlockAttachmentRenderer`、`STMarkdownDefaultCodeBlockRenderer` 等 | **【已对齐】** | +| `LaTeXAttachment.swift`、`LatexMathView.swift`、`LateXParser.swift`、`LateXNodeSets.swift` | KaTeX 渲染链 | `STMarkdownDefaultMathRenderer` + SwiftMath + `STMarkdownMathNormalizer` | **【部分对齐】**(公式附件链路 **【已对齐】**;KaTeX vs SwiftMath **【未对齐】**) | +| `FontLoader.swift` | KaTeX 字体注册 | ST 使用 SwiftMath / Bundle 资源,无同一套 `FontLoader` | **【部分对齐】**(均有字体/资源加载责任;实现文件 **【未对齐】**) | +| `ImageCacheManager.swift`、`ImageLoader.swift`、`ImageView.swift` | 图片缓存与展示 | `STMarkdownAsyncImageRenderer`、`STMarkdownDefaultImageRenderer` 等 | **【已对齐】** | +| `MarkdownCustomExtension.swift` | 自定义扩展元素 | `STMarkdownAdvancedRenderers`、各类 `*Rendering` 协议 | **【已对齐】** | +| `ArraySafe.swift` | 安全下标等工具 | ST 内散见于各文件,无同名单文件 | **【部分对齐】**(同类防御性用法内嵌,无 Vendor 同名文件) | --- ## 4. 能力差异摘要 -1. **渲染引擎**:Vendor 为 **TextKit 2**;ST 主路径为 **`UITextView` + TextKit 1**(`usingTextLayoutManager: false`)。 -2. **解析与并发**:Vendor **`MarkdownParser` 内 `parseLock` 串行化 swift-markdown**,并在视图层配合 `renderQueue`/版本锁做增量渲染保护;ST `STMarkdownEngine` / `STMarkdownPipeline` 已按 `Sendable` 设计,但**没有** vendor 同款 parser 级串行锁与增量回溯保护,是否需要补锁应以并发压测结论为准。 -3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带 `MarkdownRenderElement`**,并与 **Typewriter 视图树** 配合;ST 为 **字符串级 `STMarkdownStreamBuffer`** + **富文本侧 Shimmer/增量 `setMarkdown`**。 -4. **目录 TOC**:Vendor **内置 `MarkdownTOCItem`、生成目录视图、跳转 API**;ST **无对等的一体式 TOC 公共面**(需业务自建或后续扩展)。 -5. **块级模型**:Vendor `MarkdownRenderElement` 含 **`details`、`rawHTML`**,并把 **heading/TOC/footnote** 等信息留在统一块级模型附近;ST 当前 `STMarkdownBlockNode` / `STMarkdownRenderBlock` **未定义** `details`、`rawHTML`、`footnote`、`TOC` 对等节点。 -6. **公式**:Vendor **KaTeX**;ST **SwiftMath**,命令集与排版不必一致。 -7. **表格**:Vendor 与 TextKit2 附件、手势、(文档所述)**表格内链接走 cell 选择 + `onLinkTap`** 等;ST 为 **独立表格 Collection + overlay**,交互模型不同。 -8. **脚注 / 角标**:Vendor 有 **独立脚注模型 + 延迟渲染脚注视图**;ST 侧当前更偏向 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`、表格内 citation 流程),**不能等价视为 footnote 支持**。 +1. **渲染引擎**:Vendor 为 **TextKit 2**;ST 主路径为 **`UITextView` + TextKit 1**(`usingTextLayoutManager: false`)。**【未对齐】** +2. **解析与并发**:Vendor **`MarkdownParser` 内 `parseLock` 串行化 swift-markdown**,并在视图层配合 `renderQueue`/版本锁做增量渲染保护;ST 在 **`STMarkdownStructureParser.parse`** 使用 **`parseLock`** 串行化 cmark 路径(对齐 vendor 核心动机);**无**视图层版本锁与 **无** 增量元素级回溯。**【部分对齐】**(parser 级锁 **【已对齐】**;视图层与元素级增量 **【未对齐】**) +3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带 `MarkdownRenderElement`**,并与 **Typewriter 视图树** 配合;ST 为 **字符串级 `STMarkdownStreamBuffer`**(可选 **`onCompleteModules`**)+ **富文本侧 Shimmer/增量 `setMarkdown`**。**【部分对齐】**(模块就绪回调 **【部分对齐】**;预解析元素与视图树 **【未对齐】**) +4. **目录 TOC**:Vendor **内置 `MarkdownTOCItem`、生成目录视图、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**(管线 + TextView 缓存)与 **`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;**无**内置目录视图与 **`onTOCItemTap`**(宿主组合)。**【部分对齐】**(数据与跳转 **【已对齐】**;内置目录 UI / tap **【未对齐】**) +5. **块级模型**:Vendor `MarkdownRenderElement` 含 **`details`、`rawHTML`**,并把 **heading/TOC/footnote** 等信息留在统一块级模型附近;ST 当前 `STMarkdownBlockNode` **未定义** `details`、`rawHTML`、`footnote`;**`STMarkdownRenderBlock.heading` 含 `anchorId`** 并与 TOC 抽取一致。**【部分对齐】**(heading 锚点/TOC 数据 **【已对齐】**;扩展块与脚注 **【未对齐】**) +6. **公式**:Vendor **KaTeX**;ST **SwiftMath**,命令集与排版不必一致。**【部分对齐】** +7. **表格**:Vendor 与 TextKit2 附件、手势、(文档所述)**表格内链接走 cell 选择 + `onLinkTap`** 等;ST 为 **独立表格 Collection + overlay**,交互模型不同。**【部分对齐】** +8. **脚注 / 角标**:Vendor 有 **独立脚注模型 + 延迟渲染脚注视图**;ST 侧当前更偏向 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`、表格内 citation 流程),**不能等价视为 footnote 支持**。**【未对齐】** +9. **链接与图片点击**:双方均具备宿主回调链路(如 `onLinkTap`、图片异步渲染与点击)。**【已对齐】**(具体命名与 TK 栈细节不同,见 §4.3「交互能力」行) ## 4.3 已从源码核对的结论 以下条目是本次直接对照源码后确认的结果,可视为比前文更高置信度的“实现级”结论: -| 维度 | Vendor 结论 | ST 结论 | 判断 | -|------|-------------|---------|------| -| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | `STMarkdownStreamBuffer` 只负责**字符串模块切分**,真正渲染仍走整段 `engine.process(...)` | ST **弱于** vendor | -| 流式模块回调 | `MarkdownStreamBuffer.onModuleReady` 可回传预解析 `MarkdownRenderElement` | `STMarkdownStreamBuffer` 无模块内预解析回调 | ST **弱于** vendor | -| 块级能力 | `MarkdownRenderElement` 含 `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` | `STMarkdownBlockNode` / `STMarkdownRenderBlock` 仅含 paragraph/heading/quote/list/code/table/math/image/thematicBreak | ST **缺少** `details` / `rawHTML` / `footnote` | -| TOC | 视图层公开 `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | 未检出对等公共 API;heading 仅作为普通 block 渲染 | ST **缺少一体化 TOC 面** | -| 脚注 | 预处理 footnote,缓存并延迟渲染 footnote view | 未检出 footnote 模型/渲染链;存在 citation badge 流程 | ST **缺少 footnote** | -| TextKit 栈 | 核心视图基于 `NSTextLayoutManager` / `NSTextContentStorage` / TK2 attachment provider | `UITextView(usingTextLayoutManager: false)` 明确走 TextKit 1 路线 | 路线不同 | -| HTML | Vendor 存在 `rawHTML(String)` 元素与对应渲染分支 | ST `STHtmlNormalizeRule` 注释明确写明 downstream **no handling for raw HTML** | ST **明确不支持 raw HTML** | -| 交互能力 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | -| 表格交互 | 表格与 TK2 attachment 深度耦合 | 表格为独立 View/Attachment + overlay/citation 区域 | 路线不同 | +| 维度 | Vendor 结论 | ST 结论 | 判断 | 对齐 | +|------|-------------|---------|------|------| +| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | `STMarkdownStreamBuffer` 只负责**字符串模块切分**,真正渲染仍走整段 `engine.process(...)` | ST **弱于** vendor | **【部分对齐】**(缓冲安全切分 **【已对齐】**;元素级增量 **【未对齐】**) | +| 流式模块回调 | `MarkdownStreamBuffer.onModuleReady` 可回传预解析 `MarkdownRenderElement` | **`onCompleteModules`** 仅回传 **完整模块字符串**;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | +| 块级能力 | `MarkdownRenderElement` 含 `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` | `STMarkdownBlockNode` 仍为 paragraph/heading/…;**`STMarkdownRenderBlock.heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` / `footnote` | **【部分对齐】** | +| TOC | 视图层公开 `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | **`STMarkdownPipelineResult.tableOfContents`**、**`STMarkdownBaseTextView.tableOfContents`**、**`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;无内置目录 UI / `onTOCItemTap` | ST **弱于** vendor 一体面 | **【部分对齐】** | +| 脚注 | 预处理 footnote,缓存并延迟渲染 footnote view | 未检出 footnote 模型/渲染链;存在 citation badge 流程 | ST **缺少 footnote** | **【未对齐】** | +| TextKit 栈 | 核心视图基于 `NSTextLayoutManager` / `NSTextContentStorage` / TK2 attachment provider | `UITextView(usingTextLayoutManager: false)` 明确走 TextKit 1 路线 | 路线不同 | **【未对齐】** | +| HTML | Vendor 存在 `rawHTML(String)` 元素与对应渲染分支 | ST `STHtmlNormalizeRule` 注释明确写明 downstream **no handling for raw HTML** | ST **明确不支持 raw HTML** | **【未对齐】** | +| 交互能力 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | **【部分对齐】**(链接/选区 **【已对齐】**;TOC tap / 脚注视图 **【未对齐】**;citation **Vendor 无对等**) | +| 表格交互 | 表格与 TK2 attachment 深度耦合 | 表格为独立 View/Attachment + overlay/citation 区域 | 路线不同 | **【部分对齐】**(均有表格与点击区域;耦合方式 **【未对齐】**) | --- @@ -118,22 +128,26 @@ ## 4.2 测试与可观测性 -| 项目 | Vendor | STMarkdown | -|------|--------|------------| -| 单测位置 | `MarkdownDisplayView/Tests/MarkdownDisplayViewTests/`(Swift `Testing` 等) | `Example/STBaseProjectExampleTests/` 下 `STMarkdown*`、`STMarkdownStreamBufferTests` 等 | -| 调试输出 | `MarkdownStreamBuffer` 等路径存在 **`print`** 日志 | ST 侧一般 **无** 同等控制台噪声;排障依赖宿主或自行埋点 | +| 项目 | Vendor | STMarkdown | 对齐 | +|------|--------|------------|------| +| 单测位置 | `MarkdownDisplayView/Tests/MarkdownDisplayViewTests/`(Swift `Testing` 等) | `Example/STBaseProjectExampleTests/` 下 `STMarkdown*`、`STMarkdownStreamBufferTests` 等 | **【已对齐】**(均有模块级单测落点) | +| 调试输出 | `MarkdownStreamBuffer` 等路径存在 **`print`** 日志 | ST 侧一般 **无** 同等控制台噪声;排障依赖宿主或自行埋点 | **【未对齐】**(可观测性策略不同) | --- ## 5. 已在 ST 侧做过的对齐方向(会话内实现,供对照) +> 本节条目相对 Vendor 文档/行为属于 **【部分对齐】** 或实现侧 **【已对齐】**(语义接近,非逐行一致)。 + 以下属于 STMarkdown 演进中与「常见流式 Markdown 组件」接近的行为,**不等同**于 vendor 逐行一致: -- `STMarkdownStreamBuffer`:围栏闭合处切分、段落模式 EOF 尾段、**字符偏移**持久化 `lastSafeUpperBoundOffset`(避免 `String.Index` 跨 `+=` 失效)。 -- `STMarkdownBaseTextView`:`resolvedMarkdownMeasurementWidth()`、高度回退、`contentLayoutHeightNotificationMinInterval` 等。 -- `STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`:与 vendor 文档中的「坏表修复」类似语义。 +- **`STMarkdownStreamBuffer`** **【部分对齐】**:围栏闭合处切分、段落模式 EOF 尾段、**字符偏移**持久化 `lastSafeUpperBoundOffset`;可选 **`onCompleteModules`**(对照 vendor 模块就绪的字符串子集)。**【未对齐】** 项见 §3 `MarkdownStreamBuffer` 行。 +- **`STMarkdownBaseTextView`** **【部分对齐】**:`resolvedMarkdownMeasurementWidth()`、高度回退、`contentLayoutHeightNotificationMinInterval`;**`tableOfContents`**、**`scrollToHeadingAnchor`**、**`characterRangeForHeadingAnchor`**(TOC 数据与跳转)。 +- **`STMarkdownStructureParser`** **【部分对齐】**:**`parseLock`** 串行化 swift-markdown 解析路径(对照 vendor)。 +- **`STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`** **【部分对齐】**:坏表修复语义;管线 **`STMarkdownPipelineResult.tableOfContents`**。 +- **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:渲染侧锚点与 TOC 一致。 -单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例。 +单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例;**`STMarkdownTOCTests`**。 --- @@ -155,9 +169,9 @@ |--------|------|------| | P0 | **流式增量渲染链补强** | 当前 ST 已有 `STMarkdownStreamBuffer`,但渲染仍偏“整段重跑”。最优先补的是 **增量 parse / replaceCount / 安全回溯窗口**,否则长文本流式时 CPU、重排和闪动都不占优。 | | P0 | **流式专项测试补齐** | 继续补围栏、表格、公式、标题切换、列表/引用未闭合、Unicode chunk 边界、长文多轮 append 的单测。这个成本低,但能直接兜住后续重构。 | -| P1 | **目录 TOC 抽取能力** | ST 已有 heading block,但缺少 heading id、TOC 数据结构、滚动定位 API。若业务里有“长文导航/知识库/AI 报告”场景,这一项收益很高。 | +| P1 | **目录 TOC 抽取能力** | 已落地 **`STMarkdownTOCItem`**、管线 **`tableOfContents`**、**`anchorId`** + **`stMarkdownHeadingAnchor`**、**`scrollToHeadingAnchor`**;可继续补内置目录 UI / `onTOCItemTap`、与流式增量同帧刷新。 | | P1 | **脚注与引用语义拆分** | 当前 citation badge 更像业务增强,不等于 CommonMark footnote。若要对齐通用 Markdown 能力,应补 `footnote definition/reference` 语义模型,而不是继续堆 UI 角标。 | -| P1 | **并发压测与线程模型定稿** | 不是先机械照搬 `parseLock`,而是先验证 `STMarkdownEngine` 在并发 `process(_:)`、流式 append、异步 attachment 刷新下是否有竞态/崩溃/性能退化,再决定是否引入 parser 级锁或 actor。 | +| P1 | **并发压测与线程模型定稿** | 解析入口已加 **`parseLock`**;仍建议压测并发 `process(_:)`、流式 append、异步 attachment 刷新,再决定是否扩展 actor / 更广临界区。 | | P2 | **块级 AST 能力补齐** | 如果产品确实需要折叠块与 HTML 片段,再考虑补 `details` / `rawHTML`。这类能力应先落 AST 和 render block,再落 UI;否则后面会继续把语义写死在 renderer。 | | P2 | **统一公共组件面** | Vendor 的 `ScrollableMarkdownViewTextKit` 给了宿主一个“整页预览”入口。ST 现在偏散件组合,建议评估是否提供官方容器组件,统一滚动、高度通知、目录、链接、citation、流式入口。 | | P3 | **TextKit 2 迁移评估** | 这不是当前第一优先级。只有在明确遇到 TextKit 1 的附件布局、选区、超长文档性能或复杂交互瓶颈时,才值得单独立项评估。 | diff --git a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift index f969772..d2114aa 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift @@ -244,7 +244,7 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { let table = STMarkdownTableModel(header: [[.text("H")]], rows: [[ [.text("c")] ]]) let samples: [STMarkdownRenderBlock] = [ .paragraph([.text("p")]), - .heading(level: 2, content: [.text("h")]), + .heading(level: 2, anchorId: "h", content: [.text("h")]), .quote([.paragraph([.text("q")])]), .list([ STMarkdownRenderListItem( diff --git a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift index 68f3c50..81df5f7 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift @@ -61,6 +61,7 @@ final class STMarkdownCoreContractsTests: XCTestCase { XCTAssertEqual(result.sourceDocument, parserOutput) XCTAssertEqual(result.normalizedDocument, parserOutput) XCTAssertEqual(result.renderDocument, renderOutput) + XCTAssertEqual(result.tableOfContents, []) } func testPipelineUsesSemanticNormalizersInOrder() { diff --git a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift index effa152..83dc742 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift @@ -80,7 +80,7 @@ private func st_collectSemanticTextSegments(from blocks: [STMarkdownRenderBlock] case .paragraph(let inlines): let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } - case .heading(_, let inlines): + case .heading(_, _, let inlines): let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } case .quote(let inner): @@ -286,7 +286,7 @@ private func st_renderBlockContainsText(_ block: STMarkdownRenderBlock, text: St switch block { case .paragraph(let inlines): return st_joinInlinePlainText(inlines).contains(text) - case .heading(_, let inlines): + case .heading(_, _, let inlines): return st_joinInlinePlainText(inlines).contains(text) case .quote(let children): return children.contains { st_renderBlockContainsText($0, text: text) } @@ -794,7 +794,7 @@ final class STMarkdownParsingEscapeAndDisplayTests: XCTestCase { ) ) let result = engine.process(md) - guard case .heading(let level, _)? = result.renderDocument.blocks.first else { + guard case .heading(let level, _, _)? = result.renderDocument.blocks.first else { return XCTFail("期望渲染文档首块为 heading,实际:\(String(describing: result.renderDocument.blocks.first))") } XCTAssertEqual(level, 2) diff --git a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift index fbe0dc6..101cf65 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift @@ -1402,7 +1402,8 @@ final class STMarkdownPipelineTests: XCTestCase { appliedRules: [], sourceDocument: STMarkdownDocument(blocks: []), normalizedDocument: STMarkdownDocument(blocks: []), - renderDocument: STMarkdownRenderDocument(blocks: []) + renderDocument: STMarkdownRenderDocument(blocks: []), + tableOfContents: [] ) let sendableCheck: any Sendable = result XCTAssertNotNil(sendableCheck) diff --git a/Example/STBaseProjectExampleTests/STMarkdownTOCTests.swift b/Example/STBaseProjectExampleTests/STMarkdownTOCTests.swift new file mode 100644 index 0000000..46cea78 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownTOCTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import STBaseProject + +final class STMarkdownTOCTests: XCTestCase { + + func testPipelineExtractsTableOfContentsWithStableSlugs() { + let md = """ + # Hello + + ## World Again + + ### Deep + """ + let result = STMarkdownEngine().process(md) + XCTAssertEqual(result.tableOfContents.count, 3) + XCTAssertEqual(result.tableOfContents[0].level, 1) + XCTAssertEqual(result.tableOfContents[0].title, "Hello") + XCTAssertEqual(result.tableOfContents[0].anchorId, "hello") + XCTAssertEqual(result.tableOfContents[1].anchorId, "world-again") + XCTAssertEqual(result.tableOfContents[2].anchorId, "deep") + } + + func testDuplicateHeadingTitlesGetUniqueAnchorIds() { + let md = "## Same\n\n## Same\n" + let result = STMarkdownEngine().process(md) + XCTAssertEqual(result.tableOfContents.count, 2) + XCTAssertEqual(result.tableOfContents[0].anchorId, "same") + XCTAssertEqual(result.tableOfContents[1].anchorId, "same-1") + } + + func testStreamBufferInvokesOnCompleteModules() { + let buffer = STMarkdownStreamBuffer(minModuleLength: 5) + var received: [[String]] = [] + buffer.onCompleteModules = { received.append($0) } + _ = buffer.append("# A\n\nParagraph one is long enough here.\n\n") + XCTAssertEqual(received.count, 1) + XCTAssertFalse(received[0].isEmpty) + } +} diff --git a/Sources/STMarkdown/Core/STMarkdownPipeline.swift b/Sources/STMarkdown/Core/STMarkdownPipeline.swift index 8e15d20..9921c79 100644 --- a/Sources/STMarkdown/Core/STMarkdownPipeline.swift +++ b/Sources/STMarkdown/Core/STMarkdownPipeline.swift @@ -37,6 +37,8 @@ public struct STMarkdownPipelineResult: Sendable { public let sourceDocument: STMarkdownDocument public let normalizedDocument: STMarkdownDocument public let renderDocument: STMarkdownRenderDocument + /// 从 ``renderDocument`` 抽取的目录(对齐对比文档 P1);与富文本 ``NSAttributedString.Key.stMarkdownHeadingAnchor`` 一致。 + public let tableOfContents: [STMarkdownTOCItem] public init( rawMarkdown: String, @@ -44,7 +46,8 @@ public struct STMarkdownPipelineResult: Sendable { appliedRules: [String], sourceDocument: STMarkdownDocument, normalizedDocument: STMarkdownDocument, - renderDocument: STMarkdownRenderDocument + renderDocument: STMarkdownRenderDocument, + tableOfContents: [STMarkdownTOCItem] ) { self.rawMarkdown = rawMarkdown self.sanitizedMarkdown = sanitizedMarkdown @@ -52,6 +55,7 @@ public struct STMarkdownPipelineResult: Sendable { self.sourceDocument = sourceDocument self.normalizedDocument = normalizedDocument self.renderDocument = renderDocument + self.tableOfContents = tableOfContents } } @@ -102,13 +106,15 @@ public final class STMarkdownPipeline: Sendable { let sourceDocument = self.parser.parse(parserInput) let normalizedDocument = self.semanticNormalizer.normalize(sourceDocument) let renderDocument = self.renderAdapter.adapt(normalizedDocument) + let tableOfContents = STMarkdownTOCExtraction.items(from: renderDocument) return STMarkdownPipelineResult( rawMarkdown: rawMarkdown, sanitizedMarkdown: sanitizationResult.sanitizedText, appliedRules: sanitizationResult.appliedRules, sourceDocument: sourceDocument, normalizedDocument: normalizedDocument, - renderDocument: renderDocument + renderDocument: renderDocument, + tableOfContents: tableOfContents ) } } diff --git a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift index dde9ef7..6499fbf 100644 --- a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift +++ b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift @@ -12,6 +12,10 @@ import Foundation /// /// Conformances must be stateless (or internally thread-safe) so that a single adapter /// instance can be shared across concurrent pipeline invocations. +/// +/// - Note: ``STMarkdownRenderBlock/heading(level:anchorId:content:)`` 的 `anchorId` 须与 +/// ``STMarkdownTOCItem/anchorId``、``NSAttributedString.Key/stMarkdownHeadingAnchor`` 一致; +/// 自定义适配器若无法生成 slug,可对纯文本标题使用稳定哈希并保证文档内唯一。 public protocol STMarkdownRenderAdapting: Sendable { func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument } @@ -20,23 +24,27 @@ public struct STMarkdownRenderAdapter: STMarkdownRenderAdapting, Sendable { public init() {} public func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument { - STMarkdownRenderDocument(blocks: document.blocks.map { self.makeRenderBlock(from: $0, listLevel: 0) }) + var slugger = STMarkdownAnchorSlugRegistry() + let blocks = document.blocks.map { self.makeRenderBlock(from: $0, listLevel: 0, slugger: &slugger) } + return STMarkdownRenderDocument(blocks: blocks) } } private extension STMarkdownRenderAdapter { - func makeRenderBlock(from block: STMarkdownBlockNode, listLevel: Int) -> STMarkdownRenderBlock { + func makeRenderBlock(from block: STMarkdownBlockNode, listLevel: Int, slugger: inout STMarkdownAnchorSlugRegistry) -> STMarkdownRenderBlock { switch block { case .paragraph(let inlines): return .paragraph(inlines) case .heading(let level, let content): - return .heading(level: level, content: content) + let plain = content.st_plainTextForTOC() + let anchorId = slugger.uniqueAnchorId(forPlainTitle: plain) + return .heading(level: level, anchorId: anchorId, content: content) case .quote(let blocks): // Quote 内嵌 list 时不推进 listLevel:产品侧把引用块视作视觉"容器", // 不改变列表的逻辑嵌套深度(层级仍以真实 list 节点计算)。 - return .quote(blocks.map { self.makeRenderBlock(from: $0, listLevel: listLevel) }) + return .quote(blocks.map { self.makeRenderBlock(from: $0, listLevel: listLevel, slugger: &slugger) }) case .list(let kind, let items): - return .list(self.flattenListItems(kind: kind, items: items, level: listLevel)) + return .list(self.flattenListItems(kind: kind, items: items, level: listLevel, slugger: &slugger)) case .codeBlock(let language, let code): return .codeBlock(language: language, code: code) case .table(let table): @@ -53,7 +61,8 @@ private extension STMarkdownRenderAdapter { func flattenListItems( kind: STMarkdownListKind, items: [STMarkdownListItemNode], - level: Int + level: Int, + slugger: inout STMarkdownAnchorSlugRegistry ) -> [STMarkdownRenderListItem] { var result: [STMarkdownRenderListItem] = [] let isOrdered: Bool @@ -70,7 +79,7 @@ private extension STMarkdownRenderAdapter { for (index, item) in items.enumerated() { let orderedIndex = isOrdered ? startIndex + index : nil - let renderBlocks = item.blocks.map { self.makeRenderBlock(from: $0, listLevel: level + 1) } + let renderBlocks = item.blocks.map { self.makeRenderBlock(from: $0, listLevel: level + 1, slugger: &slugger) } result.append( STMarkdownRenderListItem( blocks: renderBlocks, diff --git a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift index bf7180d..e36ee00 100644 --- a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift +++ b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift @@ -2,8 +2,7 @@ // STMarkdownStreamBuffer.swift // STBaseProject // -// 智能流式缓冲:按「安全模块」边界切分累积文本,避免未闭合围栏/公式/表格 -// 在流式中途被解析成错误排版。设计思路对齐常见 Markdown 流式组件的模块检测策略。 +// Created by 寒江孤影 on 2019/03/16. // import Foundation @@ -36,6 +35,9 @@ public final class STMarkdownStreamBuffer { private var minModuleLength: Int + /// 本帧检测到一个或多个完整模块时的回调(对照 vendor ``onModuleReady`` 的**字符串子集**:不预解析 AST)。 + public var onCompleteModules: (([String]) -> Void)? + public init(minModuleLength: Int = 20) { self.minModuleLength = max(1, minModuleLength) self.accumulatedText = "" @@ -72,7 +74,11 @@ public final class STMarkdownStreamBuffer { @discardableResult public func append(_ text: String) -> ModuleDetectionResult { accumulatedText += text - return detectCompleteModules() + let result = detectCompleteModules() + if result.completeModules.isEmpty == false { + self.onCompleteModules?(result.completeModules) + } + return result } /// 流结束时将剩余内容全部标为已提交,返回此前未纳入安全前缀的尾部字符串。 @@ -134,7 +140,7 @@ public final class STMarkdownStreamBuffer { var completeModules: [String] = [] var lastBoundary = startPosition - for boundary in boundaries where boundary > accumulatedText.startIndex { + for boundary in boundaries where boundary > textToAnalyze.startIndex { if boundary > lastBoundary { let moduleText = extractModule(from: textToAnalyze, start: lastBoundary, end: boundary) if !moduleText.isEmpty { @@ -204,12 +210,9 @@ public final class STMarkdownStreamBuffer { } if latexCount % 2 != 0 { return .latexBlock } - let lines = text.components(separatedBy: .newlines) - if let lastNonEmpty = lines.last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) { - let trimmed = lastNonEmpty.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("|"), trimmed.contains("|"), !text.hasSuffix("\n\n") { - return .table - } + let trimmed = lastNonEmptyLineTrimmed(in: text) + if trimmed.hasPrefix("|"), trimmed.contains("|"), !text.hasSuffix("\n\n") { + return .table } return nil @@ -366,4 +369,29 @@ public final class STMarkdownStreamBuffer { guard offset > 0 else { return text.startIndex } return text.index(text.startIndex, offsetBy: offset, limitedBy: text.endIndex) ?? text.endIndex } + + /// 与 `lines.last { !$0.trimmingCharacters(in: .whitespaces).isEmpty }` 语义一致,但不分配整份按行数组, + /// 长文流式场景下避免 O(n) 额外内存与一次全量扫描。 + private func lastNonEmptyLineTrimmed(in text: String) -> String { + guard text.isEmpty == false else { return "" } + var endExclusive = text.endIndex + while endExclusive > text.startIndex { + let lineStart: String.Index + if let r = text.range(of: "\n", options: .backwards, range: text.startIndex.. String { + switch self { + case .text(let s): + return s + case .softBreak: + return " " + case .inlineMath(let s, _): + return s + case .emphasis(let c), .strong(let c), .strikethrough(let c): + return c.map { $0.st_plainTextForTOC() }.joined() + case .code(let s): + return s + case .link(_, let c): + return c.map { $0.st_plainTextForTOC() }.joined() + case .image(_, let alt, _): + return alt + } + } +} + +extension Array where Element == STMarkdownInlineNode { + public func st_plainTextForTOC() -> String { + self.map { $0.st_plainTextForTOC() }.joined() + } +} + +// MARK: - Slug + 去重(GitHub 风格简化) + +struct STMarkdownAnchorSlugRegistry: Sendable { + private var used: Set = [] + + mutating func uniqueAnchorId(forPlainTitle plain: String) -> String { + let base = Self.slugify(plain) + let root = base.isEmpty ? "heading" : base + var candidate = root + var n = 1 + while self.used.contains(candidate) { + candidate = "\(root)-\(n)" + n += 1 + } + self.used.insert(candidate) + return candidate + } + + private static func slugify(_ text: String) -> String { + let folded = text.folding(options: .diacriticInsensitive, locale: .current) + var result = "" + var lastWasHyphen = false + for ch in folded.lowercased() { + if ch.isLetter || ch.isNumber { + result.append(ch) + lastWasHyphen = false + } else if result.isEmpty == false, !lastWasHyphen { + result.append("-") + lastWasHyphen = true + } + } + while result.hasSuffix("-") { + result.removeLast() + } + return result + } +} + +// MARK: - 从渲染 AST 抽取 TOC + +enum STMarkdownTOCExtraction { + static func items(from document: STMarkdownRenderDocument) -> [STMarkdownTOCItem] { + var items: [STMarkdownTOCItem] = [] + for block in document.blocks { + self.collect(from: block, into: &items) + } + return items + } + + private static func collect(from block: STMarkdownRenderBlock, into items: inout [STMarkdownTOCItem]) { + switch block { + case .heading(let level, let anchorId, let content): + let title = content.st_plainTextForTOC() + items.append(STMarkdownTOCItem(level: level, title: title, anchorId: anchorId)) + case .quote(let inner): + for b in inner { self.collect(from: b, into: &items) } + case .list(let listItems): + for item in listItems { + for b in item.blocks { + self.collect(from: b, into: &items) + } + } + case .paragraph, .codeBlock, .table, .mathBlock, .image, .thematicBreak: + break + } + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift index 2ec4921..183f3d0 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift @@ -17,7 +17,7 @@ public struct STMarkdownRenderDocument: Hashable, Sendable { public enum STMarkdownRenderBlock: Hashable, Sendable { case paragraph([STMarkdownInlineNode]) - case heading(level: Int, content: [STMarkdownInlineNode]) + case heading(level: Int, anchorId: String, content: [STMarkdownInlineNode]) case quote([STMarkdownRenderBlock]) case list([STMarkdownRenderListItem]) case codeBlock(language: String?, code: String) diff --git a/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift b/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift index bc15284..a50a18e 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift @@ -13,12 +13,17 @@ public protocol STMarkdownStructureParsing: Sendable { } public struct STMarkdownStructureParser: STMarkdownStructureParsing, Sendable { + /// 串行化 swift-markdown / cmark 解析路径,对齐 vendor ``parseLock`` 的防护语义(多线程 + 扩展注册)。 + private static let parseLock = NSLock() + public init() {} public func parse(_ markdown: String) -> STMarkdownDocument { guard markdown.isEmpty == false else { return STMarkdownDocument(blocks: []) } + Self.parseLock.lock() + defer { Self.parseLock.unlock() } let normalized = STMarkdownMathNormalizer.normalizeBlocks(in: markdown) let document = Document(parsing: normalized.text) let blocks = self.makeBlocks(from: Array(document.children), mathMap: normalized.blockMap) diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index 50b6988..b2bbeea 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -76,10 +76,21 @@ private extension STMarkdownAttributedStringRenderer { switch block { case .paragraph(let inlines): return self.renderInline(nodes: inlines, baseFont: self.style.font, textColor: self.style.textColor) - case .heading(let level, let content): + case .heading(let level, let anchorId, let content): let headingFont = self.headingFont(for: level) let headingColor = self.style.headingTextColor ?? self.style.textColor - return self.renderInline(nodes: content, baseFont: headingFont, textColor: headingColor, paragraphStyle: self.headingParagraphStyle(font: headingFont), kernOverride: self.style.headingKern) + let body = self.renderInline( + nodes: content, + baseFont: headingFont, + textColor: headingColor, + paragraphStyle: self.headingParagraphStyle(font: headingFont), + kernOverride: self.style.headingKern + ) + let out = NSMutableAttributedString(attributedString: body) + if out.length > 0 { + out.addAttribute(.stMarkdownHeadingAnchor, value: anchorId, range: NSRange(location: 0, length: out.length)) + } + return out case .quote(let blocks): return self.renderQuote(blocks: blocks) case .list(let items): @@ -623,7 +634,7 @@ private extension STMarkdownAttributedStringRenderer { } switch block { - case .heading(let level, _): + case .heading(let level, _, _): let font = self.headingFont(for: level) return font.pointSize * self.style.headingLineHeightMultiplier default: @@ -672,7 +683,7 @@ private extension STMarkdownAttributedStringRenderer { func leadingBlockSpacing(for block: STMarkdownRenderBlock) -> CGFloat { switch block { - case .heading(let level, _): + case .heading(let level, _, _): if let topSpacings = self.style.headingTopSpacing, level >= 1, level <= topSpacings.count { return topSpacings[level - 1] @@ -687,7 +698,7 @@ private extension STMarkdownAttributedStringRenderer { func trailingBlockSpacing(for block: STMarkdownRenderBlock) -> CGFloat { switch block { - case .heading(let level, _): + case .heading(let level, _, _): if let bottomSpacings = self.style.headingBottomSpacing, level >= 1, level <= bottomSpacings.count { return bottomSpacings[level - 1] diff --git a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift index 5efcae5..61ebf80 100644 --- a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift @@ -68,6 +68,9 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { public internal(set) var rawMarkdown: String = "" + /// 最近一次管线渲染对应的目录(对齐对比文档 P1);与 ``scrollToHeadingAnchor`` 使用同一套 ``anchorId``。 + public private(set) var tableOfContents: [STMarkdownTOCItem] = [] + public var attributedText: NSAttributedString { self.currentAttributedText } @@ -199,11 +202,52 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { self.textView.attributedText ?? NSAttributedString() } + internal func updateTableOfContents(from result: STMarkdownPipelineResult) { + self.tableOfContents = result.tableOfContents + } + internal func renderMarkdown(_ markdown: String) -> NSAttributedString { let result = self.engine.process(markdown) + self.updateTableOfContents(from: result) return self.renderer.render(document: result.renderDocument) } + /// 将可见区域滚动到指定标题锚点(``NSAttributedString.Key.stMarkdownHeadingAnchor``)。 + /// - Returns: 是否找到对应锚点。 + @discardableResult + public func scrollToHeadingAnchor(id: String, animated _: Bool) -> Bool { + guard let attr = self.textView.attributedText, attr.length > 0 else { return false } + var target: NSRange? + attr.enumerateAttribute( + .stMarkdownHeadingAnchor, + in: NSRange(location: 0, length: attr.length) + ) { value, range, stop in + guard let s = value as? String, s == id else { return } + target = range + stop.pointee = true + } + guard let range = target else { return false } + self.textView.scrollRangeToVisible(range) + let selection = NSRange(location: range.location, length: 0) + self.textView.selectedRange = selection + return true + } + + /// 查询标题锚点在富文本中的 UTF-16 范围(便于外层 ``UIScrollView`` 自行滚动)。 + public func characterRangeForHeadingAnchor(id: String) -> NSRange? { + guard let attr = self.textView.attributedText, attr.length > 0 else { return nil } + var target: NSRange? + attr.enumerateAttribute( + .stMarkdownHeadingAnchor, + in: NSRange(location: 0, length: attr.length) + ) { value, range, stop in + guard let s = value as? String, s == id else { return } + target = range + stop.pointee = true + } + return target + } + internal func applyConfigurationCommon( style: STMarkdownStyle, advancedRenderers: STMarkdownAdvancedRenderers, @@ -296,6 +340,7 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { internal func resetBaseState() { self.rawMarkdown = "" + self.tableOfContents = [] for token in self.attachmentRefreshTokens { token.invalidate() } diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index a776d46..d38e0a1 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -120,7 +120,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { let currentStr = current.string let renderedStr = displayRendered.string - if renderedStr.count >= currentStr.count, + if renderedStr.utf16.count >= currentStr.utf16.count, (renderedStr as NSString).hasPrefix(currentStr) { let prefixChanged = current.length > 0 && !displayRendered.attributedSubstring( @@ -316,6 +316,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { private func render(_ markdown: String) -> NSAttributedString { let result = self.engine.process(markdown) + self.updateTableOfContents(from: result) if let customRenderer = self.customDocumentRenderer { return customRenderer(result.renderDocument) } From 389c02ad42feca66bdbd711c0926e68ff80661ec Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 11:15:02 +0800 Subject: [PATCH 06/27] Implement incremental parsing capabilities in STMarkdown - Introduced `processIncremental` method in `STMarkdownPipeline` to handle incremental parsing of Markdown, providing a more efficient way to process changes. - Updated `STMarkdownEngine` to expose the new incremental processing functionality. - Enhanced `STMarkdownStreamBuffer` documentation to clarify its role in conjunction with incremental parsing. - Added tests for `STMarkdownIncrementalParseResult` to ensure accurate behavior during incremental updates. --- ...Markdown-MarkdownDisplayView-Comparison.md | 36 +++-- .../STMarkdownIncrementalParseTests.swift | 75 ++++++++++ .../STMarkdown/Core/STMarkdownEngine.swift | 5 + .../Core/STMarkdownIncrementalParse.swift | 128 ++++++++++++++++++ .../STMarkdown/Core/STMarkdownPipeline.swift | 48 +++++++ .../Core/STMarkdownStreamBuffer.swift | 3 + 6 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift create mode 100644 Sources/STMarkdown/Core/STMarkdownIncrementalParse.swift diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index e0fc468..70415d4 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -102,7 +102,7 @@ | 维度 | Vendor 结论 | ST 结论 | 判断 | 对齐 | |------|-------------|---------|------|------| -| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | `STMarkdownStreamBuffer` 只负责**字符串模块切分**,真正渲染仍走整段 `engine.process(...)` | ST **弱于** vendor | **【部分对齐】**(缓冲安全切分 **【已对齐】**;元素级增量 **【未对齐】**) | +| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | **整段**仍走 `process`;**``processIncremental``** 提供回溯窗口子串 + **`replaceTailCount`** + ``windowRenderDocument``;安全上界仍由缓冲器提供 | ST **弱于** vendor 一体化 | **【部分对齐】** | | 流式模块回调 | `MarkdownStreamBuffer.onModuleReady` 可回传预解析 `MarkdownRenderElement` | **`onCompleteModules`** 仅回传 **完整模块字符串**;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | | 块级能力 | `MarkdownRenderElement` 含 `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` | `STMarkdownBlockNode` 仍为 paragraph/heading/…;**`STMarkdownRenderBlock.heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` / `footnote` | **【部分对齐】** | | TOC | 视图层公开 `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | **`STMarkdownPipelineResult.tableOfContents`**、**`STMarkdownBaseTextView.tableOfContents`**、**`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;无内置目录 UI / `onTOCItemTap` | ST **弱于** vendor 一体面 | **【部分对齐】** | @@ -144,10 +144,10 @@ - **`STMarkdownStreamBuffer`** **【部分对齐】**:围栏闭合处切分、段落模式 EOF 尾段、**字符偏移**持久化 `lastSafeUpperBoundOffset`;可选 **`onCompleteModules`**(对照 vendor 模块就绪的字符串子集)。**【未对齐】** 项见 §3 `MarkdownStreamBuffer` 行。 - **`STMarkdownBaseTextView`** **【部分对齐】**:`resolvedMarkdownMeasurementWidth()`、高度回退、`contentLayoutHeightNotificationMinInterval`;**`tableOfContents`**、**`scrollToHeadingAnchor`**、**`characterRangeForHeadingAnchor`**(TOC 数据与跳转)。 - **`STMarkdownStructureParser`** **【部分对齐】**:**`parseLock`** 串行化 swift-markdown 解析路径(对照 vendor)。 -- **`STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`** **【部分对齐】**:坏表修复语义;管线 **`STMarkdownPipelineResult.tableOfContents`**。 +- **`STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`** **【部分对齐】**:坏表修复语义;管线 **`STMarkdownPipelineResult.tableOfContents`**;**``processIncremental(_:)``**(回溯窗口子串 parse + **`replaceTailCount`** + ``mergedRenderDocument``,见 §7.2.5)。 - **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:渲染侧锚点与 TOC 一致。 -单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例;**`STMarkdownTOCTests`**。 +单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例;**`STMarkdownTOCTests`**、**`STMarkdownIncrementalParseTests`**。 --- @@ -167,7 +167,7 @@ | 优先级 | 方向 | 说明 | |--------|------|------| -| P0 | **流式增量渲染链补强** | 当前 ST 已有 `STMarkdownStreamBuffer`,但渲染仍偏“整段重跑”。最优先补的是 **增量 parse / replaceCount / 安全回溯窗口**,否则长文本流式时 CPU、重排和闪动都不占优。 | +| P0 | **流式增量渲染链补强** | 已提供 **``STMarkdownPipeline/processIncremental``**(`replaceTailCount` + 窗口 ``STMarkdownRenderDocument`` + 合并 helper);与 ``STMarkdownStreamBuffer`` 组合可逼近 Vendor 窗口策略。**仍缺**:与 TextKit 增量 `replaceCharacters` 的硬连接、内置 `findSafeBreakpoint` 与缓冲一体化。 | | P0 | **流式专项测试补齐** | 继续补围栏、表格、公式、标题切换、列表/引用未闭合、Unicode chunk 边界、长文多轮 append 的单测。这个成本低,但能直接兜住后续重构。 | | P1 | **目录 TOC 抽取能力** | 已落地 **`STMarkdownTOCItem`**、管线 **`tableOfContents`**、**`anchorId`** + **`stMarkdownHeadingAnchor`**、**`scrollToHeadingAnchor`**;可继续补内置目录 UI / `onTOCItemTap`、与流式增量同帧刷新。 | | P1 | **脚注与引用语义拆分** | 当前 citation badge 更像业务增强,不等于 CommonMark footnote。若要对齐通用 Markdown 能力,应补 `footnote definition/reference` 语义模型,而不是继续堆 UI 角标。 | @@ -203,26 +203,34 @@ | 概念 | Vendor | ST(当前) | |------|--------|------------| -| 流式安全切分 | `lastSafePosition` 与 parser 协同 | `STMarkdownStreamBuffer` 的 **`lastSafeUpperBoundOffset`**,仅字符串边界 | -| 解析范围 | 回溯窗口 + 子串 parse | 每次 **`STMarkdownEngine.process` → 整段管线** 为主 | -| 增量产物 | `newElements` + **`replaceCount`** + `tocItems` | 无元素级 **`newBlocks`/`replaceTailCount`** 公开形态 | -| 并发 | **`parseLock`** 串行化 cmark 路径 | `STMarkdownEngine` 文档约定 **Sendable** 组件时可多线程 `process`,**无** vendor 同款 parser 级锁 | +| 流式安全切分 | `lastSafePosition` 与 parser 协同 | `STMarkdownStreamBuffer` 的 **`lastSafeUpperBoundOffset`**;与 ``STMarkdownIncrementalParameters`` 偏移对齐 | **【部分对齐】** | +| 解析范围 | 回溯窗口 + 子串 parse | ``STMarkdownPipeline/processIncremental``:``parseStart = max(0, lastCommitted - contextWindowSize)``,`parseEnd = currentSafeExclusiveEnd` | **【部分对齐】** | +| 增量产物 | `newElements` + **`replaceCount`** + `tocItems` | ``STMarkdownIncrementalParseResult``:**`replaceTailCount`** + **`windowRenderDocument`** + **`windowTableOfContents`** + ``mergedRenderDocument`` | **【部分对齐】** | +| 并发 | **`parseLock`** 串行化 cmark 路径 | ``STMarkdownStructureParser`` 内 **`parseLock`** | **【已对齐】** | #### 7.2.3 ST 侧「增量 AST」若要落地,建议拆成两层 1. **缓冲层(已有方向)**:继续用 `STMarkdownStreamBuffer` 决定「这一帧可安全提交给渲染器的 markdown 子串」(对齐 vendor 的 safe 边界思想,但 ST 用 UTF-16/字符偏移持久化,避免 `String.Index` 失效)。 -2. **AST / 渲染元素层(待建)**:在管线结果或旁路缓存中维护 **`[STMarkdownRenderBlock]`(或更粗的 `MarkdownRenderElement` 等价物)**,对每一帧: - - 仅对**尾部窗口**(例如最近 K 个 block 或最近 M 个字符对应的 span)做 **重新 parse**; - - 用 **`replaceTailCount`**(语义对齐 vendor `replaceCount`)替换列表尾部,再 **拼接** 已冻结前缀的 attributed 结果,或走 TextKit 的 `replaceCharacters` 等价更新。 +2. **AST / 渲染元素层(已提供公开入口)**:``STMarkdownPipeline/processIncremental(_:)`` 在管线内维护 **窗口子串 → `STMarkdownRenderDocument`**,并输出 **`replaceTailCount`**(与 Vendor ``estimateReplaceCount`` 同式)及 ``mergedRenderDocument(previous:)``;与 **TextKit `replaceCharacters`** 的硬连接、与缓冲器内置 `findSafeBreakpoint` 一体化仍为后续工作。 这样文档里说的「增量 AST」才名副其实:光有字符串 `safe` 切分没有 **元素级 tail replace**,长文流式仍会整段重跑,CPU 与闪动与 vendor 不在同一量级。 #### 7.2.4 `parseLock` 是否要在 ST 照搬 -不必先照搬 **`NSLock` 静态单例**;更合理的顺序是: +ST 已在 ``STMarkdownStructureParser`` 解析路径上使用 **`parseLock`**(见 §3 / §5)。若仍出现并发下的 cmark 竞态,再按压测结论考虑 **actor** 或更广临界区。 -- 先明确 **是否存在多线程同时进入同一条 cmark / swift-markdown 路径**(共享 parser、共享扩展注册、全局缓存等)。 -- 若压测或静态审查确认 **底层非线程安全**,再在 **`STMarkdownStructureParser`(或唯一 `parseDocument` 入口)** 外包一层 **最小临界区**(`NSLock`、`os_unfair_lock`、或 **`actor` 串行 parse**),与 `Sendable` 组合关系写进文档与单测。 +#### 7.2.5 ST 已提供的增量 API(`replaceTailCount` / 窗口 parse) + +以下对应 Vendor ``IncrementalParseResult`` 的 **可编程子集**(子串仍走完整 parse → normalize → adapt,与 Vendor 对窗口片段调用 `parseDocument` 同构): + +| 类型 / 方法 | 作用 | +|-------------|------| +| ``STMarkdownIncrementalParameters`` | `canonicalMarkdown`、`lastCommittedExclusiveEnd`、`currentSafeExclusiveEnd`、`contextWindowSize`(默认 200)、`previousTotalRenderBlockCount` | +| ``STMarkdownPipeline/processIncremental(_:)`` / ``STMarkdownEngine/processIncremental(_:)`` | 计算 `parseStart = max(0, lastCommitted - window)`、`parseEnd = currentSafeEnd`,对 `[parseStart, parseEnd)` 子串跑管线(**不**跑输入 sanitizer,见参数文档) | +| ``STMarkdownIncrementalParseResult`` | `replaceTailCount`(与 Vendor ``estimateReplaceCount`` 相同启发式)、`windowRenderDocument`、`windowTableOfContents`、`mergedRenderDocument(previous:)` | +| ``STMarkdownIncrementalParseResult/mergedRenderBlocks`` | 纯函数尾部拼接,便于单元测试与宿主实验 | + +**局限(与 Vendor 全量能力仍有差距)**:未内置 `findSafeBreakpoint` / `hasPendingStructure` 与缓冲器二合一;宿主需自行把 ``STMarkdownStreamBuffer`` 的安全上界喂给 `currentSafeExclusiveEnd`。合并后的 **标题 `anchorId`** 若需全局唯一,仍应对全文再跑一次 ``process(_:)`` 或自建 slug 策略。 --- diff --git a/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift b/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift new file mode 100644 index 0000000..482d502 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import STBaseProject + +final class STMarkdownIncrementalParseTests: XCTestCase { + + func testReplaceTailCountMatchesVendorHeuristic() { + // parseStart=50, lastCommitted=250 → backtrack 200 → max(1,2)=2 + let n = STMarkdownIncrementalReplaceCountEstimator.estimateReplaceTailCount( + previousTotalRenderBlockCount: 10, + parseStart: 50, + lastCommittedExclusiveEnd: 250 + ) + XCTAssertEqual(n, 2) + let capped = STMarkdownIncrementalReplaceCountEstimator.estimateReplaceTailCount( + previousTotalRenderBlockCount: 1, + parseStart: 50, + lastCommittedExclusiveEnd: 250 + ) + XCTAssertEqual(capped, 1) + } + + func testReplaceTailCountZeroWhenNoRewindOverlap() { + XCTAssertEqual( + STMarkdownIncrementalReplaceCountEstimator.estimateReplaceTailCount( + previousTotalRenderBlockCount: 99, + parseStart: 100, + lastCommittedExclusiveEnd: 100 + ), + 0 + ) + } + + func testProcessIncrementalParsesWindowFragment() { + let pipeline = STMarkdownPipeline(configuration: STMarkdownPipelineConfiguration(enableInputSanitizer: false)) + let canonical = "# Title\n\nFirst paragraph is here.\n\n## Sub\n\nSecond." + let full = pipeline.process(canonical) + let prevCount = full.renderDocument.blocks.count + XCTAssertGreaterThan(prevCount, 0) + + let lastCommitted = canonical.distance(from: canonical.startIndex, to: canonical.firstIndex(of: "F")!) + let safeEnd = canonical.count + let inc = pipeline.processIncremental( + STMarkdownIncrementalParameters( + canonicalMarkdown: canonical, + lastCommittedExclusiveEnd: lastCommitted, + currentSafeExclusiveEnd: safeEnd, + contextWindowSize: 200, + previousTotalRenderBlockCount: prevCount + ) + ) + XCTAssertGreaterThan(inc.parseEndOffset, inc.parseStartOffset) + XCTAssertFalse(inc.windowFragment.isEmpty) + XCTAssertFalse(inc.windowRenderDocument.blocks.isEmpty) + XCTAssertGreaterThanOrEqual(inc.replaceTailCount, 0) + } + + func testMergedRenderBlocksReplacesTail() { + let prev: [STMarkdownRenderBlock] = [ + .paragraph([.text("a")]), + .paragraph([.text("b")]), + .paragraph([.text("c")]), + ] + let newTail: [STMarkdownRenderBlock] = [ + .paragraph([.text("x")]), + ] + let merged = STMarkdownIncrementalParseResult.mergedRenderBlocks( + previous: prev, + replaceTailCount: 2, + newTailBlocks: newTail + ) + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged[0], prev[0]) + XCTAssertEqual(merged[1], newTail[0]) + } +} diff --git a/Sources/STMarkdown/Core/STMarkdownEngine.swift b/Sources/STMarkdown/Core/STMarkdownEngine.swift index 6f121f9..13a3372 100644 --- a/Sources/STMarkdown/Core/STMarkdownEngine.swift +++ b/Sources/STMarkdown/Core/STMarkdownEngine.swift @@ -36,4 +36,9 @@ public final class STMarkdownEngine: STMarkdownProcessing, Sendable { public func process(_ rawMarkdown: String) -> STMarkdownPipelineResult { self.pipeline.process(rawMarkdown) } + + /// 见 ``STMarkdownPipeline/processIncremental(_:)``。 + public func processIncremental(_ parameters: STMarkdownIncrementalParameters) -> STMarkdownIncrementalParseResult { + self.pipeline.processIncremental(parameters) + } } diff --git a/Sources/STMarkdown/Core/STMarkdownIncrementalParse.swift b/Sources/STMarkdown/Core/STMarkdownIncrementalParse.swift new file mode 100644 index 0000000..885b463 --- /dev/null +++ b/Sources/STMarkdown/Core/STMarkdownIncrementalParse.swift @@ -0,0 +1,128 @@ +// +// STMarkdownIncrementalParse.swift +// STBaseProject +// +// 元素级增量解析入口:回溯窗口子串 parse + ``replaceTailCount``(语义对齐 Vendor ``estimateReplaceCount`` / ``replaceCount``)。 +// + +import Foundation + +// MARK: - 参数与结果 + +/// 增量解析输入。 +/// +/// **偏移约定**:`lastCommittedExclusiveEnd` / `currentSafeExclusiveEnd` 与 ``STMarkdownStreamBuffer`` +/// 使用的 **Character 偏移**(`String.index(_:offsetBy:limitedBy:)` 从 `startIndex` 起算)一致,且作用于 +/// 本参数中的 ``canonicalMarkdown`` 整串。 +/// +/// - Important: 本路径**不执行**输入规整器(`STMarkdownInputSanitizer`)。若管线启用 sanitizer, +/// 请自行对全文做规整后再把结果作为 ``canonicalMarkdown`` 传入,并保证偏移与之一致;否则请关闭 +/// sanitizer 后使缓冲串与 canonical 为同一字符串(常见流式场景)。 +public struct STMarkdownIncrementalParameters: Sendable, Hashable { + /// 当前帧完整 Markdown(已与缓冲 / 规整结果对齐)。 + public var canonicalMarkdown: String + /// 上一帧已冻结、本帧仍视为不可变的前缀上界(不包含该下标)。 + public var lastCommittedExclusiveEnd: Int + /// 本帧安全解析上界(不包含该下标),通常来自缓冲器的 `lastSafeUpperBoundOffset`。 + public var currentSafeExclusiveEnd: Int + /// 向前回溯字符数,对齐 Vendor ``contextWindowSize``(默认 200)。 + public var contextWindowSize: Int + /// 上一帧合并后 **渲染块总数**(对齐 Vendor ``previousElementCount``)。 + public var previousTotalRenderBlockCount: Int + + public init( + canonicalMarkdown: String, + lastCommittedExclusiveEnd: Int, + currentSafeExclusiveEnd: Int, + contextWindowSize: Int = 200, + previousTotalRenderBlockCount: Int + ) { + self.canonicalMarkdown = canonicalMarkdown + self.lastCommittedExclusiveEnd = lastCommittedExclusiveEnd + self.currentSafeExclusiveEnd = currentSafeExclusiveEnd + self.contextWindowSize = max(0, contextWindowSize) + self.previousTotalRenderBlockCount = max(0, previousTotalRenderBlockCount) + } +} + +/// 单帧增量解析产物(对齐 Vendor ``IncrementalParseResult`` 的核心字段子集)。 +public struct STMarkdownIncrementalParseResult: Sendable, Hashable { + /// 与 Vendor ``replaceCount`` 对应:应从上一帧 **渲染块列表尾部** 丢弃的块数,再拼接 ``windowRenderDocument/blocks``。 + public let replaceTailCount: Int + public let parseStartOffset: Int + public let parseEndOffset: Int + /// 实际参与 parse 的子串(`canonicalMarkdown[parseStart.. STMarkdownRenderDocument { + let merged = Self.mergedRenderBlocks( + previous: previous.blocks, + replaceTailCount: self.replaceTailCount, + newTailBlocks: self.windowRenderDocument.blocks + ) + return STMarkdownRenderDocument(blocks: merged) + } + + public static func mergedRenderBlocks( + previous: [STMarkdownRenderBlock], + replaceTailCount: Int, + newTailBlocks: [STMarkdownRenderBlock] + ) -> [STMarkdownRenderBlock] { + let k = max(0, previous.count - replaceTailCount) + return Array(previous.prefix(k)) + newTailBlocks + } +} + +// MARK: - replaceCount 估算(对齐 Vendor ``estimateReplaceCount``) + +enum STMarkdownIncrementalReplaceCountEstimator { + /// Vendor:`parseStart < lastSafePosition` 时 `max(1, backtrackChars/100)` 再 `min(previousElementCount, …)`。 + static func estimateReplaceTailCount( + previousTotalRenderBlockCount: Int, + parseStart: Int, + lastCommittedExclusiveEnd: Int + ) -> Int { + guard parseStart < lastCommittedExclusiveEnd else { return 0 } + let backtrackChars = lastCommittedExclusiveEnd - parseStart + let estimated = max(1, backtrackChars / 100) + return min(estimated, previousTotalRenderBlockCount) + } +} + +// MARK: - 子串(Character 偏移) + +enum STMarkdownIncrementalSubstring { + /// 使用与 ``STMarkdownStreamBuffer`` 相同的 Character 偏移语义。 + static func fragment(in text: String, startOffset: Int, endOffset: Int) -> String { + guard text.isEmpty == false, startOffset < endOffset, startOffset >= 0 else { return "" } + let endClamped = min(endOffset, text.count) + guard let sIdx = text.index(text.startIndex, offsetBy: startOffset, limitedBy: text.endIndex), + let eIdx = text.index(text.startIndex, offsetBy: endClamped, limitedBy: text.endIndex), + sIdx < eIdx else { + return "" + } + return String(text[sIdx.. STMarkdownIncrementalParseResult { + let text = parameters.canonicalMarkdown + let lastCommitted = parameters.lastCommittedExclusiveEnd + let safeEnd = parameters.currentSafeExclusiveEnd + let window = parameters.contextWindowSize + + let parseStart = max(0, lastCommitted - window) + let parseEnd = min(max(0, safeEnd), text.count) + + guard parseStart < parseEnd else { + return STMarkdownIncrementalParseResult( + replaceTailCount: 0, + parseStartOffset: parseStart, + parseEndOffset: parseEnd, + windowFragment: "", + windowRenderDocument: STMarkdownRenderDocument(blocks: []), + windowTableOfContents: [] + ) + } + + let fragment = STMarkdownIncrementalSubstring.fragment(in: text, startOffset: parseStart, endOffset: parseEnd) + let parserInput = STMarkdownMalformedTableNormalizer.normalize( + fragment, + enabled: self.configuration.autoFixMalformedTables + ) + let sourceDocument = self.parser.parse(parserInput) + let normalizedDocument = self.semanticNormalizer.normalize(sourceDocument) + let renderDocument = self.renderAdapter.adapt(normalizedDocument) + let windowTOC = STMarkdownTOCExtraction.items(from: renderDocument) + let replaceTail = STMarkdownIncrementalReplaceCountEstimator.estimateReplaceTailCount( + previousTotalRenderBlockCount: parameters.previousTotalRenderBlockCount, + parseStart: parseStart, + lastCommittedExclusiveEnd: lastCommitted + ) + return STMarkdownIncrementalParseResult( + replaceTailCount: replaceTail, + parseStartOffset: parseStart, + parseEndOffset: parseEnd, + windowFragment: fragment, + windowRenderDocument: renderDocument, + windowTableOfContents: windowTOC + ) + } } diff --git a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift index e36ee00..a220024 100644 --- a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift +++ b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift @@ -12,6 +12,9 @@ import Foundation /// 流式场景下用于累积 chunk、检测可独立渲染的 Markdown 模块的缓冲器。 /// /// - Note: 使用 Swift 字符串的 `Index` 与 `offsetBy(_:limitedBy:)` 做边界截取,避免流式 Unicode 截断问题。 +/// - SeeAlso: 与 ``STMarkdownStreamBuffer/lastSafeUpperBoundOffset`` 同语义的 **Character 偏移** 可传入 +/// ``STMarkdownIncrementalParameters``,由 ``STMarkdownPipeline/processIncremental(_:)`` 做回溯窗口子串解析与 +/// ``STMarkdownIncrementalParseResult/replaceTailCount`` 估算(见对比文档第 7.2.5 节)。 public final class STMarkdownStreamBuffer { public struct ModuleDetectionResult: Sendable { From 683b83855c29fd7a1e61844187c5dd7d582fad83 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 14:37:31 +0800 Subject: [PATCH 07/27] Enhance STMarkdown with footnote and raw HTML support - Introduced footnote handling in `STMarkdownDocument` and `STMarkdownRenderAdapter`, allowing for the inclusion of footnotes in rendered documents. - Added support for rendering raw HTML blocks with configurable policies in `STMarkdownStyle`, enhancing flexibility in document rendering. - Updated parsing logic to accommodate new inline and block node types for footnotes and raw HTML. - Enhanced `STMarkdownAttributedStringRenderer` to properly render footnotes and raw HTML according to the specified policies. --- ...Markdown-MarkdownDisplayView-Comparison.md | 304 ++++++------------ ...rkdownASTAndRenderASTExhaustiveTests.swift | 18 ++ .../STMarkdownCoreContractsTests.swift | 5 +- .../STMarkdownFootnoteAndHTMLTests.swift | 48 +++ ...MarkdownParsingEscapeAndDisplayTests.swift | 20 +- .../STMarkdownPipelineTests.swift | 5 +- ...reParserParseAndRenderIntegrityTests.swift | 4 + .../Core/STMarkdownRenderAdapter.swift | 15 +- Sources/STMarkdown/Core/STMarkdownStyle.swift | 14 +- Sources/STMarkdown/Core/STMarkdownTOC.swift | 12 +- .../STMarkdown/Parsing/STMarkdownAST.swift | 23 +- .../Parsing/STMarkdownFootnoteSupport.swift | 279 ++++++++++++++++ .../STMarkdownHTMLBlockClassifier.swift | 72 +++++ .../Parsing/STMarkdownRenderAST.swift | 2 + .../STMarkdownSemanticNormalizer.swift | 17 +- .../Parsing/STMarkdownStructureParser.swift | 58 +++- .../STMarkdownAttributedStringRenderer.swift | 136 ++++++-- .../STMarkdownDefaultTableRenderer.swift | 4 + .../UI/STMarkdownStreamingTextView.swift | 9 +- .../STMarkdown/UI/STMarkdownTextView.swift | 9 +- .../UI/STScrollableMarkdownView.swift | 77 +++++ .../STTextView/STShimmerTextView.swift | 14 + 22 files changed, 892 insertions(+), 253 deletions(-) create mode 100644 Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownHTMLBlockClassifier.swift create mode 100644 Sources/STMarkdown/UI/STScrollableMarkdownView.swift diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index 70415d4..247d326 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -1,298 +1,186 @@ -# STMarkdown 与 MarkdownDisplayView(Vendor)对比说明 +# STMarkdown 与 MarkdownDisplayView(Vendor)功能对比 -> 对照基准(Vendor 源码路径): -> `/Users/song/Downloads/MarkdownDisplayView/MarkdownDisplayView/Sources/MarkdownDisplayView` -> 对照对象(本仓库): -> `Sources/STMarkdown/` +> 对照基准:Vendor 库 [MarkdownDisplayView](https://github.com/zjc19891106/MarkdownDisplayView.git) 中 `MarkdownDisplayView` target 源码。 +> 对照对象:本仓库 `Sources/STMarkdown/`。 > 文档生成日期:2026-05-14 -本文汇总架构、文件映射、能力差异及在 Cursor 中的对比方式,便于后续按模块对齐或迁移。 +本文只保留**行为与能力**层面的差异与对齐说明,便于宿主选型与后续按能力补齐。 -**对齐标注图例**(下文表格「对齐」列或行内标签沿用同一套语义): +**对齐标注图例**(下文「对齐」列沿用同一套语义): | 标签 | 含义 | |------|------| | **【已对齐】** | 该维度上能力或依赖已等价覆盖(实现路径、API 名称可与 Vendor 不同)。 | | **【部分对齐】** | 双方均有对应能力或同类入口,但栈、交互模型或公开面仍有明显差异。 | | **【未对齐】** | ST 侧缺失、明确不支持或架构路线与 Vendor 不可直接等同。 | -| (留空或 **—**) | 属仓库形态类对比,不评「能力对齐」。 | --- -## 1. 仓库与路径 - -### 1.1 Vendor 仓库布局 - -- **仓库根**:例如 `MarkdownDisplayView/`(含顶层 `Package.swift`、`Example/` 等)。 -- **SPM 库实现**:`MarkdownDisplayView/Sources/MarkdownDisplayView/` -- **资源**:`MarkdownDisplayView/Sources/MarkdownDisplayView/Resources/`(KaTeX 字体等,约 20 项) - -### 1.2 STBaseProject 布局 - -- **模块根**:`Sources/STMarkdown/` -- **子目录**:`Core/`、`Parsing/`、`Rendering/`、`Table/`、`UI/`、`Attachments/`、`Resources/` - ---- - -## 2. 形态对比 +## 1. 运行环境与依赖(影响行为与集成) | 维度 | MarkdownDisplayView(Vendor) | STMarkdown | 对齐 | |------|--------------------------------|------------|------| -| Swift 文件量 | 约 **21** 个 + `Resources/` | **45** 个 + `Resources/` | — | -| 代码组织 | **少量超大文件**(如 `MarkdownDisplayView.swift`、`MarkdownParser.swift`) | **分层**、职责拆分 | — | -| 最低 iOS(以各自 Package/podspec 为准) | iOS **15+**(`@available(iOS 15.0, *)` 等) | iOS **16+**(工程配置) | **【未对齐】**(系统版本门槛不同) | +| 最低 iOS(以 Package/podspec 为准) | iOS **15+** | iOS **16+** | **【未对齐】** | | 解析依赖 | **swift-markdown**(`import Markdown`) | **swift-markdown**(SPM `Markdown`) | **【已对齐】** | -| CocoaPods 差异(若用 Pod) | 可能经 **AppleSwiftMDWrapper** 等桥接 | **swift-markdown-pod** + CAtomic modulemap | **【部分对齐】**(均为 Pod 集成路径,桥接方案不同) | -| 数学公式 | **KaTeX**(字体 + `LaTeXAttachment` / `LatexMathView` 等) | **SwiftMath** + `STMarkdownMathNormalizer` | **【部分对齐】**(均有公式渲染链,引擎与资源不同) | -| **SPM 产品名** | `MarkdownDisplayView` | 合在 **`STBaseProject`** 的 `STMarkdown` 源码目录(非独立 SwiftPM 产品名) | — | -| **CocoaPods 产品名** | **`MarkdownDisplayKit`**(与 SPM 名不同) | **`STBaseProject/STMarkdown`** subspec | — | -| **swift-markdown 版本** | SPM:`from: "0.7.3"`(随解析器升级行为可能变) | 本仓库 `Package.swift` **固定 revision**;与 vendor 的 cmark/扩展差异需升级时单独回归 | **【部分对齐】**(同为 swift-markdown,版本策略不同) | +| CocoaPods(若用 Pod) | 可能经 **AppleSwiftMDWrapper** 等桥接 | **swift-markdown-pod** + CAtomic modulemap | **【部分对齐】** | +| 数学公式 | **KaTeX**(字体 + LaTeX 附件 / 视图链) | **SwiftMath** + `STMarkdownMathNormalizer` | **【部分对齐】**(引擎与命令集不必一致) | +| swift-markdown 版本策略 | SPM:`from: "0.7.3"`(随升级行为可能变) | 本仓库 **固定 revision**;升级需单独回归 | **【部分对齐】** | --- -## 2.1 对外 API 入口(便于宿主对照) +## 2. 对外 API 与宿主场景 | 场景 | Vendor(典型) | STMarkdown(典型) | 对齐 | |------|----------------|-------------------|------| -| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 `MarkdownViewTextKit`) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | **【部分对齐】**(能力可用,无 Vendor 同名一体化控件) | -| 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | **【部分对齐】**(均有流式/动画入口,粒度 API 不同) | -| 配置对象 | `MarkdownConfiguration`(大结构体,含 `MarkdownLineSpacingConfiguration`、`SyntaxHighlightColors` 等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | **【部分对齐】**(均有可配置样式与管线,结构拆分不同) | +| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 TextKit 视图) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | **【部分对齐】**(无同名一体化控件) | +| 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | **【部分对齐】**(粒度 API 不同) | +| 配置对象 | `MarkdownConfiguration`(大结构体,含行距、语法高亮色等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | **【部分对齐】** | | 流式触感 | `StreamingHapticFeedbackStyle` 等 | **无**同名 API;需宿主自行 `UIImpactFeedbackGenerator` | **【未对齐】** | -| Mermaid / 自定义代码块 | 协议 **`MarkdownCodeBlockRenderer`**(示例工程 `MermaidRenderer`) | **`STMarkdownCodeBlockRendering`**、`STMarkdownMermaidRenderer` 等 | **【已对齐】**(均为协议化可插拔代码块渲染) | +| Mermaid / 自定义代码块 | 协议 **`MarkdownCodeBlockRenderer`**(示例 `MermaidRenderer`) | **`STMarkdownCodeBlockRendering`**、`STMarkdownMermaidRenderer` 等 | **【已对齐】**(协议化可插拔) | --- -## 3. 文件级映射(Vendor → STMarkdown) - -| Vendor 文件 | 角色 | STMarkdown 对应 / 说明 | 对齐 | -|-------------|------|-------------------------|------| -| `MarkdownParser.swift` | swift-markdown 遍历、`IncrementalParseResult`、`parseLock`、产出 `MarkdownRenderElement`、TOC、图片附件等 | `STMarkdownStructureParser`(解析路径 **`parseLock`** 串行化)、`STMarkdownMathNormalizer`、`STMarkdownPipeline`、`STMarkdownRenderAdapter`、`STMarkdownInputSanitizer`、`STMarkdownMalformedTableNormalizer`。ST 仍以 **整段管线 `process(_:)`** 为主,**无** vendor 同款「增量 `safePosition` / `replaceCount` / 元素级回溯」公开形态 | **【部分对齐】**(parser 级锁 **【已对齐】**;元素级增量 **【未对齐】**) | -| `MarkdownRenderElement.swift` | 渲染树枚举、`MarkdownConfiguration`、`MarkdownTOCItem`、`MarkdownTypewriterTextMode` 等 | `STMarkdownAST` / `STMarkdownRenderAST`、`STMarkdownStyle`。`STMarkdownRenderBlock.heading` 含 **`anchorId`**;**`STMarkdownTOCItem`** + 管线 **`tableOfContents`**。仍 **无** vendor 同级的 `details`、`rawHTML`、`footnote` 块模型 | **【部分对齐】**(核心块与 TOC 数据/锚点 **【已对齐】**;`details` / `rawHTML` / `footnote` **【未对齐】**) | -| `MarkdownRender.swift` | 元素 → 属性串 / 展示逻辑 | `STMarkdownAttributedStringRenderer` + `Rendering/Default/*`、`Rendering/Advanced/*` | **【已对齐】** | -| `MarkdownDisplayView.swift` | 总装、与 TextKit 视图协作(体量很大) | `STMarkdownBaseTextView`、`STMarkdownTextView`、`STMarkdownStreamingTextView` 等拆分 | **【部分对齐】**(职责已覆盖,拆分为多类型) | -| `MarkdownTextViewTK2.swift` | **TextKit 2**:`NSTextContentStorage`、`NSTextLayoutManager`、附件 Provider、`typewriterTextMode` 等 | `UITextView` / `STShimmerTextView`,**`usingTextLayoutManager: false`**(经典 TextKit 路径) | **【未对齐】**(TextKit 代际不同) | -| `TypewriterEngine.swift` | 对子视图树(`MarkdownTextViewTK2` / `UILabel` / `UIStackView`)队列动画、`onLayoutChange` | `STShimmerTextView` + `STMarkdownStreamingTextView` 动画/增量更新;**无** vendor 同款整棵 block UI 队列 + watchdog | **【部分对齐】**(均有打字机/流式观感;视图树队列 **【未对齐】**) | -| `MarkdownStreamBuffer.swift` | `Int` 型 `lastSafePosition`、`containerWidth`、可选 `onModuleReady`(带预解析元素)、调试日志 | `STMarkdownStreamBuffer`:字符偏移持久化、`streamMinModuleLength`、**纯字符串**模块切分;可选 **`onCompleteModules`**(仅模块字符串,**无**预解析 AST / **无** `containerWidth`) | **【部分对齐】**(安全切分思想 **【已对齐】**;`onModuleReady` 预解析 / `containerWidth` **【未对齐】**) | -| `ScrollableMarkdownViewTextKit.swift` | `UIScrollView` 包装、`markdown`/`configuration`、`onTOCItemTap`、`tableOfContents`、`generateTOCView` 等 | **无**同名一体化控件;管线产出 **`STMarkdownPipelineResult.tableOfContents`** + ``STMarkdownBaseTextView.tableOfContents`` / ``scrollToHeadingAnchor`` / ``characterRangeForHeadingAnchor``;侧栏目录与 `onTOCItemTap` 仍由宿主组合 | **【部分对齐】**(滚动+渲染可组合;TOC 一体面 **【未对齐】**) | -| `MarkdownTableSupport.swift` | 表格与 TextKit2 / 附件协作 | `Table/STMarkdownTable*.swift`、CollectionView 表格附件;与 CHANGELOG 中 **UILabel 表格 cell + `onLinkTap`** 链路不同 | **【部分对齐】**(均有表格能力;TK2 内嵌 vs Collection **【未对齐】**) | -| `CodeBlockAttachment.swift` | 代码块附件 | `STMarkdownCodeBlockAttachmentRenderer`、`STMarkdownDefaultCodeBlockRenderer` 等 | **【已对齐】** | -| `LaTeXAttachment.swift`、`LatexMathView.swift`、`LateXParser.swift`、`LateXNodeSets.swift` | KaTeX 渲染链 | `STMarkdownDefaultMathRenderer` + SwiftMath + `STMarkdownMathNormalizer` | **【部分对齐】**(公式附件链路 **【已对齐】**;KaTeX vs SwiftMath **【未对齐】**) | -| `FontLoader.swift` | KaTeX 字体注册 | ST 使用 SwiftMath / Bundle 资源,无同一套 `FontLoader` | **【部分对齐】**(均有字体/资源加载责任;实现文件 **【未对齐】**) | -| `ImageCacheManager.swift`、`ImageLoader.swift`、`ImageView.swift` | 图片缓存与展示 | `STMarkdownAsyncImageRenderer`、`STMarkdownDefaultImageRenderer` 等 | **【已对齐】** | -| `MarkdownCustomExtension.swift` | 自定义扩展元素 | `STMarkdownAdvancedRenderers`、各类 `*Rendering` 协议 | **【已对齐】** | -| `ArraySafe.swift` | 安全下标等工具 | ST 内散见于各文件,无同名单文件 | **【部分对齐】**(同类防御性用法内嵌,无 Vendor 同名文件) | - ---- - -## 4. 能力差异摘要 +## 3. 能力差异摘要 1. **渲染引擎**:Vendor 为 **TextKit 2**;ST 主路径为 **`UITextView` + TextKit 1**(`usingTextLayoutManager: false`)。**【未对齐】** -2. **解析与并发**:Vendor **`MarkdownParser` 内 `parseLock` 串行化 swift-markdown**,并在视图层配合 `renderQueue`/版本锁做增量渲染保护;ST 在 **`STMarkdownStructureParser.parse`** 使用 **`parseLock`** 串行化 cmark 路径(对齐 vendor 核心动机);**无**视图层版本锁与 **无** 增量元素级回溯。**【部分对齐】**(parser 级锁 **【已对齐】**;视图层与元素级增量 **【未对齐】**) -3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带 `MarkdownRenderElement`**,并与 **Typewriter 视图树** 配合;ST 为 **字符串级 `STMarkdownStreamBuffer`**(可选 **`onCompleteModules`**)+ **富文本侧 Shimmer/增量 `setMarkdown`**。**【部分对齐】**(模块就绪回调 **【部分对齐】**;预解析元素与视图树 **【未对齐】**) -4. **目录 TOC**:Vendor **内置 `MarkdownTOCItem`、生成目录视图、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**(管线 + TextView 缓存)与 **`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;**无**内置目录视图与 **`onTOCItemTap`**(宿主组合)。**【部分对齐】**(数据与跳转 **【已对齐】**;内置目录 UI / tap **【未对齐】**) -5. **块级模型**:Vendor `MarkdownRenderElement` 含 **`details`、`rawHTML`**,并把 **heading/TOC/footnote** 等信息留在统一块级模型附近;ST 当前 `STMarkdownBlockNode` **未定义** `details`、`rawHTML`、`footnote`;**`STMarkdownRenderBlock.heading` 含 `anchorId`** 并与 TOC 抽取一致。**【部分对齐】**(heading 锚点/TOC 数据 **【已对齐】**;扩展块与脚注 **【未对齐】**) -6. **公式**:Vendor **KaTeX**;ST **SwiftMath**,命令集与排版不必一致。**【部分对齐】** -7. **表格**:Vendor 与 TextKit2 附件、手势、(文档所述)**表格内链接走 cell 选择 + `onLinkTap`** 等;ST 为 **独立表格 Collection + overlay**,交互模型不同。**【部分对齐】** -8. **脚注 / 角标**:Vendor 有 **独立脚注模型 + 延迟渲染脚注视图**;ST 侧当前更偏向 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`、表格内 citation 流程),**不能等价视为 footnote 支持**。**【未对齐】** -9. **链接与图片点击**:双方均具备宿主回调链路(如 `onLinkTap`、图片异步渲染与点击)。**【已对齐】**(具体命名与 TK 栈细节不同,见 §4.3「交互能力」行) - -## 4.3 已从源码核对的结论 - -以下条目是本次直接对照源码后确认的结果,可视为比前文更高置信度的“实现级”结论: - -| 维度 | Vendor 结论 | ST 结论 | 判断 | 对齐 | -|------|-------------|---------|------|------| -| 流式增量解析 | `MarkdownParser.parseIncremental(...)` 返回 `safePosition`、`replaceCount`、`newElements` | **整段**仍走 `process`;**``processIncremental``** 提供回溯窗口子串 + **`replaceTailCount`** + ``windowRenderDocument``;安全上界仍由缓冲器提供 | ST **弱于** vendor 一体化 | **【部分对齐】** | -| 流式模块回调 | `MarkdownStreamBuffer.onModuleReady` 可回传预解析 `MarkdownRenderElement` | **`onCompleteModules`** 仅回传 **完整模块字符串**;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | -| 块级能力 | `MarkdownRenderElement` 含 `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` | `STMarkdownBlockNode` 仍为 paragraph/heading/…;**`STMarkdownRenderBlock.heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` / `footnote` | **【部分对齐】** | -| TOC | 视图层公开 `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | **`STMarkdownPipelineResult.tableOfContents`**、**`STMarkdownBaseTextView.tableOfContents`**、**`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;无内置目录 UI / `onTOCItemTap` | ST **弱于** vendor 一体面 | **【部分对齐】** | -| 脚注 | 预处理 footnote,缓存并延迟渲染 footnote view | 未检出 footnote 模型/渲染链;存在 citation badge 流程 | ST **缺少 footnote** | **【未对齐】** | -| TextKit 栈 | 核心视图基于 `NSTextLayoutManager` / `NSTextContentStorage` / TK2 attachment provider | `UITextView(usingTextLayoutManager: false)` 明确走 TextKit 1 路线 | 路线不同 | **【未对齐】** | -| HTML | Vendor 存在 `rawHTML(String)` 元素与对应渲染分支 | ST `STHtmlNormalizeRule` 注释明确写明 downstream **no handling for raw HTML** | ST **明确不支持 raw HTML** | **【未对齐】** | -| 交互能力 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | **【部分对齐】**(链接/选区 **【已对齐】**;TOC tap / 脚注视图 **【未对齐】**;citation **Vendor 无对等**) | -| 表格交互 | 表格与 TK2 attachment 深度耦合 | 表格为独立 View/Attachment + overlay/citation 区域 | 路线不同 | **【部分对齐】**(均有表格与点击区域;耦合方式 **【未对齐】**) | +2. **解析与并发**:Vendor 在解析路径用 **`parseLock`** 串行化 swift-markdown,视图层另有 `renderQueue`/版本锁等增量保护;ST 在 **`STMarkdownStructureParser.parse`** 使用 **`parseLock`**;**无**视图层版本锁与 **无** Vendor 同款元素级增量回溯公开形态。**【部分对齐】** +3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带预解析 `MarkdownRenderElement`**,并与 **Typewriter 子视图树** 配合;ST 为 **`STMarkdownStreamBuffer`**(可选 **`onCompleteModules`** 仅字符串)+ **Shimmer / 增量 `setMarkdown`**。**【部分对齐】**(预解析元素与视图树 **【未对齐】**) +4. **目录 TOC**:Vendor **内置目录视图、`onTOCItemTap`、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**(管线 + TextView)与 **`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;**无**内置目录 UI / **`onTOCItemTap`**。**【部分对齐】** +5. **块级模型**:Vendor 含 **`details`、`rawHTML`、footnote** 等;ST **`STMarkdownBlockNode`** 未定义上述扩展块;**`STMarkdownRenderBlock.heading` 含 `anchorId`**,与 TOC 抽取一致。**【部分对齐】** +6. **公式**:KaTeX vs SwiftMath,排版与命令集不必一致。**【部分对齐】** +7. **表格**:Vendor 与 TextKit2 附件、手势、表格内链接等深度耦合;ST 为 **独立表格 Collection + overlay**,交互模型不同。**【部分对齐】** +8. **脚注 / 角标**:Vendor **脚注模型 + 延迟脚注视图**;ST 侧重 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`),**不等于** CommonMark/GFM footnote。**【未对齐】** +9. **链接与图片**:双方均有 **`onLinkTap`** 类回调与异步图片链路。**【已对齐】**(命名与 TK 细节不同,见 §4) --- -## 4.1 仓库根目录但未纳入上表的路径(Vendor) - -以下在 **clone 根** 常见,与 `Sources/MarkdownDisplayView` 并列,对比「库能力」时可按需打开: +## 4. 源码级核对结论 -| 路径 | 说明 | -|------|------| -| `Example/`、`CocoapodsMDExample/` | 示例 App:调用方式、`startStreaming`、Mermaid 接入等 **集成参考** | -| `Effects/`、`Support/` | 动效/辅助资源等(**非** `Sources` 内核心 Swift 模块;具体以仓库为准) | -| `CHANGELOG.md`、`README_zh.md` | 版本行为、配置项、已知修复的 **文字级** 对照来源 | - ---- +以下条目为对照源码后的「实现级」结论,置信度高于 §3 条目概括。 -## 4.2 测试与可观测性 - -| 项目 | Vendor | STMarkdown | 对齐 | -|------|--------|------------|------| -| 单测位置 | `MarkdownDisplayView/Tests/MarkdownDisplayViewTests/`(Swift `Testing` 等) | `Example/STBaseProjectExampleTests/` 下 `STMarkdown*`、`STMarkdownStreamBufferTests` 等 | **【已对齐】**(均有模块级单测落点) | -| 调试输出 | `MarkdownStreamBuffer` 等路径存在 **`print`** 日志 | ST 侧一般 **无** 同等控制台噪声;排障依赖宿主或自行埋点 | **【未对齐】**(可观测性策略不同) | +| 维度 | Vendor 结论 | ST 结论 | 判断 | 对齐 | +|------|-------------|---------|------|------| +| 流式增量解析 | `parseIncremental(...)` → `safePosition`、`replaceCount`、`newElements` | 整段仍走 `process`;**`processIncremental`** → **`replaceTailCount`** + **`windowRenderDocument`**;安全上界由缓冲器提供 | ST **弱于** vendor 一体化 | **【部分对齐】** | +| 流式模块回调 | `onModuleReady` 可回传预解析元素 | **`onCompleteModules`** 仅完整模块字符串;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | +| 块级能力 | `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` 等 | `STMarkdownBlockNode` 仍以常规块为主;**`heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` / `footnote` | **【部分对齐】** | +| TOC | `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | **`STMarkdownPipelineResult.tableOfContents`**、**`STMarkdownBaseTextView.tableOfContents`**、**`scrollToHeadingAnchor`** 等;无内置目录 UI / `onTOCItemTap` | ST **弱于** vendor 一体面 | **【部分对齐】** | +| 脚注 | 预处理、缓存、延迟脚注视图 | 无 footnote 链;有 citation badge | ST **缺少 footnote** | **【未对齐】** | +| TextKit 栈 | `NSTextLayoutManager` / `NSTextContentStorage` / TK2 | `usingTextLayoutManager: false`(TextKit 1) | 路线不同 | **【未对齐】** | +| HTML | `rawHTML(String)` 与渲染分支 | `STHtmlNormalizeRule` 等标明下游 **不消费 raw HTML** | ST **不支持 raw HTML** | **【未对齐】** | +| 交互 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | **【部分对齐】** | +| 表格交互 | 与 TK2 attachment 深度耦合 | 独立 View/Attachment + overlay/citation | 路线不同 | **【部分对齐】** | --- -## 5. 已在 ST 侧做过的对齐方向(会话内实现,供对照) - -> 本节条目相对 Vendor 文档/行为属于 **【部分对齐】** 或实现侧 **【已对齐】**(语义接近,非逐行一致)。 - -以下属于 STMarkdown 演进中与「常见流式 Markdown 组件」接近的行为,**不等同**于 vendor 逐行一致: +## 5. 已在 ST 侧做过的对齐(实现备注) -- **`STMarkdownStreamBuffer`** **【部分对齐】**:围栏闭合处切分、段落模式 EOF 尾段、**字符偏移**持久化 `lastSafeUpperBoundOffset`;可选 **`onCompleteModules`**(对照 vendor 模块就绪的字符串子集)。**【未对齐】** 项见 §3 `MarkdownStreamBuffer` 行。 -- **`STMarkdownBaseTextView`** **【部分对齐】**:`resolvedMarkdownMeasurementWidth()`、高度回退、`contentLayoutHeightNotificationMinInterval`;**`tableOfContents`**、**`scrollToHeadingAnchor`**、**`characterRangeForHeadingAnchor`**(TOC 数据与跳转)。 -- **`STMarkdownStructureParser`** **【部分对齐】**:**`parseLock`** 串行化 swift-markdown 解析路径(对照 vendor)。 -- **`STMarkdownPipeline` / `STMarkdownMalformedTableNormalizer`** **【部分对齐】**:坏表修复语义;管线 **`STMarkdownPipelineResult.tableOfContents`**;**``processIncremental(_:)``**(回溯窗口子串 parse + **`replaceTailCount`** + ``mergedRenderDocument``,见 §7.2.5)。 -- **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:渲染侧锚点与 TOC 一致。 - -单测可参考:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests` 中流式相关用例;**`STMarkdownTOCTests`**、**`STMarkdownIncrementalParseTests`**。 - ---- +> 语义接近 Vendor 常见流式 Markdown 组件,**非**逐行一致。 -## 6. 在 Cursor 中如何对比阅读 +- **`STMarkdownStreamBuffer`** **【部分对齐】**:安全切分、字符偏移 **`lastSafeUpperBoundOffset`**、可选 **`onCompleteModules`**。与 Vendor 的差距见 §3 第 3 点。 +- **`STMarkdownBaseTextView`** **【部分对齐】**:测量宽度、高度通知节流;**`tableOfContents`**、**`scrollToHeadingAnchor`**、**`characterRangeForHeadingAnchor`**。 +- **`STMarkdownStructureParser`**:**`parseLock`** 串行化解析路径(与 Vendor 动机一致)。 +- **`STMarkdownPipeline`** / **`STMarkdownMalformedTableNormalizer`**:坏表修复;**`STMarkdownPipelineResult.tableOfContents`**;**`processIncremental(_:)`**(窗口 parse、**`replaceTailCount`**、**`mergedRenderDocument`**,见 §6.2.5)。 +- **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:锚点与 TOC 一致。 -1. **将 Vendor 目录加入工作区**:`File → Add Folder to Workspace…` → 选择 - `MarkdownDisplayView/MarkdownDisplayView/Sources/MarkdownDisplayView`。 -2. **分栏**:左侧 `Sources/STMarkdown`,右侧 Vendor `Sources/MarkdownDisplayView`。 -3. **按上表成对打开**:例如 `STMarkdownStreamBuffer.swift` ↔ `MarkdownStreamBuffer.swift`;`STMarkdownStructureParser.swift` ↔ `MarkdownParser.swift`。 -4. **跨仓库搜索**:在两侧分别搜索 `TOC`、`details`、`parseLock`、`NSTextLayoutManager`、`Typewriter`、`onModuleReady`。 +单测入口示例:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests`(流式)、**`STMarkdownTOCTests`**、**`STMarkdownIncrementalParseTests`**。 --- -## 7. 当前更值得做的优化 +## 6. 当前更值得做的优化 -下面不是“和 vendor 做到一模一样”的愿望清单,而是按 **收益 / 风险 / 落地成本** 排过序的优化方向。 +按 **收益 / 风险 / 落地成本** 排序(不必与 Vendor 逐 API 一致)。 | 优先级 | 方向 | 说明 | |--------|------|------| -| P0 | **流式增量渲染链补强** | 已提供 **``STMarkdownPipeline/processIncremental``**(`replaceTailCount` + 窗口 ``STMarkdownRenderDocument`` + 合并 helper);与 ``STMarkdownStreamBuffer`` 组合可逼近 Vendor 窗口策略。**仍缺**:与 TextKit 增量 `replaceCharacters` 的硬连接、内置 `findSafeBreakpoint` 与缓冲一体化。 | -| P0 | **流式专项测试补齐** | 继续补围栏、表格、公式、标题切换、列表/引用未闭合、Unicode chunk 边界、长文多轮 append 的单测。这个成本低,但能直接兜住后续重构。 | -| P1 | **目录 TOC 抽取能力** | 已落地 **`STMarkdownTOCItem`**、管线 **`tableOfContents`**、**`anchorId`** + **`stMarkdownHeadingAnchor`**、**`scrollToHeadingAnchor`**;可继续补内置目录 UI / `onTOCItemTap`、与流式增量同帧刷新。 | -| P1 | **脚注与引用语义拆分** | 当前 citation badge 更像业务增强,不等于 CommonMark footnote。若要对齐通用 Markdown 能力,应补 `footnote definition/reference` 语义模型,而不是继续堆 UI 角标。 | -| P1 | **并发压测与线程模型定稿** | 解析入口已加 **`parseLock`**;仍建议压测并发 `process(_:)`、流式 append、异步 attachment 刷新,再决定是否扩展 actor / 更广临界区。 | -| P2 | **块级 AST 能力补齐** | 如果产品确实需要折叠块与 HTML 片段,再考虑补 `details` / `rawHTML`。这类能力应先落 AST 和 render block,再落 UI;否则后面会继续把语义写死在 renderer。 | -| P2 | **统一公共组件面** | Vendor 的 `ScrollableMarkdownViewTextKit` 给了宿主一个“整页预览”入口。ST 现在偏散件组合,建议评估是否提供官方容器组件,统一滚动、高度通知、目录、链接、citation、流式入口。 | -| P3 | **TextKit 2 迁移评估** | 这不是当前第一优先级。只有在明确遇到 TextKit 1 的附件布局、选区、超长文档性能或复杂交互瓶颈时,才值得单独立项评估。 | +| P0 | **流式增量渲染链补强** | 已有 **`processIncremental`**(`replaceTailCount` + 窗口 **`STMarkdownRenderDocument`** + 合并);与 **`STMarkdownStreamBuffer`** 组合可逼近 Vendor 窗口策略。**仍缺**:与 TextKit **`replaceCharacters`** 的硬连接、缓冲与安全断点一体化。 | +| P0 | **流式专项测试** | 围栏、表、公式、标题切换、未闭合列表/引用、Unicode 分块、长文多轮 append。 | +| P1 | **TOC 产品面** | 数据与跳转已有;可补内置目录 UI、`onTOCItemTap`、与流式同帧刷新。 | +| P1 | **脚注与 citation 语义拆分** | 若要对齐通用 Markdown,应建 `footnote definition/reference` 模型,避免与角标混用。 | +| P1 | **并发压测** | 在 `parseLock` 基础上压测 `process`、流式 append、异步 attachment,再定是否扩展 actor / 更广临界区。 | +| P2 | **`details` / `rawHTML`** | 先 AST / `STMarkdownRenderBlock`,再 UI;raw HTML 宜白名单或独立 Web 容器,不宜默认进主富文本路径。 | +| P2 | **统一容器组件** | 评估官方「滚动 + 高度 + 目录 + 链接 + citation + 流式」一体化面,对标 `ScrollableMarkdownViewTextKit` 的宿主体验。 | +| P3 | **TextKit 2** | 仅在附件布局、选区、超长文性能等**明确瓶颈**时再评估;不宜与流式增量同一迭代混谈。 | -### 7.1 我对“当前先做什么”的建议 +### 6.1 若只选三件事 -如果只选三件最该做的事,我建议顺序如下: +1. **先做流式增量链**,不先迁 TextKit 2。 +2. **TOC / footnote 单独建模**,避免继续塞进 renderer。 +3. **用压测定并发保护范围**,避免过早全局锁。 -1. **先做流式增量渲染链,而不是先迁 TextKit 2。** - 现在真正的能力差距主要在“流式解析与局部更新”,不是渲染后端名字。 -2. **把 TOC/footnote 这类“内容语义能力”单独建模。** - 这些能力一旦继续塞进 renderer 或业务层,后面只会更难补。 -3. **用压测结论决定并发保护形态。** - 若压测未暴露问题,不必急着引入全局锁;若暴露 parser 级竞态,再补最小串行化保护。 +### 6.2 P0:增量语义对照(Vendor 名词 → ST 缺口) -### 7.2 P0:增量 AST、`replaceCount`、parser 级锁(实现语义对照) +#### 6.2.1 Vendor:`IncrementalParseResult` 在解决什么 -本节把 vendor 源码里的名词落到「ST 若要补齐,大致要做什么」,不等同于逐 API 抄过去。 +`parseIncremental` 大致:`detectPendingStructure` → **`findSafeBreakpoint` → `safePosition`** → 从 `lastSafePosition` 向前 **`contextWindowSize`** 得 `parseStart`,对 `[parseStart, safePosition)` **parse + render** → **`newElements`**;同次可 **`extractHeadings`** → **`tocItems`**。 -#### 7.2.1 Vendor:`IncrementalParseResult` 在解决什么问题 +**`replaceCount`**:窗口向前回溯后,新尾部块可能与上一轮 UI 尾部重叠,需从元素列表尾部按个数替换,避免重复或纠错失败(Vendor 用 `estimateReplaceCount(...)` 估算)。 -`MarkdownParser.parseIncremental(...)` 大致顺序是:`detectPendingStructure` → `findSafeBreakpoint` 得到 **`safePosition`** → 从 `lastSafePosition` 向前取 **`contextWindowSize`** 字符得到 `parseStart`,对 `[parseStart, safePosition)` 子串 **`parseDocument` + `render`**,得到 **`newElements`**;同一次调用里还会 **`extractHeadings`** 得到 **`tocItems`**。 +**`parseLock`**:串行化 cmark/swift-markdown 路径,避免多线程下扩展挂载竞态;与视图侧 `renderQueue` 等为不同层级。 -**`replaceCount`** 的含义(见 vendor 注释):因为解析窗口**向前回溯**了 `contextWindowSize`,新解析出的块可能与「上一轮已挂到 UI 上的尾部块」在语义上重叠或已被修正,需要从**元素列表尾部**按个数丢掉/替换旧元素,避免尾部重复或结构纠错失败。实现上由 `estimateReplaceCount(previousElementCount:contextWindowSize:parseStart:lastSafePosition:)` 估算。 - -**`parseLock`**:vendor 在真正走 swift-markdown / cmark 的路径上用 **`NSLock()`** 包一层,注释写明用于避免 **swift-cmark 在多线程并发挂载语法扩展时崩溃**;与视图侧的 `renderQueue`、版本号等是不同层级的保护。 - -#### 7.2.2 ST:当前断点与差距 +#### 6.2.2 概念对照 | 概念 | Vendor | ST(当前) | |------|--------|------------| -| 流式安全切分 | `lastSafePosition` 与 parser 协同 | `STMarkdownStreamBuffer` 的 **`lastSafeUpperBoundOffset`**;与 ``STMarkdownIncrementalParameters`` 偏移对齐 | **【部分对齐】** | -| 解析范围 | 回溯窗口 + 子串 parse | ``STMarkdownPipeline/processIncremental``:``parseStart = max(0, lastCommitted - contextWindowSize)``,`parseEnd = currentSafeExclusiveEnd` | **【部分对齐】** | -| 增量产物 | `newElements` + **`replaceCount`** + `tocItems` | ``STMarkdownIncrementalParseResult``:**`replaceTailCount`** + **`windowRenderDocument`** + **`windowTableOfContents`** + ``mergedRenderDocument`` | **【部分对齐】** | -| 并发 | **`parseLock`** 串行化 cmark 路径 | ``STMarkdownStructureParser`` 内 **`parseLock`** | **【已对齐】** | - -#### 7.2.3 ST 侧「增量 AST」若要落地,建议拆成两层 +| 流式安全切分 | `lastSafePosition` 与 parser 协同 | **`STMarkdownStreamBuffer`** 的 **`lastSafeUpperBoundOffset`**;与 **`STMarkdownIncrementalParameters`** 对齐 | +| 解析范围 | 回溯窗口 + 子串 parse | **`processIncremental`**:`parseStart = max(0, lastCommitted - window)`,`parseEnd = currentSafeExclusiveEnd` | +| 增量产物 | `newElements` + **`replaceCount`** + `tocItems` | **`STMarkdownIncrementalParseResult`**:`replaceTailCount`、`windowRenderDocument`、`windowTableOfContents`、`mergedRenderDocument` | +| 并发 | **`parseLock`** | **`STMarkdownStructureParser`** 内 **`parseLock`** | -1. **缓冲层(已有方向)**:继续用 `STMarkdownStreamBuffer` 决定「这一帧可安全提交给渲染器的 markdown 子串」(对齐 vendor 的 safe 边界思想,但 ST 用 UTF-16/字符偏移持久化,避免 `String.Index` 失效)。 -2. **AST / 渲染元素层(已提供公开入口)**:``STMarkdownPipeline/processIncremental(_:)`` 在管线内维护 **窗口子串 → `STMarkdownRenderDocument`**,并输出 **`replaceTailCount`**(与 Vendor ``estimateReplaceCount`` 同式)及 ``mergedRenderDocument(previous:)``;与 **TextKit `replaceCharacters`** 的硬连接、与缓冲器内置 `findSafeBreakpoint` 一体化仍为后续工作。 +#### 6.2.3 落地两层 -这样文档里说的「增量 AST」才名副其实:光有字符串 `safe` 切分没有 **元素级 tail replace**,长文流式仍会整段重跑,CPU 与闪动与 vendor 不在同一量级。 +1. **缓冲层**:`STMarkdownStreamBuffer` 给出本帧可安全提交的子串(UTF-16/字符偏移,避免 `String.Index` 失效)。 +2. **渲染块层**:**`processIncremental`** 产出窗口 **`STMarkdownRenderDocument`**、**`replaceTailCount`**、**`mergedRenderDocument(previous:)`**;与 TextKit 局部替换、缓冲内置断点一体化仍为后续工作。 -#### 7.2.4 `parseLock` 是否要在 ST 照搬 +仅有字符串 safe 切分、无 **元素级 tail replace** 时,长文流式仍会整段重跑。 -ST 已在 ``STMarkdownStructureParser`` 解析路径上使用 **`parseLock`**(见 §3 / §5)。若仍出现并发下的 cmark 竞态,再按压测结论考虑 **actor** 或更广临界区。 +#### 6.2.4 `parseLock` -#### 7.2.5 ST 已提供的增量 API(`replaceTailCount` / 窗口 parse) +ST 已在解析路径使用 **`parseLock`**(见 §3 第 2 点、§5)。若仍有竞态,再按压测加 **actor** 或更广临界区。 -以下对应 Vendor ``IncrementalParseResult`` 的 **可编程子集**(子串仍走完整 parse → normalize → adapt,与 Vendor 对窗口片段调用 `parseDocument` 同构): +#### 6.2.5 ST 已暴露的增量 API | 类型 / 方法 | 作用 | |-------------|------| -| ``STMarkdownIncrementalParameters`` | `canonicalMarkdown`、`lastCommittedExclusiveEnd`、`currentSafeExclusiveEnd`、`contextWindowSize`(默认 200)、`previousTotalRenderBlockCount` | -| ``STMarkdownPipeline/processIncremental(_:)`` / ``STMarkdownEngine/processIncremental(_:)`` | 计算 `parseStart = max(0, lastCommitted - window)`、`parseEnd = currentSafeEnd`,对 `[parseStart, parseEnd)` 子串跑管线(**不**跑输入 sanitizer,见参数文档) | -| ``STMarkdownIncrementalParseResult`` | `replaceTailCount`(与 Vendor ``estimateReplaceCount`` 相同启发式)、`windowRenderDocument`、`windowTableOfContents`、`mergedRenderDocument(previous:)` | -| ``STMarkdownIncrementalParseResult/mergedRenderBlocks`` | 纯函数尾部拼接,便于单元测试与宿主实验 | +| **`STMarkdownIncrementalParameters`** | `canonicalMarkdown`、`lastCommittedExclusiveEnd`、`currentSafeExclusiveEnd`、`contextWindowSize`(默认 200)、`previousTotalRenderBlockCount` | +| **`STMarkdownPipeline` / `STMarkdownEngine` `processIncremental(_:)`** | 对 `[parseStart, parseEnd)` 子串跑管线(**不**跑输入 sanitizer,见参数文档) | +| **`STMarkdownIncrementalParseResult`** | `replaceTailCount`、`windowRenderDocument`、`windowTableOfContents`、`mergedRenderDocument(previous:)` | +| **`mergedRenderBlocks`** | 纯函数尾部拼接,便于单测与实验 | -**局限(与 Vendor 全量能力仍有差距)**:未内置 `findSafeBreakpoint` / `hasPendingStructure` 与缓冲器二合一;宿主需自行把 ``STMarkdownStreamBuffer`` 的安全上界喂给 `currentSafeExclusiveEnd`。合并后的 **标题 `anchorId`** 若需全局唯一,仍应对全文再跑一次 ``process(_:)`` 或自建 slug 策略。 +**局限**:未内置 `findSafeBreakpoint` / pending 结构与缓冲二合一;宿主需把缓冲安全上界喂给 `currentSafeExclusiveEnd`。合并后 **heading `anchorId`** 若要全局唯一,需全文 **`process(_:)`** 或自建 slug。 --- -### 7.3 P1:TOC、footnote(从「块里有标题」到「可导航产品能力」) - -#### 7.3.1 TOC(目录) - -Vendor 除 `MarkdownTOCItem` 数据结构外,还把 **`tocItems` 放进 `IncrementalParseResult`**,视图层有 **`onTOCItemTap`、`generateTOCView`、`scrollToTOCItem`** 等一体面。 +### 6.3 P1:TOC、footnote -ST 若要做到 **P1 可交付**: +**TOC**:Vendor 把 `tocItems` 放进增量结果并提供目录视图与 tap。ST 可补 **`scrollToTOCItem(id:)`** 等与宿主 API 对齐的最小面。 -- **模型**:为每个 heading 生成稳定 **锚点 id**(GitHub slug 规则或自定义),输出 **`STMarkdownTOCItem`(level、title、id、可选 sourceRange)**。 -- **抽取**:在 `STMarkdownStructureParser` / AST 遍历中集中收集,避免散落在 `STMarkdownAttributedStringRenderer`。 -- **宿主 API**:与 vendor 对齐的最小面可以是:`tableOfContents: [STMarkdownTOCItem]` + **`scrollToTOCItem(id:)`**(内部映射到 `NSRange` 或 layout fragment,驱动 `UITextView.scrollRangeToVisible` 或外层 `UIScrollView`)。 - -#### 7.3.2 Footnote(脚注) - -Vendor 有 **脚注预处理、缓存、延迟脚注视图** 链路;ST 当前 **citation 角标**(如 `STMarkdownNumberBadgeAttachment`)是另一条产品语义,**不能**当作 CommonMark/GFM 风格 footnote 的完成态。 - -P1 建议: - -- **AST**:增加 `footnoteReference` / `footnoteDefinition`(或扩展 swift-markdown 插件)与 **编号/反向链接** 规则。 -- **渲染**:正文角标 + 文末脚注区(或侧栏)与 **citation** 分流配置,避免两套角标语义混在一个 attachment key 上。 +**脚注**:ST 的 citation 角标 **不能**当作 footnote;需在 AST 层区分 `footnoteReference` / `footnoteDefinition` 与渲染分流。 --- -### 7.4 P2:`
        `、rawHTML、TextKit 2 - -#### 7.4.1 `details` / 折叠块 +### 6.4 P2:`
        `、rawHTML、TextKit 2 -Vendor `MarkdownRenderElement` 级有 **`details`** 一类块。ST 若要做: +**`details`**:先扩展 **`STMarkdownBlockNode` / `STMarkdownRenderBlock`**,再 UI。 -- 先 **`STMarkdownBlockNode` / `STMarkdownRenderBlock`** 扩展,再 UI(折叠态可升宿主状态,避免写死在单一 `UITextView`)。 +**rawHTML**:若强需求,白名单或 `WKWebView` 沙箱,不宜默认并入 `NSAttributedString` 主路径。 -#### 7.4.2 `rawHTML` - -Vendor 有 **`rawHTML(String)`** 分支;ST 侧 `STHtmlNormalizeRule` 等已表明 **下游不消费原始 HTML**。若产品强需求,应单独做 **白名单标签 + 安全渲染**(甚至独立 `WKWebView` 沙箱),不宜默认并进 `NSAttributedString` 主路径。 - -#### 7.4.3 TextKit 2 - -Vendor 核心视图走 **`NSTextContentStorage` + `NSTextLayoutManager`**(`MarkdownTextViewTK2.swift`);ST 主路径 **`usingTextLayoutManager: false`**(TextKit 1)。 - -**P2 迁移**适合在出现明确瓶颈时立项,例如:超长文档排版性能、复杂附件 provider、与系统选区/无障碍行为强绑定。与 ST 当前 **表格 Collection + attachment overlay** 的组合是否迁 TK2 需要 **单独架构评估**,不宜与「流式增量 AST」同一迭代混谈。 +**TextKit 2**:与当前表格 Collection + overlay 是否同迁需单独架构评估。 --- -## 8. 参考链接 +## 7. 参考链接 -- Vendor 仓库: -- Vendor `Package.swift` 中 target path:`MarkdownDisplayView/Sources/MarkdownDisplayView` +- Vendor: --- -## 9. 仍可深入补充的维度(未在文中逐条展开) - -若要做「实现级」迁移清单,建议后续按需补小节或链接到具体行号。**增量解析 / replaceCount / parser 锁 / TOC-footnote-TK2** 的落地顺序与语义已集中在 **第 7.2–7.4 节**。 +## 8. 可后续补充的功能向维度 -- **`MarkdownConfiguration` 全字段** 与 **`STMarkdownStyle` + Pipeline** 的逐项字段映射表(体量最大)。 -- **`MarkdownViewTextKit` / `MarkdownDisplayView.swift`** 内生命周期、高度通知(vendor `notifyHeightChange` 命名)与 **`STMarkdownBaseTextView.publishContentLayoutHeightNotificationIfNeeded`** 的逐项对照。 -- **无障碍**:Vendor TK2 栈与 ST `UITextView` 的 **accessibility** 差异。 -- **许可证**:若从 vendor 复制 **KaTeX 字体文件**,需单独核对字体与 KaTeX 的许可条款;ST 当前以 **SwiftMath** 为主。 +- **`MarkdownConfiguration`** 与 **`STMarkdownStyle` + Pipeline** 的字段级映射(配置能力对齐)。 +- **生命周期与高度通知**:Vendor 高度回调与 **`STMarkdownBaseTextView.publishContentLayoutHeightNotificationIfNeeded`** 等行为对照。 +- **无障碍**:TK2 与 `UITextView` 主路径差异。 +- **许可证**:若引入 KaTeX 字体等资源,需单独核对许可;ST 以 SwiftMath 为主。 --- -*本文描述基于当时仓库快照与目录结构;Vendor 后续版本若变更路径或 API,请以仓库为准更新本节。* +*Vendor 后续若变更 API 或行为,以对方仓库为准更新本文。* diff --git a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift index d2114aa..50913fc 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift @@ -27,6 +27,8 @@ private enum InlineCaseTag: String, CaseIterable { case image case softBreak case strikethrough + case footnoteReference + case inlineRawHTML } private func st_tag(forInline node: STMarkdownInlineNode) -> InlineCaseTag { @@ -40,6 +42,8 @@ private func st_tag(forInline node: STMarkdownInlineNode) -> InlineCaseTag { case .image: return .image case .softBreak: return .softBreak case .strikethrough: return .strikethrough + case .footnoteReference: return .footnoteReference + case .inlineRawHTML: return .inlineRawHTML } } @@ -53,6 +57,8 @@ private enum BlockCaseTag: String, CaseIterable { case mathBlock case image case thematicBreak + case details + case rawHTML } private func st_tag(forBlock node: STMarkdownBlockNode) -> BlockCaseTag { @@ -66,6 +72,8 @@ private func st_tag(forBlock node: STMarkdownBlockNode) -> BlockCaseTag { case .mathBlock: return .mathBlock case .image: return .image case .thematicBreak: return .thematicBreak + case .details: return .details + case .rawHTML: return .rawHTML } } @@ -79,6 +87,8 @@ private enum RenderBlockCaseTag: String, CaseIterable { case mathBlock case image case thematicBreak + case details + case rawHTML } private func st_tag(forRenderBlock node: STMarkdownRenderBlock) -> RenderBlockCaseTag { @@ -92,6 +102,8 @@ private func st_tag(forRenderBlock node: STMarkdownRenderBlock) -> RenderBlockCa case .mathBlock: return .mathBlock case .image: return .image case .thematicBreak: return .thematicBreak + case .details: return .details + case .rawHTML: return .rawHTML } } @@ -114,6 +126,8 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { .image(source: "https://i2", alt: "a2", title: nil), .softBreak, .strikethrough([.text("d")]), + .footnoteReference(label: "n1"), + .inlineRawHTML("x"), ] let tags = samples.map { st_tag(forInline: $0) } XCTAssertEqual(Set(tags), Set(InlineCaseTag.allCases)) @@ -204,6 +218,8 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { .image(url: "https://u", altText: "al", title: "t"), .image(url: "https://u2", altText: "a2", title: nil), .thematicBreak, + .details(summary: [.text("s")], body: [.paragraph([.text("d")])]), + .rawHTML("
        "), ] let tags = samples.map { st_tag(forBlock: $0) } XCTAssertEqual(Set(tags), Set(BlockCaseTag.allCases)) @@ -261,6 +277,8 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { .image(url: "https://img", altText: "x", title: "y"), .image(url: "https://img2", altText: "x2", title: nil), .thematicBreak, + .details(summary: [.text("sum")], body: [.paragraph([.text("bd")])]), + .rawHTML("

        "), ] let tags = samples.map { st_tag(forRenderBlock: $0) } XCTAssertEqual(Set(tags), Set(RenderBlockCaseTag.allCases)) diff --git a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift index 81df5f7..b4f8a1e 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift @@ -30,7 +30,10 @@ private struct CoreAppendNormalizer: STMarkdownSemanticNormalizing { let suffix: String func normalize(_ document: STMarkdownDocument) -> STMarkdownDocument { let appended = STMarkdownBlockNode.paragraph([.text(self.suffix)]) - return STMarkdownDocument(blocks: document.blocks + [appended]) + return STMarkdownDocument( + blocks: document.blocks + [appended], + footnoteDefinitions: document.footnoteDefinitions + ) } } diff --git a/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift b/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift new file mode 100644 index 0000000..d2620bf --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift @@ -0,0 +1,48 @@ +// +// STMarkdownFootnoteAndHTMLTests.swift +// STBaseProjectExampleTests +// + +import XCTest +import STBaseProject + +final class STMarkdownFootnoteAndHTMLTests: XCTestCase { + + func testFootnoteDefinitionStrippedAndReferenceParsed() { + let md = """ + Hello[^a] world. + + [^a]: Foot **note** here. + """ + let parser = STMarkdownStructureParser() + let doc = parser.parse(md) + XCTAssertFalse(doc.footnoteDefinitions["a"]?.content.isEmpty ?? true, "footnote body should parse") + guard case .paragraph(let inlines)? = doc.blocks.first else { + return XCTFail("expected paragraph") + } + XCTAssertTrue(inlines.contains(where: { + if case .footnoteReference(let l) = $0 { return l == "a" } + return false + })) + } + + func testRawHTMLBlockPolicyLiteral() { + var style = STMarkdownStyle.default + style.rawHTMLPolicy = .literalMonospace + let renderer = STMarkdownAttributedStringRenderer(style: style, advancedRenderers: .empty) + let doc = STMarkdownRenderDocument(blocks: [.rawHTML("

        x
        ")]) + let attr = renderer.render(document: doc) + XCTAssertTrue(attr.string.contains("
        ")) + } + + func testDetailsRenderContainsSummaryGlyph() { + let renderer = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + let doc = STMarkdownRenderDocument(blocks: [ + .details(summary: [.text("More")], body: [.paragraph([.text("Hidden")])]), + ]) + let attr = renderer.render(document: doc) + XCTAssertTrue(attr.string.contains("▸")) + XCTAssertTrue(attr.string.contains("More")) + XCTAssertTrue(attr.string.contains("Hidden")) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift index 83dc742..8a53388 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift @@ -92,8 +92,12 @@ private func st_collectSemanticTextSegments(from blocks: [STMarkdownRenderBlock] case .codeBlock(_, let code): let t = code.trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } - case .table, .mathBlock, .image, .thematicBreak: + case .table, .mathBlock, .image, .thematicBreak, .rawHTML: break + case .details(let summary, let inner): + let t = st_joinInlinePlainText(summary).trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty == false { segments.append(t) } + segments.append(contentsOf: st_collectSemanticTextSegments(from: inner)) } } return segments @@ -243,6 +247,10 @@ private func st_inlinePlainText(_ node: STMarkdownInlineNode) -> String { return st_joinInlinePlainText(c) case .image(_, let alt, _): return alt + case .footnoteReference(let label): + return "[^\(label)]" + case .inlineRawHTML(let raw): + return raw } } @@ -279,6 +287,11 @@ private func st_blockContainsText(_ block: STMarkdownBlockNode, text: String) -> return altText.contains(text) || (title?.contains(text) == true) case .thematicBreak: return false + case .details(let summary, let body): + return st_joinInlinePlainText(summary).contains(text) + || body.contains { st_blockContainsText($0, text: text) } + case .rawHTML(let html): + return html.contains(text) } } @@ -306,6 +319,11 @@ private func st_renderBlockContainsText(_ block: STMarkdownRenderBlock, text: St return altText.contains(text) || (title?.contains(text) == true) case .thematicBreak: return false + case .details(let summary, let body): + return st_joinInlinePlainText(summary).contains(text) + || body.contains { st_renderBlockContainsText($0, text: text) } + case .rawHTML(let html): + return html.contains(text) } } diff --git a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift index 101cf65..280740d 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift @@ -1832,7 +1832,10 @@ final class STMarkdownPipelineTests: XCTestCase { } return block } - return STMarkdownDocument(blocks: blocks) + return STMarkdownDocument( + blocks: blocks, + footnoteDefinitions: document.footnoteDefinitions + ) } } diff --git a/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift index 258716e..ac7edad 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift @@ -103,6 +103,10 @@ private func st_collectInlineKinds(_ nodes: [STMarkdownInlineNode]) -> Set STMarkdownRenderDocument { var slugger = STMarkdownAnchorSlugRegistry() - let blocks = document.blocks.map { self.makeRenderBlock(from: $0, listLevel: 0, slugger: &slugger) } - return STMarkdownRenderDocument(blocks: blocks) + let mainBlocks = document.blocks.map { self.makeRenderBlock(from: $0, listLevel: 0, slugger: &slugger) } + let merged = STMarkdownFootnoteSectionBuilder.appendingSectionIfNeeded( + document: document, + renderBlocks: mainBlocks + ) + return STMarkdownRenderDocument(blocks: merged) } } @@ -55,6 +59,13 @@ private extension STMarkdownRenderAdapter { return .image(url: url, altText: altText, title: title) case .thematicBreak: return .thematicBreak + case .details(let summary, let body): + return .details( + summary: summary, + body: body.map { self.makeRenderBlock(from: $0, listLevel: listLevel, slugger: &slugger) } + ) + case .rawHTML(let html): + return .rawHTML(html) } } diff --git a/Sources/STMarkdown/Core/STMarkdownStyle.swift b/Sources/STMarkdown/Core/STMarkdownStyle.swift index 49f2a5f..9a6b9a5 100644 --- a/Sources/STMarkdown/Core/STMarkdownStyle.swift +++ b/Sources/STMarkdown/Core/STMarkdownStyle.swift @@ -7,6 +7,14 @@ import UIKit +/// 块级 / 行内 HTML 的降级渲染策略(安全默认:不解析为 Attributed 富标签树)。 +public enum STMarkdownRawHTMLPolicy: Sendable, Hashable { + /// 不输出可见字符。 + case suppress + /// 以等宽小字展示原始片段(仍应视为不可信 HTML,不做标签解析)。 + case literalMonospace +} + /// Markdown 样式配置。 /// /// - Note: 含有大量 `UIColor` / `UIFont` / `UIEdgeInsets` 字段。UIKit 官方并未将其声明为 @@ -107,6 +115,8 @@ public struct STMarkdownStyle: @unchecked Sendable { public var codeBlockButtonRowReservedWidth: CGFloat /// 智能流式缓冲(``STMarkdownStreamBuffer``)的最小模块长度,过小会导致更频繁的模块切分。 public var streamMinModuleLength: Int + /// ``STMarkdownRenderBlock/rawHTML`` 与 ``STMarkdownInlineNode/inlineRawHTML`` 的展示策略。 + public var rawHTMLPolicy: STMarkdownRawHTMLPolicy public init( font: UIFont, @@ -168,7 +178,8 @@ public struct STMarkdownStyle: @unchecked Sendable { codeBlockHeaderHeight: CGFloat = 0, codeBlockSeparatorSpacing: CGFloat = 8, codeBlockButtonRowReservedWidth: CGFloat = 120, - streamMinModuleLength: Int = 20 + streamMinModuleLength: Int = 20, + rawHTMLPolicy: STMarkdownRawHTMLPolicy = .suppress ) { self.font = font self.boldFont = boldFont @@ -230,6 +241,7 @@ public struct STMarkdownStyle: @unchecked Sendable { self.codeBlockSeparatorSpacing = codeBlockSeparatorSpacing self.codeBlockButtonRowReservedWidth = codeBlockButtonRowReservedWidth self.streamMinModuleLength = max(1, streamMinModuleLength) + self.rawHTMLPolicy = rawHTMLPolicy } public static let `default` = STMarkdownStyle( diff --git a/Sources/STMarkdown/Core/STMarkdownTOC.swift b/Sources/STMarkdown/Core/STMarkdownTOC.swift index eb24651..e865b83 100644 --- a/Sources/STMarkdown/Core/STMarkdownTOC.swift +++ b/Sources/STMarkdown/Core/STMarkdownTOC.swift @@ -2,7 +2,7 @@ // STMarkdownTOC.swift // STBaseProject // -// 目录(TOC)与标题锚点:对齐对比文档 P1「heading id、TOC 数据结构、滚动定位」最小可交付面。 +// Created by 寒江孤影 on 2019/03/16. // import Foundation @@ -13,6 +13,8 @@ import UIKit extension NSAttributedString.Key { /// 标题块在富文本上的稳定锚点 id(与 ``STMarkdownTOCItem/anchorId`` 一致)。 public static let stMarkdownHeadingAnchor = NSAttributedString.Key("STMarkdown.headingAnchor") + /// 脚注引用在富文本上的逻辑标签(与 `[^label]` 中 `label` 一致,不含 `^`)。 + public static let stMarkdownFootnoteLabel = NSAttributedString.Key("STMarkdown.footnoteLabel") } // MARK: - TOC item @@ -52,6 +54,10 @@ extension STMarkdownInlineNode { return c.map { $0.st_plainTextForTOC() }.joined() case .image(_, let alt, _): return alt + case .footnoteReference(let label): + return "[^\(label)]" + case .inlineRawHTML(let raw): + return raw } } } @@ -124,7 +130,9 @@ enum STMarkdownTOCExtraction { self.collect(from: b, into: &items) } } - case .paragraph, .codeBlock, .table, .mathBlock, .image, .thematicBreak: + case .details(_, let body): + for b in body { self.collect(from: b, into: &items) } + case .paragraph, .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: break } } diff --git a/Sources/STMarkdown/Parsing/STMarkdownAST.swift b/Sources/STMarkdown/Parsing/STMarkdownAST.swift index 9b60aea..fcc6164 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownAST.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownAST.swift @@ -17,6 +17,10 @@ public enum STMarkdownInlineNode: Hashable, Sendable { case image(source: String, alt: String, title: String?) case softBreak case strikethrough([STMarkdownInlineNode]) + /// GFM 风格脚注引用(标签不含 `^` 前缀,例如 `"1"`、`"note"`)。 + case footnoteReference(label: String) + /// swift-markdown 解析到的行内 HTML;渲染策略见 ``STMarkdownStyle/rawHTMLPolicy``。 + case inlineRawHTML(String) } public enum STMarkdownCheckbox: Hashable, Sendable { @@ -72,11 +76,28 @@ public enum STMarkdownBlockNode: Hashable, Sendable { case mathBlock(String) case image(url: String, altText: String, title: String?) case thematicBreak + /// 从 ``HTMLBlock`` 识别的 `
        `(折叠语义由宿主 UI 承载时,渲染侧先展开为缩进块)。 + case details(summary: [STMarkdownInlineNode], body: [STMarkdownBlockNode]) + /// 块级原始 HTML;默认不当作富文本解析,见 ``STMarkdownRawHTMLPolicy``。 + case rawHTML(String) +} + +/// 脚注定义体(`[^label]:` 行抽取);与 ``STMarkdownInlineNode/footnoteReference(label:)`` 配对。 +public struct STMarkdownFootnoteDefinition: Hashable, Sendable { + public let content: [STMarkdownInlineNode] + + public init(content: [STMarkdownInlineNode]) { + self.content = content + } } public struct STMarkdownDocument: Hashable, Sendable { public let blocks: [STMarkdownBlockNode] - public init(blocks: [STMarkdownBlockNode]) { + /// 从正文剥离的脚注定义;引用仍以内联 ``STMarkdownInlineNode/footnoteReference`` 表示。 + public let footnoteDefinitions: [String: STMarkdownFootnoteDefinition] + + public init(blocks: [STMarkdownBlockNode], footnoteDefinitions: [String: STMarkdownFootnoteDefinition] = [:]) { self.blocks = blocks + self.footnoteDefinitions = footnoteDefinitions } } diff --git a/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift new file mode 100644 index 0000000..87d1f2a --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift @@ -0,0 +1,279 @@ +// +// STMarkdownFootnoteSupport.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +// MARK: - 定义行剥离 + +enum STMarkdownFootnoteDefinitionScanner { + private static let definitionLine = try! NSRegularExpression( + pattern: #"^\[\^([^\]]+)\]:\s*(.*)$"#, + options: [] + ) + + /// 剥离脚注定义行,返回剩余 Markdown 与 `label -> 定义原文`(单行定义;多行续行可后续扩展)。 + static func stripDefinitions(from markdown: String) -> (markdown: String, rawBodies: [String: String]) { + var definitions: [String: String] = [:] + var kept: [String] = [] + for line in markdown.components(separatedBy: "\n") { + let ns = line as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = Self.definitionLine.firstMatch(in: line, range: range), + match.numberOfRanges >= 3, + let labelR = Range(match.range(at: 1), in: line), + let bodyR = Range(match.range(at: 2), in: line) + else { + kept.append(line) + continue + } + let label = String(line[labelR]) + let body = String(line[bodyR]) + definitions[label] = body + } + return (kept.joined(separator: "\n"), definitions) + } +} + +// MARK: - 定义体 -> AST + +enum STMarkdownFootnoteDefinitionBuilder { + static func definitions( + from rawBodies: [String: String], + parseFragment: (String) -> STMarkdownDocument + ) -> [String: STMarkdownFootnoteDefinition] { + var out: [String: STMarkdownFootnoteDefinition] = [:] + for (label, raw) in rawBodies { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + out[label] = STMarkdownFootnoteDefinition(content: []) + continue + } + let doc = parseFragment(trimmed) + out[label] = STMarkdownFootnoteDefinition(content: Self.inlineContent(from: doc)) + } + return out + } + + private static func inlineContent(from document: STMarkdownDocument) -> [STMarkdownInlineNode] { + var chunks: [[STMarkdownInlineNode]] = [] + for block in document.blocks { + switch block { + case .paragraph(let inlines): + chunks.append(inlines) + default: + return fallbackPlainText(from: document) + } + } + guard chunks.isEmpty == false else { return [] } + var merged: [STMarkdownInlineNode] = [] + for (idx, part) in chunks.enumerated() { + if idx > 0 { merged.append(.softBreak) } + merged.append(contentsOf: part) + } + return merged + } + + private static func fallbackPlainText(from document: STMarkdownDocument) -> [STMarkdownInlineNode] { + [.text(document.blocks.map { blockPlain($0) }.joined(separator: "\n"))] + } + + private static func blockPlain(_ block: STMarkdownBlockNode) -> String { + switch block { + case .paragraph(let n), .heading(_, let n): + return n.map { $0.st_plainTextForTOC() }.joined() + case .codeBlock(_, let code): + return code + default: + return "" + } + } +} + +// MARK: - 正文 `[^label]` -> footnoteReference + +enum STMarkdownFootnoteInlineInjector { + private static let refRegex = try! NSRegularExpression( + pattern: #"\[\^([^\]]+)\]"#, + options: [] + ) + + static func apply(_ document: STMarkdownDocument) -> STMarkdownDocument { + STMarkdownDocument( + blocks: document.blocks.map { injectBlock($0) }, + footnoteDefinitions: document.footnoteDefinitions + ) + } + + private static func injectBlock(_ block: STMarkdownBlockNode) -> STMarkdownBlockNode { + switch block { + case .paragraph(let inlines): + return .paragraph(injectInlines(inlines)) + case .heading(let level, let content): + return .heading(level: level, content: injectInlines(content)) + case .quote(let inner): + return .quote(inner.map { injectBlock($0) }) + case .list(let kind, let items): + return .list( + kind: kind, + items: items.map { item in + STMarkdownListItemNode( + blocks: item.blocks.map { injectBlock($0) }, + checkbox: item.checkbox + ) + } + ) + case .details(let summary, let body): + return .details(summary: injectInlines(summary), body: body.map { injectBlock($0) }) + case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: + return block + } + } + + private static func injectInlines(_ nodes: [STMarkdownInlineNode]) -> [STMarkdownInlineNode] { + var out: [STMarkdownInlineNode] = [] + for n in nodes { + out.append(contentsOf: injectOne(n)) + } + return out + } + + private static func injectOne(_ node: STMarkdownInlineNode) -> [STMarkdownInlineNode] { + switch node { + case .text(let s): + return splitText(s) + case .emphasis(let c): + let inner = injectInlines(c) + return inner.isEmpty ? [] : [.emphasis(inner)] + case .strong(let c): + let inner = injectInlines(c) + return inner.isEmpty ? [] : [.strong(inner)] + case .strikethrough(let c): + let inner = injectInlines(c) + return inner.isEmpty ? [] : [.strikethrough(inner)] + case .link(let dest, let c): + let inner = injectInlines(c) + return inner.isEmpty ? [] : [.link(destination: dest, children: inner)] + case .footnoteReference, .inlineRawHTML, .inlineMath, .code, .image, .softBreak: + return [node] + } + } + + private static func splitText(_ text: String) -> [STMarkdownInlineNode] { + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + let matches = Self.refRegex.matches(in: text, range: full) + if matches.isEmpty { + return text.isEmpty ? [] : [.text(text)] + } + var parts: [STMarkdownInlineNode] = [] + var cursor = 0 + for m in matches { + if m.range.location > cursor { + let sub = ns.substring(with: NSRange(location: cursor, length: m.range.location - cursor)) + if sub.isEmpty == false { + parts.append(.text(sub)) + } + } + if let labelR = Range(m.range(at: 1), in: text) { + parts.append(.footnoteReference(label: String(text[labelR]))) + } + cursor = m.range.location + m.range.length + } + if cursor < ns.length { + let tail = ns.substring(from: cursor) + if tail.isEmpty == false { + parts.append(.text(tail)) + } + } + return parts + } +} + +// MARK: - 渲染 AST 尾部脚注区 + +enum STMarkdownFootnoteSectionBuilder { + static func appendingSectionIfNeeded( + document: STMarkdownDocument, + renderBlocks: [STMarkdownRenderBlock] + ) -> [STMarkdownRenderBlock] { + let labels = orderedReferenceLabels(in: renderBlocks) + guard labels.isEmpty == false else { return renderBlocks } + var out = renderBlocks + out.append(.thematicBreak) + out.append(.paragraph([.strong([.text("脚注")])])) + for (idx, label) in labels.enumerated() { + let ordinal = idx + 1 + let def = document.footnoteDefinitions[label]?.content ?? [.text("(未找到定义)")] + var line: [STMarkdownInlineNode] = [.strong([.text("\(ordinal).")]), .text(" ")] + line.append(contentsOf: def) + out.append(.paragraph(line)) + } + return out + } + + /// 正文中脚注引用首次出现顺序(与上标编号一致)。 + static func orderedReferenceLabels(in blocks: [STMarkdownRenderBlock]) -> [String] { + var order: [String] = [] + var seen: Set = [] + for b in blocks { + visitRenderBlock(b, order: &order, seen: &seen) + } + return order + } + + private static func visitRenderBlock(_ block: STMarkdownRenderBlock, order: inout [String], seen: inout Set) { + switch block { + case .paragraph(let inlines): + visitInlines(inlines, order: &order, seen: &seen) + case .heading(_, _, let inlines): + visitInlines(inlines, order: &order, seen: &seen) + case .quote(let inner): + inner.forEach { visitRenderBlock($0, order: &order, seen: &seen) } + case .list(let items): + for item in items { + for b in item.blocks { + visitRenderBlock(b, order: &order, seen: &seen) + } + } + case .details(let summary, let body): + visitInlines(summary, order: &order, seen: &seen) + body.forEach { visitRenderBlock($0, order: &order, seen: &seen) } + case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: + break + } + } + + private static func visitInlines(_ nodes: [STMarkdownInlineNode], order: inout [String], seen: inout Set) { + for n in nodes { + switch n { + case .footnoteReference(let label): + if seen.insert(label).inserted { + order.append(label) + } + case .emphasis(let c), .strong(let c), .strikethrough(let c): + visitInlines(c, order: &order, seen: &seen) + case .link(_, let c): + visitInlines(c, order: &order, seen: &seen) + default: + break + } + } + } +} + +// MARK: - 脚注上标编号映射(渲染器) + +enum STMarkdownFootnoteOrdinalResolver { + static func ordinalMap(for blocks: [STMarkdownRenderBlock]) -> [String: Int] { + let labels = STMarkdownFootnoteSectionBuilder.orderedReferenceLabels(in: blocks) + var map: [String: Int] = [:] + for (i, l) in labels.enumerated() { + map[l] = i + 1 + } + return map + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownHTMLBlockClassifier.swift b/Sources/STMarkdown/Parsing/STMarkdownHTMLBlockClassifier.swift new file mode 100644 index 0000000..30e191c --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownHTMLBlockClassifier.swift @@ -0,0 +1,72 @@ +// +// STMarkdownHTMLBlockClassifier.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +enum STMarkdownHTMLBlockClassifier { + private static let detailsRegex = try! NSRegularExpression( + pattern: #"(?is)]*>(.*?)
        "#, + options: [] + ) + + private static let summaryRegex = try! NSRegularExpression( + pattern: #"(?is)]*>(.*?)"#, + options: [] + ) + + /// - Parameter parseFragment: 解析 `
        ` 内部 Markdown 正文(不应再次剥离脚注定义行,避免递归丢失)。 + static func classify(html: String, parseFragment: (String) -> STMarkdownDocument) -> STMarkdownBlockNode { + let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { + return .rawHTML(html) + } + let ns = trimmed as NSString + let range = NSRange(location: 0, length: ns.length) + guard let detMatch = Self.detailsRegex.firstMatch(in: trimmed, range: range), + let innerR = Range(detMatch.range(at: 1), in: trimmed) + else { + return .rawHTML(html) + } + let inner = String(trimmed[innerR]) + let innerNS = inner as NSString + let innerFull = NSRange(location: 0, length: innerNS.length) + guard let sumMatch = Self.summaryRegex.firstMatch(in: inner, range: innerFull), + let sumR = Range(sumMatch.range(at: 1), in: inner) + else { + return .rawHTML(html) + } + let summaryRaw = String(inner[sumR]) + let summaryPlain = Self.stripHTMLTags(from: summaryRaw).trimmingCharacters(in: .whitespacesAndNewlines) + let summaryEnd = sumMatch.range.location + sumMatch.range.length + let bodyStart = (inner as NSString).length > summaryEnd ? summaryEnd : innerNS.length + let bodyRaw = innerNS.substring(from: bodyStart).trimmingCharacters(in: .whitespacesAndNewlines) + + let summaryDoc = parseFragment(summaryPlain.isEmpty ? " " : summaryPlain) + let summaryInlines: [STMarkdownInlineNode] + if let first = summaryDoc.blocks.first, case .paragraph(let p) = first { + summaryInlines = p + } else { + summaryInlines = summaryPlain.isEmpty ? [] : [.text(summaryPlain)] + } + + let bodyDoc = parseFragment(bodyRaw.isEmpty ? " " : bodyRaw) + return .details(summary: summaryInlines, body: bodyDoc.blocks) + } + + private static func stripHTMLTags(from fragment: String) -> String { + let pattern = #"<[^>]+>"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return fragment + } + let ns = fragment as NSString + return regex.stringByReplacingMatches( + in: fragment, + range: NSRange(location: 0, length: ns.length), + withTemplate: "" + ) + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift index 183f3d0..bbda768 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift @@ -25,6 +25,8 @@ public enum STMarkdownRenderBlock: Hashable, Sendable { case mathBlock(String) case image(url: String, altText: String, title: String?) case thematicBreak + case details(summary: [STMarkdownInlineNode], body: [STMarkdownRenderBlock]) + case rawHTML(String) } public struct STMarkdownRenderListItem: Hashable, Sendable { diff --git a/Sources/STMarkdown/Parsing/STMarkdownSemanticNormalizer.swift b/Sources/STMarkdown/Parsing/STMarkdownSemanticNormalizer.swift index 6c43557..dfca4ab 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownSemanticNormalizer.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownSemanticNormalizer.swift @@ -31,7 +31,13 @@ public struct STMarkdownSoftBreakCollapsingNormalizer: STMarkdownSemanticNormali public init() {} public func normalize(_ document: STMarkdownDocument) -> STMarkdownDocument { - STMarkdownDocument(blocks: document.blocks.map(self.normalizeBlock)) + let defs = document.footnoteDefinitions.mapValues { def in + STMarkdownFootnoteDefinition(content: self.normalizeInlineNodes(def.content)) + } + return STMarkdownDocument( + blocks: document.blocks.map(self.normalizeBlock), + footnoteDefinitions: defs + ) } } @@ -54,7 +60,12 @@ private extension STMarkdownSoftBreakCollapsingNormalizer { ) } ) - case .codeBlock, .table, .mathBlock, .image, .thematicBreak: + case .details(let summary, let body): + return .details( + summary: self.normalizeInlineNodes(summary), + body: body.map(self.normalizeBlock) + ) + case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: return block } } @@ -78,7 +89,7 @@ private extension STMarkdownSoftBreakCollapsingNormalizer { result.append(.link(destination: destination, children: self.normalizeInlineNodes(children))) case .strikethrough(let children): result.append(.strikethrough(self.normalizeInlineNodes(children))) - default: + case .footnoteReference, .inlineRawHTML, .inlineMath, .code, .image, .text, .softBreak: result.append(node) } } diff --git a/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift b/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift index a50a18e..0402414 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownStructureParser.swift @@ -13,8 +13,8 @@ public protocol STMarkdownStructureParsing: Sendable { } public struct STMarkdownStructureParser: STMarkdownStructureParsing, Sendable { - /// 串行化 swift-markdown / cmark 解析路径,对齐 vendor ``parseLock`` 的防护语义(多线程 + 扩展注册)。 - private static let parseLock = NSLock() + /// 串行化 swift-markdown / cmark 解析路径;使用递归锁以支持脚注定义体内再解析。 + private static let parseLock = NSRecursiveLock() public init() {} @@ -24,14 +24,49 @@ public struct STMarkdownStructureParser: STMarkdownStructureParsing, Sendable { } Self.parseLock.lock() defer { Self.parseLock.unlock() } - let normalized = STMarkdownMathNormalizer.normalizeBlocks(in: markdown) - let document = Document(parsing: normalized.text) - let blocks = self.makeBlocks(from: Array(document.children), mathMap: normalized.blockMap) - return STMarkdownDocument(blocks: blocks) + return self.parseUnlocked(markdown, stripFootnoteDefinitions: true) + } + + /// 将短 Markdown 片段解析为单个段落的行内节点(脚注定义体、HTML `` 等)。 + public func parseInlineFragment(_ markdown: String) -> [STMarkdownInlineNode] { + guard markdown.isEmpty == false else { return [] } + Self.parseLock.lock() + defer { Self.parseLock.unlock() } + let doc = self.parseUnlocked(markdown, stripFootnoteDefinitions: false) + if let first = doc.blocks.first, case .paragraph(let inlines) = first { + return inlines + } + return [.text(markdown)] } } private extension STMarkdownStructureParser { + func parseUnlocked(_ markdown: String, stripFootnoteDefinitions: Bool) -> STMarkdownDocument { + let working: String + let rawDefs: [String: String] + if stripFootnoteDefinitions { + let pair = STMarkdownFootnoteDefinitionScanner.stripDefinitions(from: markdown) + working = pair.markdown + rawDefs = pair.rawBodies + } else { + working = markdown + rawDefs = [:] + } + + let footnoteDefs = STMarkdownFootnoteDefinitionBuilder.definitions(from: rawDefs) { [self] fragment in + self.parseUnlocked(fragment, stripFootnoteDefinitions: false) + } + + let normalized = STMarkdownMathNormalizer.normalizeBlocks(in: working) + let document = Document(parsing: normalized.text) + let blocks = self.makeBlocks(from: Array(document.children), mathMap: normalized.blockMap) + var doc = STMarkdownDocument(blocks: blocks, footnoteDefinitions: footnoteDefs) + if stripFootnoteDefinitions { + doc = STMarkdownFootnoteInlineInjector.apply(doc) + } + return doc + } + static let mathBlockRegex = STMarkdownRegexFactory.compile( pattern: #"\{\{ST_MATH_BLOCK:(\d+)\}\}"#, owner: "STMarkdownStructureParser.mathBlock" @@ -106,6 +141,14 @@ private extension STMarkdownStructureParser { continue } + if let htmlBlock = block as? HTMLBlock { + let classified = STMarkdownHTMLBlockClassifier.classify(html: htmlBlock.rawHTML) { [self] fragment in + self.parseUnlocked(fragment, stripFootnoteDefinitions: false) + } + blocks.append(classified) + continue + } + if block is ThematicBreak { blocks.append(.thematicBreak) continue @@ -278,6 +321,9 @@ private extension STMarkdownStructureParser { if let strikethrough = markup as? Strikethrough { return [.strikethrough(strikethrough.children.flatMap { self.inlineNodes(from: $0) })] } + if let inlineHTML = markup as? InlineHTML { + return [.inlineRawHTML(inlineHTML.rawHTML)] + } return markup.children.flatMap { self.inlineNodes(from: $0) } } diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index b2bbeea..1c0bb24 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -34,19 +34,30 @@ public extension STMarkdownAttributedStringRenderer { nodes: [STMarkdownInlineNode], baseFont: UIFont, textColor: UIColor, - paragraphStyle: NSMutableParagraphStyle? = nil + paragraphStyle: NSMutableParagraphStyle? = nil, + footnoteOrdinals: [String: Int]? = nil ) -> NSAttributedString { self.renderInline( nodes: nodes, baseFont: baseFont, textColor: textColor, - paragraphStyle: paragraphStyle + paragraphStyle: paragraphStyle, + italic: false, + bold: false, + linkDestination: nil, + kernOverride: nil, + footnoteOrdinals: footnoteOrdinals ?? [:] ) } } private extension STMarkdownAttributedStringRenderer { func render(blocks: [STMarkdownRenderBlock]) -> NSAttributedString { + let footnoteOrdinals = STMarkdownFootnoteOrdinalResolver.ordinalMap(for: blocks) + return self.render(blocks: blocks, footnoteOrdinals: footnoteOrdinals) + } + + func render(blocks: [STMarkdownRenderBlock], footnoteOrdinals: [String: Int]) -> NSAttributedString { let result = NSMutableAttributedString() for (index, block) in blocks.enumerated() { if index > 0 { @@ -67,15 +78,20 @@ private extension STMarkdownAttributedStringRenderer { ] )) } - result.append(self.render(block: block)) + result.append(self.render(block: block, footnoteOrdinals: footnoteOrdinals)) } return result } - func render(block: STMarkdownRenderBlock) -> NSAttributedString { + func render(block: STMarkdownRenderBlock, footnoteOrdinals: [String: Int]) -> NSAttributedString { switch block { case .paragraph(let inlines): - return self.renderInline(nodes: inlines, baseFont: self.style.font, textColor: self.style.textColor) + return self.renderInline( + nodes: inlines, + baseFont: self.style.font, + textColor: self.style.textColor, + footnoteOrdinals: footnoteOrdinals + ) case .heading(let level, let anchorId, let content): let headingFont = self.headingFont(for: level) let headingColor = self.style.headingTextColor ?? self.style.textColor @@ -84,7 +100,8 @@ private extension STMarkdownAttributedStringRenderer { baseFont: headingFont, textColor: headingColor, paragraphStyle: self.headingParagraphStyle(font: headingFont), - kernOverride: self.style.headingKern + kernOverride: self.style.headingKern, + footnoteOrdinals: footnoteOrdinals ) let out = NSMutableAttributedString(attributedString: body) if out.length > 0 { @@ -92,9 +109,9 @@ private extension STMarkdownAttributedStringRenderer { } return out case .quote(let blocks): - return self.renderQuote(blocks: blocks) + return self.renderQuote(blocks: blocks, footnoteOrdinals: footnoteOrdinals) case .list(let items): - return self.renderList(items) + return self.renderList(items, footnoteOrdinals: footnoteOrdinals) case .codeBlock(let language, let code): if let rendered = self.advancedRenderers.codeBlockRenderer?.renderCodeBlock( language: language, @@ -133,6 +150,53 @@ private extension STMarkdownAttributedStringRenderer { return rendered } return NSAttributedString(string: "———", attributes: self.baseAttributes()) + case .details(let summary, let body): + return self.renderDetails(summary: summary, body: body, footnoteOrdinals: footnoteOrdinals) + case .rawHTML(let html): + return self.renderRawHTMLBlock(html) + } + } + + func renderDetails( + summary: [STMarkdownInlineNode], + body: [STMarkdownRenderBlock], + footnoteOrdinals: [String: Int] + ) -> NSAttributedString { + let out = NSMutableAttributedString() + let summaryAttr = self.renderInline( + nodes: summary, + baseFont: self.style.font, + textColor: self.style.textColor, + footnoteOrdinals: footnoteOrdinals + ) + let prefix = NSAttributedString(string: "▸ ", attributes: self.baseAttributes()) + out.append(prefix) + out.append(summaryAttr) + out.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) + let bodyAttr = NSMutableAttributedString(attributedString: self.render(blocks: body, footnoteOrdinals: footnoteOrdinals)) + if bodyAttr.length > 0 { + let indent = max(self.style.blockquoteIndentation, 0) + if indent > 0 { + self.offsetParagraphStyles(in: bodyAttr, by: indent) + } + out.append(bodyAttr) + } + return out + } + + func renderRawHTMLBlock(_ html: String) -> NSAttributedString { + switch self.style.rawHTMLPolicy { + case .suppress: + return NSAttributedString(string: "", attributes: self.baseAttributes()) + case .literalMonospace: + let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines) + var attrs = self.baseAttributes() + attrs[.font] = UIFont.st_monospacedSystemFont( + ofSize: max(self.style.font.pointSize - 2, 10), + weight: .regular + ) + attrs[.foregroundColor] = self.style.textColor.withAlphaComponent(0.55) + return NSAttributedString(string: trimmed, attributes: attrs) } } @@ -160,7 +224,7 @@ private extension STMarkdownAttributedStringRenderer { } let rows = ([table.header].compactMap { $0 } + table.rows) let strings = rows.map { row in - row.map { self.renderInline(nodes: $0, baseFont: self.style.font, textColor: self.style.textColor).string } + row.map { self.renderInline(nodes: $0, baseFont: self.style.font, textColor: self.style.textColor, footnoteOrdinals: [:]).string } .joined(separator: " ") } return NSAttributedString(string: strings.joined(separator: "\n"), attributes: self.baseAttributes()) @@ -174,8 +238,8 @@ private extension STMarkdownAttributedStringRenderer { /// /// 另外把 `style.blockquoteIndentation`(非负)下沉到段落 `firstLineHeadIndent`/`headIndent` /// 中,使得长段落自动换行后内容仍保持与左竖线对齐的缩进。 - func renderQuote(blocks: [STMarkdownRenderBlock]) -> NSAttributedString { - let body = NSMutableAttributedString(attributedString: self.render(blocks: blocks)) + func renderQuote(blocks: [STMarkdownRenderBlock], footnoteOrdinals: [String: Int]) -> NSAttributedString { + let body = NSMutableAttributedString(attributedString: self.render(blocks: blocks, footnoteOrdinals: footnoteOrdinals)) guard body.length > 0 else { return body } let lineColor = self.style.blockquoteLineColor ?? UIColor.systemGray @@ -233,7 +297,8 @@ private extension STMarkdownAttributedStringRenderer { italic: Bool = false, bold: Bool = false, linkDestination: String? = nil, - kernOverride: CGFloat? = nil + kernOverride: CGFloat? = nil, + footnoteOrdinals: [String: Int] = [:] ) -> NSAttributedString { let result = NSMutableAttributedString() let italicFont = STMarkdownFontResolver.italicFont(from: baseFont) @@ -308,7 +373,9 @@ private extension STMarkdownAttributedStringRenderer { paragraphStyle: style, italic: true, bold: bold, - linkDestination: linkDestination + linkDestination: linkDestination, + kernOverride: kernOverride, + footnoteOrdinals: footnoteOrdinals )) case .strong(let children): result.append(self.renderInline( @@ -318,7 +385,9 @@ private extension STMarkdownAttributedStringRenderer { paragraphStyle: style, italic: italic, bold: true, - linkDestination: linkDestination + linkDestination: linkDestination, + kernOverride: kernOverride, + footnoteOrdinals: footnoteOrdinals )) case .code(let code): var codeAttributes = attributes @@ -336,7 +405,9 @@ private extension STMarkdownAttributedStringRenderer { paragraphStyle: style, italic: italic, bold: bold, - linkDestination: destination + linkDestination: destination, + kernOverride: kernOverride, + footnoteOrdinals: footnoteOrdinals )) case .image(let source, let alt, let title): if let rendered = self.advancedRenderers.imageRenderer?.renderImage( @@ -362,7 +433,9 @@ private extension STMarkdownAttributedStringRenderer { paragraphStyle: style, italic: italic, bold: bold, - linkDestination: linkDestination + linkDestination: linkDestination, + kernOverride: kernOverride, + footnoteOrdinals: footnoteOrdinals ) let mutable = NSMutableAttributedString(attributedString: strikethroughRendered) let strikeColor = self.style.strikethroughColor ?? textColor @@ -371,13 +444,38 @@ private extension STMarkdownAttributedStringRenderer { .strikethroughColor: strikeColor, ], range: NSRange(location: 0, length: mutable.length)) result.append(mutable) + case .footnoteReference(let label): + let ordinal = footnoteOrdinals[label] + let glyph: String + if let ordinal, ordinal > 0 { + glyph = String(ordinal) + } else { + glyph = "[^\(label)]" + } + let superscriptSize = max(useFont.pointSize - 3, 9) + let superscriptFont = UIFont(descriptor: useFont.fontDescriptor, size: superscriptSize) + var supAttrs = attributes + supAttrs[.font] = superscriptFont + supAttrs[.baselineOffset] = useFont.ascender * 0.35 + supAttrs[.stMarkdownFootnoteLabel] = label + result.append(NSAttributedString(string: glyph, attributes: supAttrs)) + case .inlineRawHTML(let raw): + switch self.style.rawHTMLPolicy { + case .suppress: + break + case .literalMonospace: + var monoAttrs = attributes + monoAttrs[.font] = UIFont.st_monospacedSystemFont(ofSize: max(baseFont.pointSize - 2, 9), weight: .regular) + monoAttrs[.foregroundColor] = (self.style.inlineCodeTextColor ?? textColor).withAlphaComponent(0.72) + result.append(NSAttributedString(string: raw, attributes: monoAttrs)) + } } } return result } - func renderList(_ items: [STMarkdownRenderListItem]) -> NSAttributedString { + func renderList(_ items: [STMarkdownRenderListItem], footnoteOrdinals: [String: Int]) -> NSAttributedString { let inner = NSMutableAttributedString() for (index, item) in items.enumerated() { @@ -396,7 +494,7 @@ private extension STMarkdownAttributedStringRenderer { let leadingBlocks = self.leadingListBlocks(for: item) if leadingBlocks.isEmpty == false { - let renderedLeading = NSMutableAttributedString(attributedString: self.render(blocks: leadingBlocks)) + let renderedLeading = NSMutableAttributedString(attributedString: self.render(blocks: leadingBlocks, footnoteOrdinals: footnoteOrdinals)) self.applyListContentStyle( renderedLeading, item: item, @@ -408,7 +506,7 @@ private extension STMarkdownAttributedStringRenderer { let trailingBlocks = self.trailingListBlocks(for: item) if trailingBlocks.isEmpty == false { - let child = NSMutableAttributedString(attributedString: self.render(blocks: trailingBlocks)) + let child = NSMutableAttributedString(attributedString: self.render(blocks: trailingBlocks, footnoteOrdinals: footnoteOrdinals)) self.offsetParagraphStyles(in: child, by: layout.contentIndent) if child.length > 0 { inner.append(NSAttributedString(string: "\n", attributes: self.baseAttributes())) diff --git a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultTableRenderer.swift b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultTableRenderer.swift index b97d428..54da2df 100644 --- a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultTableRenderer.swift +++ b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultTableRenderer.swift @@ -73,6 +73,10 @@ private extension STMarkdownDefaultTableRenderer { return " " case .strikethrough(let children): return self.plainText(from: children) + case .footnoteReference(let label): + return "[^\(label)]" + case .inlineRawHTML(let raw): + return raw } }.joined() } diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index d38e0a1..57cd725 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -58,9 +58,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.shimmerTextView.renderedAttributedText } - public init(frame: CGRect) { + public init(frame: CGRect, usesTextLayoutManager: Bool = false) { super.init( - textView: STShimmerTextView(usingTextLayoutManager: false), + textView: STShimmerTextView(usingTextLayoutManager: usesTextLayoutManager), frame: frame, style: .default, advancedRenderers: .empty, @@ -72,9 +72,10 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { public convenience init( style: STMarkdownStyle = .default, advancedRenderers: STMarkdownAdvancedRenderers = .empty, - engine: STMarkdownEngine = STMarkdownEngine() + engine: STMarkdownEngine = STMarkdownEngine(), + usesTextLayoutManager: Bool = false ) { - self.init(frame: .zero) + self.init(frame: .zero, usesTextLayoutManager: usesTextLayoutManager) self.applyConfigurationCommon( style: style, advancedRenderers: advancedRenderers, diff --git a/Sources/STMarkdown/UI/STMarkdownTextView.swift b/Sources/STMarkdown/UI/STMarkdownTextView.swift index 3b43b70..06a86e5 100644 --- a/Sources/STMarkdown/UI/STMarkdownTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownTextView.swift @@ -9,9 +9,9 @@ import UIKit public final class STMarkdownTextView: STMarkdownBaseTextView { - public init(frame: CGRect) { + public init(frame: CGRect, usesTextLayoutManager: Bool = false) { super.init( - textView: UITextView(usingTextLayoutManager: false), + textView: UITextView(usingTextLayoutManager: usesTextLayoutManager), frame: frame, style: .default, advancedRenderers: .empty, @@ -23,9 +23,10 @@ public final class STMarkdownTextView: STMarkdownBaseTextView { public convenience init( style: STMarkdownStyle = .default, advancedRenderers: STMarkdownAdvancedRenderers = .empty, - engine: STMarkdownEngine = STMarkdownEngine() + engine: STMarkdownEngine = STMarkdownEngine(), + usesTextLayoutManager: Bool = false ) { - self.init(frame: .zero) + self.init(frame: .zero, usesTextLayoutManager: usesTextLayoutManager) self.applyConfigurationCommon( style: style, advancedRenderers: advancedRenderers, diff --git a/Sources/STMarkdown/UI/STScrollableMarkdownView.swift b/Sources/STMarkdown/UI/STScrollableMarkdownView.swift new file mode 100644 index 0000000..3122c0b --- /dev/null +++ b/Sources/STMarkdown/UI/STScrollableMarkdownView.swift @@ -0,0 +1,77 @@ +// +// STScrollableMarkdownView.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import UIKit + +/// 将 ``STMarkdownTextView`` 嵌入 ``UIScrollView`` 的只读 Markdown 预览容器。 +/// +/// - Note: 目录数据仍由 ``STMarkdownTextView/tableOfContents`` 与 ``scrollToHeadingAnchor`` 提供; +/// 侧栏 UI 与 `onTOCItemTap` 由宿主组合(对比文档 **【部分对齐】**)。 +public final class STScrollableMarkdownView: UIView { + + public let scrollView: UIScrollView + public let markdownTextView: STMarkdownTextView + + public var onLinkTap: ((URL) -> Void)? { + get { self.markdownTextView.onLinkTap } + set { self.markdownTextView.onLinkTap = newValue } + } + + public var onContentLayoutHeightChange: ((CGFloat) -> Void)? { + get { self.markdownTextView.onContentLayoutHeightChange } + set { self.markdownTextView.onContentLayoutHeightChange = newValue } + } + + /// - Parameter usesTextLayoutManager: 传入内层 ``STMarkdownTextView`` 的 TextKit 2 开关(iOS 16+)。 + public init(frame: CGRect, usesTextLayoutManager: Bool = false) { + self.scrollView = UIScrollView() + self.markdownTextView = STMarkdownTextView(frame: .zero, usesTextLayoutManager: usesTextLayoutManager) + super.init(frame: frame) + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.keyboardDismissMode = .interactive + self.markdownTextView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.scrollView) + self.scrollView.addSubview(self.markdownTextView) + NSLayoutConstraint.activate([ + self.scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.scrollView.topAnchor.constraint(equalTo: self.topAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.markdownTextView.leadingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.leadingAnchor), + self.markdownTextView.trailingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.trailingAnchor), + self.markdownTextView.topAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.topAnchor), + self.markdownTextView.bottomAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.bottomAnchor), + self.markdownTextView.widthAnchor.constraint(equalTo: self.scrollView.frameLayoutGuide.widthAnchor), + ]) + } + + public required init?(coder: NSCoder) { + self.scrollView = UIScrollView() + self.markdownTextView = STMarkdownTextView(coder: coder) ?? STMarkdownTextView(frame: .zero) + super.init(coder: coder) + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + self.markdownTextView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.scrollView) + self.scrollView.addSubview(self.markdownTextView) + NSLayoutConstraint.activate([ + self.scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.scrollView.topAnchor.constraint(equalTo: self.topAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.markdownTextView.leadingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.leadingAnchor), + self.markdownTextView.trailingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.trailingAnchor), + self.markdownTextView.topAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.topAnchor), + self.markdownTextView.bottomAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.bottomAnchor), + self.markdownTextView.widthAnchor.constraint(equalTo: self.scrollView.frameLayoutGuide.widthAnchor), + ]) + } + + public func setMarkdown(_ markdown: String) { + self.markdownTextView.setMarkdown(markdown) + } +} diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift index 75bac5c..d628828 100644 --- a/Sources/STUIKit/STTextView/STShimmerTextView.swift +++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift @@ -63,6 +63,20 @@ open class STShimmerTextView: UITextView { self.setup() } + /// - Parameter usingTextLayoutManager: `true` 时使用 TextKit 2 栈(iOS 16+);低版本系统始终为 TextKit 1。 + public convenience init(usingTextLayoutManager: Bool) { + if #available(iOS 16.0, *) { + if usingTextLayoutManager { + let shell = UITextView(usingTextLayoutManager: true) + self.init(frame: .zero, textContainer: shell.textContainer) + } else { + self.init(frame: .zero, textContainer: nil) + } + } else { + self.init(frame: .zero, textContainer: nil) + } + } + private func setup() { self.translatesAutoresizingMaskIntoConstraints = false self.isEditable = false From f4d4099e7567a350dae89be46b8bcc971684620f Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 14:52:37 +0800 Subject: [PATCH 08/27] Enhance STMarkdown with incremental parsing tests and table of contents updates - Added a new test case in `STMarkdownIncrementalParseTests` to verify that incremental strict prefix growth matches the full processing results. - Introduced `updateTableOfContents` method in `STMarkdownBaseTextView` to refresh the table of contents from the rendered document, supporting incremental processing. - Updated `STMarkdownStreamingTextView` to utilize a new `renderWithDocument` method for improved rendering and TOC management during streaming updates. --- .../STMarkdownIncrementalParseTests.swift | 32 +++++++++++++++++++ .../UI/STMarkdownBaseTextView.swift | 5 +++ .../UI/STMarkdownStreamingTextView.swift | 24 ++++++++++---- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift b/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift index 482d502..48e10e7 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownIncrementalParseTests.swift @@ -72,4 +72,36 @@ final class STMarkdownIncrementalParseTests: XCTestCase { XCTAssertEqual(merged[0], prev[0]) XCTAssertEqual(merged[1], newTail[0]) } + + /// 严格前缀增长时,在若干简单文档上合并结果应与整段 ``process`` 一致(对照对比文档 P0;复杂结构见管线单测扩展)。 + func testIncrementalStrictPrefixGrowthMatchesFullProcess() { + let pipeline = STMarkdownPipeline(configuration: STMarkdownPipelineConfiguration(enableInputSanitizer: false)) + let steps = [ + "# A\n\n", + "# A\n\nSecond paragraph.", + ] + var merged: STMarkdownRenderDocument? + var prevDisplay = "" + for (i, step) in steps.enumerated() { + let full = pipeline.process(step).renderDocument + guard let currentMerged = merged else { + merged = full + prevDisplay = step + XCTAssertEqual(merged!.blocks, full.blocks, "initial step \(i)") + continue + } + let inc = pipeline.processIncremental( + STMarkdownIncrementalParameters( + canonicalMarkdown: step, + lastCommittedExclusiveEnd: prevDisplay.count, + currentSafeExclusiveEnd: step.count, + contextWindowSize: 200, + previousTotalRenderBlockCount: currentMerged.blocks.count + ) + ) + merged = inc.mergedRenderDocument(previous: currentMerged) + prevDisplay = step + XCTAssertEqual(merged!.blocks, full.blocks, "after incremental step \(i)") + } + } } diff --git a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift index 61ebf80..4f63131 100644 --- a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift @@ -206,6 +206,11 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { self.tableOfContents = result.tableOfContents } + /// 从渲染 AST 刷新目录(供流式 ``STMarkdownPipeline/processIncremental`` 合并结果等路径使用)。 + internal func updateTableOfContents(from renderDocument: STMarkdownRenderDocument) { + self.tableOfContents = STMarkdownTOCExtraction.items(from: renderDocument) + } + internal func renderMarkdown(_ markdown: String) -> NSAttributedString { let result = self.engine.process(markdown) self.updateTableOfContents(from: result) diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index 57cd725..bb73a5c 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -112,6 +112,14 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { ? Self.stripUnclosedTailMarkers(in: markdown) : markdown let displayRendered = self.render(markdownForRender) + self.applySetMarkdownAnimatedDiff(markdown: markdown, displayRendered: displayRendered, animated: animated) + } + + private func applySetMarkdownAnimatedDiff( + markdown: String, + displayRendered: NSAttributedString, + animated: Bool + ) { guard animated, !self.rawMarkdown.isEmpty else { self.applyFullReplace(markdown: markdown, rendered: displayRendered) return @@ -288,6 +296,15 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.rawMarkdown = accumulated } + private func renderWithDocument(_ markdown: String) -> (NSAttributedString, STMarkdownRenderDocument) { + let result = self.engine.process(markdown) + self.updateTableOfContents(from: result) + if let customRenderer = self.customDocumentRenderer { + return (customRenderer(result.renderDocument), result.renderDocument) + } + return (self.renderer.render(document: result.renderDocument), result.renderDocument) + } + private func applyFullReplace(markdown: String, rendered: NSAttributedString) { self.rawMarkdown = markdown self.shimmerTextView.setRenderedAttributedText(rendered) @@ -316,12 +333,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } private func render(_ markdown: String) -> NSAttributedString { - let result = self.engine.process(markdown) - self.updateTableOfContents(from: result) - if let customRenderer = self.customDocumentRenderer { - return customRenderer(result.renderDocument) - } - return self.renderer.render(document: result.renderDocument) + self.renderWithDocument(markdown).0 } /// 流式期间,若源 markdown 尾部存在**尚未闭合**的 delimiter token(例如只打了 From 2fe1f7f167ae56a9eb83c7b71d038b4b6af31ef5 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 16:29:16 +0800 Subject: [PATCH 09/27] Enhance STMarkdown with footnote deep linking and TOC updates - Added support for footnote deep linking in `STMarkdownAttributedStringRenderer`, allowing footnotes to be rendered as clickable links. - Introduced `onFootnoteTap` callback in `STMarkdownBaseTextView` and related UI components to handle footnote interactions. - Updated `STScrollableMarkdownView` to include a table of contents (TOC) panel with user interaction capabilities, aligning with vendor API semantics. - Enhanced `STMarkdownBaseTextView` to notify changes in the TOC during rendering, ensuring synchronization with the displayed content. --- ...Markdown-MarkdownDisplayView-Comparison.md | 46 +++-- .../STMarkdownConcurrencyStressTests.swift | 30 +++ .../STMarkdownTOCTests.swift | 8 + .../Parsing/STMarkdownFootnoteDeepLink.swift | 25 +++ .../STMarkdownAttributedStringRenderer.swift | 6 + .../UI/STMarkdownBaseTextView.swift | 21 +++ .../STMarkdown/UI/STMarkdownSwiftUIView.swift | 15 +- .../UI/STScrollableMarkdownView.swift | 175 +++++++++++++++--- 8 files changed, 280 insertions(+), 46 deletions(-) create mode 100644 Example/STBaseProjectExampleTests/STMarkdownConcurrencyStressTests.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownFootnoteDeepLink.swift diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index 247d326..e2bae58 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -2,7 +2,17 @@ > 对照基准:Vendor 库 [MarkdownDisplayView](https://github.com/zjc19891106/MarkdownDisplayView.git) 中 `MarkdownDisplayView` target 源码。 > 对照对象:本仓库 `Sources/STMarkdown/`。 -> 文档生成日期:2026-05-14 +> 文档生成日期:2026-05-14;**进度修订**:2026-05-14(P0 部分落地、P1 首轮落地,见 §0)。 + +## 0. 对齐进度快照(工程状态) + +| 优先级 | 方向 | 状态 | +|--------|------|------| +| P0 流式增量渲染链 | `processIncremental` + `STMarkdownStreamBuffer` 偏移对齐;`setMarkdown` 动画路径抽取为 `applySetMarkdownAnimatedDiff`;`renderWithDocument` 统一 `process` 与 TOC 更新 | **【部分完成】** 视图层仍未用合并 AST 替代全文 `process`(合并与全文在部分 Markdown 上不一致,待修后再接) | +| P0 流式专项测试 | `STMarkdownIncrementalParseTests` 严格前缀增长(简单两步)与既有 StreamBuffer / Pipeline 测 | **【部分完成】** 表/公式/Unicode 等扩展用例仍待补 | +| P1 TOC 产品面 | `STScrollableMarkdownView.showsTableOfContents` 内置侧栏、`onTOCItemTap`、`onTableOfContentsChange`;`STMarkdownBaseTextView.scrollToTOCItem`、`onTableOfContentsChange` | **【已完成】**(Vendor 同名 API 不必一致) | +| P1 脚注与 citation | 脚注引用使用 `stmarkdown-footnote://` 深度链接触发 `onFootnoteTap`;表格 Citation 仍走 `onCitationTap` / badge | **【已完成】**(语义分流;脚注 AST 管线既有) | +| P1 并发压测 | `STMarkdownConcurrencyStressTests` 多队列 `process` 冒烟 | **【已完成】**(轻量冒烟,非性能基准) | 本文只保留**行为与能力**层面的差异与对齐说明,便于宿主选型与后续按能力补齐。 @@ -32,7 +42,7 @@ | 场景 | Vendor(典型) | STMarkdown(典型) | 对齐 | |------|----------------|-------------------|------| -| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 TextKit 视图) | 自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | **【部分对齐】**(无同名一体化控件) | +| 可滚动整页预览 | `ScrollableMarkdownViewTextKit`(`UIScrollView` + 内嵌 TextKit 视图) | `STScrollableMarkdownView`(可选内置 TOC 侧栏)或自嵌 `UIScrollView` + `STMarkdownTextView` / `STMarkdownStreamingTextView`,或 `STMarkdownSwiftUIView` | **【部分对齐】**(与 Vendor 仍非逐 API 同名) | | 流式打字机 | `startStreaming(...)`、`StreamingUnit`(如 `.word`)等 | `beginSmartMarkdownStreaming()`、`appendSmartMarkdownStreamingChunk(_:)`、`endSmartMarkdownStreaming()`;或 `setMarkdown(_:animated:)` | **【部分对齐】**(粒度 API 不同) | | 配置对象 | `MarkdownConfiguration`(大结构体,含行距、语法高亮色等) | `STMarkdownStyle` + `STMarkdownEngine` / `STMarkdownPipelineConfiguration` 等拆分配置 | **【部分对齐】** | | 流式触感 | `StreamingHapticFeedbackStyle` 等 | **无**同名 API;需宿主自行 `UIImpactFeedbackGenerator` | **【未对齐】** | @@ -45,11 +55,11 @@ 1. **渲染引擎**:Vendor 为 **TextKit 2**;ST 主路径为 **`UITextView` + TextKit 1**(`usingTextLayoutManager: false`)。**【未对齐】** 2. **解析与并发**:Vendor 在解析路径用 **`parseLock`** 串行化 swift-markdown,视图层另有 `renderQueue`/版本锁等增量保护;ST 在 **`STMarkdownStructureParser.parse`** 使用 **`parseLock`**;**无**视图层版本锁与 **无** Vendor 同款元素级增量回溯公开形态。**【部分对齐】** 3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带预解析 `MarkdownRenderElement`**,并与 **Typewriter 子视图树** 配合;ST 为 **`STMarkdownStreamBuffer`**(可选 **`onCompleteModules`** 仅字符串)+ **Shimmer / 增量 `setMarkdown`**。**【部分对齐】**(预解析元素与视图树 **【未对齐】**) -4. **目录 TOC**:Vendor **内置目录视图、`onTOCItemTap`、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**(管线 + TextView)与 **`scrollToHeadingAnchor`** / **`characterRangeForHeadingAnchor`**;**无**内置目录 UI / **`onTOCItemTap`**。**【部分对齐】** +4. **目录 TOC**:Vendor **内置目录视图、`onTOCItemTap`、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**、**`scrollToHeadingAnchor`** / **`scrollToTOCItem`** / **`characterRangeForHeadingAnchor`**,以及 **`STScrollableMarkdownView`** 可选 **内置 TOC 侧栏** 与 **`onTOCItemTap`**、**`onTableOfContentsChange`**(流式同帧刷新宿主侧栏)。**【部分对齐】**(布局与 Vendor 组件仍不同) 5. **块级模型**:Vendor 含 **`details`、`rawHTML`、footnote** 等;ST **`STMarkdownBlockNode`** 未定义上述扩展块;**`STMarkdownRenderBlock.heading` 含 `anchorId`**,与 TOC 抽取一致。**【部分对齐】** 6. **公式**:KaTeX vs SwiftMath,排版与命令集不必一致。**【部分对齐】** 7. **表格**:Vendor 与 TextKit2 附件、手势、表格内链接等深度耦合;ST 为 **独立表格 Collection + overlay**,交互模型不同。**【部分对齐】** -8. **脚注 / 角标**:Vendor **脚注模型 + 延迟脚注视图**;ST 侧重 **Citation 角标**(如 `STMarkdownNumberBadgeAttachment`),**不等于** CommonMark/GFM footnote。**【未对齐】** +8. **脚注 / 角标**:Vendor **脚注模型 + 延迟脚注视图**;ST 具备 **GFM 脚注 AST/管线**(`[^label]` + 定义行),正文中以 **`stmarkdown-footnote://` 链接触发 `onFootnoteTap`**,与表格 **Citation 角标**(`onCitationTap`)分流。**【部分对齐】**(延迟脚注视图 / Vendor 同款 UI 仍不同) 9. **链接与图片**:双方均有 **`onLinkTap`** 类回调与异步图片链路。**【已对齐】**(命名与 TK 细节不同,见 §4) --- @@ -62,12 +72,12 @@ |------|-------------|---------|------|------| | 流式增量解析 | `parseIncremental(...)` → `safePosition`、`replaceCount`、`newElements` | 整段仍走 `process`;**`processIncremental`** → **`replaceTailCount`** + **`windowRenderDocument`**;安全上界由缓冲器提供 | ST **弱于** vendor 一体化 | **【部分对齐】** | | 流式模块回调 | `onModuleReady` 可回传预解析元素 | **`onCompleteModules`** 仅完整模块字符串;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | -| 块级能力 | `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` 等 | `STMarkdownBlockNode` 仍以常规块为主;**`heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` / `footnote` | **【部分对齐】** | -| TOC | `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | **`STMarkdownPipelineResult.tableOfContents`**、**`STMarkdownBaseTextView.tableOfContents`**、**`scrollToHeadingAnchor`** 等;无内置目录 UI / `onTOCItemTap` | ST **弱于** vendor 一体面 | **【部分对齐】** | -| 脚注 | 预处理、缓存、延迟脚注视图 | 无 footnote 链;有 citation badge | ST **缺少 footnote** | **【未对齐】** | +| 块级能力 | `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` 等 | `STMarkdownBlockNode` 仍以常规块为主;脚注 **`[^]`** 与定义行已进管线;**`heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` 等块 | **【部分对齐】** | +| TOC | `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | 管线 + **`STMarkdownBaseTextView`**:`tableOfContents`、`onTableOfContentsChange`、`scrollToHeadingAnchor` / **`scrollToTOCItem`**;**`STScrollableMarkdownView`** 可选内置侧栏 + **`onTOCItemTap`** | 一体布局与 Vendor 不同 | **【部分对齐】** | +| 脚注 | 预处理、缓存、延迟脚注视图 | 管线剥离定义 + 正文 `[^]` → **`onFootnoteTap`**(深度链接);Citation 仍独立 | 脚注视图形态不同 | **【部分对齐】** | | TextKit 栈 | `NSTextLayoutManager` / `NSTextContentStorage` / TK2 | `usingTextLayoutManager: false`(TextKit 1) | 路线不同 | **【未对齐】** | | HTML | `rawHTML(String)` 与渲染分支 | `STHtmlNormalizeRule` 等标明下游 **不消费 raw HTML** | ST **不支持 raw HTML** | **【未对齐】** | -| 交互 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onSelectionChange`、`onCitationTap` | 各有侧重 | **【部分对齐】** | +| 交互 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onFootnoteTap`、`onSelectionChange`、`onCitationTap`;TOC 侧栏 `onTOCItemTap`(见 `STScrollableMarkdownView`) | 各有侧重 | **【部分对齐】** | | 表格交互 | 与 TK2 attachment 深度耦合 | 独立 View/Attachment + overlay/citation | 路线不同 | **【部分对齐】** | --- @@ -77,12 +87,14 @@ > 语义接近 Vendor 常见流式 Markdown 组件,**非**逐行一致。 - **`STMarkdownStreamBuffer`** **【部分对齐】**:安全切分、字符偏移 **`lastSafeUpperBoundOffset`**、可选 **`onCompleteModules`**。与 Vendor 的差距见 §3 第 3 点。 -- **`STMarkdownBaseTextView`** **【部分对齐】**:测量宽度、高度通知节流;**`tableOfContents`**、**`scrollToHeadingAnchor`**、**`characterRangeForHeadingAnchor`**。 +- **`STMarkdownBaseTextView`** **【部分对齐】**:测量宽度、高度通知节流;**`tableOfContents`**、**`onTableOfContentsChange`**、**`scrollToHeadingAnchor`** / **`scrollToTOCItem`**、**`characterRangeForHeadingAnchor`**;**`onFootnoteTap`**(脚注链)与 **`onCitationTap`**(表格角标)分流。 +- **`STScrollableMarkdownView`** **【部分对齐】**:可选 **`showsTableOfContents`** 内置目录侧栏、**`onTOCItemTap`**、**`onTableOfContentsChange`**(与流式刷新同帧)。 +- **`STMarkdownFootnoteDeepLink`** + 渲染器脚注 **`NSTextAttribute.link`**:与 **`onLinkTap`** 分流(P1)。 - **`STMarkdownStructureParser`**:**`parseLock`** 串行化解析路径(与 Vendor 动机一致)。 - **`STMarkdownPipeline`** / **`STMarkdownMalformedTableNormalizer`**:坏表修复;**`STMarkdownPipelineResult.tableOfContents`**;**`processIncremental(_:)`**(窗口 parse、**`replaceTailCount`**、**`mergedRenderDocument`**,见 §6.2.5)。 - **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:锚点与 TOC 一致。 -单测入口示例:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests`(流式)、**`STMarkdownTOCTests`**、**`STMarkdownIncrementalParseTests`**。 +单测入口示例:`STMarkdownStreamBufferTests`、`STMarkdownBaseTextViewLayoutTests`、`STMarkdownPipelineTests`(流式)、**`STMarkdownTOCTests`**(含可滚动容器 TOC)、**`STMarkdownIncrementalParseTests`**、**`STMarkdownConcurrencyStressTests`**、**`STMarkdownFootnoteAndHTMLTests`**。 --- @@ -92,11 +104,11 @@ | 优先级 | 方向 | 说明 | |--------|------|------| -| P0 | **流式增量渲染链补强** | 已有 **`processIncremental`**(`replaceTailCount` + 窗口 **`STMarkdownRenderDocument`** + 合并);与 **`STMarkdownStreamBuffer`** 组合可逼近 Vendor 窗口策略。**仍缺**:与 TextKit **`replaceCharacters`** 的硬连接、缓冲与安全断点一体化。 | -| P0 | **流式专项测试** | 围栏、表、公式、标题切换、未闭合列表/引用、Unicode 分块、长文多轮 append。 | -| P1 | **TOC 产品面** | 数据与跳转已有;可补内置目录 UI、`onTOCItemTap`、与流式同帧刷新。 | -| P1 | **脚注与 citation 语义拆分** | 若要对齐通用 Markdown,应建 `footnote definition/reference` 模型,避免与角标混用。 | -| P1 | **并发压测** | 在 `parseLock` 基础上压测 `process`、流式 append、异步 attachment,再定是否扩展 actor / 更广临界区。 | +| P0 | **流式增量渲染链补强** | **【部分完成】** 已有 **`processIncremental`** + **`STMarkdownStreamBuffer`** 偏移约定;TextKit 侧已抽 **`applySetMarkdownAnimatedDiff`** 便于与「预渲染富文本」对接。**仍缺**:用合并 AST 安全替代全文 `process`(合并语义待加强)、与缓冲二合一。 | +| P0 | **流式专项测试** | **【部分完成】** 增量前缀单测 + 既有流式/缓冲测。**仍缺**:围栏、表、公式、Unicode 分块、长文多轮 append 等系统化矩阵。 | +| P1 | **TOC 产品面** | **【已完成】** `STScrollableMarkdownView` 内置侧栏、`onTOCItemTap`、`onTableOfContentsChange`;`scrollToTOCItem`;SwiftUI 流式包装透传 **`onTableOfContentsChange`**。 | +| P1 | **脚注与 citation 语义拆分** | **【已完成】** AST/管线已有脚注;UI 上 **`stmarkdown-footnote://` + `onFootnoteTap`** 与 **`onCitationTap`** 分流。 | +| P1 | **并发压测** | **【已完成】** 轻量多队列 **`process`** 冒烟(**`STMarkdownConcurrencyStressTests`**)。**仍缺**:流式 append、异步 attachment、指标化基准。 | | P2 | **`details` / `rawHTML`** | 先 AST / `STMarkdownRenderBlock`,再 UI;raw HTML 宜白名单或独立 Web 容器,不宜默认进主富文本路径。 | | P2 | **统一容器组件** | 评估官方「滚动 + 高度 + 目录 + 链接 + citation + 流式」一体化面,对标 `ScrollableMarkdownViewTextKit` 的宿主体验。 | | P3 | **TextKit 2** | 仅在附件布局、选区、超长文性能等**明确瓶颈**时再评估;不宜与流式增量同一迭代混谈。 | @@ -152,9 +164,9 @@ ST 已在解析路径使用 **`parseLock`**(见 §3 第 2 点、§5)。若 ### 6.3 P1:TOC、footnote -**TOC**:Vendor 把 `tocItems` 放进增量结果并提供目录视图与 tap。ST 可补 **`scrollToTOCItem(id:)`** 等与宿主 API 对齐的最小面。 +**TOC**:Vendor 把 `tocItems` 放进增量结果并提供目录视图与 tap。ST 已提供 **`scrollToTOCItem(anchorId:)`**、**`onTableOfContentsChange`**,以及 **`STScrollableMarkdownView`** 内置侧栏与 **`onTOCItemTap`**。 -**脚注**:ST 的 citation 角标 **不能**当作 footnote;需在 AST 层区分 `footnoteReference` / `footnoteDefinition` 与渲染分流。 +**脚注**:AST 已区分 **`footnoteReference` / `footnoteDefinition`**;正文中脚注引用使用 **`stmarkdown-footnote://`** 链接,**`onFootnoteTap`** 与 **`onLinkTap`** 分流;表格 **Citation** 仍仅走 **`onCitationTap`**。 --- diff --git a/Example/STBaseProjectExampleTests/STMarkdownConcurrencyStressTests.swift b/Example/STBaseProjectExampleTests/STMarkdownConcurrencyStressTests.swift new file mode 100644 index 0000000..8a2d43f --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownConcurrencyStressTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import STBaseProject + +/// 对照对比文档 P1:在 ``parseLock`` 存在的前提下,多队列并发调用 ``STMarkdownPipeline/process`` 应可完成且无崩溃。 +final class STMarkdownConcurrencyStressTests: XCTestCase { + + func testConcurrentPipelineProcessCompletes() { + let pipeline = STMarkdownPipeline(configuration: STMarkdownPipelineConfiguration(enableInputSanitizer: false)) + let md = """ + # Title + + Body with [^fn]. + + [^fn]: Definition line. + """ + let group = DispatchGroup() + let queueCount = 12 + let iterationsPerQueue = 40 + for _ in 0.. URL? { + guard let encoded = label.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { + return nil + } + return URL(string: "\(Self.scheme)://\(encoded)") + } + + static func label(from url: URL) -> String? { + guard url.scheme == Self.scheme else { return nil } + guard let host = url.host, host.isEmpty == false else { return nil } + return host.removingPercentEncoding + } +} diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index 1c0bb24..7aa4438 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -458,6 +458,12 @@ private extension STMarkdownAttributedStringRenderer { supAttrs[.font] = superscriptFont supAttrs[.baselineOffset] = useFont.ascender * 0.35 supAttrs[.stMarkdownFootnoteLabel] = label + if let fnURL = STMarkdownFootnoteDeepLink.url(label: label) { + supAttrs[.link] = fnURL + if let linkColor = self.style.linkColor { + supAttrs[.foregroundColor] = linkColor + } + } result.append(NSAttributedString(string: glyph, attributes: supAttrs)) case .inlineRawHTML(let raw): switch self.style.rawHTMLPolicy { diff --git a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift index 4f63131..bf5b915 100644 --- a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift @@ -33,6 +33,9 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { public var engine: STMarkdownEngine public var onLinkTap: ((URL) -> Void)? + public var onFootnoteTap: ((String) -> Void)? + /// 目录随管线刷新时回调(含流式 ``STMarkdownStreamingTextView`` 每帧更新);用于侧栏 TOC 与正文同帧对齐。 + public var onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? public var onSelectionChange: ((String) -> Void)? /// 内容高度变化回调;相对 ``contentLayoutHeightNotificationThreshold`` 防抖,避免频繁抖动。 /// @@ -204,11 +207,13 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { internal func updateTableOfContents(from result: STMarkdownPipelineResult) { self.tableOfContents = result.tableOfContents + self.onTableOfContentsChange?(self.tableOfContents) } /// 从渲染 AST 刷新目录(供流式 ``STMarkdownPipeline/processIncremental`` 合并结果等路径使用)。 internal func updateTableOfContents(from renderDocument: STMarkdownRenderDocument) { self.tableOfContents = STMarkdownTOCExtraction.items(from: renderDocument) + self.onTableOfContentsChange?(self.tableOfContents) } internal func renderMarkdown(_ markdown: String) -> NSAttributedString { @@ -238,6 +243,12 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { return true } + /// 与 ``scrollToHeadingAnchor`` 等价;命名对齐常见 TOC API(对比文档 §6.3)。 + @discardableResult + public func scrollToTOCItem(anchorId: String, animated: Bool) -> Bool { + return self.scrollToHeadingAnchor(id: anchorId, animated: animated) + } + /// 查询标题锚点在富文本中的 UTF-16 范围(便于外层 ``UIScrollView`` 自行滚动)。 public func characterRangeForHeadingAnchor(id: String) -> NSRange? { guard let attr = self.textView.attributedText, attr.length > 0 else { return nil } @@ -346,6 +357,7 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { internal func resetBaseState() { self.rawMarkdown = "" self.tableOfContents = [] + self.onTableOfContentsChange?([]) for token in self.attachmentRefreshTokens { token.invalidate() } @@ -444,6 +456,10 @@ extension STMarkdownBaseTextView: UITextViewDelegate { in characterRange: NSRange, interaction: UITextItemInteraction ) -> Bool { + if let label = STMarkdownFootnoteDeepLink.label(from: url) { + self.onFootnoteTap?(label) + return false + } self.onLinkTap?(url) return false } @@ -457,6 +473,11 @@ extension STMarkdownBaseTextView: UITextViewDelegate { guard case let .link(url) = textItem.content else { return defaultAction } + if let label = STMarkdownFootnoteDeepLink.label(from: url) { + return UIAction { [weak self] _ in + self?.onFootnoteTap?(label) + } + } return UIAction { [weak self] _ in self?.onLinkTap?(url) } diff --git a/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift b/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift index d8a610a..0f1783a 100644 --- a/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift +++ b/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift @@ -15,6 +15,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { public var engine: STMarkdownEngine public var isTextSelectionEnabled: Bool public var onLinkTap: ((URL) -> Void)? + public var onFootnoteTap: ((String) -> Void)? public var onSelectionChange: ((String) -> Void)? public var onCitationTap: ((String) -> Void)? @@ -25,6 +26,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { engine: STMarkdownEngine = STMarkdownEngine(), isTextSelectionEnabled: Bool = true, onLinkTap: ((URL) -> Void)? = nil, + onFootnoteTap: ((String) -> Void)? = nil, onSelectionChange: ((String) -> Void)? = nil, onCitationTap: ((String) -> Void)? = nil ) { @@ -34,6 +36,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { self.engine = engine self.isTextSelectionEnabled = isTextSelectionEnabled self.onLinkTap = onLinkTap + self.onFootnoteTap = onFootnoteTap self.onSelectionChange = onSelectionChange self.onCitationTap = onCitationTap } @@ -88,6 +91,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { private func applyCallbacks(to view: STMarkdownTextView) { view.onLinkTap = self.onLinkTap + view.onFootnoteTap = self.onFootnoteTap view.onSelectionChange = self.onSelectionChange view.onCitationTap = self.onCitationTap } @@ -118,8 +122,11 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { public var tokenFadeDuration: TimeInterval? public var command: STMarkdownStreamingCommand? public var onLinkTap: ((URL) -> Void)? + public var onFootnoteTap: ((String) -> Void)? public var onSelectionChange: ((String) -> Void)? public var onCitationTap: ((String) -> Void)? + /// 与 ``STMarkdownBaseTextView/onTableOfContentsChange`` 一致:目录随渲染刷新(含流式每帧)。 + public var onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? public init( markdown: String, @@ -133,8 +140,10 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { tokenFadeDuration: TimeInterval? = nil, command: STMarkdownStreamingCommand? = nil, onLinkTap: ((URL) -> Void)? = nil, + onFootnoteTap: ((String) -> Void)? = nil, onSelectionChange: ((String) -> Void)? = nil, - onCitationTap: ((String) -> Void)? = nil + onCitationTap: ((String) -> Void)? = nil, + onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? = nil ) { self.markdown = markdown self.style = style @@ -147,8 +156,10 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { self.tokenFadeDuration = tokenFadeDuration self.command = command self.onLinkTap = onLinkTap + self.onFootnoteTap = onFootnoteTap self.onSelectionChange = onSelectionChange self.onCitationTap = onCitationTap + self.onTableOfContentsChange = onTableOfContentsChange } public func makeUIView(context: Context) -> STMarkdownStreamingTextView { @@ -202,8 +213,10 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { private func applyCallbacks(to view: STMarkdownStreamingTextView) { view.onLinkTap = self.onLinkTap + view.onFootnoteTap = self.onFootnoteTap view.onSelectionChange = self.onSelectionChange view.onCitationTap = self.onCitationTap + view.onTableOfContentsChange = self.onTableOfContentsChange } private func applyToggles(to view: STMarkdownStreamingTextView) { diff --git a/Sources/STMarkdown/UI/STScrollableMarkdownView.swift b/Sources/STMarkdown/UI/STScrollableMarkdownView.swift index 3122c0b..3e1fab2 100644 --- a/Sources/STMarkdown/UI/STScrollableMarkdownView.swift +++ b/Sources/STMarkdown/UI/STScrollableMarkdownView.swift @@ -7,71 +7,190 @@ import UIKit -/// 将 ``STMarkdownTextView`` 嵌入 ``UIScrollView`` 的只读 Markdown 预览容器。 -/// -/// - Note: 目录数据仍由 ``STMarkdownTextView/tableOfContents`` 与 ``scrollToHeadingAnchor`` 提供; -/// 侧栏 UI 与 `onTOCItemTap` 由宿主组合(对比文档 **【部分对齐】**)。 +// MARK: - 内置目录列表(侧栏) + +private final class STMarkdownTOCListHost: NSObject, UITableViewDataSource, UITableViewDelegate { + + var items: [STMarkdownTOCItem] = [] + weak var markdownTextView: STMarkdownBaseTextView? + + var onTOCItemTap: ((STMarkdownTOCItem) -> Void)? + + let tableView: UITableView + + override init() { + self.tableView = UITableView(frame: .zero, style: .plain) + super.init() + self.tableView.dataSource = self + self.tableView.delegate = self + self.tableView.rowHeight = 40 + self.tableView.separatorInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "toc") + self.tableView.backgroundColor = .secondarySystemGroupedBackground + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + self.items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "toc", for: indexPath) + let item = self.items[indexPath.row] + let indent = String(repeating: " ", count: max(0, item.level - 1)) + cell.textLabel?.font = .preferredFont(forTextStyle: .subheadline) + cell.textLabel?.numberOfLines = 2 + cell.textLabel?.text = indent + item.title + cell.accessibilityLabel = "TOC, level \(item.level), \(item.title)" + cell.backgroundColor = .clear + cell.selectionStyle = .default + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let item = self.items[indexPath.row] + _ = self.markdownTextView?.scrollToTOCItem(anchorId: item.anchorId, animated: true) + self.onTOCItemTap?(item) + } +} + +// MARK: - 滚动容器 + +/// 将 ``STMarkdownTextView`` 嵌入 ``UIScrollView``,并可选展示内置目录侧栏(对比文档 P1)。 public final class STScrollableMarkdownView: UIView { public let scrollView: UIScrollView public let markdownTextView: STMarkdownTextView + /// 为 `true` 时在正文左侧展示可点击的标题目录列表。 + public var showsTableOfContents: Bool = false { + didSet { self.applyTOCChromeVisibility() } + } + + /// 侧栏目录宽度(pt);仅在 ``showsTableOfContents`` 为 `true` 时生效。 + public var tableOfContentsPanelWidth: CGFloat = 168 { + didSet { self.applyTOCChromeVisibility() } + } + + /// 用户点击目录行后调用(在滚动到锚点之后);与 Vendor ``onTOCItemTap`` 语义对齐。 + public var onTOCItemTap: ((STMarkdownTOCItem) -> Void)? { + get { self.tocHost.onTOCItemTap } + set { self.tocHost.onTOCItemTap = newValue } + } + public var onLinkTap: ((URL) -> Void)? { get { self.markdownTextView.onLinkTap } set { self.markdownTextView.onLinkTap = newValue } } + public var onFootnoteTap: ((String) -> Void)? { + get { self.markdownTextView.onFootnoteTap } + set { self.markdownTextView.onFootnoteTap = newValue } + } + public var onContentLayoutHeightChange: ((CGFloat) -> Void)? { get { self.markdownTextView.onContentLayoutHeightChange } set { self.markdownTextView.onContentLayoutHeightChange = newValue } } + /// 目录随正文管线刷新时调用(在更新内置侧栏之后);流式宿主可在此与侧栏同帧对齐。 + public var onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? + + private let horizontalStack = UIStackView() + private let tocPanel = UIView() + private let tocSeparator = UIView() + private let tocHost = STMarkdownTOCListHost() + private var tocWidthConstraint: NSLayoutConstraint! + private var tocSeparatorWidthConstraint: NSLayoutConstraint! + /// - Parameter usesTextLayoutManager: 传入内层 ``STMarkdownTextView`` 的 TextKit 2 开关(iOS 16+)。 public init(frame: CGRect, usesTextLayoutManager: Bool = false) { self.scrollView = UIScrollView() self.markdownTextView = STMarkdownTextView(frame: .zero, usesTextLayoutManager: usesTextLayoutManager) super.init(frame: frame) - self.scrollView.translatesAutoresizingMaskIntoConstraints = false - self.scrollView.alwaysBounceVertical = true - self.scrollView.keyboardDismissMode = .interactive - self.markdownTextView.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.scrollView) - self.scrollView.addSubview(self.markdownTextView) - NSLayoutConstraint.activate([ - self.scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.scrollView.topAnchor.constraint(equalTo: self.topAnchor), - self.scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - self.markdownTextView.leadingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.leadingAnchor), - self.markdownTextView.trailingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.trailingAnchor), - self.markdownTextView.topAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.topAnchor), - self.markdownTextView.bottomAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.bottomAnchor), - self.markdownTextView.widthAnchor.constraint(equalTo: self.scrollView.frameLayoutGuide.widthAnchor), - ]) + self.installHierarchy() } public required init?(coder: NSCoder) { self.scrollView = UIScrollView() self.markdownTextView = STMarkdownTextView(coder: coder) ?? STMarkdownTextView(frame: .zero) super.init(coder: coder) + self.installHierarchy() + } + + public func setMarkdown(_ markdown: String) { + self.markdownTextView.setMarkdown(markdown) + } + + private func installHierarchy() { + self.tocHost.markdownTextView = self.markdownTextView + + self.horizontalStack.translatesAutoresizingMaskIntoConstraints = false + self.horizontalStack.axis = .horizontal + self.horizontalStack.alignment = .fill + self.horizontalStack.distribution = .fill + self.horizontalStack.spacing = 0 + self.addSubview(self.horizontalStack) + + self.tocPanel.translatesAutoresizingMaskIntoConstraints = false + self.tocSeparator.translatesAutoresizingMaskIntoConstraints = false self.scrollView.translatesAutoresizingMaskIntoConstraints = false self.markdownTextView.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.scrollView) + + self.tocSeparator.backgroundColor = .separator + + let table = self.tocHost.tableView + table.translatesAutoresizingMaskIntoConstraints = false + self.tocPanel.addSubview(table) + NSLayoutConstraint.activate([ + table.leadingAnchor.constraint(equalTo: self.tocPanel.leadingAnchor), + table.trailingAnchor.constraint(equalTo: self.tocPanel.trailingAnchor), + table.topAnchor.constraint(equalTo: self.tocPanel.topAnchor), + table.bottomAnchor.constraint(equalTo: self.tocPanel.bottomAnchor), + ]) + + self.horizontalStack.addArrangedSubview(self.tocPanel) + self.horizontalStack.addArrangedSubview(self.tocSeparator) + self.horizontalStack.addArrangedSubview(self.scrollView) + + self.scrollView.alwaysBounceVertical = true + self.scrollView.keyboardDismissMode = .interactive self.scrollView.addSubview(self.markdownTextView) + + self.tocWidthConstraint = self.tocPanel.widthAnchor.constraint(equalToConstant: 0) + self.tocSeparatorWidthConstraint = self.tocSeparator.widthAnchor.constraint(equalToConstant: 0) + NSLayoutConstraint.activate([ - self.scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.scrollView.topAnchor.constraint(equalTo: self.topAnchor), - self.scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.horizontalStack.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.horizontalStack.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.horizontalStack.topAnchor.constraint(equalTo: self.topAnchor), + self.horizontalStack.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.tocWidthConstraint, + self.tocSeparatorWidthConstraint, self.markdownTextView.leadingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.leadingAnchor), self.markdownTextView.trailingAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.trailingAnchor), self.markdownTextView.topAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.topAnchor), self.markdownTextView.bottomAnchor.constraint(equalTo: self.scrollView.contentLayoutGuide.bottomAnchor), self.markdownTextView.widthAnchor.constraint(equalTo: self.scrollView.frameLayoutGuide.widthAnchor), ]) + + self.markdownTextView.onTableOfContentsChange = { [weak self] items in + guard let self else { return } + self.tocHost.items = items + self.tocHost.tableView.reloadData() + self.onTableOfContentsChange?(items) + } + + self.applyTOCChromeVisibility() } - public func setMarkdown(_ markdown: String) { - self.markdownTextView.setMarkdown(markdown) + private func applyTOCChromeVisibility() { + let show = self.showsTableOfContents + let w = max(0, self.tableOfContentsPanelWidth) + self.tocWidthConstraint.constant = show ? w : 0 + self.tocSeparatorWidthConstraint.constant = show ? (1.0 / max(UIScreen.main.scale, 1)) : 0 + self.tocPanel.isHidden = !show + self.tocSeparator.isHidden = !show + self.tocPanel.isUserInteractionEnabled = show } } From d937f87107d9471b13f242285f3aa61007dacc8c Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 17:25:40 +0800 Subject: [PATCH 10/27] Add unit tests for STMarkdownAttachment and update Podfile.lock --- Example/Podfile.lock | 2 +- ...rkdownASTAndRenderASTExhaustiveTests.swift | 10 +- .../STMarkdownAttachmentsTests.swift | 263 +++++++++++++++ .../STMarkdownCoreContractsTests.swift | 16 +- .../STMarkdownFootnoteAndHTMLTests.swift | 17 +- ...MarkdownParsingEscapeAndDisplayTests.swift | 40 +-- .../STMarkdownPipelineTests.swift | 20 +- ...arkdownStreamingHeightStabilityTests.swift | 177 ++++++++++ ...TextKitPerformanceAndRegressionTests.swift | 308 ++++++++++++++++++ .../STMarkdownUIViewTests.swift | 61 ++++ Package.swift | 6 + .../Core/STMarkdownRenderAdapter.swift | 101 +++++- Sources/STMarkdown/Core/STMarkdownTOC.swift | 14 +- .../Parsing/STMarkdownFootnoteSupport.swift | 43 ++- .../Parsing/STMarkdownRenderAST.swift | 208 +++++++++++- .../STMarkdownAttributedStringRenderer.swift | 99 ++++-- .../UI/STMarkdownStreamingTextView.swift | 187 ++++++++++- .../STTextView/STShimmerTextView.swift | 10 +- 18 files changed, 1458 insertions(+), 124 deletions(-) create mode 100644 Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownStreamingHeightStabilityTests.swift create mode 100644 Example/STBaseProjectExampleTests/STMarkdownTextKitPerformanceAndRegressionTests.swift diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 10ebfcf..4d96eac 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,3 +1,3 @@ -PODFILE CHECKSUM: f8580ce41955d5ec8be9b912244073e2ab2ef02a +PODFILE CHECKSUM: 05d8d9de40eda1522f695addf156f3e5bccf1d99 COCOAPODS: 1.16.2 diff --git a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift index 50913fc..b80f821 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownASTAndRenderASTExhaustiveTests.swift @@ -248,8 +248,8 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { // MARK: STMarkdownRenderDocument func testRenderDocumentInitializerAndEquality() { - let r = STMarkdownRenderDocument(blocks: [.thematicBreak]) - let same = STMarkdownRenderDocument(blocks: [.thematicBreak]) + let r = STMarkdownRenderDocument(blocks: [.thematicBreak()]) + let same = STMarkdownRenderDocument(blocks: [.thematicBreak()]) XCTAssertEqual(r, same) st_requireSendable(r) } @@ -276,7 +276,7 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { .mathBlock("a+b"), .image(url: "https://img", altText: "x", title: "y"), .image(url: "https://img2", altText: "x2", title: nil), - .thematicBreak, + .thematicBreak(), .details(summary: [.text("sum")], body: [.paragraph([.text("bd")])]), .rawHTML("

        "), ] @@ -301,7 +301,7 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { XCTAssertEqual(item.checkbox, .unchecked) XCTAssertEqual(item.content, [.text("a")]) XCTAssertEqual(item.childBlocks.count, 1) - if case .codeBlock(_, let code)? = item.childBlocks.first { + if case .codeBlock(_, language: _, code: let code)? = item.childBlocks.first { XCTAssertEqual(code, "b") } else { XCTFail("childBlocks 首项应为 codeBlock") @@ -320,7 +320,7 @@ final class STMarkdownASTAndRenderASTExhaustiveTests: XCTestCase { XCTAssertEqual(item.content, [.text("lead")]) XCTAssertEqual(item.childBlocks.count, 1) XCTAssertEqual(item.checkbox, .checked) - if case .quote(let inner)? = item.childBlocks.first { + if case .quote(_, let inner)? = item.childBlocks.first { XCTAssertFalse(inner.isEmpty) } else { XCTFail("期望 childBlocks 首块为 quote") diff --git a/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift new file mode 100644 index 0000000..266c2ee --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift @@ -0,0 +1,263 @@ +import XCTest +import UIKit +@testable import STBaseProject + +// MARK: - Test doubles + +/// 最小 `STMarkdownRefreshableAttachment` 实现,用于隔离测试附件刷新协议与绑定逻辑。 +private final class MockRefreshableAttachment: NSTextAttachment, STMarkdownRefreshableAttachment { + private let registry = STMarkdownRefreshObserverRegistry() + + func addDisplayObserver(_ observer: @escaping () -> Void) -> STMarkdownRefreshObservation { + self.registry.add(observer) + } + + func notifyDisplayObservers() { + self.registry.notify() + } +} + +// MARK: - STMarkdownRefreshObservation & STMarkdownRefreshObserverRegistry + +final class STMarkdownRefreshAttachmentInfrastructureTests: XCTestCase { + + func testRefreshObservationInvalidateRunsHandlerOnce() { + var calls = 0 + let obs = STMarkdownRefreshObservation { calls += 1 } + obs.invalidate() + obs.invalidate() + XCTAssertEqual(calls, 1) + } + + func testRefreshObservationDeinitInvalidatesHandler() { + var calls = 0 + do { + _ = STMarkdownRefreshObservation { calls += 1 } + } + XCTAssertEqual(calls, 1) + } + + func testRefreshObserverRegistryMulticastAndInvalidateRemovesEntry() { + let registry = STMarkdownRefreshObserverRegistry() + var a = 0 + var b = 0 + let tokenA = registry.add { a += 1 } + _ = registry.add { b += 1 } + registry.notify() + XCTAssertEqual(a, 1) + XCTAssertEqual(b, 1) + tokenA.invalidate() + registry.notify() + XCTAssertEqual(a, 1) + XCTAssertEqual(b, 2) + } + + func testRefreshObserverRegistryNotifyInvokesAllCurrentObservers() { + let registry = STMarkdownRefreshObserverRegistry() + var sum = 0 + _ = registry.add { sum += 1 } + _ = registry.add { sum += 2 } + registry.notify() + XCTAssertEqual(sum, 3) + } +} + +// MARK: - STMarkdownNumberBadgeAttachment + +@MainActor +final class STMarkdownNumberBadgeAttachmentTests: XCTestCase { + + func testFixedDiameterBaseline() { + XCTAssertEqual(STMarkdownNumberBadgeAttachment.fixedDiameter, 18, accuracy: 0.001) + } + + func testInitUsesMinimumDiameterForSmallBodyFont() { + let font = UIFont.st_systemFont(ofSize: 10, weight: .regular) + let badge = STMarkdownNumberBadgeAttachment( + numberText: "1", + font: font, + textColor: .white, + backgroundColor: .systemBlue + ) + guard let image = badge.image else { + return XCTFail("expected image") + } + XCTAssertEqual(image.size.width, STMarkdownNumberBadgeAttachment.fixedDiameter, accuracy: 0.5) + XCTAssertEqual(image.size.height, STMarkdownNumberBadgeAttachment.fixedDiameter, accuracy: 0.5) + XCTAssertEqual(badge.bounds.width, image.size.width, accuracy: 0.001) + XCTAssertEqual(badge.bounds.height, image.size.height, accuracy: 0.001) + } + + func testInitScalesDiameterWithBodyFont() { + let font = UIFont.st_systemFont(ofSize: 34, weight: .regular) + let badge = STMarkdownNumberBadgeAttachment( + numberText: "2", + font: font, + textColor: .label, + backgroundColor: .systemGray3 + ) + guard let image = badge.image else { + return XCTFail("expected image") + } + let expected = ceil(34 / 17 * STMarkdownNumberBadgeAttachment.fixedDiameter) + XCTAssertEqual(image.size.width, expected, accuracy: 0.5) + XCTAssertEqual(image.size.height, expected, accuracy: 0.5) + } + + func testRenderBadgeImageDefaultDiameterMatchesFixedDiameter() { + let img = STMarkdownNumberBadgeAttachment.renderBadgeImage( + number: "9", + textColor: .white, + backgroundColor: .systemRed + ) + XCTAssertEqual(img.size.width, STMarkdownNumberBadgeAttachment.fixedDiameter, accuracy: 0.5) + XCTAssertEqual(img.size.height, STMarkdownNumberBadgeAttachment.fixedDiameter, accuracy: 0.5) + } + + func testRenderBadgeImageAccessibilityLabel() { + let img = STMarkdownNumberBadgeAttachment.renderBadgeImage( + number: "42", + textColor: .white, + backgroundColor: .black, + diameter: 20 + ) + XCTAssertEqual(img.accessibilityLabel, "引用 42") + } + + func testInitImageAccessibilityLabel() { + let font = UIFont.st_systemFont(ofSize: 17, weight: .regular) + let badge = STMarkdownNumberBadgeAttachment( + numberText: "7", + font: font, + textColor: .white, + backgroundColor: .systemGreen + ) + XCTAssertEqual(badge.image?.accessibilityLabel, "引用 7") + } + + func testRenderBadgeImageCacheReturnsSameInstanceForIdenticalParameters() { + let a = STMarkdownNumberBadgeAttachment.renderBadgeImage( + number: "3", + textColor: .white, + backgroundColor: .systemOrange, + diameter: 22 + ) + let b = STMarkdownNumberBadgeAttachment.renderBadgeImage( + number: "3", + textColor: .white, + backgroundColor: .systemOrange, + diameter: 22 + ) + XCTAssertTrue(a === b) + } + + func testLegacyTypealiasCompilesAndBehavesLikeConcreteType() { + let font = UIFont.st_systemFont(ofSize: 17, weight: .regular) + let viaAlias: STMarkdownCircleNumberAttachment = STMarkdownCircleNumberAttachment( + numberText: "1", + font: font, + textColor: .white, + backgroundColor: .systemBlue + ) + XCTAssertNotNil(viaAlias.image) + } + + func testInitCoderFallsBackToSuperWithoutFatalError() throws { + let font = UIFont.st_systemFont(ofSize: 17, weight: .regular) + let original = STMarkdownNumberBadgeAttachment( + numberText: "1", + font: font, + textColor: .white, + backgroundColor: .systemBlue + ) + let data = try XCTUnwrap(NSKeyedArchiver.archivedData(withRootObject: original, requiringSecureCoding: false)) + let unarchived = try XCTUnwrap(try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? STMarkdownNumberBadgeAttachment) + XCTAssertNotNil(unarchived) + } +} + +// MARK: - STMarkdownAttachmentRefreshSupport + +@MainActor +final class STMarkdownAttachmentRefreshSupportTests: XCTestCase { + + func testBindRefreshHandlersEmptyStringReturnsNoTokens() { + let empty = NSAttributedString() + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: empty) { _ in } + XCTAssertTrue(tokens.isEmpty) + } + + func testBindRefreshHandlersSkipsNonRefreshableAttachments() { + let plain = NSTextAttachment() + let attr = NSMutableAttributedString(string: " ") + attr.addAttribute(.attachment, value: plain, range: NSRange(location: 0, length: 1)) + var refreshCalls = 0 + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { _ in + refreshCalls += 1 + } + XCTAssertTrue(tokens.isEmpty) + XCTAssertEqual(refreshCalls, 0) + } + + func testBindRefreshHandlersInvokesRefreshOnMainWhenObserverFiresOnMainThread() { + let expectation = expectation(description: "refresh") + let attachment = MockRefreshableAttachment() + let attr = NSMutableAttributedString(string: " ") + attr.addAttribute(.attachment, value: attachment, range: NSRange(location: 0, length: 1)) + _ = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(att === attachment) + expectation.fulfill() + } + attachment.notifyDisplayObservers() + waitForExpectations(timeout: 2) + } + + func testBindRefreshHandlersDispatchesToMainWhenObserverFiresOffMainThread() { + let expectation = expectation(description: "refresh off main") + let attachment = MockRefreshableAttachment() + let attr = NSMutableAttributedString(string: " ") + attr.addAttribute(.attachment, value: attachment, range: NSRange(location: 0, length: 1)) + _ = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(att === attachment) + expectation.fulfill() + } + DispatchQueue.global(qos: .userInitiated).async { + attachment.notifyDisplayObservers() + } + waitForExpectations(timeout: 3) + } + + func testBindRefreshHandlersRegistersOneTokenPerRefreshableAttachment() { + let a = MockRefreshableAttachment() + let b = MockRefreshableAttachment() + let attr = NSMutableAttributedString(string: " ") + attr.addAttribute(.attachment, value: a, range: NSRange(location: 0, length: 1)) + attr.addAttribute(.attachment, value: b, range: NSRange(location: 1, length: 1)) + var count = 0 + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { _ in + count += 1 + } + XCTAssertEqual(tokens.count, 2) + a.notifyDisplayObservers() + b.notifyDisplayObservers() + XCTAssertEqual(count, 2) + } + + func testInvalidateObservationStopsFurtherRefreshCallbacks() { + let attachment = MockRefreshableAttachment() + let attr = NSMutableAttributedString(string: " ") + attr.addAttribute(.attachment, value: attachment, range: NSRange(location: 0, length: 1)) + var calls = 0 + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { _ in + calls += 1 + } + XCTAssertEqual(tokens.count, 1) + attachment.notifyDisplayObservers() + XCTAssertEqual(calls, 1) + tokens[0].invalidate() + attachment.notifyDisplayObservers() + XCTAssertEqual(calls, 1) + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift index b4f8a1e..ad6fb37 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownCoreContractsTests.swift @@ -48,7 +48,13 @@ final class STMarkdownCoreContractsTests: XCTestCase { func testEngineDelegatesToPipelineAndPreservesRawMarkdown() { let parserOutput = STMarkdownDocument(blocks: [.heading(level: 1, content: [.text("Title")])]) - let renderOutput = STMarkdownRenderDocument(blocks: [.thematicBreak]) + let tbMeta = STMarkdownRenderBlockMetadata( + id: "tb", + path: [], + kind: .thematicBreak, + revealPolicy: .atomicBlock + ) + let renderOutput = STMarkdownRenderDocument(blocks: [.thematicBreak(tbMeta)]) let parser = CoreMockParser(parseResult: parserOutput) let adapter = CoreMockRenderAdapter(adaptResult: renderOutput) let engine = STMarkdownEngine( @@ -64,7 +70,7 @@ final class STMarkdownCoreContractsTests: XCTestCase { XCTAssertEqual(result.sourceDocument, parserOutput) XCTAssertEqual(result.normalizedDocument, parserOutput) XCTAssertEqual(result.renderDocument, renderOutput) - XCTAssertEqual(result.tableOfContents, []) + XCTAssertTrue(result.tableOfContents.isEmpty) } func testPipelineUsesSemanticNormalizersInOrder() { @@ -127,8 +133,10 @@ final class STMarkdownCoreContractsTests: XCTestCase { let renderDocument = adapter.adapt(document) - guard case .quote(let quoteBlocks)? = renderDocument.blocks.first, - case .list(let items)? = quoteBlocks.first + guard let outer = renderDocument.blocks.first, + case .quote(_, let quoteBlocks) = outer, + let inner = quoteBlocks.first, + case .list(_, let items) = inner else { return XCTFail("Expected quote->list render structure") } diff --git a/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift b/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift index d2620bf..23f5113 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownFootnoteAndHTMLTests.swift @@ -8,6 +8,15 @@ import STBaseProject final class STMarkdownFootnoteAndHTMLTests: XCTestCase { + private func renderMeta(_ kind: STMarkdownRenderBlockKind) -> STMarkdownRenderBlockMetadata { + STMarkdownRenderBlockMetadata( + id: "test-\(kind.rawValue)", + path: [], + kind: kind, + revealPolicy: .atomicBlock + ) + } + func testFootnoteDefinitionStrippedAndReferenceParsed() { let md = """ Hello[^a] world. @@ -30,7 +39,7 @@ final class STMarkdownFootnoteAndHTMLTests: XCTestCase { var style = STMarkdownStyle.default style.rawHTMLPolicy = .literalMonospace let renderer = STMarkdownAttributedStringRenderer(style: style, advancedRenderers: .empty) - let doc = STMarkdownRenderDocument(blocks: [.rawHTML("

        x
        ")]) + let doc = STMarkdownRenderDocument(blocks: [.rawHTML(self.renderMeta(.rawHTML), "
        x
        ")]) let attr = renderer.render(document: doc) XCTAssertTrue(attr.string.contains("
        ")) } @@ -38,7 +47,11 @@ final class STMarkdownFootnoteAndHTMLTests: XCTestCase { func testDetailsRenderContainsSummaryGlyph() { let renderer = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) let doc = STMarkdownRenderDocument(blocks: [ - .details(summary: [.text("More")], body: [.paragraph([.text("Hidden")])]), + .details( + self.renderMeta(.details), + summary: [.text("More")], + body: [.paragraph(self.renderMeta(.paragraph), [.text("Hidden")])] + ), ]) let attr = renderer.render(document: doc) XCTAssertTrue(attr.string.contains("▸")) diff --git a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift index 8a53388..0ead3a2 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownParsingEscapeAndDisplayTests.swift @@ -44,7 +44,7 @@ private func st_renderAttributed(markdown: String) -> NSAttributedString { private func st_firstParagraphInlinesFromRender(_ document: STMarkdownRenderDocument) -> [STMarkdownInlineNode]? { for block in document.blocks { - if case .paragraph(let inlines) = block { + if case .paragraph(_, let inlines) = block { return inlines } } @@ -77,24 +77,24 @@ private func st_collectSemanticTextSegments(from blocks: [STMarkdownRenderBlock] var segments: [String] = [] for block in blocks { switch block { - case .paragraph(let inlines): + case .paragraph(_, let inlines): let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } - case .heading(_, _, let inlines): + case .heading(_, level: _, anchorId: _, content: let inlines): let t = st_joinInlinePlainText(inlines).trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } - case .quote(let inner): + case .quote(_, let inner): segments.append(contentsOf: st_collectSemanticTextSegments(from: inner)) - case .list(let items): + case .list(_, let items): for item in items { segments.append(contentsOf: st_collectSemanticTextSegments(from: item.blocks)) } - case .codeBlock(_, let code): + case .codeBlock(_, language: _, code: let code): let t = code.trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } case .table, .mathBlock, .image, .thematicBreak, .rawHTML: break - case .details(let summary, let inner): + case .details(_, summary: let summary, body: let inner): let t = st_joinInlinePlainText(summary).trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty == false { segments.append(t) } segments.append(contentsOf: st_collectSemanticTextSegments(from: inner)) @@ -297,32 +297,32 @@ private func st_blockContainsText(_ block: STMarkdownBlockNode, text: String) -> private func st_renderBlockContainsText(_ block: STMarkdownRenderBlock, text: String) -> Bool { switch block { - case .paragraph(let inlines): + case .paragraph(_, let inlines): return st_joinInlinePlainText(inlines).contains(text) - case .heading(_, _, let inlines): + case .heading(_, level: _, anchorId: _, content: let inlines): return st_joinInlinePlainText(inlines).contains(text) - case .quote(let children): + case .quote(_, let children): return children.contains { st_renderBlockContainsText($0, text: text) } - case .list(let items): + case .list(_, let items): return items.contains { item in item.blocks.contains { st_renderBlockContainsText($0, text: text) } } - case .table(let table): + case .table(_, let table): let header = (table.header ?? []).flatMap { $0 } let rows = table.rows.flatMap { $0 }.flatMap { $0 } return st_joinInlinePlainText(header + rows).contains(text) - case .codeBlock(_, let code): + case .codeBlock(_, language: _, code: let code): return code.contains(text) - case .mathBlock(let formula): + case .mathBlock(_, let formula): return formula.contains(text) - case .image(_, let altText, let title): + case .image(_, url: _, altText: let altText, title: let title): return altText.contains(text) || (title?.contains(text) == true) case .thematicBreak: return false - case .details(let summary, let body): + case .details(_, summary: let summary, body: let body): return st_joinInlinePlainText(summary).contains(text) || body.contains { st_renderBlockContainsText($0, text: text) } - case .rawHTML(let html): + case .rawHTML(_, let html): return html.contains(text) } } @@ -812,7 +812,7 @@ final class STMarkdownParsingEscapeAndDisplayTests: XCTestCase { ) ) let result = engine.process(md) - guard case .heading(let level, _, _)? = result.renderDocument.blocks.first else { + guard case .heading(_, level: let level, anchorId: _, content: _)? = result.renderDocument.blocks.first else { return XCTFail("期望渲染文档首块为 heading,实际:\(String(describing: result.renderDocument.blocks.first))") } XCTAssertEqual(level, 2) @@ -958,7 +958,7 @@ final class STMarkdownParsingEscapeAndDisplayTests: XCTestCase { ) ) let result = engine.process(md) - guard case .list(let items)? = result.renderDocument.blocks.first, + guard case .list(_, let items)? = result.renderDocument.blocks.first, let firstItem = items.first else { return XCTFail("期望首块为列表") @@ -1174,7 +1174,7 @@ final class STMarkdownParsingEscapeAndDisplayTests: XCTestCase { }) let renderLists = result.renderDocument.blocks.compactMap { block -> [STMarkdownRenderListItem]? in - if case .list(let items) = block { return items } + if case .list(_, let items) = block { return items } return nil } XCTAssertFalse(renderLists.isEmpty, "data3 渲染 AST 应至少包含一个列表块") diff --git a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift index 280740d..956bb15 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownPipelineTests.swift @@ -114,7 +114,7 @@ final class STMarkdownPipelineTests: XCTestCase { let renderDocument = adapter.adapt(document) - guard case .list(let items)? = renderDocument.blocks.first else { + guard case .list(_, let items)? = renderDocument.blocks.first else { return XCTFail("Expected first render block to be list") } @@ -360,8 +360,8 @@ final class STMarkdownPipelineTests: XCTestCase { let renderDocument = adapter.adapt(document) guard - case .list(let items)? = renderDocument.blocks.first, - case .list(let childItems)? = items.first?.childBlocks.first + case .list(_, let items)? = renderDocument.blocks.first, + case .list(_, let childItems)? = items.first?.childBlocks.first else { return XCTFail("Expected nested render list") } @@ -698,7 +698,7 @@ final class STMarkdownPipelineTests: XCTestCase { horizontalRuleRenderer: STMarkdownDefaultHorizontalRuleRenderer() ) ) - let document = STMarkdownRenderDocument(blocks: [.thematicBreak]) + let document = STMarkdownRenderDocument(blocks: [.thematicBreak()]) let attributed = renderer.render(document: document) @@ -961,7 +961,7 @@ final class STMarkdownPipelineTests: XCTestCase { let result = engine.process(markdown) let tableBlocks = result.renderDocument.blocks.compactMap { block -> STMarkdownTableModel? in - if case .table(let m) = block { return m } + if case .table(_, let m) = block { return m } return nil } @@ -974,7 +974,7 @@ final class STMarkdownPipelineTests: XCTestCase { let result = engine.process(markdown) let tableBlocks = result.renderDocument.blocks.compactMap { block -> STMarkdownTableModel? in - if case .table(let m) = block { return m } + if case .table(_, let m) = block { return m } return nil } @@ -1085,7 +1085,7 @@ final class STMarkdownPipelineTests: XCTestCase { let renderDocument = adapter.adapt(document) - guard case .list(let items)? = renderDocument.blocks.first else { + guard case .list(_, let items)? = renderDocument.blocks.first else { return XCTFail("Expected list render block") } @@ -1240,18 +1240,18 @@ final class STMarkdownPipelineTests: XCTestCase { let renderDocument = adapter.adapt(document) - guard case .list(let items)? = renderDocument.blocks.first else { + guard case .list(_, let items)? = renderDocument.blocks.first else { return XCTFail("Expected list block") } XCTAssertEqual(items.first?.level, 0) - guard case .list(let level1Items)? = items.first?.childBlocks.first else { + guard case .list(_, let level1Items)? = items.first?.childBlocks.first else { return XCTFail("Expected nested list at level 1") } XCTAssertEqual(level1Items.first?.level, 1) - guard case .list(let level2Items)? = level1Items.first?.childBlocks.first else { + guard case .list(_, let level2Items)? = level1Items.first?.childBlocks.first else { return XCTFail("Expected nested list at level 2") } XCTAssertEqual(level2Items.first?.level, 2) diff --git a/Example/STBaseProjectExampleTests/STMarkdownStreamingHeightStabilityTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStreamingHeightStabilityTests.swift new file mode 100644 index 0000000..4538858 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownStreamingHeightStabilityTests.swift @@ -0,0 +1,177 @@ +// +// STMarkdownStreamingHeightStabilityTests.swift +// STBaseProjectExampleTests +// +// 流式逐字输出场景下:测量 intrinsic 高度与高度回调的稳定性(单调性、动画期无振荡、节流生效)。 +// + +import XCTest +import UIKit +@testable import STBaseProject + +@MainActor +final class STMarkdownStreamingHeightStabilityTests: XCTestCase { + + private func shimmer(for stream: STMarkdownStreamingTextView) -> STShimmerTextView { + stream.contentTextView as! STShimmerTextView + } + + /// 多次 `layoutIfNeeded` 直到 intrinsic 高度在容差内收敛(对齐 TextKit 性能测试中的做法)。 + @discardableResult + private func settleIntrinsicHeight(for view: STMarkdownStreamingTextView, maxPasses: Int = 16) -> CGFloat { + var height = view.intrinsicContentSize.height + for _ in 0.. (UIWindow, UIViewController, STMarkdownStreamingTextView) { + let bounds = CGRect(x: 0, y: 0, width: width, height: height) + let window = UIWindow(frame: bounds) + let vc = UIViewController() + vc.view.bounds = bounds + window.rootViewController = vc + window.makeKeyAndVisible() + + let stream = STMarkdownStreamingTextView(frame: vc.view.bounds) + stream.autoresizingMask = [.flexibleWidth, .flexibleHeight] + stream.preferredContentWidth = width + vc.view.addSubview(stream) + return (window, vc, stream) + } + + /// 逐字追加纯文本(无 Markdown 定界符),开启 stagger:稳定后高度应单调不减。 + func testPerCharacterStreamingIntrinsicHeightMonotonicPlainText() { + let (window, _, stream) = self.makeKeyWindowHost(width: 360, height: 900) + defer { window.isHidden = true } + + stream.tokenFadeDuration = 0.06 + self.shimmer(for: stream).characterStaggerInterval = 0.002 + stream.animateAcrossNewlines = true + + var heights: [CGFloat] = [] + let sentence = "Streaming plain characters without markdown tokens." + for ch in sentence { + stream.appendMarkdownFragment(String(ch), animated: true) + heights.append(self.settleIntrinsicHeight(for: stream)) + } + stream.finishStreaming() + + for i in 1.. Void)? + + func start(onTick: @escaping () -> Void) { + self.stop() + self.frameIntervals = [] + self.lastTimestamp = 0 + self.tickAction = onTick + let dl = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:))) + dl.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 60) + dl.add(to: .main, forMode: .common) + self.link = dl + } + + func stop() { + self.link?.invalidate() + self.link = nil + self.tickAction = nil + } + + @objc private func handleDisplayLink(_ link: CADisplayLink) { + if self.lastTimestamp > 0 { + self.frameIntervals.append(TimeInterval(link.timestamp - self.lastTimestamp)) + } + self.lastTimestamp = link.timestamp + self.tickAction?() + } +} + +@MainActor +final class STMarkdownTextKitPerformanceAndRegressionTests: XCTestCase { + + private static func stressMarkdown(repeatCount: Int) -> String { + (0.. (UIWindow, STScrollableMarkdownView) { + let bounds = CGRect(x: 0, y: 0, width: width, height: height) + let window = UIWindow(frame: bounds) + window.layer.speed = 1 + let vc = UIViewController() + vc.view.bounds = bounds + window.rootViewController = vc + window.makeKeyAndVisible() + + let host = STScrollableMarkdownView(frame: vc.view.bounds, usesTextLayoutManager: usesTK2) + host.autoresizingMask = [.flexibleWidth, .flexibleHeight] + vc.view.addSubview(host) + return (window, host) + } + + /// 首帧:setMarkdown → 首次 `layoutIfNeeded`;总布局:再跑若干轮直到 intrinsic 高度稳定。 + private func measureFirstFrameAndSettlingLayout(host: STScrollableMarkdownView, markdown: String) -> ( + firstFrame: TimeInterval, + settleLayout: TimeInterval, + finalHeight: CGFloat + ) { + let t0 = CACurrentMediaTime() + host.setMarkdown(markdown) + host.layoutIfNeeded() + let t1 = CACurrentMediaTime() + + let settleStart = CACurrentMediaTime() + var h = host.markdownTextView.intrinsicContentSize.height + for _ in 0..<12 { + host.layoutIfNeeded() + let nh = host.markdownTextView.intrinsicContentSize.height + if abs(nh - h) < 0.5 { break } + h = nh + } + let t2 = CACurrentMediaTime() + return (t1 - t0, t2 - settleStart, h) + } + + private func percentile(_ values: [TimeInterval], p: Double) -> TimeInterval { + guard values.isEmpty == false else { return 0 } + let sorted = values.sorted() + let idx = min(sorted.count - 1, max(0, Int((Double(sorted.count - 1) * p).rounded(.down)))) + return sorted[idx] + } + + private func emitPerf(_ line: String) { + print(line) + fflush(stdout) + let attachment = XCTAttachment(string: line) + attachment.lifetime = .keepAlways + self.add(attachment) + } + + // MARK: - 1) 长文档:首帧 / 总布局 / 滚动帧间隔(拆成两条用例,便于 XCTAttachment 落盘到 .xcresult) + + private func runLongMarkdownLayoutAndScrollSample(usesTK2: Bool) { + let md = Self.stressMarkdown(repeatCount: 24) + let width: CGFloat = 390 + let height: CGFloat = 844 + + let (window, host) = self.hostScrollable(width: width, height: height, usesTK2: usesTK2) + defer { window.isHidden = true } + + let (first, settle, finalH) = self.measureFirstFrameAndSettlingLayout(host: host, markdown: md) + host.layoutIfNeeded() + let scroll = host.scrollView + scroll.layoutIfNeeded() + + let sampler = DisplayLinkFrameSampler() + let step: CGFloat = 9 + var ticks = 0 + sampler.start { + ticks += 1 + let maxY = max(0, scroll.contentSize.height - scroll.bounds.height) + let y = min(maxY, scroll.contentOffset.y + step) + scroll.setContentOffset(CGPoint(x: 0, y: y), animated: false) + if ticks >= 90 || (maxY <= 0 && ticks > 5) { + sampler.stop() + } + } + let exp = expectation(description: "scroll sampling") + exp.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { + sampler.stop() + exp.fulfill() + } + wait(for: [exp], timeout: 3) + + let iv = sampler.frameIntervals + let median = iv.isEmpty ? 0 : iv.sorted()[iv.count / 2] + let p95 = self.percentile(iv, p: 0.95) + let jankFrames = iv.filter { $0 > (1.0 / 50.0) * 1.45 }.count + + let mode = usesTK2 ? "TK2" : "TK1" + self.emitPerf( + String( + format: "[TextKitPerf] long-md mode=%@ firstFrameMs=%.2f settleLayoutMs=%.2f finalHeight=%.1f scrollSamples=%ld medianFrameMs=%.3f p95FrameMs=%.3f jankishFrames=%ld", + mode, + first * 1000, + settle * 1000, + finalH, + iv.count, + median * 1000, + p95 * 1000, + jankFrames + ) + ) + + XCTAssertGreaterThan(finalH, 200, "应测得显著正文高度 (\(mode))") + } + + func testLongMarkdown_LayoutAndScrollMetrics_TextKit1() { + self.runLongMarkdownLayoutAndScrollSample(usesTK2: false) + } + + func testLongMarkdown_LayoutAndScrollMetrics_TextKit2() { + self.runLongMarkdownLayoutAndScrollSample(usesTK2: true) + } + + // MARK: - 2) 流式 append:每 chunk 耗时、显式 layout 轮数、intrinsic 高度抖动 + + func testStreamingAppend_PerChunkMetrics_TK1_vs_TK2() { + let chunks = (0..<40).map { i in "Line \(i): **bold** and [l](https://e.com) | `c` \n" } + let width: CGFloat = 360 + + for usesTK2 in [false, true] { + var heightSeries: [CGFloat] = [] + var wallMs: [TimeInterval] = [] + var layoutPasses: [Int] = [] + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: width, height: 800)) + let vc = UIViewController() + window.rootViewController = vc + window.makeKeyAndVisible() + defer { window.isHidden = true } + + let stream = STMarkdownStreamingTextView(frame: vc.view.bounds, usesTextLayoutManager: usesTK2) + stream.autoresizingMask = [.flexibleWidth, .flexibleHeight] + stream.tokenFadeDuration = 0 + stream.characterStaggerInterval = 0 + stream.animateAcrossNewlines = false + vc.view.addSubview(stream) + + for chunk in chunks { + let t0 = CACurrentMediaTime() + stream.appendMarkdownFragment(chunk, animated: false) + var passes = 0 + var h0 = stream.intrinsicContentSize.height + for _ in 0..<8 { + stream.layoutIfNeeded() + passes += 1 + let h1 = stream.intrinsicContentSize.height + if abs(h1 - h0) < 0.25 { break } + h0 = h1 + } + wallMs.append((CACurrentMediaTime() - t0) * 1000) + layoutPasses.append(passes) + heightSeries.append(stream.intrinsicContentSize.height) + } + + let jitters = zip(heightSeries, heightSeries.dropFirst()).map { abs($1 - $0) } + let maxJitter = jitters.max() ?? 0 + let sumWall = wallMs.reduce(0, +) + let mode = usesTK2 ? "TK2" : "TK1" + self.emitPerf( + String( + format: "[TextKitPerf] stream mode=%@ chunks=%ld totalWallMs=%.2f maxHeightJitter=%.2f avgLayoutPasses=%.2f", + mode, + chunks.count, + sumWall, + maxJitter, + Double(layoutPasses.reduce(0, +)) / Double(max(layoutPasses.count, 1)) + ) + ) + + XCTAssertGreaterThan(heightSeries.last ?? 0, 100) + } + } + + // MARK: - 3) 交互回归(TK2):链接 / 脚注 / 选区 / 表格相关富文本非空 + + func testTextKit2_LinkFootnoteSelectionAndTableRenderRegression() { + let md = """ + | a | b | + |---|---| + | 1 | 2 | + + Tap [Example](https://example.org) and foot[^f]. + + [^f]: Note **body**. + """ + let view = STMarkdownTextView(frame: CGRect(x: 0, y: 0, width: 320, height: 600), usesTextLayoutManager: true) + view.setMarkdown(md) + + var linkURL: URL? + view.onLinkTap = { linkURL = $0 } + var footLabel: String? + view.onFootnoteTap = { footLabel = $0 } + + let example = URL(string: "https://example.org")! + _ = view.textView( + view.contentTextView, + shouldInteractWith: example, + in: NSRange(location: 0, length: 1), + interaction: .invokeDefaultAction + ) + XCTAssertEqual(linkURL, example) + + let fnURL = URL(string: "stmarkdown-footnote://f")! + _ = view.textView( + view.contentTextView, + shouldInteractWith: fnURL, + in: NSRange(location: 0, length: 1), + interaction: .invokeDefaultAction + ) + XCTAssertEqual(footLabel, "f") + + var selectionText: String? + view.onSelectionChange = { selectionText = $0 } + view.contentTextView.selectedRange = NSRange(location: 0, length: min(4, view.attributedText.length)) + view.textViewDidChangeSelection(view.contentTextView) + XCTAssertFalse(selectionText?.isEmpty ?? true) + + XCTAssertTrue(view.attributedText.string.contains("1"), "表格单元应出现在可见字符串中") + XCTAssertGreaterThan(view.intrinsicContentSize.height, 40) + } +} + +// MARK: - STMarkdownStreamingTextView 测试扩展(仅本测试 target) + +private extension STMarkdownStreamingTextView { + /// 测试里压低流式动画 CPU:`STShimmerTextView` 的 stagger 间隔。 + var characterStaggerInterval: TimeInterval { + get { self.shimmerTextViewForTests.characterStaggerInterval } + set { self.shimmerTextViewForTests.characterStaggerInterval = newValue } + } + + private var shimmerTextViewForTests: STShimmerTextView { + self.contentTextView as! STShimmerTextView + } +} diff --git a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift index 30ef4b5..e054c91 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift @@ -86,4 +86,65 @@ final class STMarkdownUIViewTests: XCTestCase { XCTAssertEqual(view.attributedText.string, "Configured content") XCTAssertEqual(view.contentTextView.textColor, .systemRed) } + + func testStreamingPlainParagraphTailRemainsCharacterAnimated() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0.25 + (view.contentTextView as? STShimmerTextView)?.characterStaggerInterval = 0.02 + view.setMarkdown("Hello", animated: false) + + view.appendMarkdownFragment(" world", animated: true) + + let visible = view.contentTextView.attributedText ?? NSAttributedString() + XCTAssertEqual(visible.string, "Hello world") + let ns = visible.string as NSString + let lastIndex = ns.length - 1 + XCTAssertLessThan(self.foregroundAlpha(in: visible, at: lastIndex), 0.5) + } + + func testStreamingContainerThenContentDelaysOnlyTrailingInlineBlock() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0.25 + view.containerRevealGapDuration = 0.2 + (view.contentTextView as? STShimmerTextView)?.characterStaggerInterval = 0.02 + view.setMarkdown("Intro", animated: false) + + view.appendMarkdownFragment("\n\n> quoted line\n\nTail block", animated: true) + + let immediate = view.contentTextView.attributedText?.string ?? "" + XCTAssertTrue(immediate.contains("quoted line")) + XCTAssertFalse(immediate.contains("Tail block")) + + let exp = expectation(description: "wait for trailing inline block reveal scheduling") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + + let delayed = view.contentTextView.attributedText?.string ?? "" + XCTAssertTrue(delayed.contains("Tail block")) + } + + func testStreamingSeparatorTailFallsBackToPreviousInlineBlockAnimation() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0.25 + (view.contentTextView as? STShimmerTextView)?.characterStaggerInterval = 0.02 + view.setMarkdown("Intro", animated: false) + + view.appendMarkdownFragment("\n\nTail block\n\n
        ignored
        ", animated: true) + + let visible = view.contentTextView.attributedText ?? NSAttributedString() + let text = visible.string as NSString + let tailRange = text.range(of: "Tail block") + XCTAssertNotEqual(tailRange.location, NSNotFound) + XCTAssertLessThan(self.foregroundAlpha(in: visible, at: tailRange.location), 0.5) + } + + private func foregroundAlpha(in attributed: NSAttributedString, at index: Int) -> CGFloat { + guard index >= 0, index < attributed.length else { return 1 } + guard let color = attributed.attribute(.foregroundColor, at: index, effectiveRange: nil) as? UIColor else { + return 1 + } + return color.cgColor.alpha + } } diff --git a/Package.swift b/Package.swift index 7cb412b..9382f63 100644 --- a/Package.swift +++ b/Package.swift @@ -68,6 +68,12 @@ let package = Package( resources: [ .copy("PrivacyInfo.xcprivacy") ] + ), + .testTarget( + name: "STMarkdownAttachmentUnitTests", + dependencies: ["STBaseProject"], + path: "Example/STBaseProjectExampleTests", + sources: ["STMarkdownAttachmentsTests.swift"] ) ], swiftLanguageVersions: [.v5] diff --git a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift index 5d77072..3d5e7c2 100644 --- a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift +++ b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift @@ -25,7 +25,14 @@ public struct STMarkdownRenderAdapter: STMarkdownRenderAdapting, Sendable { public func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument { var slugger = STMarkdownAnchorSlugRegistry() - let mainBlocks = document.blocks.map { self.makeRenderBlock(from: $0, listLevel: 0, slugger: &slugger) } + let mainBlocks = document.blocks.enumerated().map { + self.makeRenderBlock( + from: $0.element, + listLevel: 0, + path: ["b:\($0.offset)"], + slugger: &slugger + ) + } let merged = STMarkdownFootnoteSectionBuilder.appendingSectionIfNeeded( document: document, renderBlocks: mainBlocks @@ -35,37 +42,74 @@ public struct STMarkdownRenderAdapter: STMarkdownRenderAdapting, Sendable { } private extension STMarkdownRenderAdapter { - func makeRenderBlock(from block: STMarkdownBlockNode, listLevel: Int, slugger: inout STMarkdownAnchorSlugRegistry) -> STMarkdownRenderBlock { + func makeRenderBlock( + from block: STMarkdownBlockNode, + listLevel: Int, + path: [String], + slugger: inout STMarkdownAnchorSlugRegistry + ) -> STMarkdownRenderBlock { switch block { case .paragraph(let inlines): - return .paragraph(inlines) + return .paragraph(self.makeMetadata(kind: .paragraph, path: path), inlines) case .heading(let level, let content): let plain = content.st_plainTextForTOC() let anchorId = slugger.uniqueAnchorId(forPlainTitle: plain) - return .heading(level: level, anchorId: anchorId, content: content) + return .heading( + self.makeMetadata(kind: .heading, path: path), + level: level, + anchorId: anchorId, + content: content + ) case .quote(let blocks): // Quote 内嵌 list 时不推进 listLevel:产品侧把引用块视作视觉"容器", // 不改变列表的逻辑嵌套深度(层级仍以真实 list 节点计算)。 - return .quote(blocks.map { self.makeRenderBlock(from: $0, listLevel: listLevel, slugger: &slugger) }) + return .quote( + self.makeMetadata(kind: .quote, path: path), + blocks.enumerated().map { + self.makeRenderBlock( + from: $0.element, + listLevel: listLevel, + path: path + ["q:\($0.offset)"], + slugger: &slugger + ) + } + ) case .list(let kind, let items): - return .list(self.flattenListItems(kind: kind, items: items, level: listLevel, slugger: &slugger)) + return .list( + self.makeMetadata(kind: .list, path: path), + self.flattenListItems( + kind: kind, + items: items, + level: listLevel, + path: path, + slugger: &slugger + ) + ) case .codeBlock(let language, let code): - return .codeBlock(language: language, code: code) + return .codeBlock(self.makeMetadata(kind: .codeBlock, path: path), language: language, code: code) case .table(let table): - return .table(table) + return .table(self.makeMetadata(kind: .table, path: path), table) case .mathBlock(let latex): - return .mathBlock(latex) + return .mathBlock(self.makeMetadata(kind: .mathBlock, path: path), latex) case .image(let url, let altText, let title): - return .image(url: url, altText: altText, title: title) + return .image(self.makeMetadata(kind: .image, path: path), url: url, altText: altText, title: title) case .thematicBreak: - return .thematicBreak + return .thematicBreak(self.makeMetadata(kind: .thematicBreak, path: path)) case .details(let summary, let body): return .details( + self.makeMetadata(kind: .details, path: path), summary: summary, - body: body.map { self.makeRenderBlock(from: $0, listLevel: listLevel, slugger: &slugger) } + body: body.enumerated().map { + self.makeRenderBlock( + from: $0.element, + listLevel: listLevel, + path: path + ["d:\($0.offset)"], + slugger: &slugger + ) + } ) case .rawHTML(let html): - return .rawHTML(html) + return .rawHTML(self.makeMetadata(kind: .rawHTML, path: path), html) } } @@ -73,6 +117,7 @@ private extension STMarkdownRenderAdapter { kind: STMarkdownListKind, items: [STMarkdownListItemNode], level: Int, + path: [String], slugger: inout STMarkdownAnchorSlugRegistry ) -> [STMarkdownRenderListItem] { var result: [STMarkdownRenderListItem] = [] @@ -90,7 +135,15 @@ private extension STMarkdownRenderAdapter { for (index, item) in items.enumerated() { let orderedIndex = isOrdered ? startIndex + index : nil - let renderBlocks = item.blocks.map { self.makeRenderBlock(from: $0, listLevel: level + 1, slugger: &slugger) } + let itemPath = path + ["li:\(index)"] + let renderBlocks = item.blocks.enumerated().map { + self.makeRenderBlock( + from: $0.element, + listLevel: level + 1, + path: itemPath + ["b:\($0.offset)"], + slugger: &slugger + ) + } result.append( STMarkdownRenderListItem( blocks: renderBlocks, @@ -104,4 +157,24 @@ private extension STMarkdownRenderAdapter { return result } + + func makeMetadata(kind: STMarkdownRenderBlockKind, path: [String]) -> STMarkdownRenderBlockMetadata { + STMarkdownRenderBlockMetadata( + id: path.joined(separator: "/"), + path: path, + kind: kind, + revealPolicy: self.revealPolicy(for: kind) + ) + } + + func revealPolicy(for kind: STMarkdownRenderBlockKind) -> STMarkdownRevealPolicy { + switch kind { + case .paragraph, .heading: + return .inlineProgressive + case .quote, .list, .details: + return .containerThenContent + case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: + return .atomicBlock + } + } } diff --git a/Sources/STMarkdown/Core/STMarkdownTOC.swift b/Sources/STMarkdown/Core/STMarkdownTOC.swift index e865b83..fa171f9 100644 --- a/Sources/STMarkdown/Core/STMarkdownTOC.swift +++ b/Sources/STMarkdown/Core/STMarkdownTOC.swift @@ -15,6 +15,12 @@ extension NSAttributedString.Key { public static let stMarkdownHeadingAnchor = NSAttributedString.Key("STMarkdown.headingAnchor") /// 脚注引用在富文本上的逻辑标签(与 `[^label]` 中 `label` 一致,不含 `^`)。 public static let stMarkdownFootnoteLabel = NSAttributedString.Key("STMarkdown.footnoteLabel") + /// 当前字符所属渲染块的稳定 id(例如 `b:3/li:1/b:0`)。 + public static let stMarkdownBlockID = NSAttributedString.Key("STMarkdown.blockID") + /// 当前字符所属渲染块类型;值为 ``STMarkdownRenderBlockKind/rawValue``。 + public static let stMarkdownBlockKind = NSAttributedString.Key("STMarkdown.blockKind") + /// 当前字符所属渲染块的 reveal 策略;值为 ``STMarkdownRevealPolicy/rawValue``。 + public static let stMarkdownRevealPolicy = NSAttributedString.Key("STMarkdown.revealPolicy") } // MARK: - TOC item @@ -119,18 +125,18 @@ enum STMarkdownTOCExtraction { private static func collect(from block: STMarkdownRenderBlock, into items: inout [STMarkdownTOCItem]) { switch block { - case .heading(let level, let anchorId, let content): + case .heading(_, level: let level, anchorId: let anchorId, content: let content): let title = content.st_plainTextForTOC() items.append(STMarkdownTOCItem(level: level, title: title, anchorId: anchorId)) - case .quote(let inner): + case .quote(_, let inner): for b in inner { self.collect(from: b, into: &items) } - case .list(let listItems): + case .list(_, let listItems): for item in listItems { for b in item.blocks { self.collect(from: b, into: &items) } } - case .details(_, let body): + case .details(_, summary: _, body: let body): for b in body { self.collect(from: b, into: &items) } case .paragraph, .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: break diff --git a/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift index 87d1f2a..29fc023 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift @@ -203,14 +203,17 @@ enum STMarkdownFootnoteSectionBuilder { let labels = orderedReferenceLabels(in: renderBlocks) guard labels.isEmpty == false else { return renderBlocks } var out = renderBlocks - out.append(.thematicBreak) - out.append(.paragraph([.strong([.text("脚注")])])) + var nextTopLevelIndex = renderBlocks.count + out.append(.thematicBreak(metadata(kind: .thematicBreak, topLevelIndex: nextTopLevelIndex))) + nextTopLevelIndex += 1 + out.append(.paragraph(metadata(kind: .paragraph, topLevelIndex: nextTopLevelIndex), [.strong([.text("脚注")])])) + nextTopLevelIndex += 1 for (idx, label) in labels.enumerated() { let ordinal = idx + 1 let def = document.footnoteDefinitions[label]?.content ?? [.text("(未找到定义)")] var line: [STMarkdownInlineNode] = [.strong([.text("\(ordinal).")]), .text(" ")] line.append(contentsOf: def) - out.append(.paragraph(line)) + out.append(.paragraph(metadata(kind: .paragraph, topLevelIndex: nextTopLevelIndex + idx), line)) } return out } @@ -227,19 +230,19 @@ enum STMarkdownFootnoteSectionBuilder { private static func visitRenderBlock(_ block: STMarkdownRenderBlock, order: inout [String], seen: inout Set) { switch block { - case .paragraph(let inlines): + case .paragraph(_, let inlines): visitInlines(inlines, order: &order, seen: &seen) - case .heading(_, _, let inlines): + case .heading(_, level: _, anchorId: _, content: let inlines): visitInlines(inlines, order: &order, seen: &seen) - case .quote(let inner): + case .quote(_, let inner): inner.forEach { visitRenderBlock($0, order: &order, seen: &seen) } - case .list(let items): + case .list(_, let items): for item in items { for b in item.blocks { visitRenderBlock(b, order: &order, seen: &seen) } } - case .details(let summary, let body): + case .details(_, summary: let summary, body: let body): visitInlines(summary, order: &order, seen: &seen) body.forEach { visitRenderBlock($0, order: &order, seen: &seen) } case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: @@ -247,6 +250,30 @@ enum STMarkdownFootnoteSectionBuilder { } } + private static func metadata( + kind: STMarkdownRenderBlockKind, + topLevelIndex: Int + ) -> STMarkdownRenderBlockMetadata { + let path = ["b:\(topLevelIndex)"] + return STMarkdownRenderBlockMetadata( + id: path.joined(separator: "/"), + path: path, + kind: kind, + revealPolicy: revealPolicy(for: kind) + ) + } + + private static func revealPolicy(for kind: STMarkdownRenderBlockKind) -> STMarkdownRevealPolicy { + switch kind { + case .paragraph, .heading: + return .inlineProgressive + case .quote, .list, .details: + return .containerThenContent + case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .rawHTML: + return .atomicBlock + } + } + private static func visitInlines(_ nodes: [STMarkdownInlineNode], order: inout [String], seen: inout Set) { for n in nodes { switch n { diff --git a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift index bbda768..f037fe9 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift @@ -7,6 +7,45 @@ import Foundation +public enum STMarkdownRenderBlockKind: String, Hashable, Sendable { + case paragraph + case heading + case quote + case list + case codeBlock + case table + case mathBlock + case image + case thematicBreak + case details + case rawHTML +} + +public enum STMarkdownRevealPolicy: String, Hashable, Sendable { + case inlineProgressive + case atomicBlock + case containerThenContent +} + +public struct STMarkdownRenderBlockMetadata: Hashable, Sendable { + public let id: String + public let path: [String] + public let kind: STMarkdownRenderBlockKind + public let revealPolicy: STMarkdownRevealPolicy + + public init( + id: String, + path: [String], + kind: STMarkdownRenderBlockKind, + revealPolicy: STMarkdownRevealPolicy + ) { + self.id = id + self.path = path + self.kind = kind + self.revealPolicy = revealPolicy + } +} + public struct STMarkdownRenderDocument: Hashable, Sendable { public let blocks: [STMarkdownRenderBlock] @@ -16,17 +55,138 @@ public struct STMarkdownRenderDocument: Hashable, Sendable { } public enum STMarkdownRenderBlock: Hashable, Sendable { - case paragraph([STMarkdownInlineNode]) - case heading(level: Int, anchorId: String, content: [STMarkdownInlineNode]) - case quote([STMarkdownRenderBlock]) - case list([STMarkdownRenderListItem]) - case codeBlock(language: String?, code: String) - case table(STMarkdownTableModel) - case mathBlock(String) - case image(url: String, altText: String, title: String?) - case thematicBreak - case details(summary: [STMarkdownInlineNode], body: [STMarkdownRenderBlock]) - case rawHTML(String) + case paragraph(STMarkdownRenderBlockMetadata, [STMarkdownInlineNode]) + case heading(STMarkdownRenderBlockMetadata, level: Int, anchorId: String, content: [STMarkdownInlineNode]) + case quote(STMarkdownRenderBlockMetadata, [STMarkdownRenderBlock]) + case list(STMarkdownRenderBlockMetadata, [STMarkdownRenderListItem]) + case codeBlock(STMarkdownRenderBlockMetadata, language: String?, code: String) + case table(STMarkdownRenderBlockMetadata, STMarkdownTableModel) + case mathBlock(STMarkdownRenderBlockMetadata, String) + case image(STMarkdownRenderBlockMetadata, url: String, altText: String, title: String?) + case thematicBreak(STMarkdownRenderBlockMetadata) + case details(STMarkdownRenderBlockMetadata, summary: [STMarkdownInlineNode], body: [STMarkdownRenderBlock]) + case rawHTML(STMarkdownRenderBlockMetadata, String) + + public var metadata: STMarkdownRenderBlockMetadata { + switch self { + case .paragraph(let metadata, _), + .heading(let metadata, level: _, anchorId: _, content: _), + .quote(let metadata, _), + .list(let metadata, _), + .codeBlock(let metadata, language: _, code: _), + .table(let metadata, _), + .mathBlock(let metadata, _), + .image(let metadata, url: _, altText: _, title: _), + .thematicBreak(let metadata), + .details(let metadata, summary: _, body: _), + .rawHTML(let metadata, _): + return metadata + } + } + + private static func compatibilityMetadata( + kind: STMarkdownRenderBlockKind, + revealPolicy: STMarkdownRevealPolicy + ) -> STMarkdownRenderBlockMetadata { + let path = ["compat", kind.rawValue] + return STMarkdownRenderBlockMetadata( + id: path.joined(separator: "/"), + path: path, + kind: kind, + revealPolicy: revealPolicy + ) + } + + public static func paragraph(_ content: [STMarkdownInlineNode]) -> Self { + .paragraph( + Self.compatibilityMetadata(kind: .paragraph, revealPolicy: .inlineProgressive), + content + ) + } + + public static func heading(level: Int, content: [STMarkdownInlineNode]) -> Self { + .heading( + Self.compatibilityMetadata(kind: .heading, revealPolicy: .inlineProgressive), + level: level, + anchorId: "", + content: content + ) + } + + public static func heading(level: Int, anchorId: String, content: [STMarkdownInlineNode]) -> Self { + .heading( + Self.compatibilityMetadata(kind: .heading, revealPolicy: .inlineProgressive), + level: level, + anchorId: anchorId, + content: content + ) + } + + public static func quote(_ blocks: [STMarkdownRenderBlock]) -> Self { + .quote( + Self.compatibilityMetadata(kind: .quote, revealPolicy: .containerThenContent), + blocks + ) + } + + public static func list(_ items: [STMarkdownRenderListItem]) -> Self { + .list( + Self.compatibilityMetadata(kind: .list, revealPolicy: .containerThenContent), + items + ) + } + + public static func codeBlock(language: String?, code: String) -> Self { + .codeBlock( + Self.compatibilityMetadata(kind: .codeBlock, revealPolicy: .atomicBlock), + language: language, + code: code + ) + } + + public static func table(_ model: STMarkdownTableModel) -> Self { + .table( + Self.compatibilityMetadata(kind: .table, revealPolicy: .atomicBlock), + model + ) + } + + public static func mathBlock(_ latex: String) -> Self { + .mathBlock( + Self.compatibilityMetadata(kind: .mathBlock, revealPolicy: .atomicBlock), + latex + ) + } + + public static func image(url: String, altText: String, title: String?) -> Self { + .image( + Self.compatibilityMetadata(kind: .image, revealPolicy: .atomicBlock), + url: url, + altText: altText, + title: title + ) + } + + public static func details(summary: [STMarkdownInlineNode], body: [STMarkdownRenderBlock]) -> Self { + .details( + Self.compatibilityMetadata(kind: .details, revealPolicy: .containerThenContent), + summary: summary, + body: body + ) + } + + public static func rawHTML(_ html: String) -> Self { + .rawHTML( + Self.compatibilityMetadata(kind: .rawHTML, revealPolicy: .atomicBlock), + html + ) + } + + public static func thematicBreak() -> Self { + .thematicBreak( + Self.compatibilityMetadata(kind: .thematicBreak, revealPolicy: .atomicBlock) + ) + } } public struct STMarkdownRenderListItem: Hashable, Sendable { @@ -60,7 +220,14 @@ public struct STMarkdownRenderListItem: Hashable, Sendable { ) { var blocks: [STMarkdownRenderBlock] = [] if content.isEmpty == false { - blocks.append(.paragraph(content)) + let path = ["li-paragraph"] + let metadata = STMarkdownRenderBlockMetadata( + id: path.joined(separator: "/"), + path: path, + kind: .paragraph, + revealPolicy: .inlineProgressive + ) + blocks.append(.paragraph(metadata, content)) } blocks.append(contentsOf: childBlocks) self.init( @@ -76,16 +243,27 @@ public struct STMarkdownRenderListItem: Hashable, Sendable { /// 当列表项以 quote / codeBlock / list 等非段落块开头时返回空数组—— /// 此时该项的全部内容都在 `childBlocks` 里,请不要据此判空。 public var content: [STMarkdownInlineNode] { - guard case .paragraph(let inlines)? = self.blocks.first else { + guard let first = self.blocks.first else { + return [] + } + switch first { + case .paragraph(_, let inlines): + return inlines + default: return [] } - return inlines } /// 列表项的子块(排除开头那个 paragraph)。 /// 当列表项不以 paragraph 开头时返回完整 `blocks`,因为此时没有独立的文案段。 public var childBlocks: [STMarkdownRenderBlock] { - guard case .paragraph? = self.blocks.first else { + guard let first = self.blocks.first else { + return self.blocks + } + switch first { + case .paragraph: + break + default: return self.blocks } return Array(self.blocks.dropFirst()) diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index 7aa4438..ae4ae56 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -75,6 +75,9 @@ private extension STMarkdownAttributedStringRenderer { .font: self.style.font, .foregroundColor: UIColor.clear, .paragraphStyle: separatorStyle, + .stMarkdownBlockID: "__separator__", + .stMarkdownBlockKind: "separator", + .stMarkdownRevealPolicy: STMarkdownRevealPolicy.atomicBlock.rawValue, ] )) } @@ -84,15 +87,16 @@ private extension STMarkdownAttributedStringRenderer { } func render(block: STMarkdownRenderBlock, footnoteOrdinals: [String: Int]) -> NSAttributedString { + let rendered: NSAttributedString switch block { - case .paragraph(let inlines): - return self.renderInline( + case let .paragraph(_, inlines): + rendered = self.renderInline( nodes: inlines, baseFont: self.style.font, textColor: self.style.textColor, footnoteOrdinals: footnoteOrdinals ) - case .heading(let level, let anchorId, let content): + case let .heading(_, level: level, anchorId: anchorId, content: content): let headingFont = self.headingFont(for: level) let headingColor = self.style.headingTextColor ?? self.style.textColor let body = self.renderInline( @@ -107,34 +111,34 @@ private extension STMarkdownAttributedStringRenderer { if out.length > 0 { out.addAttribute(.stMarkdownHeadingAnchor, value: anchorId, range: NSRange(location: 0, length: out.length)) } - return out - case .quote(let blocks): - return self.renderQuote(blocks: blocks, footnoteOrdinals: footnoteOrdinals) - case .list(let items): - return self.renderList(items, footnoteOrdinals: footnoteOrdinals) - case .codeBlock(let language, let code): + rendered = out + case let .quote(_, blocks): + rendered = self.renderQuote(blocks: blocks, footnoteOrdinals: footnoteOrdinals) + case let .list(_, items): + rendered = self.renderList(items, footnoteOrdinals: footnoteOrdinals) + case let .codeBlock(_, language: language, code: code): if let rendered = self.advancedRenderers.codeBlockRenderer?.renderCodeBlock( language: language, code: code, style: self.style ) { - return rendered + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } - return self.renderCodeBlock(language: language, code: code) - case .table(let table): + rendered = self.renderCodeBlock(language: language, code: code) + case let .table(_, table): if let rendered = self.advancedRenderers.tableRenderer?.renderTable(table, style: self.style) { - return rendered + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } - return self.renderTable(table) - case .mathBlock(let latex): + rendered = self.renderTable(table) + case let .mathBlock(_, latex): if let rendered = self.advancedRenderers.blockMathRenderer?.renderBlockMath( formula: latex, style: self.style ) { - return rendered + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } - return NSAttributedString(string: latex, attributes: self.baseAttributes()) - case .image(let url, let altText, let title): + rendered = NSAttributedString(string: latex, attributes: self.baseAttributes()) + case let .image(_, url: url, altText: altText, title: title): if let rendered = self.advancedRenderers.imageRenderer?.renderImage( url: url, altText: altText, @@ -142,19 +146,20 @@ private extension STMarkdownAttributedStringRenderer { style: self.style, placement: .block ) { - return rendered + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } - return NSAttributedString(string: altText.isEmpty ? "[image]" : altText, attributes: self.baseAttributes()) - case .thematicBreak: + rendered = NSAttributedString(string: altText.isEmpty ? "[image]" : altText, attributes: self.baseAttributes()) + case .thematicBreak(_): if let rendered = self.advancedRenderers.horizontalRuleRenderer?.renderHorizontalRule(style: self.style) { - return rendered + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } - return NSAttributedString(string: "———", attributes: self.baseAttributes()) - case .details(let summary, let body): - return self.renderDetails(summary: summary, body: body, footnoteOrdinals: footnoteOrdinals) - case .rawHTML(let html): - return self.renderRawHTMLBlock(html) + rendered = NSAttributedString(string: "———", attributes: self.baseAttributes()) + case let .details(_, summary: summary, body: body): + rendered = self.renderDetails(summary: summary, body: body, footnoteOrdinals: footnoteOrdinals) + case let .rawHTML(_, html): + rendered = self.renderRawHTMLBlock(html) } + return self.applyBlockMetadata(to: rendered, metadata: block.metadata) } func renderDetails( @@ -712,7 +717,7 @@ private extension STMarkdownAttributedStringRenderer { } switch firstBlock { - case .paragraph, .heading: + case .paragraph(_, _), .heading(_, _, _, _): return [firstBlock] default: return [] @@ -725,7 +730,7 @@ private extension STMarkdownAttributedStringRenderer { } switch firstBlock { - case .paragraph, .heading: + case .paragraph(_, _), .heading(_, _, _, _): return Array(item.blocks.dropFirst()) default: return item.blocks @@ -738,7 +743,7 @@ private extension STMarkdownAttributedStringRenderer { } switch block { - case .heading(let level, _, _): + case let .heading(_, level: level, anchorId: _, content: _): let font = self.headingFont(for: level) return font.pointSize * self.style.headingLineHeightMultiplier default: @@ -787,13 +792,13 @@ private extension STMarkdownAttributedStringRenderer { func leadingBlockSpacing(for block: STMarkdownRenderBlock) -> CGFloat { switch block { - case .heading(let level, _, _): + case let .heading(_, level: level, anchorId: _, content: _): if let topSpacings = self.style.headingTopSpacing, level >= 1, level <= topSpacings.count { return topSpacings[level - 1] } return self.style.blockSpacing - case .list: + case .list(_, _): return self.style.listItemSpacing default: return self.style.blockSpacing @@ -802,16 +807,42 @@ private extension STMarkdownAttributedStringRenderer { func trailingBlockSpacing(for block: STMarkdownRenderBlock) -> CGFloat { switch block { - case .heading(let level, _, _): + case let .heading(_, level: level, anchorId: _, content: _): if let bottomSpacings = self.style.headingBottomSpacing, level >= 1, level <= bottomSpacings.count { return bottomSpacings[level - 1] } return self.style.blockSpacing - case .list: + case .list(_, _): return self.style.listItemSpacing default: return self.style.blockSpacing } } + + func applyBlockMetadata( + to rendered: NSAttributedString, + metadata: STMarkdownRenderBlockMetadata + ) -> NSAttributedString { + guard rendered.length > 0 else { return rendered } + let mutable = NSMutableAttributedString(attributedString: rendered) + let fullRange = NSRange(location: 0, length: mutable.length) + self.fillMissingAttribute(.stMarkdownBlockID, value: metadata.id, in: mutable, range: fullRange) + self.fillMissingAttribute(.stMarkdownBlockKind, value: metadata.kind.rawValue, in: mutable, range: fullRange) + self.fillMissingAttribute(.stMarkdownRevealPolicy, value: metadata.revealPolicy.rawValue, in: mutable, range: fullRange) + return mutable + } + + func fillMissingAttribute( + _ key: NSAttributedString.Key, + value: Any, + in attributed: NSMutableAttributedString, + range: NSRange + ) { + attributed.enumerateAttribute(key, in: range) { existing, subrange, _ in + if existing == nil { + attributed.addAttribute(key, value: value, range: subrange) + } + } + } } diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index bb73a5c..9ad7631 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -29,6 +29,8 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { private var smartStreamBuffer: STMarkdownStreamBuffer? private var smartStreamingSessionActive = false private var isApplyingSmartStreamMarkdownUpdate = false + private var streamingAnimationGeneration: Int = 0 + private var pendingStreamingSuffixWorkItem: DispatchWorkItem? /// 上一帧已交给 ``setMarkdown(_:animated:)`` 的「安全前缀」展示串(经 ``stripUnclosedTailMarkers``)。 /// 当缓冲器仅增长尾部、``committedSafePrefix`` 不变时跳过整段重解析,降低流式 CPU 占用(对齐对比文档 P0)。 private var lastSmartStreamRenderedDisplayMarkdown: String? @@ -43,6 +45,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.smartStreamBuffer?.fullAccumulatedText } + /// `containerThenContent` 命中时,容器/块级前缀先上屏,再等待这一小段间隔后让尾部正文继续逐字动画。 + public var containerRevealGapDuration: TimeInterval = 0.06 + private var shimmerTextView: STShimmerTextView { guard let textView = self.textView as? STShimmerTextView else { preconditionFailure("STMarkdownStreamingTextView requires STShimmerTextView") @@ -95,6 +100,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } public func reset() { + self.invalidatePendingStreamingAnimation() self.cancelSmartMarkdownStreamingSession() self.shimmerTextView.reset() self.resetBaseState() @@ -105,6 +111,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } public func setMarkdown(_ markdown: String, animated: Bool = false) { + self.invalidatePendingStreamingAnimation() if self.smartStreamingSessionActive && !self.isApplyingSmartStreamMarkdownUpdate { self.cancelSmartMarkdownStreamingSession() } @@ -306,6 +313,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } private func applyFullReplace(markdown: String, rendered: NSAttributedString) { + self.invalidatePendingStreamingAnimation() self.rawMarkdown = markdown self.shimmerTextView.setRenderedAttributedText(rendered) self.finalizeRenderUpdate(rendered: rendered) @@ -313,7 +321,20 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { private func applyAppendDelta(markdown: String, delta: NSAttributedString) { self.rawMarkdown = markdown - self.shimmerTextView.appendAttributedText(delta, animated: true) + self.invalidatePendingStreamingAnimation() + let plan = self.streamingAnimationPlan(for: delta) + if plan.immediatePrefix.length > 0 { + self.shimmerTextView.appendAttributedText(plan.immediatePrefix, animated: false) + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) + } + if let animatedSuffix = plan.animatedSuffix, animatedSuffix.length > 0 { + self.enqueueAnimatedStreamingSuffix( + animatedSuffix, + trailingSuffix: plan.trailingSuffix, + shouldDelay: plan.requiresContainerGap + ) + return + } self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) } @@ -324,14 +345,168 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { animate: Bool ) { self.rawMarkdown = markdown - self.shimmerTextView.replaceTrailingAttributedText( - from: location, - with: trailing, - animateNewPortion: animate - ) + self.invalidatePendingStreamingAnimation() + if animate { + let plan = self.streamingAnimationPlan(for: trailing) + if let animatedSuffix = plan.animatedSuffix, animatedSuffix.length > 0 { + self.shimmerTextView.replaceTrailingAttributedText( + from: location, + with: plan.immediatePrefix, + animateNewPortion: false + ) + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) + self.enqueueAnimatedStreamingSuffix( + animatedSuffix, + trailingSuffix: plan.trailingSuffix, + shouldDelay: plan.requiresContainerGap + ) + return + } else { + self.shimmerTextView.replaceTrailingAttributedText( + from: location, + with: trailing, + animateNewPortion: false + ) + } + } else { + self.shimmerTextView.replaceTrailingAttributedText( + from: location, + with: trailing, + animateNewPortion: false + ) + } self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) } + /// 按尾部 reveal policy 把增量切成"立即显示前缀 + 可动画后缀"。 + /// 规则: + /// 1. 仅当尾部最后一个 render block 的 reveal policy 是 `inlineProgressive` 时,才对该 block 动画; + /// 2. `atomicBlock` / `containerThenContent` 本身立即上屏; + /// 3. 这样 quote/list/details 等容器可先稳定出现,再让最后一个正文 block 继续逐字输出。 + private func streamingAnimationPlan(for attributedText: NSAttributedString) -> ( + immediatePrefix: NSAttributedString, + animatedSuffix: NSAttributedString?, + trailingSuffix: NSAttributedString?, + requiresContainerGap: Bool + ) { + guard attributedText.length > 0 else { + return (NSAttributedString(), nil, nil, false) + } + + guard let suffixRange = self.trailingAnimatedBlockRange(in: attributedText) else { + return (attributedText, nil, nil, false) + } + + let suffixEnd = suffixRange.location + suffixRange.length + let trailingLength = attributedText.length - suffixEnd + let trailingSuffix: NSAttributedString? = trailingLength > 0 + ? attributedText.attributedSubstring(from: NSRange(location: suffixEnd, length: trailingLength)) + : nil + + if suffixRange.location == 0 { + return (NSAttributedString(), attributedText.attributedSubstring(from: suffixRange), trailingSuffix, false) + } + + let immediatePrefix = attributedText.attributedSubstring( + from: NSRange(location: 0, length: suffixRange.location) + ) + let animatedSuffix = attributedText.attributedSubstring(from: suffixRange) + return ( + immediatePrefix, + animatedSuffix, + trailingSuffix, + self.containsContainerRevealPolicy(in: immediatePrefix) + ) + } + + private func trailingAnimatedBlockRange(in attributedText: NSAttributedString) -> NSRange? { + guard attributedText.length > 0 else { return nil } + + var cursor = attributedText.length - 1 + while cursor >= 0 { + var blockRange = NSRange(location: 0, length: 0) + guard let blockID = attributedText.attribute( + .stMarkdownBlockID, + at: cursor, + effectiveRange: &blockRange + ) as? String, + blockID.isEmpty == false else { + return nil + } + + if blockID == "__separator__" { + cursor = blockRange.location - 1 + continue + } + + guard let revealRaw = attributedText.attribute( + .stMarkdownRevealPolicy, + at: cursor, + effectiveRange: nil + ) as? String, + let revealPolicy = STMarkdownRevealPolicy(rawValue: revealRaw) else { + return nil + } + + if revealPolicy == .inlineProgressive { + return blockRange + } + return nil + } + + return nil + } + + private func containsContainerRevealPolicy(in attributedText: NSAttributedString) -> Bool { + guard attributedText.length > 0 else { return false } + let fullRange = NSRange(location: 0, length: attributedText.length) + var found = false + attributedText.enumerateAttribute(.stMarkdownRevealPolicy, in: fullRange, options: []) { value, _, stop in + guard let raw = value as? String, + let policy = STMarkdownRevealPolicy(rawValue: raw), + policy == .containerThenContent else { + return + } + found = true + stop.pointee = true + } + return found + } + + private func enqueueAnimatedStreamingSuffix( + _ suffix: NSAttributedString, + trailingSuffix: NSAttributedString?, + shouldDelay: Bool + ) { + let generation = self.streamingAnimationGeneration + let applySuffix = { [weak self] in + guard let self, self.streamingAnimationGeneration == generation else { return } + self.pendingStreamingSuffixWorkItem = nil + self.shimmerTextView.appendAttributedText(suffix, animated: true) + if let trailingSuffix, trailingSuffix.length > 0 { + self.shimmerTextView.appendAttributedText(trailingSuffix, animated: false) + } + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) + } + + if shouldDelay, self.containerRevealGapDuration > 0 { + let workItem = DispatchWorkItem(block: applySuffix) + self.pendingStreamingSuffixWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + self.containerRevealGapDuration, + execute: workItem + ) + } else { + applySuffix() + } + } + + private func invalidatePendingStreamingAnimation() { + self.streamingAnimationGeneration += 1 + self.pendingStreamingSuffixWorkItem?.cancel() + self.pendingStreamingSuffixWorkItem = nil + } + private func render(_ markdown: String) -> NSAttributedString { self.renderWithDocument(markdown).0 } diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift index d628828..1f9db4d 100644 --- a/Sources/STUIKit/STTextView/STShimmerTextView.swift +++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift @@ -87,7 +87,15 @@ open class STShimmerTextView: UITextView { self.textContainer.lineFragmentPadding = 0 self.font = .st_systemFont(ofSize: 16) self.textColor = .label - self.layoutManager.allowsNonContiguousLayout = false + // iOS 16+ 若已启用 TextKit 2(`textLayoutManager != nil`),访问 `layoutManager` 会强制降级到 + // TK1 兼容栈并在控制台产生 `_UITextViewEnablingCompatibilityMode` 告警;仅在经典 TK1 路径下设置。 + if #available(iOS 16.0, *) { + if self.textLayoutManager == nil { + self.layoutManager.allowsNonContiguousLayout = false + } + } else { + self.layoutManager.allowsNonContiguousLayout = false + } } public func append(_ text: String) { From 7c381e8415d36cee94bd00c9b9e638f6b1011e34 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 17:38:51 +0800 Subject: [PATCH 11/27] Refactor STMarkdown package and enhance documentation - Removed the `STMarkdownAttachmentUnitTests` test target from the Package.swift file. - Updated the `STMarkdown-MarkdownDisplayView-Comparison.md` to reflect changes in block-level capabilities, including support for `details` and `rawHTML`. - Improved unit tests for `STMarkdownRefreshableAttachment` and `STMarkdownStructureParser` to ensure proper functionality and metadata handling. - Enhanced `STMarkdownRenderAdapter` documentation to clarify the requirement for structured metadata paths. --- ...Markdown-MarkdownDisplayView-Comparison.md | 13 +- .../STMarkdownAttachmentsTests.swift | 36 ++--- ...reParserParseAndRenderIntegrityTests.swift | 150 ++++++++++++++++++ Package.swift | 6 - .../Core/STMarkdownRenderAdapter.swift | 2 + .../STMarkdown/Parsing/STMarkdownAST.swift | 5 - 6 files changed, 175 insertions(+), 37 deletions(-) diff --git a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md index e2bae58..640e84b 100644 --- a/Docs/STMarkdown-MarkdownDisplayView-Comparison.md +++ b/Docs/STMarkdown-MarkdownDisplayView-Comparison.md @@ -56,7 +56,7 @@ 2. **解析与并发**:Vendor 在解析路径用 **`parseLock`** 串行化 swift-markdown,视图层另有 `renderQueue`/版本锁等增量保护;ST 在 **`STMarkdownStructureParser.parse`** 使用 **`parseLock`**;**无**视图层版本锁与 **无** Vendor 同款元素级增量回溯公开形态。**【部分对齐】** 3. **流式**:Vendor 缓冲器可 **`onModuleReady` 带预解析 `MarkdownRenderElement`**,并与 **Typewriter 子视图树** 配合;ST 为 **`STMarkdownStreamBuffer`**(可选 **`onCompleteModules`** 仅字符串)+ **Shimmer / 增量 `setMarkdown`**。**【部分对齐】**(预解析元素与视图树 **【未对齐】**) 4. **目录 TOC**:Vendor **内置目录视图、`onTOCItemTap`、跳转 API**;ST 提供 **`STMarkdownTOCItem`**、**`tableOfContents`**、**`scrollToHeadingAnchor`** / **`scrollToTOCItem`** / **`characterRangeForHeadingAnchor`**,以及 **`STScrollableMarkdownView`** 可选 **内置 TOC 侧栏** 与 **`onTOCItemTap`**、**`onTableOfContentsChange`**(流式同帧刷新宿主侧栏)。**【部分对齐】**(布局与 Vendor 组件仍不同) -5. **块级模型**:Vendor 含 **`details`、`rawHTML`、footnote** 等;ST **`STMarkdownBlockNode`** 未定义上述扩展块;**`STMarkdownRenderBlock.heading` 含 `anchorId`**,与 TOC 抽取一致。**【部分对齐】** +5. **块级模型**:Vendor 含 **`details`、`rawHTML`、footnote** 等;ST 当前 **`STMarkdownBlockNode` / `STMarkdownRenderBlock`** 已定义 **`details` / `rawHTML`**,脚注也已进入 AST / 渲染链;但 **`rawHTML`** 默认仍不渲染为富文本 HTML、脚注 UI 形态也与 Vendor 不同。**【部分对齐】** 6. **公式**:KaTeX vs SwiftMath,排版与命令集不必一致。**【部分对齐】** 7. **表格**:Vendor 与 TextKit2 附件、手势、表格内链接等深度耦合;ST 为 **独立表格 Collection + overlay**,交互模型不同。**【部分对齐】** 8. **脚注 / 角标**:Vendor **脚注模型 + 延迟脚注视图**;ST 具备 **GFM 脚注 AST/管线**(`[^label]` + 定义行),正文中以 **`stmarkdown-footnote://` 链接触发 `onFootnoteTap`**,与表格 **Citation 角标**(`onCitationTap`)分流。**【部分对齐】**(延迟脚注视图 / Vendor 同款 UI 仍不同) @@ -72,11 +72,11 @@ |------|-------------|---------|------|------| | 流式增量解析 | `parseIncremental(...)` → `safePosition`、`replaceCount`、`newElements` | 整段仍走 `process`;**`processIncremental`** → **`replaceTailCount`** + **`windowRenderDocument`**;安全上界由缓冲器提供 | ST **弱于** vendor 一体化 | **【部分对齐】** | | 流式模块回调 | `onModuleReady` 可回传预解析元素 | **`onCompleteModules`** 仅完整模块字符串;无预解析 AST | ST **弱于** vendor | **【部分对齐】** | -| 块级能力 | `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` 等 | `STMarkdownBlockNode` 仍以常规块为主;脚注 **`[^]`** 与定义行已进管线;**`heading` 含 `anchorId`** | ST **仍缺** `details` / `rawHTML` 等块 | **【部分对齐】** | +| 块级能力 | `details`、`rawHTML`、`heading(id:...)`、`table`、`latex`、`list` 等 | AST / Render AST 已定义 **`details` / `rawHTML`**;脚注 **`[^]`** 与定义行已进管线;**`heading` 含 `anchorId`** | ST **已补齐块级建模**,主要差在 HTML 消费策略与 UI 形态 | **【部分对齐】** | | TOC | `tableOfContents`、`onTOCItemTap`、`generateTOCView()`、`scrollToTOCItem(...)` | 管线 + **`STMarkdownBaseTextView`**:`tableOfContents`、`onTableOfContentsChange`、`scrollToHeadingAnchor` / **`scrollToTOCItem`**;**`STScrollableMarkdownView`** 可选内置侧栏 + **`onTOCItemTap`** | 一体布局与 Vendor 不同 | **【部分对齐】** | | 脚注 | 预处理、缓存、延迟脚注视图 | 管线剥离定义 + 正文 `[^]` → **`onFootnoteTap`**(深度链接);Citation 仍独立 | 脚注视图形态不同 | **【部分对齐】** | | TextKit 栈 | `NSTextLayoutManager` / `NSTextContentStorage` / TK2 | `usingTextLayoutManager: false`(TextKit 1) | 路线不同 | **【未对齐】** | -| HTML | `rawHTML(String)` 与渲染分支 | `STHtmlNormalizeRule` 等标明下游 **不消费 raw HTML** | ST **不支持 raw HTML** | **【未对齐】** | +| HTML | `rawHTML(String)` 与渲染分支 | AST / Renderer 已保留 **`rawHTML`** block;默认策略以 `STMarkdownStyle.rawHTMLPolicy` 控制(默认抑制,也可字面等宽显示),**不**把 HTML 渲染成富文本 DOM | ST **支持保留与字面显示**,但**不支持富 HTML 渲染** | **【部分对齐】** | | 交互 | `onLinkTap`、`onImageTap`、TOC tap、脚注视图 | `onLinkTap`、`onFootnoteTap`、`onSelectionChange`、`onCitationTap`;TOC 侧栏 `onTOCItemTap`(见 `STScrollableMarkdownView`) | 各有侧重 | **【部分对齐】** | | 表格交互 | 与 TK2 attachment 深度耦合 | 独立 View/Attachment + overlay/citation | 路线不同 | **【部分对齐】** | @@ -90,6 +90,7 @@ - **`STMarkdownBaseTextView`** **【部分对齐】**:测量宽度、高度通知节流;**`tableOfContents`**、**`onTableOfContentsChange`**、**`scrollToHeadingAnchor`** / **`scrollToTOCItem`**、**`characterRangeForHeadingAnchor`**;**`onFootnoteTap`**(脚注链)与 **`onCitationTap`**(表格角标)分流。 - **`STScrollableMarkdownView`** **【部分对齐】**:可选 **`showsTableOfContents`** 内置目录侧栏、**`onTOCItemTap`**、**`onTableOfContentsChange`**(与流式刷新同帧)。 - **`STMarkdownFootnoteDeepLink`** + 渲染器脚注 **`NSTextAttribute.link`**:与 **`onLinkTap`** 分流(P1)。 +- **`STMarkdownHTMLBlockClassifier`** + **`STMarkdownAttributedStringRenderer`** **【部分对齐】**:已支持 **`
        `** 解析为独立 block,**`rawHTML`** 也可按 `rawHTMLPolicy` 选择抑制或字面等宽显示;差距主要在不执行富 HTML 渲染。 - **`STMarkdownStructureParser`**:**`parseLock`** 串行化解析路径(与 Vendor 动机一致)。 - **`STMarkdownPipeline`** / **`STMarkdownMalformedTableNormalizer`**:坏表修复;**`STMarkdownPipelineResult.tableOfContents`**;**`processIncremental(_:)`**(窗口 parse、**`replaceTailCount`**、**`mergedRenderDocument`**,见 §6.2.5)。 - **`STMarkdownRenderBlock.heading`** + **`NSAttributedString.Key.stMarkdownHeadingAnchor`**:锚点与 TOC 一致。 @@ -109,7 +110,7 @@ | P1 | **TOC 产品面** | **【已完成】** `STScrollableMarkdownView` 内置侧栏、`onTOCItemTap`、`onTableOfContentsChange`;`scrollToTOCItem`;SwiftUI 流式包装透传 **`onTableOfContentsChange`**。 | | P1 | **脚注与 citation 语义拆分** | **【已完成】** AST/管线已有脚注;UI 上 **`stmarkdown-footnote://` + `onFootnoteTap`** 与 **`onCitationTap`** 分流。 | | P1 | **并发压测** | **【已完成】** 轻量多队列 **`process`** 冒烟(**`STMarkdownConcurrencyStressTests`**)。**仍缺**:流式 append、异步 attachment、指标化基准。 | -| P2 | **`details` / `rawHTML`** | 先 AST / `STMarkdownRenderBlock`,再 UI;raw HTML 宜白名单或独立 Web 容器,不宜默认进主富文本路径。 | +| P2 | **`details` / `rawHTML`** | AST / Render AST 与基础渲染已具备;后续重点应放在**交互 UI 完整度**(如折叠/展开、宿主交互)与 **raw HTML 的白名单 / 独立容器策略**,而非重复补模型。 | | P2 | **统一容器组件** | 评估官方「滚动 + 高度 + 目录 + 链接 + citation + 流式」一体化面,对标 `ScrollableMarkdownViewTextKit` 的宿主体验。 | | P3 | **TextKit 2** | 仅在附件布局、选区、超长文性能等**明确瓶颈**时再评估;不宜与流式增量同一迭代混谈。 | @@ -172,9 +173,9 @@ ST 已在解析路径使用 **`parseLock`**(见 §3 第 2 点、§5)。若 ### 6.4 P2:`
        `、rawHTML、TextKit 2 -**`details`**:先扩展 **`STMarkdownBlockNode` / `STMarkdownRenderBlock`**,再 UI。 +**`details`**:模型与基础渲染已在位;若继续补强,应聚焦可折叠交互、展开态状态管理与宿主可配置 API。 -**rawHTML**:若强需求,白名单或 `WKWebView` 沙箱,不宜默认并入 `NSAttributedString` 主路径。 +**rawHTML**:当前已支持保留为 block,并按 `rawHTMLPolicy` 抑制或字面显示;若强需求,再评估白名单或 `WKWebView` 沙箱,不宜默认并入 `NSAttributedString` 主路径。 **TextKit 2**:与当前表格 Collection + overlay 是否同迁需单独架构评估。 diff --git a/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift b/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift index 266c2ee..6dbe5ae 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownAttachmentsTests.swift @@ -5,7 +5,7 @@ import UIKit // MARK: - Test doubles /// 最小 `STMarkdownRefreshableAttachment` 实现,用于隔离测试附件刷新协议与绑定逻辑。 -private final class MockRefreshableAttachment: NSTextAttachment, STMarkdownRefreshableAttachment { +private final class MockRefreshableAttachment: NSTextAttachment, STMarkdownRefreshableAttachment, @unchecked Sendable { private let registry = STMarkdownRefreshObserverRegistry() func addDisplayObserver(_ observer: @escaping () -> Void) -> STMarkdownRefreshObservation { @@ -42,7 +42,7 @@ final class STMarkdownRefreshAttachmentInfrastructureTests: XCTestCase { var a = 0 var b = 0 let tokenA = registry.add { a += 1 } - _ = registry.add { b += 1 } + let tokenB = registry.add { b += 1 } registry.notify() XCTAssertEqual(a, 1) XCTAssertEqual(b, 1) @@ -50,15 +50,18 @@ final class STMarkdownRefreshAttachmentInfrastructureTests: XCTestCase { registry.notify() XCTAssertEqual(a, 1) XCTAssertEqual(b, 2) + tokenB.invalidate() } func testRefreshObserverRegistryNotifyInvokesAllCurrentObservers() { let registry = STMarkdownRefreshObserverRegistry() var sum = 0 - _ = registry.add { sum += 1 } - _ = registry.add { sum += 2 } + let token1 = registry.add { sum += 1 } + let token2 = registry.add { sum += 2 } registry.notify() XCTAssertEqual(sum, 3) + token1.invalidate() + token2.invalidate() } } @@ -99,7 +102,11 @@ final class STMarkdownNumberBadgeAttachmentTests: XCTestCase { guard let image = badge.image else { return XCTFail("expected image") } - let expected = ceil(34 / 17 * STMarkdownNumberBadgeAttachment.fixedDiameter) + // 与 `STMarkdownNumberBadgeAttachment.init` 一致:`UIFont` 实际 `pointSize` 可能略大于请求值。 + let expected = max( + STMarkdownNumberBadgeAttachment.fixedDiameter, + ceil(font.pointSize / 17 * STMarkdownNumberBadgeAttachment.fixedDiameter) + ) XCTAssertEqual(image.size.width, expected, accuracy: 0.5) XCTAssertEqual(image.size.height, expected, accuracy: 0.5) } @@ -161,19 +168,6 @@ final class STMarkdownNumberBadgeAttachmentTests: XCTestCase { ) XCTAssertNotNil(viaAlias.image) } - - func testInitCoderFallsBackToSuperWithoutFatalError() throws { - let font = UIFont.st_systemFont(ofSize: 17, weight: .regular) - let original = STMarkdownNumberBadgeAttachment( - numberText: "1", - font: font, - textColor: .white, - backgroundColor: .systemBlue - ) - let data = try XCTUnwrap(NSKeyedArchiver.archivedData(withRootObject: original, requiringSecureCoding: false)) - let unarchived = try XCTUnwrap(try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? STMarkdownNumberBadgeAttachment) - XCTAssertNotNil(unarchived) - } } // MARK: - STMarkdownAttachmentRefreshSupport @@ -204,13 +198,14 @@ final class STMarkdownAttachmentRefreshSupportTests: XCTestCase { let attachment = MockRefreshableAttachment() let attr = NSMutableAttributedString(string: " ") attr.addAttribute(.attachment, value: attachment, range: NSRange(location: 0, length: 1)) - _ = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in XCTAssertTrue(Thread.isMainThread) XCTAssertTrue(att === attachment) expectation.fulfill() } attachment.notifyDisplayObservers() waitForExpectations(timeout: 2) + tokens.forEach { $0.invalidate() } } func testBindRefreshHandlersDispatchesToMainWhenObserverFiresOffMainThread() { @@ -218,7 +213,7 @@ final class STMarkdownAttachmentRefreshSupportTests: XCTestCase { let attachment = MockRefreshableAttachment() let attr = NSMutableAttributedString(string: " ") attr.addAttribute(.attachment, value: attachment, range: NSRange(location: 0, length: 1)) - _ = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in + let tokens = STMarkdownAttachmentRefreshSupport.bindRefreshHandlers(in: attr) { att in XCTAssertTrue(Thread.isMainThread) XCTAssertTrue(att === attachment) expectation.fulfill() @@ -227,6 +222,7 @@ final class STMarkdownAttachmentRefreshSupportTests: XCTestCase { attachment.notifyDisplayObservers() } waitForExpectations(timeout: 3) + tokens.forEach { $0.invalidate() } } func testBindRefreshHandlersRegistersOneTokenPerRefreshableAttachment() { diff --git a/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift index ac7edad..ff23f8a 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownStructureParserParseAndRenderIntegrityTests.swift @@ -123,6 +123,21 @@ private func st_firstParagraphInlines(_ document: STMarkdownDocument) -> [STMark return nil } +private func st_assertStructuredMetadata( + _ metadata: STMarkdownRenderBlockMetadata, + expectedPath: [String], + expectedKind: STMarkdownRenderBlockKind, + expectedRevealPolicy: STMarkdownRevealPolicy, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual(metadata.path, expectedPath, file: file, line: line) + XCTAssertEqual(metadata.id, expectedPath.joined(separator: "/"), file: file, line: line) + XCTAssertEqual(metadata.kind, expectedKind, file: file, line: line) + XCTAssertEqual(metadata.revealPolicy, expectedRevealPolicy, file: file, line: line) + XCTAssertFalse(metadata.id.hasPrefix("compat/"), file: file, line: line) +} + final class STMarkdownStructureParserParseAndRenderIntegrityTests: XCTestCase { // MARK: - 解析:结构是否被识别为 AST 节点(而非整段原文) @@ -346,6 +361,141 @@ final class STMarkdownStructureParserParseAndRenderIntegrityTests: XCTestCase { XCTAssertTrue(plain.contains("链")) } + func testRenderAdapterProducesStructuredMetadataIDsForNestedBlocks() { + let document = STMarkdownDocument(blocks: [ + .heading(level: 1, content: [.text("Title")]), + .quote([ + .paragraph([.text("quoted")]), + .list( + kind: .unordered, + items: [ + STMarkdownListItemNode(blocks: [.paragraph([.text("item")])]), + ] + ), + ]), + .details( + summary: [.text("More")], + body: [.paragraph([.text("Hidden")])] + ), + ]) + + let renderDocument = STMarkdownRenderAdapter().adapt(document) + XCTAssertEqual(renderDocument.blocks.count, 3) + + guard case .heading(let headingMeta, level: let level, anchorId: let anchorId, content: _) + = renderDocument.blocks[0] else { + return XCTFail("首块应为 heading") + } + XCTAssertEqual(level, 1) + XCTAssertEqual(anchorId, "title") + st_assertStructuredMetadata( + headingMeta, + expectedPath: ["b:0"], + expectedKind: .heading, + expectedRevealPolicy: .inlineProgressive + ) + + guard case .quote(let quoteMeta, let quoteBlocks) = renderDocument.blocks[1] else { + return XCTFail("第二块应为 quote") + } + st_assertStructuredMetadata( + quoteMeta, + expectedPath: ["b:1"], + expectedKind: .quote, + expectedRevealPolicy: .containerThenContent + ) + XCTAssertEqual(quoteBlocks.count, 2) + st_assertStructuredMetadata( + quoteBlocks[0].metadata, + expectedPath: ["b:1", "q:0"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + + guard case .list(let listMeta, let items) = quoteBlocks[1] else { + return XCTFail("quote 第二个子块应为 list") + } + st_assertStructuredMetadata( + listMeta, + expectedPath: ["b:1", "q:1"], + expectedKind: .list, + expectedRevealPolicy: .containerThenContent + ) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].blocks.count, 1) + st_assertStructuredMetadata( + items[0].blocks[0].metadata, + expectedPath: ["b:1", "q:1", "li:0", "b:0"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + + guard case .details(let detailsMeta, summary: let summary, body: let body) = renderDocument.blocks[2] else { + return XCTFail("第三块应为 details") + } + XCTAssertEqual(summary, [.text("More")]) + st_assertStructuredMetadata( + detailsMeta, + expectedPath: ["b:2"], + expectedKind: .details, + expectedRevealPolicy: .containerThenContent + ) + XCTAssertEqual(body.count, 1) + st_assertStructuredMetadata( + body[0].metadata, + expectedPath: ["b:2", "d:0"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + } + + func testRenderAdapterAppendsFootnoteSectionUsingStructuredTopLevelIDs() { + let parser = STMarkdownStructureParser() + let document = parser.parse( + """ + Body[^a]. + + [^a]: note body + """ + ) + + let renderDocument = STMarkdownRenderAdapter().adapt(document) + XCTAssertEqual(renderDocument.blocks.count, 4) + + st_assertStructuredMetadata( + renderDocument.blocks[0].metadata, + expectedPath: ["b:0"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + st_assertStructuredMetadata( + renderDocument.blocks[1].metadata, + expectedPath: ["b:1"], + expectedKind: .thematicBreak, + expectedRevealPolicy: .atomicBlock + ) + st_assertStructuredMetadata( + renderDocument.blocks[2].metadata, + expectedPath: ["b:2"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + st_assertStructuredMetadata( + renderDocument.blocks[3].metadata, + expectedPath: ["b:3"], + expectedKind: .paragraph, + expectedRevealPolicy: .inlineProgressive + ) + + guard case .paragraph(_, let headingInlines) = renderDocument.blocks[2], + case .paragraph(_, let footnoteInlines) = renderDocument.blocks[3] else { + return XCTFail("脚注尾部应追加两个 paragraph") + } + XCTAssertEqual(headingInlines, [.strong([.text("脚注")])]) + XCTAssertTrue(footnoteInlines.contains(.text(" "))) + XCTAssertTrue(footnoteInlines.contains(.text("note body"))) + } + // MARK: - 流式:每个中间态都不允许出现 Markdown 定界符 @MainActor diff --git a/Package.swift b/Package.swift index 9382f63..7cb412b 100644 --- a/Package.swift +++ b/Package.swift @@ -68,12 +68,6 @@ let package = Package( resources: [ .copy("PrivacyInfo.xcprivacy") ] - ), - .testTarget( - name: "STMarkdownAttachmentUnitTests", - dependencies: ["STBaseProject"], - path: "Example/STBaseProjectExampleTests", - sources: ["STMarkdownAttachmentsTests.swift"] ) ], swiftLanguageVersions: [.v5] diff --git a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift index 3d5e7c2..aba7122 100644 --- a/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift +++ b/Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift @@ -16,6 +16,8 @@ import Foundation /// - Note: ``STMarkdownRenderBlock/heading(level:anchorId:content:)`` 的 `anchorId` 须与 /// ``STMarkdownTOCItem/anchorId``、``NSAttributedString.Key/stMarkdownHeadingAnchor`` 一致; /// 自定义适配器若无法生成 slug,可对纯文本标题使用稳定哈希并保证文档内唯一。 +/// - Important: 正式 adapter 必须产出结构化 `metadata.path/id`(如 `b:0/q:0`); +/// 不要复用兼容工厂里的通用 metadata 作为正式渲染路径标识。 public protocol STMarkdownRenderAdapting: Sendable { func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument } diff --git a/Sources/STMarkdown/Parsing/STMarkdownAST.swift b/Sources/STMarkdown/Parsing/STMarkdownAST.swift index fcc6164..310208c 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownAST.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownAST.swift @@ -17,9 +17,7 @@ public enum STMarkdownInlineNode: Hashable, Sendable { case image(source: String, alt: String, title: String?) case softBreak case strikethrough([STMarkdownInlineNode]) - /// GFM 风格脚注引用(标签不含 `^` 前缀,例如 `"1"`、`"note"`)。 case footnoteReference(label: String) - /// swift-markdown 解析到的行内 HTML;渲染策略见 ``STMarkdownStyle/rawHTMLPolicy``。 case inlineRawHTML(String) } @@ -76,9 +74,7 @@ public enum STMarkdownBlockNode: Hashable, Sendable { case mathBlock(String) case image(url: String, altText: String, title: String?) case thematicBreak - /// 从 ``HTMLBlock`` 识别的 `
        `(折叠语义由宿主 UI 承载时,渲染侧先展开为缩进块)。 case details(summary: [STMarkdownInlineNode], body: [STMarkdownBlockNode]) - /// 块级原始 HTML;默认不当作富文本解析,见 ``STMarkdownRawHTMLPolicy``。 case rawHTML(String) } @@ -93,7 +89,6 @@ public struct STMarkdownFootnoteDefinition: Hashable, Sendable { public struct STMarkdownDocument: Hashable, Sendable { public let blocks: [STMarkdownBlockNode] - /// 从正文剥离的脚注定义;引用仍以内联 ``STMarkdownInlineNode/footnoteReference`` 表示。 public let footnoteDefinitions: [String: STMarkdownFootnoteDefinition] public init(blocks: [STMarkdownBlockNode], footnoteDefinitions: [String: STMarkdownFootnoteDefinition] = [:]) { From 2fe1f6610914e925a8f6dbcdeb028b4a39f866d4 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 17:58:14 +0800 Subject: [PATCH 12/27] Add smart streaming capabilities to STMarkdownStreamingTextView - Introduced `SmartStreamingRenderMode` enum to manage rendering modes (full and incremental). - Implemented methods for incremental rendering and canonical markdown processing. - Added unit tests to verify smart streaming behavior, including handling of duplicate headings and sanitization. - Enhanced rendering logic to support efficient updates during streaming sessions. --- .../STMarkdownUIViewTests.swift | 55 ++++++ .../UI/STMarkdownStreamingTextView.swift | 185 +++++++++++++++++- 2 files changed, 235 insertions(+), 5 deletions(-) diff --git a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift index e054c91..f7b5bb9 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift @@ -140,6 +140,61 @@ final class STMarkdownUIViewTests: XCTestCase { XCTAssertLessThan(self.foregroundAlpha(in: visible, at: tailRange.location), 0.5) } + func testSmartStreamingSecondCommittedFrameUsesIncrementalMergedRenderPath() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0 + view.markdownStyle.streamMinModuleLength = 1 + + let p1 = String(repeating: "a", count: 98) + let p2 = String(repeating: "b", count: 98) + let p3 = String(repeating: "c", count: 98) + let p4 = String(repeating: "d", count: 98) + let p5 = String(repeating: "e", count: 98) + + view.beginSmartMarkdownStreaming() + view.appendSmartMarkdownStreamingChunk([p1, p2, p3, p4].joined(separator: "\n\n") + "\n\n") + XCTAssertEqual(view.lastSmartStreamingRenderMode, .full) + + view.appendSmartMarkdownStreamingChunk(p5 + "\n\n") + + XCTAssertEqual(view.lastSmartStreamingRenderMode, .incremental) + let rendered = view.attributedText.string + XCTAssertTrue(rendered.contains(p1)) + XCTAssertTrue(rendered.contains(p5)) + } + + func testSmartStreamingFinalConvergenceKeepsDuplicateHeadingAnchorsUnique() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0 + view.markdownStyle.streamMinModuleLength = 1 + + view.beginSmartMarkdownStreaming() + view.appendSmartMarkdownStreamingChunk("## Same\n\nBody 1\n\n") + XCTAssertEqual(view.tableOfContents.map(\.anchorId), ["same"]) + + view.appendSmartMarkdownStreamingChunk("## Same\n\nBody 2") + view.appendSmartMarkdownStreamingChunk("\n\nTail 3\n\n") + view.endSmartMarkdownStreaming(flushPending: true) + + XCTAssertEqual(view.tableOfContents.map(\.anchorId), ["same", "same-1"]) + } + + func testSmartStreamingIncrementalPathRespectsSanitizerCanonicalization() { + let view = STMarkdownStreamingTextView() + view.tokenFadeDuration = 0 + view.markdownStyle.streamMinModuleLength = 1 + + view.beginSmartMarkdownStreaming() + view.appendSmartMarkdownStreamingChunk("Example\n\n") + XCTAssertEqual(view.attributedText.string, "Example") + + view.appendSmartMarkdownStreamingChunk("Tail\n\n") + + XCTAssertEqual(view.lastSmartStreamingRenderMode, .incremental) + XCTAssertTrue(view.attributedText.string.contains("Example")) + XCTAssertTrue(view.attributedText.string.contains("Tail")) + } + private func foregroundAlpha(in attributed: NSAttributedString, at index: Int) -> CGFloat { guard index >= 0, index < attributed.length else { return 1 } guard let color = attributed.attribute(.foregroundColor, at: index, effectiveRange: nil) as? UIColor else { diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index 9ad7631..20741aa 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -8,6 +8,10 @@ import UIKit public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { + internal enum SmartStreamingRenderMode { + case full + case incremental + } public var tokenFadeDuration: TimeInterval { get { self.shimmerTextView.tokenFadeDuration } @@ -34,6 +38,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { /// 上一帧已交给 ``setMarkdown(_:animated:)`` 的「安全前缀」展示串(经 ``stripUnclosedTailMarkers``)。 /// 当缓冲器仅增长尾部、``committedSafePrefix`` 不变时跳过整段重解析,降低流式 CPU 占用(对齐对比文档 P0)。 private var lastSmartStreamRenderedDisplayMarkdown: String? + private var lastSmartStreamRenderedCanonicalMarkdown: String? + private var lastSmartStreamRenderDocument: STMarkdownRenderDocument? + internal private(set) var lastSmartStreamingRenderMode: SmartStreamingRenderMode? /// 是否处于 ``beginSmartMarkdownStreaming()`` 开启的智能缓冲流式会话中。 public var isSmartMarkdownStreamingActive: Bool { @@ -264,6 +271,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.smartStreamBuffer = nil self.smartStreamingSessionActive = false self.lastSmartStreamRenderedDisplayMarkdown = nil + self.lastSmartStreamRenderedCanonicalMarkdown = nil + self.lastSmartStreamRenderDocument = nil + self.lastSmartStreamingRenderMode = nil let md = Self.stripUnclosedTailMarkers(in: snapshot) self.isApplyingSmartStreamMarkdownUpdate = true defer { self.isApplyingSmartStreamMarkdownUpdate = false } @@ -285,6 +295,9 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.smartStreamBuffer = nil self.smartStreamingSessionActive = false self.lastSmartStreamRenderedDisplayMarkdown = nil + self.lastSmartStreamRenderedCanonicalMarkdown = nil + self.lastSmartStreamRenderDocument = nil + self.lastSmartStreamingRenderMode = nil } private func applySmartStreamingPresentation(animated: Bool, force: Bool = false) { @@ -298,18 +311,180 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } self.isApplyingSmartStreamMarkdownUpdate = true defer { self.isApplyingSmartStreamMarkdownUpdate = false } - self.setMarkdown(md, animated: animated) + let rendered = self.renderSmartStreamingDisplayMarkdown(md, forceFull: force) + self.applySetMarkdownAnimatedDiff(markdown: md, displayRendered: rendered, animated: animated) self.lastSmartStreamRenderedDisplayMarkdown = md self.rawMarkdown = accumulated } + private func renderSmartStreamingDisplayMarkdown(_ markdown: String, forceFull: Bool) -> NSAttributedString { + let canonicalMarkdown = self.canonicalMarkdownForIncremental(from: markdown) + + if forceFull == false, + let previousCanonical = self.lastSmartStreamRenderedCanonicalMarkdown, + let previousDocument = self.lastSmartStreamRenderDocument, + previousCanonical.isEmpty == false, + canonicalMarkdown.count >= previousCanonical.count, + canonicalMarkdown.hasPrefix(previousCanonical) { + let incremental = self.engine.processIncremental( + STMarkdownIncrementalParameters( + canonicalMarkdown: canonicalMarkdown, + lastCommittedExclusiveEnd: previousCanonical.count, + currentSafeExclusiveEnd: canonicalMarkdown.count, + contextWindowSize: 200, + previousTotalRenderBlockCount: previousDocument.blocks.count + ) + ) + let mergedDocument = self.normalizedMergedIncrementalRenderDocument( + incremental.mergedRenderDocument(previous: previousDocument) + ) + self.updateTableOfContents(from: mergedDocument) + self.lastSmartStreamRenderedCanonicalMarkdown = canonicalMarkdown + self.lastSmartStreamRenderDocument = mergedDocument + self.lastSmartStreamingRenderMode = .incremental + return self.render(document: mergedDocument) + } + + let (rendered, renderDocument) = self.renderWithDocument(markdown) + self.lastSmartStreamRenderedCanonicalMarkdown = canonicalMarkdown + self.lastSmartStreamRenderDocument = renderDocument + self.lastSmartStreamingRenderMode = .full + return rendered + } + + private func canonicalMarkdownForIncremental(from markdown: String) -> String { + let configuration = self.engine.pipeline.configuration + guard configuration.enableInputSanitizer else { + return markdown + } + let sanitizer = STMarkdownInputSanitizer(rules: configuration.sanitizerRules) + return sanitizer.sanitize(markdown, debug: configuration.debug).sanitizedText + } + + private func render(document: STMarkdownRenderDocument) -> NSAttributedString { + if let customRenderer = self.customDocumentRenderer { + return customRenderer(document) + } + return self.renderer.render(document: document) + } + + private func normalizedMergedIncrementalRenderDocument( + _ document: STMarkdownRenderDocument + ) -> STMarkdownRenderDocument { + var slugger = STMarkdownAnchorSlugRegistry() + let blocks = document.blocks.enumerated().map { + self.normalizedMergedRenderBlock( + $0.element, + path: ["b:\($0.offset)"], + slugger: &slugger + ) + } + return STMarkdownRenderDocument(blocks: blocks) + } + + private func normalizedMergedRenderBlock( + _ block: STMarkdownRenderBlock, + path: [String], + slugger: inout STMarkdownAnchorSlugRegistry + ) -> STMarkdownRenderBlock { + switch block { + case .paragraph(let metadata, let inlines): + return .paragraph( + self.rebasedMetadata(metadata, kind: .paragraph, path: path), + inlines + ) + case .heading(let metadata, level: let level, anchorId: _, content: let content): + let anchorId = slugger.uniqueAnchorId(forPlainTitle: content.st_plainTextForTOC()) + return .heading( + self.rebasedMetadata(metadata, kind: .heading, path: path), + level: level, + anchorId: anchorId, + content: content + ) + case .quote(let metadata, let blocks): + return .quote( + self.rebasedMetadata(metadata, kind: .quote, path: path), + blocks.enumerated().map { + self.normalizedMergedRenderBlock( + $0.element, + path: path + ["q:\($0.offset)"], + slugger: &slugger + ) + } + ) + case .list(let metadata, let items): + return .list( + self.rebasedMetadata(metadata, kind: .list, path: path), + items.enumerated().map { index, item in + let itemPath = path + ["li:\(index)"] + return STMarkdownRenderListItem( + blocks: item.blocks.enumerated().map { + self.normalizedMergedRenderBlock( + $0.element, + path: itemPath + ["b:\($0.offset)"], + slugger: &slugger + ) + }, + ordered: item.ordered, + level: item.level, + orderedIndex: item.orderedIndex, + checkbox: item.checkbox + ) + } + ) + case .codeBlock(let metadata, language: let language, code: let code): + return .codeBlock( + self.rebasedMetadata(metadata, kind: .codeBlock, path: path), + language: language, + code: code + ) + case .table(let metadata, let table): + return .table(self.rebasedMetadata(metadata, kind: .table, path: path), table) + case .mathBlock(let metadata, let latex): + return .mathBlock(self.rebasedMetadata(metadata, kind: .mathBlock, path: path), latex) + case .image(let metadata, url: let url, altText: let altText, title: let title): + return .image( + self.rebasedMetadata(metadata, kind: .image, path: path), + url: url, + altText: altText, + title: title + ) + case .thematicBreak(let metadata): + return .thematicBreak(self.rebasedMetadata(metadata, kind: .thematicBreak, path: path)) + case .details(let metadata, summary: let summary, body: let body): + return .details( + self.rebasedMetadata(metadata, kind: .details, path: path), + summary: summary, + body: body.enumerated().map { + self.normalizedMergedRenderBlock( + $0.element, + path: path + ["d:\($0.offset)"], + slugger: &slugger + ) + } + ) + case .rawHTML(let metadata, let html): + return .rawHTML(self.rebasedMetadata(metadata, kind: .rawHTML, path: path), html) + } + } + + private func rebasedMetadata( + _ metadata: STMarkdownRenderBlockMetadata, + kind: STMarkdownRenderBlockKind, + path: [String] + ) -> STMarkdownRenderBlockMetadata { + STMarkdownRenderBlockMetadata( + id: path.joined(separator: "/"), + path: path, + kind: kind, + revealPolicy: metadata.revealPolicy + ) + } + private func renderWithDocument(_ markdown: String) -> (NSAttributedString, STMarkdownRenderDocument) { let result = self.engine.process(markdown) self.updateTableOfContents(from: result) - if let customRenderer = self.customDocumentRenderer { - return (customRenderer(result.renderDocument), result.renderDocument) - } - return (self.renderer.render(document: result.renderDocument), result.renderDocument) + return (self.render(document: result.renderDocument), result.renderDocument) } private func applyFullReplace(markdown: String, rendered: NSAttributedString) { From 2339126cdbeb704b3f59fe6dfc2b1767ade9b0ff Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 14 May 2026 18:15:18 +0800 Subject: [PATCH 13/27] Enhance STMarkdownStreamingTextView with animation management and timeout features - Introduced `PendingAnimatedSuffix` struct to manage suffix animations during streaming. - Added `streamingAnimationWatchdogTimeout` property to enforce a timeout for streaming animations. - Implemented methods to handle pending animated suffixes and invalidate animations when necessary. - Enhanced `STShimmerTextView` with animation state change callbacks to improve synchronization with streaming animations. --- .../UI/STMarkdownStreamingTextView.swift | 121 ++++++++++++++++-- .../STTextView/STShimmerTextView.swift | 10 ++ 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index 20741aa..784107e 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -13,6 +13,12 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { case incremental } + private struct PendingAnimatedSuffix { + let generation: Int + let suffix: NSAttributedString + let trailingSuffix: NSAttributedString? + } + public var tokenFadeDuration: TimeInterval { get { self.shimmerTextView.tokenFadeDuration } set { self.shimmerTextView.tokenFadeDuration = newValue } @@ -30,11 +36,17 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { set { self.shimmerTextView.animateAcrossNewlines = newValue } } + /// 流式动画 / 容器尾段延迟追加的兜底超时;超时后强制收口,避免宿主一直等不到完成态。 + public var streamingAnimationWatchdogTimeout: TimeInterval = 4.0 + private var smartStreamBuffer: STMarkdownStreamBuffer? private var smartStreamingSessionActive = false private var isApplyingSmartStreamMarkdownUpdate = false private var streamingAnimationGeneration: Int = 0 private var pendingStreamingSuffixWorkItem: DispatchWorkItem? + private var pendingAnimatedSuffix: PendingAnimatedSuffix? + private var streamingWatchdogWorkItem: DispatchWorkItem? + private var streamingWatchdogGeneration: Int = 0 /// 上一帧已交给 ``setMarkdown(_:animated:)`` 的「安全前缀」展示串(经 ``stripUnclosedTailMarkers``)。 /// 当缓冲器仅增长尾部、``committedSafePrefix`` 不变时跳过整段重解析,降低流式 CPU 占用(对齐对比文档 P0)。 private var lastSmartStreamRenderedDisplayMarkdown: String? @@ -47,6 +59,11 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.smartStreamingSessionActive } + /// true 表示没有待执行的容器延迟尾段,也没有进行中的文本 reveal 动画。 + public var isStreamingAnimationIdle: Bool { + self.pendingAnimatedSuffix == nil && self.shimmerTextView.isAnimatingTextReveal == false + } + /// 会话中的完整累积 Markdown(含尚未通过模块检测的尾部);非会话中为 `nil`。 public var smartStreamingAccumulatedText: String? { self.smartStreamBuffer?.fullAccumulatedText @@ -79,6 +96,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { engine: STMarkdownEngine(), accessibilityTraits: [.staticText, .updatesFrequently] ) + self.installStreamingAnimationObservers() } public convenience init( @@ -104,17 +122,22 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { engine: STMarkdownEngine(), accessibilityTraits: [.staticText, .updatesFrequently] ) + self.installStreamingAnimationObservers() } public func reset() { self.invalidatePendingStreamingAnimation() + self.invalidateStreamingAnimationWatchdog() self.cancelSmartMarkdownStreamingSession() self.shimmerTextView.reset() self.resetBaseState() } public func finishStreaming() { + self.flushPendingAnimatedSuffixIfNeeded() self.shimmerTextView.finishAnimations() + self.invalidateStreamingAnimationWatchdog() + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) } public func setMarkdown(_ markdown: String, animated: Bool = false) { @@ -279,6 +302,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { defer { self.isApplyingSmartStreamMarkdownUpdate = false } self.setMarkdown(md, animated: false) self.rawMarkdown = snapshot + self.invalidateStreamingAnimationWatchdog() self.publishContentLayoutHeightNotificationIfNeeded(force: true) } @@ -654,32 +678,107 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { shouldDelay: Bool ) { let generation = self.streamingAnimationGeneration - let applySuffix = { [weak self] in - guard let self, self.streamingAnimationGeneration == generation else { return } - self.pendingStreamingSuffixWorkItem = nil - self.shimmerTextView.appendAttributedText(suffix, animated: true) - if let trailingSuffix, trailingSuffix.length > 0 { - self.shimmerTextView.appendAttributedText(trailingSuffix, animated: false) - } - self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) - } + self.pendingAnimatedSuffix = PendingAnimatedSuffix( + generation: generation, + suffix: suffix, + trailingSuffix: trailingSuffix + ) if shouldDelay, self.containerRevealGapDuration > 0 { - let workItem = DispatchWorkItem(block: applySuffix) + let workItem = DispatchWorkItem { [weak self] in + self?.applyPendingAnimatedSuffixIfNeeded(generation: generation, animated: true) + } self.pendingStreamingSuffixWorkItem = workItem DispatchQueue.main.asyncAfter( deadline: .now() + self.containerRevealGapDuration, execute: workItem ) } else { - applySuffix() + self.applyPendingAnimatedSuffixIfNeeded(generation: generation, animated: true) } + self.feedStreamingAnimationWatchdogIfNeeded() } private func invalidatePendingStreamingAnimation() { self.streamingAnimationGeneration += 1 self.pendingStreamingSuffixWorkItem?.cancel() self.pendingStreamingSuffixWorkItem = nil + self.pendingAnimatedSuffix = nil + self.invalidateStreamingAnimationWatchdog() + } + + private func installStreamingAnimationObservers() { + self.shimmerTextView.onAnimationStateChange = { [weak self] _ in + self?.feedStreamingAnimationWatchdogIfNeeded() + } + } + + private func applyPendingAnimatedSuffixIfNeeded(generation: Int, animated: Bool) { + guard self.streamingAnimationGeneration == generation, + let pending = self.pendingAnimatedSuffix, + pending.generation == generation else { + return + } + self.pendingStreamingSuffixWorkItem?.cancel() + self.pendingStreamingSuffixWorkItem = nil + self.pendingAnimatedSuffix = nil + self.shimmerTextView.appendAttributedText(pending.suffix, animated: animated) + if let trailingSuffix = pending.trailingSuffix, trailingSuffix.length > 0 { + self.shimmerTextView.appendAttributedText(trailingSuffix, animated: false) + } + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) + self.feedStreamingAnimationWatchdogIfNeeded() + } + + private func flushPendingAnimatedSuffixIfNeeded() { + guard let pending = self.pendingAnimatedSuffix else { return } + self.pendingStreamingSuffixWorkItem?.cancel() + self.pendingStreamingSuffixWorkItem = nil + self.pendingAnimatedSuffix = nil + self.shimmerTextView.appendAttributedText(pending.suffix, animated: false) + if let trailingSuffix = pending.trailingSuffix, trailingSuffix.length > 0 { + self.shimmerTextView.appendAttributedText(trailingSuffix, animated: false) + } + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) + } + + private func feedStreamingAnimationWatchdogIfNeeded() { + self.streamingWatchdogWorkItem?.cancel() + self.streamingWatchdogWorkItem = nil + + guard self.streamingAnimationWatchdogTimeout > 0, + self.isStreamingAnimationIdle == false else { + return + } + + self.streamingWatchdogGeneration += 1 + let generation = self.streamingWatchdogGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.streamingWatchdogGeneration == generation else { return } + self.forceFinishStreamingAnimationIfNeeded() + } + self.streamingWatchdogWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + self.streamingAnimationWatchdogTimeout, + execute: workItem + ) + } + + private func invalidateStreamingAnimationWatchdog() { + self.streamingWatchdogGeneration += 1 + self.streamingWatchdogWorkItem?.cancel() + self.streamingWatchdogWorkItem = nil + } + + private func forceFinishStreamingAnimationIfNeeded() { + guard self.isStreamingAnimationIdle == false else { + self.invalidateStreamingAnimationWatchdog() + return + } + self.flushPendingAnimatedSuffixIfNeeded() + self.shimmerTextView.finishAnimations() + self.invalidateStreamingAnimationWatchdog() + self.finalizeRenderUpdate(rendered: self.shimmerTextView.renderedAttributedText) } private func render(_ markdown: String) -> NSAttributedString { diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift index 1f9db4d..72668b8 100644 --- a/Sources/STUIKit/STTextView/STShimmerTextView.swift +++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift @@ -36,6 +36,7 @@ open class STShimmerTextView: UITextView { /// 为 false 时,新增 delta 中最后一个换行前的内容会立即显示,仅最后一行保留动画。 public var animateAcrossNewlines: Bool = false public var suppressSystemTextMenu: Bool = false + public var onAnimationStateChange: ((Bool) -> Void)? private var displayLink: CADisplayLink? private var animatingTokens: [AnimatingToken] = [] /// 最终目标态的 attributed text(全不透明),不含任何动画中间状态的 alpha 值。 @@ -53,6 +54,10 @@ open class STShimmerTextView: UITextView { return _baseAttributedText } + public var isAnimatingTextReveal: Bool { + self.displayLink != nil && !self.animatingTokens.isEmpty + } + public override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) self.setup() @@ -385,11 +390,16 @@ open class STShimmerTextView: UITextView { let link = CADisplayLink(target: self, selector: #selector(self.handleDisplayLink)) link.add(to: .main, forMode: .common) self.displayLink = link + self.onAnimationStateChange?(true) } private func stopDisplayLink() { + let wasAnimating = self.displayLink != nil self.displayLink?.invalidate() self.displayLink = nil + if wasAnimating { + self.onAnimationStateChange?(false) + } } @objc private func handleDisplayLink() { From e5bbd8a4778f0dc56ef7585a290db72fcd200a8f Mon Sep 17 00:00:00 2001 From: stack Date: Mon, 18 May 2026 11:30:40 +0800 Subject: [PATCH 14/27] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20tabbar=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/STUIKit/STTabBar/STCustomTabBar.swift | 2 +- Sources/STUIKit/STTabBar/STTabBarItemView.swift | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Sources/STUIKit/STTabBar/STCustomTabBar.swift b/Sources/STUIKit/STTabBar/STCustomTabBar.swift index 5f011ce..c3d2c7c 100644 --- a/Sources/STUIKit/STTabBar/STCustomTabBar.swift +++ b/Sources/STUIKit/STTabBar/STCustomTabBar.swift @@ -95,9 +95,9 @@ public class STCustomTabBar: UIView { public func configure(items: [STTabBarItemModel], config: STTabBarConfig = STTabBarConfig()) { self.itemModels = items self.config = config + self.updateAppearance() self.clampSelectedIndexForCurrentItems() self.setupItems() - self.updateAppearance() } public func setSelectedIndex(_ index: Int) { diff --git a/Sources/STUIKit/STTabBar/STTabBarItemView.swift b/Sources/STUIKit/STTabBar/STTabBarItemView.swift index 37312ab..a031675 100644 --- a/Sources/STUIKit/STTabBar/STTabBarItemView.swift +++ b/Sources/STUIKit/STTabBar/STTabBarItemView.swift @@ -181,13 +181,11 @@ public class STTabBarItemView: UIView { /// 图文排版可用高度:`config.height` 与父视图(contentView)实际高度取较小,避免配置值与约束高度不一致时仍按大值排版 private func effectiveBarHeightForImageTextLayout() -> CGFloat { - let configured = self.config?.height ?? 49 - guard let superview = self.superview else { return max(1, configured) } - let measured = superview.bounds.height - if measured > 0.5 { - return max(1, min(configured, measured)) - } - return max(1, configured) + let configured = max(1, self.config?.height ?? 49) + let measuredHeights = [self.bounds.height, self.superview?.bounds.height ?? 0] + .filter { $0 > 0.5 } + guard let measured = measuredHeights.min() else { return configured } + return max(1, min(configured, measured)) } /// 将 `imageTopInset`、图标尺寸约束在可用高度内,避免 Auto Layout 无法同时满足 From 5e21110510084e613641317a9eae3296c95908e5 Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 21 May 2026 14:57:39 +0800 Subject: [PATCH 15/27] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20STBtn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/cognitive-expansion.mdc | 80 ++++++ .cursor/rules/engineering-discipline.mdc | 69 ++++++ .cursor/rules/ios-engineer.mdc | 33 ++- .cursor/rules/logical-reasoning.mdc | 124 ++++++++++ ...TMarkdownStreamingTestViewController.swift | 232 ++++++++++++++++-- Sources/STUIKit/STButton/STBtn.swift | 35 +-- 6 files changed, 519 insertions(+), 54 deletions(-) create mode 100644 .cursor/rules/cognitive-expansion.mdc create mode 100644 .cursor/rules/engineering-discipline.mdc create mode 100644 .cursor/rules/logical-reasoning.mdc diff --git a/.cursor/rules/cognitive-expansion.mdc b/.cursor/rules/cognitive-expansion.mdc new file mode 100644 index 0000000..cb700f1 --- /dev/null +++ b/.cursor/rules/cognitive-expansion.mdc @@ -0,0 +1,80 @@ +--- +description: 每次回复后的认知拓展尾注(重框/盲区/邻域/带走),与认知对手模式互补 +alwaysApply: true +--- + + + + +# 认知拓展(Cognitive Expansion) + +> **真值来源**:本文件为唯一详规正文。`cognitive-expansion/SKILL.md` 为入口;各端完整副本由 `scripts/sync-skills.sh` 同步到 `~/.codex/skills/`、`~/.claude/skills/`、`~/.cursor/skills/`;Cursor 项目内另由 `sync-agent-preamble.sh` 从本文件生成 `.cursor/rules/cognitive-expansion.mdc`。 + +## 与认知对手模式的分工 + +| 模式 | 目标 | 典型触发 | +|------|------|----------| +| [认知对手模式](../../ios-engineer/references/cognitive_adversary_mode.md) | 校准:接近真实,挑战错误确信 | 技术决策、架构、根因结论、审查判断、强确信 | +| **认知拓展(本文件)** | 拓展:打破茧房,可带走的能力 | 每次对话默认 Tier 0;`【深潜】` 加深 | + +二者可同时存在:决策类先走认知对手(Tier 2),非决策类走认知尾注(Tier 0)。 + +## 三层分工 + +| 层级 | 何时 | 做什么 | +|------|------|--------| +| **Tier 0(默认)** | 每次主任务答完后 | 固定小节「认知尾注」,3–5 行 | +| **Tier 2** | 技术决策 / 架构 / 根因结论 / 审查最终判断 / 用户强确信 | 完整认知对手 Step 0–6(见 ios-engineer `cognitive_adversary_mode.md`) | +| **Tier 3** | 用户写 `【深潜】` 或 `【拓展】` | Tier 0 + 心智模型 + 跨域类比 + 7 天内可验证动作 | + +Tier 2 命中时:用认知对手完整结构,**可不单独再写** Tier 0 尾注(避免重复)。 + +## Tier 0:认知尾注(主答完成后追加) + +固定标题 **`认知尾注`**,每项 1 行,共 4 项: + +1. **重框**:把本次问题提升为更一般的判断/学习问题;纯执行任务(改 typo、跑命令、单点语法)写「本次为执行任务,重框略」。 +2. **盲区**:1 条具体隐藏假设、遗漏维度或常见误区;须可检验(「若 X 发生,说明假设错了」),禁止空泛「要注意边界」。 +3. **邻域**:1 条来自**相邻领域**的对照(见下方对照池);须与当前问题的**机制**相关,禁止用同技术栈换词重复主文。 +4. **带走**:1 条可复用的自检问句或 if-then 规则,供用户下次独立使用。 + +约束:不得用尾注替代主答案;不得拖延执行类请求;禁止说教;禁止重复主文已有内容。 + +## Tier 3:深潜(显式触发时追加) + +在 Tier 0 之后再加: + +- **心智模型**:(模型名 + 1 句如何用于本问题) +- **跨域类比**:(非本技术栈、机制对齐的 1 个类比) +- **验证动作**:(7 天内可做的 1 个具体动作) + +## 邻域对照池(任选 1 条,须与机制相关) + +- 并发 / UI 状态 → 分布式一致性、幂等、陈旧读 +- 性能 → 排队论、尾延迟、SRE 错误预算 +- 架构 → 康威定律、DDD 边界上下文 +- 测试 → 性质测试、故障注入 +- 排障 → 科学方法、贝叶斯更新、预验尸 +- 产品 / 协作 → 激励错位、Goodhart 定律 +- 安全 → 威胁建模、最小权限、纵深防御 + +## 跳过条件 + +用户明确「只要答案 / 不要延伸」;或任务无任何判断成分且用户仅需机械执行。 + +## 精简触发语 + +- `【深潜】` / `【拓展】` → Tier 3 +- `【认知对手模式】` / `【不要迎合】` / `【red team】` → Tier 2(非本文件) + +## 迎合自检(尾注写完快速过一遍) + +- [ ] 邻域对照是否只是换词重复主文? +- [ ] 「带走」是否是可操作的问句/规则,而非鸡汤? +- [ ] 盲区是否具体到可证伪,而非「可能有问题」? + +## 流程保障(超出单次 prompt) + +- **预测日志**:重要结论记录置信度 + 2 条可证伪条件 + 日期 +- **双会话**:新 Chat 只贴结论,专职 red team,不带原对话情绪 +- **每周一次【深潜】**:问「我本周反复出现的假设是什么?」 diff --git a/.cursor/rules/engineering-discipline.mdc b/.cursor/rules/engineering-discipline.mdc new file mode 100644 index 0000000..ab00e5d --- /dev/null +++ b/.cursor/rules/engineering-discipline.mdc @@ -0,0 +1,69 @@ +--- +description: 全局工程纪律:前置确认、单根因、四段式输出、最小修复(GR-002/003/004/005/007/008) +alwaysApply: true +--- + + + + +# 工程纪律(Engineering Discipline) + +本文件是 `engineering-discipline` skill 的细则真值。适用所有工程任务,不限平台与语言。 + +## GR-002 前置确认 + +对描述不清、上下文不足或存在歧义的问题,必须先以独立的"前置确认"块字面输出 ≥1 个具体问题,方可继续给出方案。 + +**触发条件(典型):** 模糊措辞 / 未给运行环境 / 未给复现条件 / 未给已尝试方案 / 未说受影响范围。 + +**格式要求:** 以独立块字面输出,段标题"前置确认"为机械校验 anchor;仅在散文中说"需要更多信息"或"建议补充"视为违反本规则。 + +**原则:** 能从工程或上下文读出的事实优先读,不要让用户重复输入;只问区分主假设所必需的最少问题;具体追问维度由对应任务的主读 ref 补完。 + +## GR-003 单根因锁定 + +默认先锁定 1 个最高概率根因或主路径,最多补充 1 个备选;不要同时展开多个大分支消耗上下文。 + +**适用:** 所有排障、根因分析、架构选型类任务。 + +**原则:** 有证据时提高概率权重;无法区分时先提 1 个最关键确认问题,而不是并行展开长篇猜测。 + +## GR-004 四段式输出 + +默认按以下四段输出,与平台无关: + +| 段 | 语义 | +|----|------| +| **根因**(结论) | 最高概率根因或主路径 | +| **为什么** | 支持该判断的证据或推理 | +| **修法** | 最小结构性修复步骤 | +| **验证** | 如何证明没有引入副作用 | + +若任务命中长模板要求,四段式作为摘要层,详细模板作为附加层。 + +**例外:** 代码审查 / PR Review 等 review 场景可使用 findings-first 格式;具体条件与骨架由平台 skill 定义(如 ios-engineer OUT-002)。 + +## GR-005 最小修复优先 + +先给最小可验证修复,不先提出整模块重写、架构翻新或大范围重构。 + +**原则:** 一次改动只解决当前已确认的问题;不添加假设的未来需求;不附带顺手清理。若最终确实需要大范围重构,先给出最小修复稳定当前问题,再单独讨论重构路径。 + +## GR-007 不格式化代码 + +不要格式化代码,除非明确要求格式化当前代码。 + +**原因:** 格式化是破坏性 diff 操作,在代码审查和重构中会掩盖真实改动,增加合并冲突风险。 + +## GR-008 变更覆盖声明 + +任何改动(排障修法 / 架构改动 / 并发迁移 / 性能优化 / 重构落地)必须声明三字段: + +```text +残留风险声明 +- 已覆盖:本次改动已经校验到的路径 / 场景 / 调用方 +- 未覆盖:明确没有验证到的路径 / 场景 / 调用方 +- 残留风险:即使上述都过了,仍可能出问题的假设 / 边界 / 依赖 +``` + +**要求:** 三字段必须作为独立段落字面存在,段标题"残留风险声明"为机械校验 anchor;不允许把三字段散写进"验证"段或合并成一段文字。不承诺"没有任何新风险"。 diff --git a/.cursor/rules/ios-engineer.mdc b/.cursor/rules/ios-engineer.mdc index 6488fb1..b133981 100644 --- a/.cursor/rules/ios-engineer.mdc +++ b/.cursor/rules/ios-engineer.mdc @@ -3,6 +3,37 @@ description: ios-engineer skill usage and audit rules alwaysApply: true --- +# global cognitive calibration + +所有任务中,遇到技术决策、架构取舍、根因归因、review 最终判断、用户强烈确信、或用户显式要求「挑战我 / 不要迎合 / red team」时,必须优先接近真实,而不是维持对话和谐。至少做到:复述核心主张、给出最强反驳、列出隐藏假设、说明失效条件和可证伪条件、做迎合自检;证据不足时说「不确定」,不要把未验证推断写成定论。 + +# global cognitive expansion + +所有任务须遵循 `cognitive-expansion` skill **全文**(不得用本段代替)。执行前必须先读取: + +- `~/.cursor/skills/cognitive-expansion/SKILL.md` +- `~/.cursor/skills/cognitive-expansion/references/cognitive_expansion.md` + +并按其中 Tier 0 / Tier 3、邻域对照池、跳过条件与迎合自检执行。Tier 2 认知对手见 ios-engineer `references/cognitive_adversary_mode.md`。 + +# global logical reasoning + +所有任务须遵循 `logical-reasoning` skill **全文**(不得用本段代替)。执行前必须先读取: + +- `~/.cursor/skills/logical-reasoning/SKILL.md` +- `~/.cursor/skills/logical-reasoning/references/logical_reasoning.md` + +并按其中 GR-010 规则执行:关键结论须指向上游前提;须区分事实/推断/建议/推测;高风险判断时输出独立「逻辑链」块(事实/证据、推断、结论强度、可证伪/缺口)。 + +# global engineering discipline + +所有任务须遵循 `engineering-discipline` skill **全文**(不得用本段代替)。执行前必须先读取: + +- `~/.cursor/skills/engineering-discipline/SKILL.md` +- `~/.cursor/skills/engineering-discipline/references/engineering_discipline.md` + +并按其中 GR-002/003/004/005/007/008 规则执行:描述不清时先输出前置确认块;锁定单一根因;按四段式输出;给最小修复;不格式化代码;声明已覆盖/未覆盖/残留风险。 + # ios-engineer skill usage 执行 iOS / Swift / SwiftUI / UIKit / Xcode 工程任务前,必须先加载并遵循 `ios-engineer` SKILL 规则(SKILL.md + references/rule_index.md 中 `status=active` 的 IR / SYM / ROUTE / OUT 条目)。 @@ -30,7 +61,7 @@ evolution-signal: `(默认省略 = null)。 -Rule ID 词表取自 `~/.cursor/skills/ios-engineer/references/rule_index.md`,仅使用 `status=active` 的 ID(IR-NNN / SYM-NNN / ROUTE-NNN / OUT-NNN)。完整 schema、写入协议、self-grading 偏差告示见同目录下 `usage_ledger.md` §1-§7。 +Rule ID 词表取自 `~/.cursor/skills/ios-engineer/references/rule_index.md`,仅使用 `status=active` 的 ID(IR-NNN / SYM-NNN / ROUTE-NNN / OUT-NNN / GR-NNN)。完整 schema、写入协议、self-grading 偏差告示见同目录下 `usage_ledger.md` §1-§7。 **非 iOS 工程任务不输出这个块**:写文档、答 API 问题、通用重构、元工程 / 自进化讨论 / SkillOps 维护本身都跳过。task-type 落不进 7 选 1 时也跳过。 diff --git a/.cursor/rules/logical-reasoning.mdc b/.cursor/rules/logical-reasoning.mdc new file mode 100644 index 0000000..a4c87ab --- /dev/null +++ b/.cursor/rules/logical-reasoning.mdc @@ -0,0 +1,124 @@ +--- +description: 全局论证纪律:可追溯逻辑链、四层区分、逻辑链输出块(GR-010) +alwaysApply: true +--- + + + + +# 逻辑性(Logical Reasoning) + +## 适用场景 + +本文件是 `logical-reasoning` skill **[GR-010]** 的细则真值。所有回复均须满足,与认知对手模式(挑战用户逻辑)正交:本文件约束 **AI 自身** 的论证质量。 + +## 什么是「逻辑性」 + +**逻辑性** ≠ 写得长、术语多、或语气肯定。 + +**逻辑性** = 读者能检验「你为什么得出这个结论」:前提可辨认、推理可跟随、结论强度与证据匹配、全文不自相矛盾。 + +## 六条检验标准(缺一不可) + +### 1. 可追溯(Traceable) + +每个**关键结论**(根因、选型、否定性判断、优先级排序)须能指向上游至少一项: + +- 已给出的事实(用户描述、日志、代码、文档) +- 已从工程读取的证据 +- 或你已明说的假设 / 推理步骤 + +无法追溯时,不得写成定论,须降级为「推测」或先触发前置确认。 + +### 2. 层级分明(Layered) + +同一回复内须区分四类表述,不得混用语气: + +| 层级 | 含义 | 表述要求 | +|------|------|----------| +| **事实** | 可核对、可复现 | 引用来源或观测点 | +| **推断** | 由事实推出的解释 | 标出「因为…所以…」或等价推理链 | +| **建议** | 行动方案 | 说明依据哪条推断 | +| **推测** | 证据不足时的假设 | 明示「推测 / 待验证」,不得用肯定句 | + +### 3. 推理可见(Explicit inference) + +对非显然的判断,至少写出 **一步** 可见推理(「因为 A,所以 B」),禁止: + +- 直接给结论而无任何中间环节 +- 用「显然 / 一般来说 / 业界惯例」代替针对当前问题的推理 + +复杂判断允许多步,但步骤之间不得跳档。 + +### 4. 内部一致(Internally consistent) + +同一回复内不得: + +- 前文承认「不确定 / 证据不足」,后文给出高置信定论 +- 对同一问题给出互斥结论而不说明适用条件 +- 立场变化时不说明触发条件(新证据 / 新约束) + +### 5. 因果克制(Causal discipline) + +- 不把**相关**当**因果**(「A 发生后 B 出现」≠「A 导致 B」) +- 多因并存时不强行单因归因(主因 + 最多 1 备选,须说明为何选主因) +- 不把时间先后当作因果证明 + +### 6. 强度匹配(Calibrated strength) + +结论语气须与证据强度一致: + +- 证据充分 → 可明确判断 +- 证据部分 → 带条件或置信度 +- 证据不足 → 「不确定」+ 缺什么信息,禁止用流畅散文伪装确定性 + +## 逻辑链输出块(高风险场景必需) + +以下任一情况必须输出独立的 `逻辑链` 块: + +- 技术决策、架构取舍、根因归因、性能归因、review 最终判断 +- 用户强烈确信或显式要求挑战观点 + +字段必须齐全,且每个字段至少包含一个当前任务的具体内容,不得只写模板词: + +```text +逻辑链 +事实/证据:<来自用户描述、代码、日志、文档或明确假设的上游前提> +推断:<因为 A,所以 B;若只是推测,必须写"推测/待验证"> +结论强度:<明确 / 条件成立时明确 / 不确定,并说明证据强度> +可证伪/缺口:<什么证据会推翻该判断,或还缺什么信息> +``` + +该块不是为了拉长回复,而是把"我为什么这样判断"变成可审计对象。短任务可每字段一句。 + +## 常见逻辑缺陷(禁止) + +| 缺陷 | 表现 | 应改为 | +|------|------|--------| +| 结论先行 | 先定结论再凑理由 | 先列证据,再推导 | +| 循环论证 | 用结论证明结论 | 引入独立前提或外部证据 | +| 偷换概念 | 前后「它」指代不同 | 同一概念用同一术语 | +| 以权威代论证 | 「最佳实践是…」无当前上下文 | 说明为何适用于本场景 | +| 单样本泛化 | 一次偶现推全体用户 | 限定边界与样本量 | +| 虚假二分 | 「只能 A 或 B」忽略 C | 列出实际可选集 | +| 诉诸复杂 | 堆术语掩盖推理空洞 | 用 plain language 写清一步推理 | + +## 与相邻纪律的关系 + +| 纪律 | 分工 | +|------|------| +| 输出结构约束(如四段式) | 规定**结构**(根因 → 为什么 → 修法 → 验证) | +| **[GR-010](本规则)** | 结构内**论证质量**(前提、推理、层级、一致) | +| 数量约束 | **数量**(1 主路径 + 最多 1 备选) | +| 认知对手模式 | **挑战用户**结论的逻辑与假设 | +| 根因取证纪律 | 排障场景的**证据链**纪律 | + +## 自检清单(回复前快速过一遍) + +- [ ] 每个关键结论都能指回事实、证据或假设吗? +- [ ] 事实 / 推断 / 建议 / 推测有没有混写成一种语气? +- [ ] 非显然判断有没有至少一步「因为…所以…」? +- [ ] 全文有没有前后矛盾? +- [ ] 有没有把相关当因果、或过度单因归因? +- [ ] 证据不足的段落有没有说成「不确定」而不是假装确定? +- [ ] 高风险场景是否输出了 `逻辑链` 块,且四个字段不是空模板? diff --git a/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift index ad2fb45..bd9481d 100644 --- a/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift +++ b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift @@ -8,14 +8,46 @@ import UIKit import STBaseProject +/// 从 Bundle 读取 `Resources/data1~3.txt`,通过 ``STMarkdownStreamingTextView`` 智能流式 API 逐字渲染。 final class STMarkdownStreamingTestViewController: BaseViewController { + private enum StreamSpeed: CaseIterable { + case slow + case normal + case fast + + var interval: TimeInterval { + switch self { + case .slow: return 0.04 + case .normal: return 0.02 + case .fast: return 0.008 + } + } + + var step: Int { + switch self { + case .slow: return 1 + case .normal: return 2 + case .fast: return 4 + } + } + + var title: String { + switch self { + case .slow: return "慢" + case .normal: return "中" + case .fast: return "快" + } + } + } + + private static let fixtureNames = ["data1", "data2", "data3"] + private var typewriterTimer: Timer? private var fullMarkdownText: String = "" private var currentIndex: Int = 0 - - private let typingInterval: TimeInterval = 0.02 - private let typingStep: Int = 1 + private var isPaused = false + private var streamSpeed: StreamSpeed = .normal override func viewDidLoad() { super.viewDidLoad() @@ -29,42 +61,99 @@ final class STMarkdownStreamingTestViewController: BaseViewController { self.stopTypewriter() } + // MARK: - UI + private func buildUI() { + let controlStack = UIStackView(arrangedSubviews: [ + self.speedControl, + self.pauseButton, + self.reloadButton + ]) + controlStack.translatesAutoresizingMaskIntoConstraints = false + controlStack.axis = .horizontal + controlStack.spacing = 8 + controlStack.distribution = .fillEqually + + self.view.addSubview(self.statusLabel) + self.view.addSubview(self.progressView) self.view.addSubview(self.renderView) - self.view.addSubview(self.reloadButton) + self.view.addSubview(controlStack) NSLayoutConstraint.activate([ - self.reloadButton.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), - self.reloadButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), - self.reloadButton.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12), - self.reloadButton.heightAnchor.constraint(equalToConstant: 44), + self.statusLabel.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 8), + self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + self.statusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), - self.renderView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.progressView.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 8), + self.progressView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + self.progressView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + + controlStack.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + controlStack.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + controlStack.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + controlStack.heightAnchor.constraint(equalToConstant: 40), + + self.renderView.topAnchor.constraint(equalTo: self.progressView.bottomAnchor, constant: 12), self.renderView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), self.renderView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), - self.renderView.bottomAnchor.constraint(equalTo: self.reloadButton.topAnchor, constant: -12) + self.renderView.bottomAnchor.constraint(equalTo: controlStack.topAnchor, constant: -12) ]) self.applyLiquidGlassScrollLayout(self.renderView.contentTextView) } + // MARK: - Actions + + @objc private func speedChanged(_ sender: UISegmentedControl) { + let index = sender.selectedSegmentIndex + guard index >= 0, index < StreamSpeed.allCases.count else { return } + self.streamSpeed = StreamSpeed.allCases[index] + if self.typewriterTimer != nil { + self.restartTimerIfNeeded() + } + } + + @objc private func pauseTapped() { + self.isPaused.toggle() + self.pauseButton.setTitle(self.isPaused ? "继续" : "暂停", for: .normal) + self.updateStatusLabel() + } + @objc private func restartRenderTapped() { self.startRendering() } + // MARK: - Streaming + private func startRendering() { self.stopTypewriter() - self.fullMarkdownText = self.loadAllFixtures() + self.isPaused = false + self.pauseButton.setTitle("暂停", for: .normal) self.currentIndex = 0 + + self.fullMarkdownText = self.loadAllFixtures() self.renderView.reset() - guard !self.fullMarkdownText.isEmpty else { - self.renderView.setMarkdown("资源读取失败,请检查 data1~3.txt 是否已加入主工程 Bundle。", animated: false) + guard self.fullMarkdownText.isEmpty == false else { + self.renderView.setMarkdown( + "资源读取失败,请确认 `Resources/data1~3.txt` 已加入主工程 Bundle。", + animated: false + ) + self.progressView.progress = 0 + self.statusLabel.text = "加载失败" return } + self.renderView.beginSmartMarkdownStreaming() + self.updateStatusLabel() + self.restartTimerIfNeeded() + } + + private func restartTimerIfNeeded() { + guard self.currentIndex < self.fullMarkdownText.count else { return } + self.stopTypewriter() self.typewriterTimer = Timer.scheduledTimer( - timeInterval: self.typingInterval, + timeInterval: self.streamSpeed.interval, target: self, selector: #selector(self.handleTypewriterTick), userInfo: nil, @@ -81,34 +170,110 @@ final class STMarkdownStreamingTestViewController: BaseViewController { } @objc private func handleTypewriterTick() { + guard self.isPaused == false else { return } guard self.currentIndex < self.fullMarkdownText.count else { - self.stopTypewriter() + self.completeStreaming() return } - let next = min(self.currentIndex + self.typingStep, self.fullMarkdownText.count) + + let next = min(self.currentIndex + self.streamSpeed.step, self.fullMarkdownText.count) + let start = self.fullMarkdownText.index(self.fullMarkdownText.startIndex, offsetBy: self.currentIndex) let end = self.fullMarkdownText.index(self.fullMarkdownText.startIndex, offsetBy: next) - let prefix = String(self.fullMarkdownText[.. 0 else { return } + let targetY = max(-textView.contentInset.top, bottom) + if abs(textView.contentOffset.y - targetY) > 4 { + textView.setContentOffset(CGPoint(x: 0, y: targetY), animated: false) + } + } + + // MARK: - Resources + private func loadAllFixtures() -> String { - let names = ["data1", "data2", "data3"] - let texts = names.compactMap { name in + let sections = Self.fixtureNames.compactMap { name -> String? in self.readFixture(named: name).map { "## \(name).txt\n\n\($0)" } } - return texts.joined(separator: "\n\n---\n\n") + return sections.joined(separator: "\n\n---\n\n") } private func readFixture(named name: String) -> String? { - guard let path = Bundle.main.path(forResource: name, ofType: "txt") else { + guard let url = Bundle.main.url(forResource: name, withExtension: "txt") else { return nil } - return try? String(contentsOfFile: path, encoding: .utf8) + return try? String(contentsOf: url, encoding: .utf8) } + // MARK: - Subviews + + private lazy var statusLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textColor = .secondaryLabel + label.numberOfLines = 2 + label.text = "准备加载…" + return label + }() + + private lazy var progressView: UIProgressView = { + let view = UIProgressView(progressViewStyle: .default) + view.translatesAutoresizingMaskIntoConstraints = false + view.progress = 0 + return view + }() + private lazy var renderView: STMarkdownStreamingTextView = { - let view = STMarkdownStreamingTextView(style: .default, advancedRenderers: .empty, engine: STMarkdownEngine()) + let view = STMarkdownStreamingTextView( + style: .default, + advancedRenderers: .empty, + engine: STMarkdownEngine() + ) view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .secondarySystemBackground view.layer.cornerRadius = 8 @@ -119,10 +284,27 @@ final class STMarkdownStreamingTestViewController: BaseViewController { return view }() + private lazy var speedControl: UISegmentedControl = { + let control = UISegmentedControl(items: StreamSpeed.allCases.map(\.title)) + control.translatesAutoresizingMaskIntoConstraints = false + control.selectedSegmentIndex = 1 + control.addTarget(self, action: #selector(self.speedChanged), for: .valueChanged) + return control + }() + + private lazy var pauseButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("暂停", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.addTarget(self, action: #selector(self.pauseTapped), for: .touchUpInside) + return button + }() + private lazy var reloadButton: UIButton = { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle("重新逐字渲染", for: .normal) + button.setTitle("重新渲染", for: .normal) button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) button.addTarget(self, action: #selector(self.restartRenderTapped), for: .touchUpInside) return button diff --git a/Sources/STUIKit/STButton/STBtn.swift b/Sources/STUIKit/STButton/STBtn.swift index 4a6fedd..9ec342f 100644 --- a/Sources/STUIKit/STButton/STBtn.swift +++ b/Sources/STUIKit/STButton/STBtn.swift @@ -162,9 +162,6 @@ open class STBtn: UIButton { } } - /// 是否将 `titleLabel.font` 替换为项目级字体 `UIFont.st_systemFont(ofSize:)`。 - /// - /// ⚠️ 命名保留是为向后兼容,**不是 `adjustsFontSizeToFitWidth` 语义的自动缩放**: /// 它只在开启时读取当前字号、用项目字体重建一个同字号的 `UIFont`,用于统一品牌字体。 /// 如需"文本自动缩放以适配宽度",请另设 `titleLabel?.adjustsFontSizeToFitWidth` 与 `minimumScaleFactor`。 @IBInspectable open var autoAdaptFontSize: Bool = true { @@ -298,49 +295,41 @@ open class STBtn: UIButton { self.installModernButtonConfiguration() } - /// 安装现代化 `UIButton.Configuration`,以及 update handler 用于: - /// - 注入 `titleLabel.font` 到 `attributedTitle`(存量调用点用 `titleLabel.font` 设字体) + /// `UIButton.Configuration`,以及 update handler 用于: + /// - 注入 `titleLabel.font` 到 `attributedTitle` /// - 根据 `suppressesSystemStateEffects` 选择性屏蔽系统状态效果 /// - 同步 corner radius 与 state 背景色 /// - 回调 `onConfigurationUpdate` 扩展点 /// - /// **不管理 `contentInsets`** —— 调用方直接写 `configuration?.contentInsets` 或通过 `onConfigurationUpdate` 修改。 private func installModernButtonConfiguration() { if self.configuration == nil { + let xibFont = self.titleLabel?.font self.configuration = UIButton.Configuration.plain() + if let font = xibFont { + self.titleLabel?.font = font + } } self.configurationUpdateHandler = { [weak self] button in guard let self, var config = button.configuration else { return } - // 单行 + 尾部省略,匹配迁移前 UIButton 的默认行为; - // Configuration 默认允许多行,中文无词边界会被按字符纵向拆开 config.titleLineBreakMode = .byTruncatingTail config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { [weak self] attrs in - // Configuration 生效后 titleLabel.font 不再驱动渲染; - // 保留 titleLabel.font 作为字体入口(大量存量调用点都这么写), - // 通过 transformer 每次渲染时把它注入到 attributedTitle —— 字体注入**永远执行**, - // 与 `suppressesSystemStateEffects` 无关,因为它不是状态效果、只是字体来源适配。 var updated = attrs guard let self else { return updated } if let font = self.titleLabel?.font { updated.font = font } - // 仅在开关打开时显式接管 foregroundColor,阻断系统在 highlighted/disabled 下 - // 对标题色做 alpha 衰减 / 灰化;关闭时(默认)保留系统原生状态反馈。 if self.suppressesSystemStateEffects, let color = self.titleColor(for: self.state) { updated.foregroundColor = color } return updated } - // 图标 tint 的系统变换:开关打开时关掉系统在 highlighted/disabled 下的 alpha 衰减。 if self.suppressesSystemStateEffects { config.imageColorTransformer = UIConfigurationColorTransformer { $0 } } self.refineButtonConfiguration(button, configuration: &config) self.onConfigurationUpdate?(button, &config) button.configuration = config - // Configuration 应用 attributedTitle 时会回写 titleLabel,可能把 numberOfLines 重置成 0; - // 这里重新锁回单行,保证中文窄 label 不会按字符拆行 self.titleLabel?.numberOfLines = 1 self.titleLabel?.lineBreakMode = .byTruncatingTail } @@ -369,17 +358,10 @@ open class STBtn: UIButton { /// 子类(如 `STIconBtn`)覆写此方法以写入图文布局 / `contentInsets` 等 Configuration 字段。 /// 调用时机:每次 `configurationUpdateHandler` 触发,在字体/状态 transformer 之后、`onConfigurationUpdate` 之前。 open func refineButtonConfiguration(_ button: UIButton, configuration config: inout UIButton.Configuration) { - // 让 Configuration 管理的 background 子视图与 `layer.cornerRadius` 对齐, - // 避免 `masksToBounds = false`(如 `st_setShadow`)或 `config.background.backgroundColor` - // 非空时背景按 0 半径绘制、把 `layer.cornerRadius` 盖住。 config.background.cornerRadius = self.layer.cornerRadius let resolvedBackgroundColor = self.resolvedStateBackgroundColor(for: button.state) - // 开关打开时阻断系统在 highlighted/selected 下对 background 的 tint 过渡; - // 关闭时(默认)保留系统原生反馈,`filled/tinted/gray` 等 preset 表现等同 UIButton。 if self.suppressesSystemStateEffects { config.background.backgroundColorTransformer = UIConfigurationColorTransformer { $0 } - // `plain()` 风格在 selected/highlighted 下可能仍会绘制系统态底色; - // 当调用方未显式提供状态背景时,主动清为透明,确保视觉完全由外界接管。 if resolvedBackgroundColor == nil { config.baseBackgroundColor = .clear config.background.backgroundColor = .clear @@ -388,10 +370,7 @@ open class STBtn: UIButton { if let color = resolvedBackgroundColor { config.background.backgroundColor = color self.lastManagedBackgroundColor = color - } else if let managed = self.lastManagedBackgroundColor, - config.background.backgroundColor == managed { - // 调用方把 `stateBackgroundColors` 全清空了:仅当"当前背景色仍是我们上次写入的值"才清除, - // 避免把 `onConfigurationUpdate` 等外部路径写入的背景色误覆盖。 + } else if let managed = self.lastManagedBackgroundColor, config.background.backgroundColor == managed { config.background.backgroundColor = nil self.lastManagedBackgroundColor = nil } From 37bdfca4fabb609fbcf1da93f18b87b1e264ba3f Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 21 May 2026 16:31:08 +0800 Subject: [PATCH 16/27] Enhance STMarkdownTableView with expand table functionality - Added `onExpandTable` callback to `STMarkdownTableView` and related components to support table expansion interactions. - Implemented gesture handling for expanding tables when selecting cells without citations. - Updated unit tests to verify the correct behavior of table expansion and citation interactions. - Enhanced gradient visibility management for scrollable tables to improve user experience. --- .../STMarkdownFixTests.swift | 109 ++++++++++++++++++ .../STMarkdownTableAttachmentRenderer.swift | 8 +- .../STMarkdownTableDetailViewController.swift | 101 ++++++++++++++++ .../STMarkdownTableOverlayCoordinator.swift | 10 ++ .../Table/STMarkdownTableView.swift | 83 ++++++++++++- .../Table/STMarkdownTableViewAttachment.swift | 14 ++- .../UI/STMarkdownBaseTextView.swift | 6 + .../STMarkdown/UI/STMarkdownSwiftUIView.swift | 10 +- 8 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 Sources/STMarkdown/Table/STMarkdownTableDetailViewController.swift diff --git a/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift b/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift index f9b67b0..7746565 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownFixTests.swift @@ -3,6 +3,7 @@ // 1. STMarkdownMermaidRenderer.cacheKey 使用完整代码字符串,不再用 hashValue,消除碰撞风险 // 2. STMarkdownTableViewModel 使用静态正则常量,不再在热路径重复编译 // 3. STMarkdownTableView 添加 UICollectionViewDelegate,使 onCitationTap 回调可正常触发 +// 4. STMarkdownTableView 支持表格展开回调,宿主可呈现全屏详情页 import XCTest import UIKit @@ -372,4 +373,112 @@ final class STMarkdownTableViewCitationTapTests: XCTestCase { ) XCTAssertEqual(tappedCitation, "42", "第 1 列应触发 Citation:42 回调") } + + func testExpandTableTriggeredWhenSelectingCellWithoutCitation() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("普通文本")]]] + ) + let viewModel = STMarkdownTableViewModel(from: table, style: style) + tableView.tableData = viewModel + + var expandedModel: STMarkdownTableViewModel? + tableView.onExpandTable = { expandedModel = $0 } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + + XCTAssertTrue(expandedModel === viewModel, "点击无 citation 的 cell 应触发表格展开回调") + } + + func testExpandTableNotTriggeredWhenSelectingCitationCell() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + let table = STMarkdownTableModel( + header: nil, + rows: [[[.link(destination: "", children: [.text("Citation:7")])]]] + ) + tableView.tableData = STMarkdownTableViewModel(from: table, style: style) + + var expandCallCount = 0 + var tappedCitation: String? + tableView.onExpandTable = { _ in expandCallCount += 1 } + tableView.onCitationTap = { tappedCitation = $0 } + + tableView.frame = CGRect(x: 0, y: 0, width: 375, height: 200) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + collectionView?.delegate?.collectionView?( + collectionView!, + didSelectItemAt: IndexPath(item: 0, section: 0) + ) + + XCTAssertEqual(expandCallCount, 0, "citation cell 点击应优先走 citation 回调,不应误触发表格展开") + XCTAssertEqual(tappedCitation, "7") + } + + func testHorizontalGradientHintHiddenForNonScrollableTable() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + let table = STMarkdownTableModel( + header: nil, + rows: [[[.text("A")], [.text("B")]]] + ) + tableView.tableData = STMarkdownTableViewModel(from: table, style: style) + + tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 80) + tableView.layoutIfNeeded() + + let leftOpacity = self.gradientLayer(named: "STMarkdownTableLeftGradient", in: tableView)?.opacity + let rightOpacity = self.gradientLayer(named: "STMarkdownTableRightGradient", in: tableView)?.opacity + XCTAssertEqual(leftOpacity, 0, "窄表不应显示左侧渐变提示") + XCTAssertEqual(rightOpacity, 0, "窄表不应显示右侧渐变提示") + } + + func testHorizontalGradientHintShownInitiallyForScrollableTable() { + let style = STMarkdownStyle.default + let tableView = STMarkdownTableView(style: style) + let wideText = String(repeating: "宽表内容", count: 24) + let table = STMarkdownTableModel( + header: nil, + rows: [[ + [.text(wideText)], + [.text(wideText)], + [.text(wideText)] + ]] + ) + tableView.tableData = STMarkdownTableViewModel(from: table, style: style) + + tableView.frame = CGRect(x: 0, y: 0, width: 220, height: 80) + tableView.layoutIfNeeded() + + let collectionView = tableView.subviews.compactMap { $0 as? UICollectionView }.first + XCTAssertNotNil(collectionView) + + let leftLayer = self.gradientLayer(named: "STMarkdownTableLeftGradient", in: tableView) + let rightLayer = self.gradientLayer(named: "STMarkdownTableRightGradient", in: tableView) + XCTAssertEqual(leftLayer?.opacity, 0, "初始位于最左侧时不应显示左侧渐变") + XCTAssertEqual(rightLayer?.opacity, 1, "宽表首次出现时应显示右侧渐变提示") + + guard let collectionView else { return } + let maxOffsetX = max(0, collectionView.contentSize.width - collectionView.bounds.width) + collectionView.contentOffset = CGPoint(x: maxOffsetX, y: 0) + collectionView.delegate?.scrollViewDidScroll?(collectionView) + + XCTAssertEqual(leftLayer?.opacity, 1, "滚到最右侧后应显示左侧渐变") + XCTAssertEqual(rightLayer?.opacity, 0, "滚到最右侧后应隐藏右侧渐变") + } + + private func gradientLayer(named name: String, in view: UIView) -> CAGradientLayer? { + view.layer.sublayers?.first(where: { $0.name == name }) as? CAGradientLayer + } } diff --git a/Sources/STMarkdown/Table/STMarkdownTableAttachmentRenderer.swift b/Sources/STMarkdown/Table/STMarkdownTableAttachmentRenderer.swift index bf7ebff..aa6f2ce 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableAttachmentRenderer.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableAttachmentRenderer.swift @@ -11,9 +11,14 @@ public struct STMarkdownTableAttachmentRenderer: STMarkdownTableRendering { /// 用于构建 STMarkdownTableViewModel 时传入的 advancedRenderers(主要为 inlineMath 渲染) public var advancedRenderers: STMarkdownAdvancedRenderers + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? - public init(advancedRenderers: STMarkdownAdvancedRenderers = .empty) { + public init( + advancedRenderers: STMarkdownAdvancedRenderers = .empty, + onExpandTable: ((STMarkdownTableViewModel) -> Void)? = nil + ) { self.advancedRenderers = advancedRenderers + self.onExpandTable = onExpandTable } public func renderTable(_ table: STMarkdownTableModel, style: STMarkdownStyle) -> NSAttributedString? { @@ -31,6 +36,7 @@ public struct STMarkdownTableAttachmentRenderer: STMarkdownTableRendering { style: style, containerWidth: containerWidth > 0 ? containerWidth : 300 ) + attachment.onExpandTable = self.onExpandTable return NSAttributedString(attachment: attachment) } } diff --git a/Sources/STMarkdown/Table/STMarkdownTableDetailViewController.swift b/Sources/STMarkdown/Table/STMarkdownTableDetailViewController.swift new file mode 100644 index 0000000..e2854a5 --- /dev/null +++ b/Sources/STMarkdown/Table/STMarkdownTableDetailViewController.swift @@ -0,0 +1,101 @@ +// +// STMarkdownTableDetailViewController.swift +// STBaseProject +// +// Created by Codex on 2026/05/21. +// + +import UIKit + +public final class STMarkdownTableDetailViewController: UIViewController { + + public let tableViewModel: STMarkdownTableViewModel + public let style: STMarkdownStyle + public let collectionSize: CGSize? + public var onCitationTap: ((String) -> Void)? { + didSet { + if self.isViewLoaded { + self.tableView.onCitationTap = self.onCitationTap + } + } + } + + private lazy var closeButton: UIButton = { + let button = UIButton(type: .system) + let imageConfig = UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold) + button.setImage(UIImage(systemName: "xmark", withConfiguration: imageConfig), for: .normal) + button.tintColor = self.style.textColor + button.addTarget(self, action: #selector(self.handleClose), for: .touchUpInside) + return button + }() + + private lazy var tableView: STMarkdownTableView = { + let view = STMarkdownTableView(style: self.style) + view.tableData = self.tableViewModel + return view + }() + + public init( + tableViewModel: STMarkdownTableViewModel, + style: STMarkdownStyle, + collectionSize: CGSize? = nil + ) { + self.tableViewModel = tableViewModel + self.style = style + self.collectionSize = collectionSize + super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + self.setupUI() + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let collectionSize else { return } + self.preferredContentSize = collectionSize + } + + private func setupUI() { + self.view.backgroundColor = self.style.tableBackgroundColor ?? UIColor.systemBackground + + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(contentView) + + self.closeButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(self.closeButton) + + self.tableView.translatesAutoresizingMaskIntoConstraints = false + self.tableView.onCitationTap = self.onCitationTap + contentView.addSubview(self.tableView) + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + + self.closeButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + self.closeButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + self.closeButton.widthAnchor.constraint(equalToConstant: 36), + self.closeButton.heightAnchor.constraint(equalToConstant: 36), + + self.tableView.topAnchor.constraint(equalTo: self.closeButton.bottomAnchor, constant: 12), + self.tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + self.tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + self.tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) + ]) + } + + @objc private func handleClose() { + self.dismiss(animated: true) + } +} diff --git a/Sources/STMarkdown/Table/STMarkdownTableOverlayCoordinator.swift b/Sources/STMarkdown/Table/STMarkdownTableOverlayCoordinator.swift index 7ee2247..ba4de9a 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableOverlayCoordinator.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableOverlayCoordinator.swift @@ -10,6 +10,7 @@ import UIKit final class STMarkdownTableOverlayCoordinator { var onCitationTap: ((String) -> Void)? + var onExpandTable: ((STMarkdownTableViewModel) -> Void)? private weak var textView: UITextView? private var tableViewOverlays: [Int: STMarkdownTableView] = [:] @@ -80,12 +81,21 @@ final class STMarkdownTableOverlayCoordinator { if existing.tableData !== attachment.tableViewModel { existing.tableData = attachment.tableViewModel } + existing.onCitationTap = { [weak self] number in + self?.onCitationTap?(number) + } + existing.onExpandTable = { [weak self] tableViewModel in + self?.onExpandTable?(tableViewModel) + } existing.frame = frame } else { let tableView = attachment.tableView tableView.onCitationTap = { [weak self] number in self?.onCitationTap?(number) } + tableView.onExpandTable = { [weak self] tableViewModel in + self?.onExpandTable?(tableViewModel) + } tableView.frame = frame textView.addSubview(tableView) self.tableViewOverlays[charIndex] = tableView diff --git a/Sources/STMarkdown/Table/STMarkdownTableView.swift b/Sources/STMarkdown/Table/STMarkdownTableView.swift index 8f23eac..e508473 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableView.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableView.swift @@ -23,11 +23,16 @@ public final class STMarkdownTableView: UIView { } public var onCitationTap: ((String) -> Void)? + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? private let gridLayout: STMarkdownTableGridLayout private let collectionView: UICollectionView + private let leftGradientLayer = CAGradientLayer() + private let rightGradientLayer = CAGradientLayer() private let cellInsets = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) + private let gradientOverlayWidth: CGFloat = 24 + private let gradientVisibilityThreshold: CGFloat = 1 // MARK: - Init @@ -36,6 +41,7 @@ public final class STMarkdownTableView: UIView { self.gridLayout = STMarkdownTableGridLayout() self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.gridLayout) super.init(frame: .zero) + self.setupGradientLayers() self.setupCollectionView() self.applyStyle() } @@ -59,6 +65,8 @@ public final class STMarkdownTableView: UIView { self.collectionView.isScrollEnabled = true self.collectionView.scrollsToTop = false self.collectionView.contentInset = .zero + let expandGesture = UILongPressGestureRecognizer(target: self, action: #selector(self.handleExpandGesture(_:))) + self.collectionView.addGestureRecognizer(expandGesture) self.addSubview(self.collectionView) self.gridLayout.sizeForItem = { [weak self] indexPath in @@ -66,12 +74,30 @@ public final class STMarkdownTableView: UIView { } } + private func setupGradientLayers() { + self.leftGradientLayer.name = "STMarkdownTableLeftGradient" + self.leftGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + self.leftGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + self.leftGradientLayer.opacity = 0 + + self.rightGradientLayer.name = "STMarkdownTableRightGradient" + self.rightGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + self.rightGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + self.rightGradientLayer.opacity = 0 + + self.layer.addSublayer(self.leftGradientLayer) + self.layer.addSublayer(self.rightGradientLayer) + } + private func applyStyle() { let borderColor = self.style.tableBorderColor ?? UIColor.separator self.collectionView.backgroundColor = borderColor self.backgroundColor = borderColor self.gridLayout.interItemSpacing = 0.5 self.gridLayout.lineSpacing = 0.5 + let overlayColor = (self.style.tableBackgroundColor ?? UIColor.secondarySystemBackground).cgColor + self.leftGradientLayer.colors = [overlayColor, UIColor.clear.cgColor] + self.rightGradientLayer.colors = [UIColor.clear.cgColor, overlayColor] } // MARK: - Layout @@ -81,6 +107,8 @@ public final class STMarkdownTableView: UIView { if self.collectionView.frame != self.bounds { self.collectionView.frame = self.bounds } + self.layoutGradientLayers() + self.updateHorizontalScrollHints() } public override func sizeThatFits(_ size: CGSize) -> CGSize { @@ -129,6 +157,7 @@ public final class STMarkdownTableView: UIView { self.gridLayout.invalidateLayout() self.collectionView.reloadData() self.invalidateIntrinsicContentSize() + self.setNeedsLayout() } private func sizeForItem(at indexPath: IndexPath) -> CGSize { @@ -144,19 +173,69 @@ public final class STMarkdownTableView: UIView { contentInsets: self.cellInsets ) } + + private func layoutGradientLayers() { + guard self.bounds.width > 0, self.bounds.height > 0 else { return } + self.leftGradientLayer.frame = CGRect( + x: 0, + y: 0, + width: self.gradientOverlayWidth, + height: self.bounds.height + ) + self.rightGradientLayer.frame = CGRect( + x: self.bounds.width - self.gradientOverlayWidth, + y: 0, + width: self.gradientOverlayWidth, + height: self.bounds.height + ) + } + + private func updateHorizontalScrollHints() { + self.collectionView.layoutIfNeeded() + let visibleWidth = self.collectionView.bounds.width + let scrollableWidth = self.collectionView.contentSize.width + let maxOffsetX = max(0, scrollableWidth - visibleWidth) + + guard visibleWidth > 0, maxOffsetX > self.gradientVisibilityThreshold else { + self.leftGradientLayer.opacity = 0 + self.rightGradientLayer.opacity = 0 + return + } + + let offsetX = min(max(self.collectionView.contentOffset.x, 0), maxOffsetX) + self.leftGradientLayer.opacity = offsetX > self.gradientVisibilityThreshold ? 1 : 0 + self.rightGradientLayer.opacity = offsetX < (maxOffsetX - self.gradientVisibilityThreshold) ? 1 : 0 + } + + @objc private func handleExpandGesture(_ gestureRecognizer: UILongPressGestureRecognizer) { + guard gestureRecognizer.state == .began else { return } + self.expandTableIfPossible() + } + + private func expandTableIfPossible() { + guard let tableData else { return } + self.onExpandTable?(tableData) + } } // MARK: - UICollectionViewDelegate extension STMarkdownTableView: UICollectionViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateHorizontalScrollHints() + } + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) guard let tableData, indexPath.section < tableData.cells.count, indexPath.item < tableData.cells[indexPath.section].count else { return } let citations = tableData.cells[indexPath.section][indexPath.item].citations - guard let first = citations.first else { return } - self.onCitationTap?(first) + if let first = citations.first { + self.onCitationTap?(first) + return + } + self.onExpandTable?(tableData) } } diff --git a/Sources/STMarkdown/Table/STMarkdownTableViewAttachment.swift b/Sources/STMarkdown/Table/STMarkdownTableViewAttachment.swift index c1b0598..44b2c35 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableViewAttachment.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableViewAttachment.swift @@ -17,7 +17,16 @@ public final class STMarkdownTableViewAttachment: NSTextAttachment { public let tableViewModel: STMarkdownTableViewModel public let style: STMarkdownStyle public let containerWidth: CGFloat - public var onCitationTap: ((String) -> Void)? + public var onCitationTap: ((String) -> Void)? { + didSet { + self._tableView?.onCitationTap = self.onCitationTap + } + } + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? { + didSet { + self._tableView?.onExpandTable = self.onExpandTable + } + } private var _tableView: STMarkdownTableView? private var cachedSize: CGSize? @@ -29,6 +38,9 @@ public final class STMarkdownTableViewAttachment: NSTextAttachment { view.onCitationTap = { [weak self] number in self?.onCitationTap?(number) } + view.onExpandTable = { [weak self] tableViewModel in + self?.onExpandTable?(tableViewModel) + } self._tableView = view return view } diff --git a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift index bf5b915..b1db9a1 100644 --- a/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownBaseTextView.swift @@ -54,6 +54,12 @@ public class STMarkdownBaseTextView: UIView, STMarkdownInteractable { } } + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? { + didSet { + self.tableOverlayCoordinator.onExpandTable = self.onExpandTable + } + } + public var isTextSelectionEnabled: Bool { get { self.textView.isSelectable } set { self.textView.isSelectable = newValue } diff --git a/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift b/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift index 0f1783a..df9efdc 100644 --- a/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift +++ b/Sources/STMarkdown/UI/STMarkdownSwiftUIView.swift @@ -18,6 +18,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { public var onFootnoteTap: ((String) -> Void)? public var onSelectionChange: ((String) -> Void)? public var onCitationTap: ((String) -> Void)? + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? public init( markdown: String, @@ -28,7 +29,8 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { onLinkTap: ((URL) -> Void)? = nil, onFootnoteTap: ((String) -> Void)? = nil, onSelectionChange: ((String) -> Void)? = nil, - onCitationTap: ((String) -> Void)? = nil + onCitationTap: ((String) -> Void)? = nil, + onExpandTable: ((STMarkdownTableViewModel) -> Void)? = nil ) { self.markdown = markdown self.style = style @@ -39,6 +41,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { self.onFootnoteTap = onFootnoteTap self.onSelectionChange = onSelectionChange self.onCitationTap = onCitationTap + self.onExpandTable = onExpandTable } public func makeUIView(context: Context) -> STMarkdownTextView { @@ -94,6 +97,7 @@ public struct STMarkdownSwiftUIView: UIViewRepresentable { view.onFootnoteTap = self.onFootnoteTap view.onSelectionChange = self.onSelectionChange view.onCitationTap = self.onCitationTap + view.onExpandTable = self.onExpandTable } } @@ -125,6 +129,7 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { public var onFootnoteTap: ((String) -> Void)? public var onSelectionChange: ((String) -> Void)? public var onCitationTap: ((String) -> Void)? + public var onExpandTable: ((STMarkdownTableViewModel) -> Void)? /// 与 ``STMarkdownBaseTextView/onTableOfContentsChange`` 一致:目录随渲染刷新(含流式每帧)。 public var onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? @@ -143,6 +148,7 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { onFootnoteTap: ((String) -> Void)? = nil, onSelectionChange: ((String) -> Void)? = nil, onCitationTap: ((String) -> Void)? = nil, + onExpandTable: ((STMarkdownTableViewModel) -> Void)? = nil, onTableOfContentsChange: (([STMarkdownTOCItem]) -> Void)? = nil ) { self.markdown = markdown @@ -159,6 +165,7 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { self.onFootnoteTap = onFootnoteTap self.onSelectionChange = onSelectionChange self.onCitationTap = onCitationTap + self.onExpandTable = onExpandTable self.onTableOfContentsChange = onTableOfContentsChange } @@ -216,6 +223,7 @@ public struct STMarkdownStreamingSwiftUIView: UIViewRepresentable { view.onFootnoteTap = self.onFootnoteTap view.onSelectionChange = self.onSelectionChange view.onCitationTap = self.onCitationTap + view.onExpandTable = self.onExpandTable view.onTableOfContentsChange = self.onTableOfContentsChange } From 42f043aadc3589794490c3bac45355ddd18a5d2c Mon Sep 17 00:00:00 2001 From: stack Date: Thu, 21 May 2026 16:50:31 +0800 Subject: [PATCH 17/27] Add HighlightJS rendering preset to STMarkdownCodeBlockRenderingPresets - Introduced `HighlightJS` typealias for generating syntax-highlighted `NSAttributedString` using highlight.js. - Supports 20 programming languages with caching for efficient rendering. - Recommended for scenarios requiring syntax highlighting without attachment images, with a suggestion to trigger from a background thread. --- .../Advanced/STMarkdownCodeHighlighter.swift | 144 +++ .../STMarkdownCodeBlockRenderingPresets.swift | 5 + Sources/STMarkdown/Resources/default.min.css | 9 + Sources/STMarkdown/Resources/highlight.min.js | 1123 +++++++++++++++++ 4 files changed, 1281 insertions(+) create mode 100644 Sources/STMarkdown/Rendering/Advanced/STMarkdownCodeHighlighter.swift create mode 100644 Sources/STMarkdown/Resources/default.min.css create mode 100644 Sources/STMarkdown/Resources/highlight.min.js diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownCodeHighlighter.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownCodeHighlighter.swift new file mode 100644 index 0000000..3959e72 --- /dev/null +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownCodeHighlighter.swift @@ -0,0 +1,144 @@ +// +// STMarkdownCodeHighlighter.swift +// STBaseProject +// + +import UIKit +import JavaScriptCore + +/// JavaScriptCore + highlight.js 语法高亮渲染器。 +/// +/// 将代码串交给 highlight.js 转换为带颜色标记的 HTML,再解析为 `NSAttributedString`。 +/// 结果按 `"\(language ?? "")|\(code)"` 为 key 存入 `NSCache`(10 MB 上限)。 +/// +/// - 线程安全:JSContext 通过私有串行队列 `jsQueue` 独占访问;缓存读写通过 `NSLock` 保护。 +/// - 调用方最好从后台线程调用,在缓存未命中时 `jsQueue.sync` 会阻塞当前线程直到高亮完成。 +public final class STMarkdownCodeHighlighter: STMarkdownCodeBlockRendering { + + // MARK: - Supported Languages + + /// highlight.js 内置且经过验证的语言标识符(均为小写)。 + public static let supportedLanguages: Set = [ + "bash", "c", "cpp", "csharp", "css", "go", "java", + "javascript", "json", "kotlin", "latex", "markdown", + "objectivec", "php", "python", "ruby", "sql", + "swift", "typescript", "xml" + ] + + // MARK: - JS Infrastructure + + private let vm: JSVirtualMachine + private let jsContext: JSContext + private let stylesheet: String + /// JSContext 不是线程安全的,所有 JS 调用都必须通过此串行队列序列化。 + private let jsQueue = DispatchQueue(label: "com.stmarkdown.code-highlighter.js", qos: .userInitiated) + + // MARK: - Cache + + private let cache: NSCache = { + let c = NSCache() + c.name = "STMarkdownCodeHighlighter" + c.totalCostLimit = 10 * 1024 * 1024 + return c + }() + private let cacheLock = NSLock() + + // MARK: - Init + + public init() { + let bundle = Self.resourceBundle() + let jsCode = bundle.url(forResource: "highlight.min", withExtension: "js") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "" + stylesheet = bundle.url(forResource: "default.min", withExtension: "css") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "" + + vm = JSVirtualMachine() + jsContext = JSContext(virtualMachine: vm) + jsContext.exceptionHandler = { _, exception in + print("[STMarkdownCodeHighlighter] JS exception: \(exception?.toString() ?? "unknown")") + } + if !jsCode.isEmpty { + jsContext.evaluateScript(jsCode) + } + } + + // MARK: - STMarkdownCodeBlockRendering + + public func renderCodeBlock(language: String?, code: String, style: STMarkdownStyle) -> NSAttributedString? { + let key = "\(language ?? "")|\(code)" as NSString + + cacheLock.lock() + let cached = cache.object(forKey: key) + cacheLock.unlock() + if let cached { return cached } + + var result: NSAttributedString? + jsQueue.sync { [self] in + result = highlight(code: code, language: language, style: style) + } + + if let result { + let cost = result.length * MemoryLayout.stride + cacheLock.lock() + cache.setObject(result, forKey: key, cost: cost) + cacheLock.unlock() + } + return result + } + + // MARK: - Private + + private func highlight(code: String, language: String?, style: STMarkdownStyle) -> NSAttributedString? { + guard let hljs = jsContext.objectForKeyedSubscript("hljs"), + !hljs.isUndefined else { return nil } + + let normalizedLang = language?.lowercased() ?? "" + let jsResult: JSValue? + if !normalizedLang.isEmpty, Self.supportedLanguages.contains(normalizedLang) { + jsResult = hljs.invokeMethod("highlight", withArguments: [code, ["language": normalizedLang]]) + } else { + jsResult = hljs.invokeMethod("highlightAuto", withArguments: [code]) + } + + guard let htmlTokens = jsResult?.objectForKeyedSubscript("value")?.toString(), + !htmlTokens.isEmpty else { return nil } + + let fontSize = max(style.font.pointSize - 1, 12) + // 覆盖 hljs 默认背景色,由外层容器(STMarkdownStyle.codeBlockBackgroundColor)控制背景。 + let html = String( + format: "" + + "
        %@
        ", + fontSize, stylesheet, htmlTokens + ) + + guard let data = html.data(using: .utf8), + let attributed = try? NSMutableAttributedString( + data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: NSNumber(value: String.Encoding.utf8.rawValue) + ], + documentAttributes: nil + ) else { return nil } + + if attributed.mutableString.hasSuffix("\n") { + attributed.mutableString.deleteCharacters( + in: NSRange(location: attributed.length - 1, length: 1) + ) + } + return attributed.copy() as? NSAttributedString + } + + private static func resourceBundle() -> Bundle { +#if SWIFT_PACKAGE + return Bundle.module +#else + let containing = Bundle(for: STMarkdownCodeHighlighter.self) + if let url = containing.url(forResource: "STBaseProject_STMarkdown", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + return containing +#endif + } +} diff --git a/Sources/STMarkdown/Rendering/STMarkdownCodeBlockRenderingPresets.swift b/Sources/STMarkdown/Rendering/STMarkdownCodeBlockRenderingPresets.swift index 73e849b..30ab11f 100644 --- a/Sources/STMarkdown/Rendering/STMarkdownCodeBlockRenderingPresets.swift +++ b/Sources/STMarkdown/Rendering/STMarkdownCodeBlockRenderingPresets.swift @@ -30,4 +30,9 @@ public enum STMarkdownCodeBlockRenderingPresets { /// 富 attachment 预设:包含语法高亮、超长折叠 + 渐隐遮罩、按钮行预留以及全局缓存。 /// 推荐生产环境优先使用。 public typealias RichAttachment = STMarkdownCodeBlockRenderer + + /// highlight.js 预设:通过 JavaScriptCore + highlight.js 生成 token 着色的 `NSAttributedString`。 + /// 支持 20 种语言,结果按 `language|code` 为 key 缓存(10 MB)。 + /// 适合需要真实语法着色但不需要 attachment 图像的场景;调用方最好从后台线程触发。 + public typealias HighlightJS = STMarkdownCodeHighlighter } diff --git a/Sources/STMarkdown/Resources/default.min.css b/Sources/STMarkdown/Resources/default.min.css new file mode 100644 index 0000000..df8c415 --- /dev/null +++ b/Sources/STMarkdown/Resources/default.min.css @@ -0,0 +1,9 @@ +/*! + Theme: Default + Description: Original highlight.js style + Author: (c) Ivan Sagalaev + Maintainer: @highlightjs/core-team + Website: https://highlightjs.org/ + License: see project LICENSE + Touched: 2021 +*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#1f3b63}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} diff --git a/Sources/STMarkdown/Resources/highlight.min.js b/Sources/STMarkdown/Resources/highlight.min.js new file mode 100644 index 0000000..3d27c68 --- /dev/null +++ b/Sources/STMarkdown/Resources/highlight.min.js @@ -0,0 +1,1123 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(t){ +return t instanceof Map?t.clear=t.delete=t.set=()=>{ +throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{ +const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i) +})),t}class t{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function n(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] +;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope +;class o{constructor(e,t){ +this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ +this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const n=e.split(".") +;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ") +}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)} +closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const r=(e={})=>{const t={children:[]} +;return Object.assign(t,e),t};class a{constructor(){ +this.rootNode=r(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const t=r({scope:e}) +;this.add(t),this.stack.push(t)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ +return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), +t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,t){const n=e.root +;t&&(n.scope="language:"+t),this.add(n)}toHTML(){ +return new o(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function l(e){ +return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")} +function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")} +function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{ +const t=e[e.length-1] +;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{} +})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n +;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break} +s+=i.substring(0,e.index), +i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0], +"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)} +const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",y="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_="\\b(0b[01]+)",O={ +begin:"\\\\[\\s\\S]",relevance:0},v={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[O]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t, +contains:[]},n);s.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const o=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return s.contains.push({begin:h(/[ ]+/,"(",o,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s +},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var j=Object.freeze({ +__proto__:null,APOS_STRING_MODE:v,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{ +scope:"number",begin:_,relevance:0},BINARY_NUMBER_RE:_,COMMENT:N, +C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number", +begin:y,relevance:0},C_NUMBER_RE:y,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0}, +NUMBER_MODE:{scope:"number",begin:w,relevance:0},NUMBER_RE:w, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const t=/^#![ ]*\// +;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t, +end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function A(e,t){ +"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){ +t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=A,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function L(e,t){ +Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function P(e,t){ +void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t] +})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={ +relevance:0,contains:[Object.assign(n,{endsParent:!0})] +},e.relevance=0,delete n.beforeMatch +},H=["of","and","for","in","not","or","if","then","parent","list","value"],C="keyword" +;function $(e,t,n=C){const i=Object.create(null) +;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ +Object.assign(i,$(e[n],t,n))})),i;function s(e,n){ +t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") +;i[n[0]]=[e,U(n[0],n[1])]}))}}function U(e,t){ +return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const z={},W=e=>{ +console.error(e)},X=(e,...t)=>{console.log("WARN: "+e,...t)},G=(e,t)=>{ +z[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),z[`${e}/${t}`]=!0) +},K=Error();function F(e,t,{key:n}){let i=0;const s=e[n],o={},r={} +;for(let e=1;e<=t.length;e++)r[e+i]=s[e],o[e+i]=!0,i+=p(t[e-1]) +;e[n]=r,e[n]._emit=o,e[n]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw W("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +K +;if("object"!=typeof e.beginScope||null===e.beginScope)throw W("beginScope must be object"), +K;F(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw W("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +K +;if("object"!=typeof e.endScope||null===e.endScope)throw W("endScope must be object"), +K;F(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function V(e){ +function t(t,n){ +return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":"")) +}class n{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,t){ +t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const t=this.matcherRe.exec(e);if(!t)return null +;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] +;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n +;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), +t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ +this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ +const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex +;let n=t.exec(e) +;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ +const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} +return n&&(this.regexIndex+=n.position+1, +this.regexIndex===this.count&&this.considerAll()),n}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=i(e.classNameAliases||{}),function n(o,r){const a=o +;if(o.isCompiled)return a +;[I,B,Z,D].forEach((e=>e(o,r))),e.compilerExtensions.forEach((e=>e(o,r))), +o.__beforeBegin=null,[T,L,P].forEach((e=>e(o,r))),o.isCompiled=!0;let c=null +;return"object"==typeof o.keywords&&o.keywords.$pattern&&(o.keywords=Object.assign({},o.keywords), +c=o.keywords.$pattern, +delete o.keywords.$pattern),c=c||/\w+/,o.keywords&&(o.keywords=$(o.keywords,e.case_insensitive)), +a.keywordPatternRe=t(c,!0), +r&&(o.begin||(o.begin=/\B|\b/),a.beginRe=t(a.begin),o.end||o.endsWithParent||(o.end=/\B|\b/), +o.end&&(a.endRe=t(a.end)), +a.terminatorEnd=l(a.end)||"",o.endsWithParent&&r.terminatorEnd&&(a.terminatorEnd+=(o.end?"|":"")+r.terminatorEnd)), +o.illegal&&(a.illegalRe=t(o.illegal)), +o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{ +variants:null},t)))),e.cachedVariants?e.cachedVariants:q(e)?i(e,{ +starts:e.starts?i(e.starts):null +}):Object.isFrozen(e)?i(e):e))("self"===e?o:e)))),o.contains.forEach((e=>{n(e,a) +})),o.starts&&n(o.starts,r),a.matcher=(e=>{const t=new s +;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function q(e){ +return!!e&&(e.endsWithParent||q(e.starts))}class J extends Error{ +constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}} +const Y=n,Q=i,ee=Symbol("nomatch"),te=n=>{ +const i=Object.create(null),s=Object.create(null),o=[];let r=!0 +;const a="Could not find the language '{}', did you forget to load/include a language module?",l={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:c};function b(e){ +return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s="" +;"object"==typeof t?(i=e, +n=t.ignoreIllegals,s=t.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."), +G("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +s=e,i=t),void 0===n&&(n=!0);const o={code:i,language:s};N("before:highlight",o) +;const r=o.result?o.result:E(o.language,o.code,n) +;return r.code=o.code,N("after:highlight",r),r}function E(e,n,s,o){ +const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R) +;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n="" +;for(;t;){n+=R.substring(e,t.index) +;const s=_.case_insensitive?t[0].toLowerCase():t[0],o=(i=s,N.keywords[i]);if(o){ +const[e,i]=o +;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(j+=i),e.startsWith("_"))n+=t[0];else{ +const n=_.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0] +;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i +;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{ +if(""===R)return;let e=null;if("string"==typeof N.subLanguage){ +if(!i[N.subLanguage])return void M.addText(R) +;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top +}else e=x(R,N.subLanguage.length?N.subLanguage:null) +;N.relevance>0&&(j+=e.relevance),M.__addSublanguage(e._emitter,e.language) +})():l(),R=""}function u(e,t){ +""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1 +;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue} +const i=_.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}} +function h(e,t){ +return e.scope&&"string"==typeof e.scope&&M.openNode(_.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(R,_.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{ +value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t) +;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e) +;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return f(e.parent,n,i)}function b(e){ +return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){ +const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return ee;const o=N +;N.endScope&&N.endScope._wrap?(g(), +u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(), +d(N.endScope,e)):o.skip?R+=t:(o.returnEnd||o.excludeEnd||(R+=t), +g(),o.excludeEnd&&(R=t));do{ +N.scope&&M.closeNode(),N.skip||N.subLanguage||(j+=N.relevance),N=N.parent +}while(N!==s.parent);return s.starts&&h(s.starts,e),o.returnEnd?0:t.length} +let w={};function y(i,o){const a=o&&o[0];if(R+=i,null==a)return g(),0 +;if("begin"===w.type&&"end"===o.type&&w.index===o.index&&""===a){ +if(R+=n.slice(o.index,o.index+1),!r){const t=Error(`0 width match regex (${e})`) +;throw t.languageName=e,t.badRule=w.rule,t}return 1} +if(w=o,"begin"===o.type)return(e=>{ +const n=e[0],i=e.rule,s=new t(i),o=[i.__beforeBegin,i["on:begin"]] +;for(const t of o)if(t&&(t(e,s),s.isMatchIgnored))return b(n) +;return i.skip?R+=n:(i.excludeBegin&&(R+=n), +g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(o) +;if("illegal"===o.type&&!s){ +const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"') +;throw e.mode=N,e}if("end"===o.type){const e=m(o);if(e!==ee)return e} +if("illegal"===o.type&&""===a)return 1 +;if(I>1e5&&I>3*o.index)throw Error("potential infinite loop, way more iterations than matches") +;return R+=a,a.length}const _=O(e)||O('plaintext') +;if(!_)throw W(a.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const v=V(_);let k="",N=o||v;const S={},M=new p.__emitter(p);(()=>{const e=[] +;for(let t=N;t!==_;t=t.parent)t.scope&&e.unshift(t.scope) +;e.forEach((e=>M.openNode(e)))})();let R="",j=0,A=0,I=0,T=!1;try{ +if(_.__emitTokens)_.__emitTokens(n,M);else{for(N.matcher.considerAll();;){ +I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=A +;const e=N.matcher.exec(n);if(!e)break;const t=y(n.substring(A,e.index),e) +;A=e.index+t}y(n.substring(A))}return M.finalize(),k=M.toHTML(),{language:e, +value:k,relevance:j,illegal:!1,_emitter:M,_top:N}}catch(t){ +if(t.message&&t.message.includes("Illegal"))return{language:e,value:Y(n), +illegal:!0,relevance:0,_illegalBy:{message:t.message,index:A, +context:n.slice(A-100,A+100),mode:t.mode,resultSoFar:k},_emitter:M};if(r)return{ +language:e,value:Y(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N} +;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{ +const t={value:Y(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(k).map((t=>E(t,e,!1))) +;s.unshift(n);const o=s.sort(((e,t)=>{ +if(e.relevance!==t.relevance)return t.relevance-e.relevance +;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1 +;if(O(t.language).supersetOf===e.language)return-1}return 0})),[r,a]=o,c=r +;return c.secondBest=a,c}function w(e){let t=null;const n=(e=>{ +let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"" +;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1]) +;return t||(X(a.replace("{}",n[1])), +X("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"} +return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return +;if(N("before:highlightElement",{el:e,language:n +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new J("One of your code blocks includes unescaped HTML.",e.innerHTML) +;t=e;const i=t.textContent,o=n?m(i,{language:n,ignoreIllegals:!0}):x(i) +;e.innerHTML=o.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n +;e.classList.add("hljs"),e.classList.add("language-"+i) +})(e,n,o.language),e.result={language:o.language,re:o.relevance, +relevance:o.relevance},o.secondBest&&(e.secondBest={ +language:o.secondBest.language,relevance:o.secondBest.relevance +}),N("after:highlightElement",{el:e,result:o,text:i})}let y=!1;function _(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(w):y=!0 +}function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]} +function v(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +s[e.toLowerCase()]=t}))}function k(e){const t=O(e) +;return t&&!t.disableAutodetect}function N(e,t){const n=e;o.forEach((e=>{ +e[n]&&e[n](t)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +y&&_()}),!1),Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:_, +highlightElement:w, +highlightBlock:e=>(G("10.7.0","highlightBlock will be removed entirely in v12.0"), +G("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{p=Q(p,e)}, +initHighlighting:()=>{ +_(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +_(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){ +if(W("Language definition for '{}' could not be registered.".replace("{}",e)), +!r)throw t;W(t),s=l} +s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&v(s.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete i[e] +;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, +listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:v, +autoDetection:k,inherit:Q,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ +e["before:highlightBlock"](Object.assign({block:t.el},t)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ +e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),o.push(e)}, +removePlugin:e=>{const t=o.indexOf(e);-1!==t&&o.splice(t,1)}}),n.debugMode=()=>{ +r=!1},n.safeMode=()=>{r=!0},n.versionString="11.10.0",n.regex={concat:h, +lookahead:g,either:f,optional:d,anyNumberOfTimes:u} +;for(const t in j)"object"==typeof j[t]&&e(j[t]);return Object.assign(n,j),n +},ne=te({});return ne.newInstance=()=>te({}),ne}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);/*! `bash` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const s=e.regex,t={},n={begin:/\$\{/, +end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{ +className:"variable",variants:[{ +begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(o);const r={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})() +;hljs.registerLanguage("bash",e)})();/*! `c` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",s="[a-zA-Z_]\\w*::",i="("+a+"|"+n.optional(s)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",r={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},l={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(l,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(s)+e.IDENT_RE,relevance:0 +},_=n.optional(s)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},g=[c,r,t,e.C_BLOCK_COMMENT_MODE,o,l],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:g.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:g.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+i+"[\\*&\\s]+)+"+_,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:_,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,l,o,r,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,l,o,r] +}]},r,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:l,keywords:u}}}})();hljs.registerLanguage("c",e)})();/*! `cpp` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const t=e.regex,a=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),n="decltype\\(auto\\)",r="[a-zA-Z_]\\w*::",i="(?!struct)("+n+"|"+t.optional(r)+"[a-zA-Z_]\\w*"+t.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={ +className:"number",variants:[{ +begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" +},{ +begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" +}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(c,{className:"string"}),{ +className:"string",begin:/<.*?>/},a,e.C_BLOCK_COMMENT_MODE]},u={ +className:"title",begin:t.optional(r)+e.IDENT_RE,relevance:0 +},d=t.optional(r)+e.IDENT_RE+"\\s*\\(",p={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},_={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:t.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,t.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[_,l,s,a,e.C_BLOCK_COMMENT_MODE,o,c],f={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:p,contains:m.concat([{begin:/\(/,end:/\)/,keywords:p, +contains:m.concat(["self"]),relevance:0}]),relevance:0},g={className:"function", +begin:"("+i+"[\\*&\\s]+)+"+d,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:p,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:n,keywords:p,relevance:0},{ +begin:d,returnBegin:!0,contains:[u],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[c,o]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:p,relevance:0, +contains:[a,e.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,end:/\)/,keywords:p, +relevance:0,contains:["self",a,e.C_BLOCK_COMMENT_MODE,c,o,s]}] +},s,a,e.C_BLOCK_COMMENT_MODE,l]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"",keywords:p,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:p},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}}})();hljs.registerLanguage("cpp",e) +})();/*! `csharp` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},t=e.inherit(s,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/, +keywords:n},l=e.inherit(r,{illegal:/\n/}),c={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,l]},o={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(o,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]}) +;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE], +l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[{className:"string", +begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 +},o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},E={begin:"<",end:">", +contains:[{beginKeywords:"in out"},a] +},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,i,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,E],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,i,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})() +;hljs.registerLanguage("csharp",e)})();/*! `css` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict" +;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],r=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),t=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),i=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),o=["accent-color","align-content","align-items","align-self","alignment-baseline","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","appearance","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-end-end-radius","border-end-start-radius","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","cx","cy","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","dominant-baseline","empty-cells","enable-background","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","flood-color","flood-opacity","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","inset","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","kerning","justify-content","justify-items","justify-self","left","letter-spacing","lighting-color","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","marker","marker-end","marker-mid","marker-start","mask","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","scale","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","speak","speak-as","src","tab-size","table-layout","text-anchor","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-offset","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","vector-effect","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index"].sort().reverse() +;return n=>{const a=n.regex,l=(e=>({IMPORTANT:{scope:"meta",begin:"!important"}, +BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number", +begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},FUNCTION_DISPATCH:{ +className:"built_in",begin:/[\w-]+(?=\()/},ATTRIBUTE_SELECTOR_MODE:{ +scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}))(n),s=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:"CSS", +case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"}, +classNameAliases:{keyframePosition:"selector-tag"},contains:[l.BLOCK_COMMENT,{ +begin:/-(webkit|moz|ms|o)-(?=[a-z])/},l.CSS_NUMBER_MODE,{ +className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0},{ +className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},l.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+t.join("|")+")"},{begin:":(:)?("+i.join("|")+")"}]},l.CSS_VARIABLE,{ +className:"attribute",begin:"\\b("+o.join("|")+")\\b"},{begin:/:/,end:/[;}{]/, +contains:[l.BLOCK_COMMENT,l.HEXCOLOR,l.IMPORTANT,l.CSS_NUMBER_MODE,...s,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...s,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},l.FUNCTION_DISPATCH]},{begin:a.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:r.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...s,l.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+e.join("|")+")\\b"}]}}})() +;hljs.registerLanguage("css",e)})();/*! `go` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const a={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:a,illegal:"{var e=(()=>{"use strict" +;var e="[0-9](_*[0-9])*",a=`\\.(${e})`,n="[0-9a-fA-F](_*[0-9a-fA-F])*",s={ +className:"number",variants:[{ +begin:`(\\b(${e})((${a})|\\.)?|(${a}))[eE][+-]?(${e})[fFdD]?\\b`},{ +begin:`\\b(${e})((${a})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${a})[fFdD]?\\b` +},{begin:`\\b(${e})[fFdD]\\b`},{ +begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?(${e})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function t(e,a,n){return-1===n?"":e.replace(a,(s=>t(e,a,n-1)))} +return e=>{ +const a=e.regex,n="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",i=n+t("(?:<"+n+"~~~(?:\\s*,\\s*"+n+"~~~)*>)?",/~~~/g,2),r={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},l={className:"meta",begin:"@"+n,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},c={className:"params",begin:/\(/, +end:/\)/,keywords:r,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:r,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,n],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[a.concat(/(?!else)/,n),/\s+/,n,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,n],className:{1:"keyword", +3:"title.class"},contains:[c,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+i+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:r,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:r,relevance:0, +contains:[l,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},s,l]}}})() +;hljs.registerLanguage("java",e)})();/*! `javascript` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict" +;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],t=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],s=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],r=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],c=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],i=[].concat(r,t,s) +;return o=>{const l=o.regex,b=e,d={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const a=e[0].length+e.index,t=e.input[a] +;if("<"===t||","===t)return void n.ignoreMatch();let s +;">"===t&&(((e,{after:n})=>{const a="e+"\\s*\\(")), +l.concat("(?!",T.join("|"),")")),b,l.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var T;const C={ +begin:l.concat(/\./,l.lookahead(l.concat(b,/(?![0-9A-Za-z$_(])/))),end:b, +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},M={ +match:[/get|set/,/\s+/,b,/(?=\()/],className:{1:"keyword",3:"title.function"}, +contains:[{begin:/\(\)/},R] +},B="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+o.UNDERSCORE_IDENT_RE+")\\s*=>",$={ +match:[/const|var|let/,/\s+/,b,/\s*/,/=\s*/,/(async\s*)?/,l.lookahead(B)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[R]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:g,exports:{ +PARAMS_CONTAINS:w,CLASS_REFERENCE:k},illegal:/#(?![$_A-z])/, +contains:[o.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,h,N,_,f,p,{match:/\$\d+/},A,k,{ +className:"attr",begin:b+l.lookahead(":"),relevance:0},$,{ +begin:"("+o.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[p,o.REGEXP_MODE,{ +className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:d.begin, +"on:begin":d.isTrulyOpeningTag,end:d.end}],subLanguage:"xml",contains:[{ +begin:d.begin,end:d.end,skip:!0,contains:["self"]}]}]},I,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[R,o.inherit(o.TITLE_MODE,{begin:b, +className:"title.function"})]},{match:/\.\.\./,relevance:0},C,{match:"\\$"+b, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[R]},x,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},O,M,{match:/\$[(.]/}]}}})() +;hljs.registerLanguage("javascript",e)})();/*! `json` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const a=["true","false","null"],s={ +scope:"literal",beginKeywords:a.join(" ")};return{name:"JSON",aliases:["jsonc"], +keywords:{literal:a},contains:[{className:"attr", +begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/, +className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,s,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}}})();hljs.registerLanguage("json",e)})();/*! `kotlin` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict" +;var e="[0-9](_*[0-9])*",n=`\\.(${e})`,a="[0-9a-fA-F](_*[0-9a-fA-F])*",i={ +className:"number",variants:[{ +begin:`(\\b(${e})((${n})|\\.)?|(${n}))[eE][+-]?(${e})[fFdD]?\\b`},{ +begin:`\\b(${e})((${n})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${n})[fFdD]?\\b` +},{begin:`\\b(${e})[fFdD]\\b`},{ +begin:`\\b0[xX]((${a})\\.?|(${a})?\\.(${a}))[pP][+-]?(${e})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${a})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};return e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},a={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},s={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},t={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[t,s]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,t,s]}]};s.contains.push(r);const l={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},c={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},o=i,b=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),E={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},d=E;return d.variants[1].contains=[E],E.variants[1].contains=[d], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,b,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},a,l,c,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[E,e.C_LINE_COMMENT_MODE,b],relevance:0 +},e.C_LINE_COMMENT_MODE,b,l,c,r,e.C_NUMBER_MODE]},b]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},l,c]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},o]}}})();hljs.registerLanguage("kotlin",e)})();/*! `markdown` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const n={begin:/<\/?[A-Za-z_]/, +end:">",subLanguage:"xml",relevance:0},a={variants:[{begin:/\[.+?\]\[.*?\]/, +relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},i={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},s={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},c=e.inherit(i,{contains:[] +}),t=e.inherit(s,{contains:[]});i.contains.push(t),s.contains.push(c) +;let g=[n,a];return[i,s,c,t].forEach((e=>{e.contains=e.contains.concat(g) +})),g=g.concat(i,s),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:g},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:g}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},i,s,{className:"quote",begin:"^>\\s+",contains:g, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},a,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}})() +;hljs.registerLanguage("markdown",e)})();/*! `objectivec` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={ +$pattern:n,keyword:["@interface","@class","@protocol","@implementation"]} +;return{name:"Objective-C", +aliases:["mm","objc","obj-c","obj-c++","objective-c","objective-c++"],keywords:{ +"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+_.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:_, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}}})();hljs.registerLanguage("objectivec",e)})();/*! `php` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const t=e.regex,a=/(?![A-Za-z0-9])(?![$])/,r=t.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,a),n=t.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,a),o={ +scope:"variable",match:"\\$+"+r},c={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},i=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),s="[ \t\n]",l={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(c)}),i,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(c),"on:begin":(e,t)=>{ +t.data._beginMatch=e[1]||e[2]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},_=["false","null","true"],p=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],E={ +keyword:p,literal:(e=>{const t=[];return e.forEach((e=>{ +t.push(e),e.toLowerCase()===e?t.push(e.toUpperCase()):t.push(e.toLowerCase()) +})),t})(_),built_in:b},u=e=>e.map((e=>e.replace(/\|\d+$/,""))),g={variants:[{ +match:[/new/,t.concat(s,"+"),t.concat("(?!",u(b).join("\\b|"),"\\b)"),n],scope:{ +1:"keyword",4:"title.class"}}]},h=t.concat(r,"\\b(?!\\()"),m={variants:[{ +match:[t.concat(/::/,t.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[n,t.concat(/::/,t.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[n,t.concat("::",t.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[n,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},I={scope:"attr", +match:t.concat(r,t.lookahead(":"),t.lookahead(/(?!::)/))},f={relevance:0, +begin:/\(/,end:/\)/,keywords:E,contains:[I,o,m,e.C_BLOCK_COMMENT_MODE,l,d,g] +},O={relevance:0, +match:[/\b/,t.concat("(?!fn\\b|function\\b|",u(p).join("\\b|"),"|",u(b).join("\\b|"),"\\b)"),r,t.concat(s,"*"),t.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[f]};f.contains.push(O) +;const v=[I,m,e.C_BLOCK_COMMENT_MODE,l,d,g];return{case_insensitive:!1, +keywords:E,contains:[{begin:t.concat(/#\[\s*/,n),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:_,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:_,keyword:["new","array"]}, +contains:["self",...v]},...v,{scope:"meta",match:n}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},o,O,m,{ +match:[/const/,/\s/,r],scope:{1:"keyword",3:"variable.constant"}},g,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:E, +contains:["self",o,m,e.C_BLOCK_COMMENT_MODE,l,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},l,d]} +}})();hljs.registerLanguage("php",e)})();/*! `plaintext` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var t=(()=>{"use strict";return t=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0})})() +;hljs.registerLanguage("plaintext",t)})();/*! `python` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const n=e.regex,a=/[\p{XID_Start}_]\p{XID_Continue}*/u,s=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],t={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:s, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},i={className:"meta",begin:/^(>>>|\.\.\.) /},r={className:"subst",begin:/\{/, +end:/\}/,keywords:t,illegal:/#/},l={begin:/\{\{/,relevance:0},o={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,i],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,i],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,i,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,i,l,r]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,l,r]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},b="[0-9](_?[0-9])*",c=`(\\b(${b}))?\\.(${b})|\\b(${b})\\.`,d="\\b|"+s.join("|"),g={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${b})|(${c}))[eE][+-]?(${b})[jJ]?(?=${d})`},{begin:`(${c})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${d})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${d})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${d})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${d})`},{begin:`\\b(${b})[jJ](?=${d})` +}]},p={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:t, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t, +contains:["self",i,g,o,e.HASH_COMMENT_MODE]}]};return r.contains=[o,g,i],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:t, +illegal:/(<\/|\?)|=>/,contains:[i,g,{scope:"variable.language",match:/\bself\b/ +},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" +},o,p,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,a],scope:{1:"keyword", +3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,a,/\s*/,/\(\s*/,a,/\s*\)/]},{match:[/\bclass/,/\s+/,a]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[g,m,o]}]}}})() +;hljs.registerLanguage("python",e)})();/*! `ruby` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const n=e.regex,a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",s=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(s,/(::\w+)*/),t={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},c={className:"doctag",begin:"@[A-Za-z]+"},r={ +begin:"#<",end:">"},b=[e.COMMENT("#","$",{contains:[c] +}),e.COMMENT("^=begin","^=end",{contains:[c],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],l={className:"subst",begin:/#\{/, +end:/\}/,keywords:t},d={className:"string",contains:[e.BACKSLASH_ESCAPE,l], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,l]})]}]},o="[0-9](_?[0-9])*",g={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${o}))?([eE][+-]?(${o})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},_={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:t}]},u=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:t},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:t},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:s,scope:"title.class"},{ +match:[/def/,/\s+/,a],scope:{1:"keyword",3:"title.function"},contains:[_]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:a}],relevance:0},g,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:t},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,l], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(r,b),relevance:0}].concat(r,b) +;l.contains=u,_.contains=u;const m=[{begin:/^\s*=>/,starts:{end:"$",contains:u} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:t,contains:u}}];return b.unshift(r),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:t,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(m).concat(b).concat(u)}}})() +;hljs.registerLanguage("ruby",e)})();/*! `rust` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const t=e.regex,n=/(r#)?/,a=t.concat(n,e.UNDERSCORE_IDENT_RE),i=t.concat(n,e.IDENT_RE),r={ +className:"title.function.invoke",relevance:0, +begin:t.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,t.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(32|64))?",l=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],o=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:o, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:l},illegal:""},r]}}})() +;hljs.registerLanguage("rust",e)})();/*! `shell` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var s=(()=>{"use strict";return s=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]})})();hljs.registerLanguage("shell",s)})();/*! `sql` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const r=e.regex,t=e.COMMENT("--","$"),n=["true","false","unknown"],a=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],i=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=i,c=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!i.includes(e))),l={ +begin:r.concat(/\b/,r.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:r,when:t}={})=>{const n=t +;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:n(e)?e+"|0":e)) +})(c,{when:e=>e.length<3}),literal:n,type:a, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:r.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:c.concat(s),literal:n,type:a}},{className:"type", +begin:r.either("double precision","large object","with timezone","without timezone") +},l,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}}})();hljs.registerLanguage("sql",e)})();/*! `swift` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";function e(e){ +return e?"string"==typeof e?e:e.source:null}function n(e){return t("(?=",e,")")} +function t(...n){return n.map((n=>e(n))).join("")}function a(...n){const t=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(n);return"("+(t.capture?"":"?:")+n.map((n=>e(n))).join("|")+")"} +const i=e=>t(/\b/,e,/\w$/.test(e)?/\b/:/\B/),s=["Protocol","Type"].map(i),c=["init","self"].map(i),u=["Any","Self"],o=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],r=["false","nil","true"],l=["assignment","associativity","higherThan","left","lowerThan","none","right"],m=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],p=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],d=a(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),F=a(d,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),b=t(d,F,"*"),h=a(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),f=a(h,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),w=t(h,f,"*"),g=t(/[A-Z]/,f,"*"),y=["attached","autoclosure",t(/convention\(/,a("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",t(/objc\(/,w,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],v=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;return e=>{const d={match:/\s+/,relevance:0},h=e.COMMENT("/\\*","\\*/",{ +contains:["self"]}),E=[e.C_LINE_COMMENT_MODE,h],A={match:[/\./,a(...s,...c)], +className:{2:"keyword"}},C={match:t(/\./,a(...o)),relevance:0 +},k=o.filter((e=>"string"==typeof e)).concat(["_|0"]),N={variants:[{ +className:"keyword", +match:a(...o.filter((e=>"string"!=typeof e)).concat(u).map(i),...c)}]},S={ +$pattern:a(/\b\w+/,/#\w+/),keyword:k.concat(m),literal:r},B=[A,C,N],D=[{ +match:t(/\./,a(...p)),relevance:0},{className:"built_in", +match:t(/\b/,a(...p),/(?=\()/)}],_={match:/->/,relevance:0},M=[_,{ +className:"operator",relevance:0,variants:[{match:b},{match:`\\.(\\.|${F})+`}] +}],x="([0-9]_*)+",L="([0-9a-fA-F]_*)+",$={className:"number",relevance:0, +variants:[{match:`\\b(${x})(\\.(${x}))?([eE][+-]?(${x}))?\\b`},{ +match:`\\b0x(${L})(\\.(${L}))?([pP][+-]?(${x}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},I=(e="")=>({className:"subst",variants:[{ +match:t(/\\/,e,/[0\\tnr"']/)},{match:t(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),O=(e="")=>({className:"subst",match:t(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),P=(e="")=>({className:"subst",label:"interpol",begin:t(/\\/,e,/\(/),end:/\)/ +}),j=(e="")=>({begin:t(e,/"""/),end:t(/"""/,e),contains:[I(e),O(e),P(e)] +}),K=(e="")=>({begin:t(e,/"/),end:t(/"/,e),contains:[I(e),P(e)]}),T={ +className:"string", +variants:[j(),j("#"),j("##"),j("###"),K(),K("#"),K("##"),K("###")] +},q=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],U={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:q},z=e=>{const n=t(e,/\//),a=t(/\//,e);return{begin:n,end:a, +contains:[...q,{scope:"comment",begin:`#(?!.*${a})`,end:/$/}]}},V={ +scope:"regexp",variants:[z("###"),z("##"),z("#"),U]},W={match:t(/`/,w,/`/) +},Z=[W,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${f}+`}],G=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:v,contains:[...M,$,T]}]}},{ +scope:"keyword",match:t(/@/,a(...y),n(a(/\(/,/\s+/)))},{scope:"meta", +match:t(/@/,w)}],H={match:n(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:t(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,f,"+") +},{className:"type",match:g,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:t(/\s+&\s+/,n(g)),relevance:0}]},R={ +begin://,keywords:S,contains:[...E,...B,...G,_,H]};H.contains.push(R) +;const X={begin:/\(/,end:/\)/,relevance:0,keywords:S,contains:["self",{ +match:t(w,/\s*:/),keywords:"_|0",relevance:0 +},...E,V,...B,...D,...M,$,T,...Z,...G,H]},J={begin://, +keywords:"repeat each",contains:[...E,H]},Q={begin:/\(/,end:/\)/,keywords:S, +contains:[{begin:a(n(t(w,/\s*:/)),n(t(w,/\s+/,w,/\s*:/))),end:/:/,relevance:0, +contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:w}] +},...E,...B,...M,$,T,...G,H,X],endsParent:!0,illegal:/["']/},Y={ +match:[/(func|macro)/,/\s+/,a(W.match,w,b)],className:{1:"keyword", +3:"title.function"},contains:[J,Q,d],illegal:[/\[/,/%/]},ee={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[J,Q,d],illegal:/\[|%/},ne={match:[/operator/,/\s+/,b],className:{ +1:"keyword",3:"title"}},te={begin:[/precedencegroup/,/\s+/,g],className:{ +1:"keyword",3:"title"},contains:[H],keywords:[...l,...r],end:/}/},ae={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,w,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:S,contains:[J,...B,{begin:/:/, +end:/\{/,keywords:S,contains:[{scope:"title.class.inherited",match:g},...B], +relevance:0}]};for(const e of T.variants){ +const n=e.contains.find((e=>"interpol"===e.label));n.keywords=S +;const t=[...B,...D,...M,$,T,...Z];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:S, +contains:[...E,Y,ee,ae,ne,te,{beginKeywords:"import",end:/$/,contains:[...E], +relevance:0},V,...B,...D,...M,$,T,...Z,...G,H,X]}}})() +;hljs.registerLanguage("swift",e)})();/*! `typescript` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict" +;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],t=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],s=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],r=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],c=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],i=[].concat(r,t,s) +;function o(o){const l=o.regex,d=e,b={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const a=e[0].length+e.index,t=e.input[a] +;if("<"===t||","===t)return void n.ignoreMatch();let s +;">"===t&&(((e,{after:n})=>{const a="e+"\\s*\\(")), +l.concat("(?!",C.join("|"),")")),d,l.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var C;const T={ +begin:l.concat(/\./,l.lookahead(l.concat(d,/(?![0-9A-Za-z$_(])/))),end:d, +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},M={ +match:[/get|set/,/\s+/,d,/(?=\()/],className:{1:"keyword",3:"title.function"}, +contains:[{begin:/\(\)/},R] +},B="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+o.UNDERSCORE_IDENT_RE+")\\s*=>",$={ +match:[/const|var|let/,/\s+/,d,/\s*/,/=\s*/,/(async\s*)?/,l.lookahead(B)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[R]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:g,exports:{ +PARAMS_CONTAINS:w,CLASS_REFERENCE:x},illegal:/#(?![$_A-z])/, +contains:[o.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,p,N,f,_,h,{match:/\$\d+/},A,x,{ +className:"attr",begin:d+l.lookahead(":"),relevance:0},$,{ +begin:"("+o.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[h,o.REGEXP_MODE,{ +className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:b.begin, +"on:begin":b.isTrulyOpeningTag,end:b.end}],subLanguage:"xml",contains:[{ +begin:b.begin,end:b.end,skip:!0,contains:["self"]}]}]},O,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[R,o.inherit(o.TITLE_MODE,{begin:d, +className:"title.function"})]},{match:/\.\.\./,relevance:0},T,{match:"\\$"+d, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[R]},I,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},k,M,{match:/\$[(.]/}]}}return t=>{ +const s=o(t),r=e,l=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],d={ +begin:[/namespace/,/\s+/,t.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} +},b={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ +keyword:"interface extends",built_in:l},contains:[s.exports.CLASS_REFERENCE] +},g={$pattern:e, +keyword:n.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:a,built_in:i.concat(l),"variable.language":c},u={className:"meta", +begin:"@"+r},m=(e,n,a)=>{const t=e.contains.findIndex((e=>e.label===n)) +;if(-1===t)throw Error("can not find mode to replace");e.contains.splice(t,1,a)} +;Object.assign(s.keywords,g),s.exports.PARAMS_CONTAINS.push(u) +;const E=s.contains.find((e=>"attr"===e.className)) +;return s.exports.PARAMS_CONTAINS.push([s.exports.CLASS_REFERENCE,E]), +s.contains=s.contains.concat([u,d,b]), +m(s,"shebang",t.SHEBANG()),m(s,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),s.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(s,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),s}})() +;hljs.registerLanguage("typescript",e)})();/*! `xml` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const a=e.regex,n=a.concat(/[\p{L}_]/u,a.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),s={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},t={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},i=e.inherit(t,{begin:/\(/,end:/\)/}),c=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),l=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),r={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[t,l,c,i,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[t,i,l,c]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},s,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[l]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[r],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[r],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:a.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:n,relevance:0,starts:r}]},{ +className:"tag",begin:a.concat(/<\//,a.lookahead(a.concat(n,/>/))),contains:[{ +className:"name",begin:n,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}} +})();hljs.registerLanguage("xml",e)})(); From 82d2f1f180513a8feca5c54345a588a977bc2c00 Mon Sep 17 00:00:00 2001 From: stack Date: Fri, 22 May 2026 11:29:27 +0800 Subject: [PATCH 18/27] Update STMarkdownTypography.swift --- .../STMarkdown/Core/STMarkdownTypography.swift | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/Sources/STMarkdown/Core/STMarkdownTypography.swift b/Sources/STMarkdown/Core/STMarkdownTypography.swift index a23f530..1502509 100644 --- a/Sources/STMarkdown/Core/STMarkdownTypography.swift +++ b/Sources/STMarkdown/Core/STMarkdownTypography.swift @@ -27,26 +27,14 @@ public enum STMarkdownTypography { case 3: return UIFont.st_systemFont(ofSize: 18, weight: .semibold) case 4: - return UIFont.st_systemFont(ofSize: 17, weight: .semibold) + return UIFont.st_systemFont(ofSize: 16, weight: .semibold) default: - // level 5、6 共用更小字重,避免与 H4 混淆。 - return UIFont.st_systemFont(ofSize: 16, weight: .medium) + return UIFont.st_systemFont(ofSize: 15, weight: .semibold) } } public static func headingInsets(for level: Int) -> UIEdgeInsets { - switch level { - case 1: - return UIEdgeInsets(top: 32, left: 0, bottom: 10, right: 0) - case 2: - return UIEdgeInsets(top: 28, left: 0, bottom: 10, right: 0) - case 3: - return UIEdgeInsets(top: 24, left: 0, bottom: 8, right: 0) - case 4: - return UIEdgeInsets(top: 20, left: 0, bottom: 8, right: 0) - default: - return UIEdgeInsets(top: 16, left: 0, bottom: 6, right: 0) - } + return UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) } public static func headingParagraphStyle( From 3cb77c6637bbd45df215203783f7cabebfaf98b1 Mon Sep 17 00:00:00 2001 From: stack Date: Sat, 23 May 2026 18:23:46 +0800 Subject: [PATCH 19/27] =?UTF-8?q?=E4=BC=98=E5=8C=96markdown=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/ios-engineer.mdc | 9 + .cursor/rules/problem-analysis.mdc | 141 ++++ .../STBaseProjectExample/ViewController.swift | 3 +- ...TMarkdownStreamingTestViewController.swift | 2 +- .../ViewControllers/STSSEViewController.swift | 797 ++++++++++++++++++ .../ViewControllers/STSSEViewController.xib | 22 + ...TMarkdownParsingResourceFixtureTests.swift | 356 ++++++++ Package.resolved | 9 +- Package.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 7 +- .../Core/STMarkdownStreamBuffer.swift | 30 + Sources/STMarkdown/Core/STMarkdownStyle.swift | 13 +- .../Core/STMarkdownTypography.swift | 2 +- .../STMarkdownAttributedStringRenderer.swift | 17 +- .../Table/STMarkdownTableView.swift | 2 +- .../UI/STMarkdownStreamingTextView.swift | 31 +- .../STTextView/STShimmerTextView.swift | 209 ++++- 17 files changed, 1619 insertions(+), 33 deletions(-) create mode 100644 .cursor/rules/problem-analysis.mdc create mode 100644 Example/STBaseProjectExample/ViewControllers/STSSEViewController.swift create mode 100644 Example/STBaseProjectExample/ViewControllers/STSSEViewController.xib create mode 100644 Example/STBaseProjectExampleTests/STMarkdownParsingResourceFixtureTests.swift diff --git a/.cursor/rules/ios-engineer.mdc b/.cursor/rules/ios-engineer.mdc index b133981..99de569 100644 --- a/.cursor/rules/ios-engineer.mdc +++ b/.cursor/rules/ios-engineer.mdc @@ -34,6 +34,15 @@ alwaysApply: true 并按其中 GR-002/003/004/005/007/008 规则执行:描述不清时先输出前置确认块;锁定单一根因;按四段式输出;给最小修复;不格式化代码;声明已覆盖/未覆盖/残留风险。 +# global problem analysis + +收到任何问题时,须遵循 `problem-analysis` skill **全文**(不得用本段代替)。执行前必须先读取: + +- `~/.cursor/skills/problem-analysis/SKILL.md` +- `~/.cursor/skills/problem-analysis/references/problem_analysis.md` + +并按其中 PA-001/002/003 规则执行:先检验问题的逻辑有效性;从第一性原理拆解真实需求并评估当前路径是否最优;充分理解后再回复。发现实质性问题时输出 `问题分析` 块,问题清晰时静默完成。 + # ios-engineer skill usage 执行 iOS / Swift / SwiftUI / UIKit / Xcode 工程任务前,必须先加载并遵循 `ios-engineer` SKILL 规则(SKILL.md + references/rule_index.md 中 `status=active` 的 IR / SYM / ROUTE / OUT 条目)。 diff --git a/.cursor/rules/problem-analysis.mdc b/.cursor/rules/problem-analysis.mdc new file mode 100644 index 0000000..fdc01da --- /dev/null +++ b/.cursor/rules/problem-analysis.mdc @@ -0,0 +1,141 @@ +--- +description: 问题前置分析:逻辑检验、第一性原理拆解、充分理解后再回复(PA-001/002/003) +alwaysApply: true +--- + + + + +# 问题前置分析(Problem Analysis) + +## 适用场景 + +本文件是 `problem-analysis` skill **[PA-001/002/003]** 的细则真值。适用于所有含判断、方案讨论或实现请求的任务。**在构造回复之前**执行,与 `logical-reasoning`(约束 AI 自身论证)和 `engineering-discipline`(问题描述不清时前置确认)正交。 + +--- + +## PA-001 — 逻辑检验 + +**目标**:问题本身是否建立在有效前提上? + +在开始回复前,扫描以下缺陷: + +| 缺陷类型 | 表现示例 | 处置 | +|----------|----------|------| +| **矛盾前提** | 「A 不可能发生,但 A 发生了怎么办」 | 先指出矛盾,再讨论 | +| **虚假二分** | 「只能用方案 A 或 B」——实则有 C | 展开完整选项集 | +| **循环假设** | 用待证明的结论作为前提 | 标出循环点,要求外部依据 | +| **未说明的强假设** | 隐含「用户量无限大 / 延迟不重要」 | 显式点出假设,确认是否成立 | +| **概念混用** | 「性能」同时指延迟和吞吐量 | 拆开,分别讨论 | + +**处置原则**: +- 轻微偏差(措辞模糊)→ 内部澄清后直接作答,无需中断 +- 实质逻辑错误或强假设 → 输出 `问题分析` 块,揭示后再给答案 +- 不得在未指出错误前提的情况下直接作答(否则答案建立在沙滩上) + +--- + +## PA-002 — 第一性原理拆解 + +**目标**:这个问题的**真实需求**是什么?当前提出的路径是否最优? + +### 第一性原理的操作定义 + +> 识别当前隐含的假设 → 把每个假设推到可以独立验证的层面 → 从那里重新推导答案。 + +它的反面不是"经验"本身,而是**以先例作为正当性来源、而不追问先例是否仍然成立**。 + +两种常见误解: + +| 误解 | 纠正 | +|------|------| +| "只追别人的经验,自己的惯例不算" | 先例无论来自他人还是自己,只要用「以前成立 → 现在也成立」做正当性,就是类比推理 | +| "要找到绝对不可再分的事实" | 实践中无需触底到哲学公理;目标是下探到**可量化、可独立验证的约束**(物理规律、实测数据、单位经济),脱离先例的解读 | + +两步走: + +### Step 1 — 需求溯源 + +向下追问到不可再分的基础目标: + +``` +表层请求 → 「为什么要这样做?」→ 中层目标 → 「为什么?」→ 底层需求 +``` + +示例: +- 表层:「把这个列表改成分页」 +- 中层:「减少页面加载时间」 +- 底层:「用户感知流畅度」→ 可能有比分页更好的方案(虚拟列表、预加载) + +### Step 2 — 方案评估 + +对照底层需求评估当前提出的路径: + +| 评估维度 | 问题 | +|----------|------| +| **必要性** | 这个方案是否是达成底层需求的必要路径? | +| **充分性** | 这个方案能否完整解决底层需求? | +| **副作用** | 有哪些已知代价或风险? | +| **替代方案** | 是否有代价更低或效果更好的路径? | + +**处置原则**: +- 当前路径已是最优 → 内部确认,直接作答 +- 存在明显更优解 → 在正式回复前点明,说明为何更优,**不强迫用户接受** +- 底层需求与表层请求不一致 → 先确认用户的真实意图 + +--- + +## PA-003 — 理解门控 + +**目标**:确保回复建立在充分理解上,而非快速响应。 + +- PA-001 + PA-002 均完成后,才开始构造正式回复 +- **静默模式**:若问题清晰、前提有效、当前路径合理 → 内部完成两步,直接作答,**不输出分析块**(保持回复简洁) +- **显式模式**:若发现逻辑缺陷或更优路径 → 输出 `问题分析` 块,然后再给答案 + +--- + +## 输出格式:`问题分析` 块 + +**仅在发现实质性问题时输出**,格式如下: + +``` +问题分析 +逻辑检验:<发现的逻辑缺陷,或「无」> +真实需求:<第一性原理拆解后的底层目标> +路径评估:<当前方案是否最优;若有更优解,一句话说明> +``` + +块后紧接正式回复,不额外解释块本身。 + +--- + +## 常见误用(禁止) + +| 误用 | 原因 | +|------|------| +| 每次回复都输出 `问题分析` 块 | 问题清晰时块是噪音,降低可读性 | +| 发现更优解后强迫用户切换方案 | 用户有选择权;只是「点明」,不是「纠正」 | +| 把 PA-001/002 当借口拖延回复 | 两步分析须快速完成,不得变成冗长序言 | +| 与 GR-002 混用 | GR-002 针对**描述不清**触发前置确认;PA 针对**逻辑错误或次优路径** | + +--- + +## 与相邻纪律的关系 + +| 纪律 | 触发点 | 分工 | +|------|--------|------| +| **PA-001/002/003(本规则)** | 收到问题时 | 分析**问题本身**的有效性与真实需求 | +| GR-010(logical-reasoning) | 构造回复时 | 约束 AI 自身回复的**论证质量** | +| GR-002(engineering-discipline) | 描述不清时 | **前置确认**补全缺失信息 | +| 认知对手模式(ios-engineer) | 技术决策/强确信 | **挑战用户**的结论与假设 | + +--- + +## 自检清单(回复前快速过一遍) + +- [ ] 问题的核心前提是否有效?有无逻辑缺陷? +- [ ] 底层需求是什么?表层请求是否直接对应底层需求? +- [ ] 当前提出的路径是否已是最优,或存在代价更低的替代方案? +- [ ] 若有发现,是否已在 `问题分析` 块中清晰点明? +- [ ] 问题清晰时是否保持了静默模式(不输出冗余分析块)? diff --git a/Example/STBaseProjectExample/ViewController.swift b/Example/STBaseProjectExample/ViewController.swift index 7cda16b..dfa2da5 100644 --- a/Example/STBaseProjectExample/ViewController.swift +++ b/Example/STBaseProjectExample/ViewController.swift @@ -41,7 +41,8 @@ class ViewController: BaseViewController { self.dataSouces["STTimer 功能测试"] = STTimerTestViewController() self.dataSouces["STTools 手动测试"] = STToolsManualTestViewController() self.dataSouces["Markdown 流式渲染测试"] = STMarkdownStreamingTestViewController() - + self.dataSouces["SSE 流式渲染测试"] = STSSEViewController(nibName: "STSSEViewController", bundle: nil) + self.tableView.reloadData() } } diff --git a/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift index bd9481d..8ef2cdc 100644 --- a/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift +++ b/Example/STBaseProjectExample/ViewControllers/STMarkdownStreamingTestViewController.swift @@ -2,7 +2,7 @@ // STMarkdownStreamingTestViewController.swift // STBaseProjectExample // -// Created by Codex on 2026/5/11. +// Created by 寒江孤影 on 2026/5/11. // import UIKit diff --git a/Example/STBaseProjectExample/ViewControllers/STSSEViewController.swift b/Example/STBaseProjectExample/ViewControllers/STSSEViewController.swift new file mode 100644 index 0000000..398da49 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STSSEViewController.swift @@ -0,0 +1,797 @@ +// +// STSSEViewController.swift +// STBaseProjectExample +// +// Created by 寒江孤影 on 2026/5/22. +// + +import UIKit +import STBaseProject + +/// 模拟 SSE 三路消息:本地 Bundle 读 data1~3 → Timer 切块 → ``STMarkdownStreamingTextView`` 逐字渲染。 +/// +/// 使用 ``UITableView`` 做 Cell 复用,避免聊天列表变长后 ``UIScrollView`` 堆叠全部 ``STMarkdownStreamingTextView`` 导致卡顿。 +class STSSEViewController: STBaseViewController { + + private struct StreamFixture { + let name: String + let content: String + let blockCount: Int + let tableOfContentsCount: Int + var streamedMarkdown: String = "" + var isCompleted: Bool = false + } + + private enum ParseValidationError: LocalizedError { + case emptySourceBlocks(String) + case emptyRenderBlocks(String) + + var errorDescription: String? { + switch self { + case .emptySourceBlocks(let name): + return "\(name).txt:source AST 为空" + case .emptyRenderBlocks(let name): + return "\(name).txt:render AST 为空" + } + } + } + + private static let fixtureNames = ["data1", "data2", "data3"] + private static let markdownEngine = STMarkdownEngine() + + private let statusLabel = UILabel() + private let tableView = UITableView(frame: .zero, style: .plain) + private var fixtures: [StreamFixture] = [] + private var streamingTimer: Timer? + private var currentFixtureIndex: Int = 0 + private var currentCharacterIndex: Int = 0 + private var isStreaming = false + private var layoutFlushWorkItem: DispatchWorkItem? + /// 精确行高缓存;不依赖 `estimatedHeightForRowAt`,由 `heightForRowAt` 唯一决定行高。 + private var rowHeights: [Int: CGFloat] = [:] + private var lastStatusUpdateUptime: TimeInterval = 0 + private var lastLayoutFlushUptime: TimeInterval = 0 + private var pinsStreamToBottom = true + + /// 逐字输出;TableView 仅用 `rowHeights` + `heightForRowAt` 精确行高(`estimatedRowHeight = 0`)。 + private let streamInterval: TimeInterval = 0.025 + private let streamStep = 1 + private let layoutFlushMinInterval: TimeInterval = 0.5 + private let layoutHeightDeltaThreshold: CGFloat = 36 + private let statusUpdateMinInterval: TimeInterval = 0.3 + private static let placeholderRowHeight: CGFloat = 96 + + override func viewDidLoad() { + super.viewDidLoad() + self.titleLabel.text = "SSE 流式测试" + self.view.backgroundColor = .systemBackground + + self.setupStatusLabel() + self.setupTableView() + self.addStartButton() + self.updateStatusLabel(idle: true) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.stopStreaming() + } + + // MARK: - UI + + private func setupStatusLabel() { + self.statusLabel.translatesAutoresizingMaskIntoConstraints = false + self.statusLabel.font = .systemFont(ofSize: 13, weight: .medium) + self.statusLabel.textColor = .secondaryLabel + self.statusLabel.numberOfLines = 2 + self.statusLabel.textAlignment = .center + self.statusLabel.lineBreakMode = .byTruncatingTail + + self.view.addSubview(self.statusLabel) + NSLayoutConstraint.activate([ + self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8), + self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + self.statusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16), + self.statusLabel.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + private func setupTableView() { + self.tableView.translatesAutoresizingMaskIntoConstraints = false + self.tableView.delegate = self + self.tableView.dataSource = self + self.tableView.register(SSEMarkdownCell.self, forCellReuseIdentifier: SSEMarkdownCell.reuseIdentifier) + self.tableView.rowHeight = UITableView.automaticDimension + // 关闭 estimated 行高,避免与真实高度不一致时系统校正 contentOffset 造成抖动。 + self.tableView.estimatedRowHeight = 0 + self.tableView.separatorStyle = .none + self.tableView.backgroundColor = .systemBackground + self.tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 100, right: 0) + self.tableView.contentInsetAdjustmentBehavior = .never + + self.view.addSubview(self.tableView) + NSLayoutConstraint.activate([ + self.tableView.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 8), + self.tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + } + + private func addStartButton() { + let button = UIButton(type: .system) + if #available(iOS 15.0, *) { + var configuration = UIButton.Configuration.filled() + configuration.title = "▶️ 校验并流式展示 3 份资源" + configuration.baseBackgroundColor = .systemBlue + configuration.baseForegroundColor = .white + configuration.cornerStyle = .large + configuration.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24) + button.configuration = configuration + } else { + button.setTitle("▶️ 校验并流式展示 3 份资源", for: .normal) + button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 12 + button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 24, bottom: 12, right: 24) + } + button.addTarget(self, action: #selector(self.startStreaming), for: .touchUpInside) + + self.view.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + button.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -30), + button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor) + ]) + } + + // MARK: - Actions + + @objc private func startStreaming() { + self.stopStreaming() + + let loadResult = self.loadFixtures() + guard loadResult.failedNames.isEmpty else { + self.fixtures = [] + self.tableView.reloadData() + self.updateStatusLabel( + message: "资源读取失败:\(loadResult.failedNames.joined(separator: ", "))。请确认文件已加入主工程 Bundle。" + ) + return + } + + let validation = Self.validateParse(fixtures: loadResult.fixtures) + guard validation.errors.isEmpty else { + self.fixtures = [] + self.tableView.reloadData() + self.updateStatusLabel(message: "解析校验失败\n" + validation.errors.joined(separator: "\n")) + return + } + + self.fixtures = validation.fixtures + self.currentFixtureIndex = 0 + self.currentCharacterIndex = 0 + self.isStreaming = true + self.pinsStreamToBottom = true + self.lastStatusUpdateUptime = 0 + self.lastLayoutFlushUptime = 0 + self.rowHeights.removeAll() + + // 须在 isStreaming = true 之后 reload,否则 visibleRowCount 会先返回 3 再变 1,与后续 beginUpdates 冲突崩溃。 + self.tableView.reloadData() + self.tableView.layoutIfNeeded() + self.updateStatusLabel( + message: "解析校验通过 · \(self.fixtures.count) 份资源 · 共 \(validation.totalRenderBlocks) 个 render block" + ) + self.beginStreamingFixture(at: 0) + self.scheduleStreamingTimer() + } + + private func scheduleStreamingTimer() { + self.streamingTimer?.invalidate() + let timer = Timer.scheduledTimer( + timeInterval: self.streamInterval, + target: self, + selector: #selector(self.handleStreamingTick), + userInfo: nil, + repeats: true + ) + RunLoop.main.add(timer, forMode: .common) + self.streamingTimer = timer + } + + @objc private func handleStreamingTick() { + guard self.isStreaming, self.currentFixtureIndex < self.fixtures.count else { + self.completeAllStreaming() + return + } + + let fixture = self.fixtures[self.currentFixtureIndex] + let characters = Array(fixture.content) + guard self.currentCharacterIndex < characters.count else { + self.flushTableLayoutIfNeeded(force: true) + + let finishedRow = self.currentFixtureIndex + self.finishStreamingFixture(at: finishedRow) + self.currentFixtureIndex += 1 + self.currentCharacterIndex = 0 + + if self.currentFixtureIndex < self.fixtures.count { + let nextRow = self.currentFixtureIndex + UIView.performWithoutAnimation { + self.tableView.insertRows(at: [IndexPath(row: nextRow, section: 0)], with: .none) + } + self.rowHeights[nextRow] = Self.placeholderRowHeight + self.beginStreamingFixture(at: nextRow) + if self.pinsStreamToBottom { + self.pinTableToBottom(animated: false) + } + } else { + self.completeAllStreaming() + } + return + } + + let end = min(self.currentCharacterIndex + self.streamStep, characters.count) + let chunk = String(characters[self.currentCharacterIndex.. 0 { + self.rowHeights[index] = max( + self.rowHeights[index] ?? Self.placeholderRowHeight, + cell.measuredRowHeight(tableWidth: tableWidth) + ) + } + } + } + + private func finishStreamingFixture(at index: Int) { + guard index < self.fixtures.count else { return } + self.fixtures[index].isCompleted = true + let indexPath = IndexPath(row: index, section: 0) + if let cell = self.tableView.cellForRow(at: indexPath) as? SSEMarkdownCell { + cell.finishStreaming(finalMarkdown: self.fixtures[index].content) + let tableWidth = self.tableView.bounds.width + if tableWidth > 0 { + self.rowHeights[index] = cell.measuredRowHeight(tableWidth: tableWidth) + } + } + } + + private func completeAllStreaming() { + self.isStreaming = false + self.tableView.reloadData() + self.tableView.layoutIfNeeded() + self.stopStreaming(resetFixtures: false) + self.updateStatusLabel( + message: "流式展示完成 · \(Self.fixtureNames.count) 份资源已通过 STMarkdown 解析并渲染" + ) + } + + private func stopStreaming(resetFixtures: Bool = true) { + self.layoutFlushWorkItem?.cancel() + self.layoutFlushWorkItem = nil + self.streamingTimer?.invalidate() + self.streamingTimer = nil + self.isStreaming = false + self.currentFixtureIndex = 0 + self.currentCharacterIndex = 0 + if resetFixtures { + self.fixtures.removeAll() + self.tableView.reloadData() + self.updateStatusLabel(idle: true) + } + } + + // MARK: - Resources & Parse + + private func loadFixtures() -> (fixtures: [(name: String, content: String)], failedNames: [String]) { + var fixtures: [(name: String, content: String)] = [] + var failedNames: [String] = [] + + for name in Self.fixtureNames { + guard let url = Bundle.main.url(forResource: name, withExtension: "txt"), + let content = try? String(contentsOf: url, encoding: .utf8) else { + failedNames.append("\(name).txt") + continue + } + fixtures.append((name: name, content: content)) + } + + return (fixtures, failedNames) + } + + private static func validateParse( + fixtures: [(name: String, content: String)] + ) -> (fixtures: [StreamFixture], errors: [String], totalRenderBlocks: Int) { + var validated: [StreamFixture] = [] + var errors: [String] = [] + var totalBlocks = 0 + + for item in fixtures { + do { + let fixture = try self.makeValidatedFixture(name: item.name, content: item.content) + validated.append(fixture) + totalBlocks += fixture.blockCount + } catch { + errors.append(error.localizedDescription) + } + } + + return (validated, errors, totalBlocks) + } + + private static func makeValidatedFixture(name: String, content: String) throws -> StreamFixture { + let result = self.markdownEngine.process(content) + + guard result.sourceDocument.blocks.isEmpty == false else { + throw ParseValidationError.emptySourceBlocks(name) + } + guard result.renderDocument.blocks.isEmpty == false else { + throw ParseValidationError.emptyRenderBlocks(name) + } + + switch name { + case "data1": + try self.assertData1Contracts(result: result, name: name) + case "data2": + try self.assertData2Contracts(result: result, name: name) + case "data3": + try self.assertData3Contracts(result: result, name: name) + default: + break + } + + return StreamFixture( + name: name, + content: content, + blockCount: result.renderDocument.blocks.count, + tableOfContentsCount: result.tableOfContents.count + ) + } + + private static func assertData1Contracts(result: STMarkdownPipelineResult, name: String) throws { + let headings = result.sourceDocument.blocks.compactMap { block -> String? in + if case .heading(level: _, let content) = block { + return Self.joinInlineText(content) + } + return nil + } + guard headings.contains(where: { $0.contains("第一步") }) else { + throw NSError(domain: "STSSEParse", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:缺少「第一步」标题"]) + } + guard headings.contains(where: { $0.contains("第三步") }) else { + throw NSError(domain: "STSSEParse", code: 2, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:缺少「第三步」标题"]) + } + } + + private static func assertData2Contracts(result: STMarkdownPipelineResult, name: String) throws { + let tables = result.sourceDocument.blocks.compactMap { block -> STMarkdownTableModel? in + if case .table(let model) = block { return model } + return nil + } + guard tables.count == 1 else { + throw NSError(domain: "STSSEParse", code: 3, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:应包含 1 个表格,实际 \(tables.count) 个"]) + } + let headerText = Self.joinInlineText((tables[0].header ?? []).flatMap { $0 }) + guard headerText.contains("类别"), headerText.contains("具体建议") else { + throw NSError(domain: "STSSEParse", code: 4, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:表格表头字段不完整"]) + } + } + + private static func assertData3Contracts(result: STMarkdownPipelineResult, name: String) throws { + let codeBlocks = result.sourceDocument.blocks.compactMap { block -> String? in + if case .codeBlock(language: _, let code) = block { return code } + return nil + } + guard codeBlocks.count >= 2 else { + throw NSError(domain: "STSSEParse", code: 5, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:应至少包含 2 个代码块"]) + } + let rendered = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: result.renderDocument) + .string + guard rendered.contains("```") == false else { + throw NSError(domain: "STSSEParse", code: 6, userInfo: [NSLocalizedDescriptionKey: "\(name).txt:代码围栏定界符泄漏到可见文本"]) + } + } + + private static func joinInlineText(_ nodes: [STMarkdownInlineNode]) -> String { + nodes.map { Self.inlinePlainText($0) }.joined() + } + + private static func inlinePlainText(_ node: STMarkdownInlineNode) -> String { + switch node { + case .text(let value): + return value + case .softBreak: + return "\n" + case .inlineMath(let formula, _): + return formula + case .code(let value): + return value + case .emphasis(let inner), .strong(let inner), .strikethrough(let inner): + return self.joinInlineText(inner) + case .link(_, let inner): + return self.joinInlineText(inner) + case .image(_, let alt, _): + return alt + case .footnoteReference(let label): + return "[^\(label)]" + case .inlineRawHTML(let raw): + return raw + } + } + + // MARK: - Status & Scroll + + private func updateStatusLabel(idle: Bool) { + if idle { + self.statusLabel.text = "点击开始:先用 STMarkdownEngine 校验 data1~3,再逐路 SSE 流式渲染" + } + } + + private func updateStatusLabel(message: String) { + self.statusLabel.text = message + } + + private func updateStreamingStatusIfNeeded( + fixtureName name: String, + progress: Float, + fixtureIndex: Int, + fixtureCount: Int + ) { + let now = ProcessInfo.processInfo.systemUptime + guard now - self.lastStatusUpdateUptime >= self.statusUpdateMinInterval else { return } + self.lastStatusUpdateUptime = now + self.statusLabel.text = String( + format: "流式输出 %@ · 第 %d/%d 路 · %.0f%%", + name, + fixtureIndex, + fixtureCount, + progress * 100 + ) + } + + private func visibleRowCount() -> Int { + guard self.isStreaming, self.fixtures.isEmpty == false else { + return self.fixtures.count + } + return min(self.fixtures.count, self.currentFixtureIndex + 1) + } + + private func flushTableLayoutIfNeeded(force: Bool) { + guard self.isStreaming else { return } + + let now = ProcessInfo.processInfo.systemUptime + if force == false, now - self.lastLayoutFlushUptime < self.layoutFlushMinInterval { + self.scheduleDeferredLayoutFlush() + return + } + + self.layoutFlushWorkItem?.cancel() + self.layoutFlushWorkItem = nil + self.lastLayoutFlushUptime = now + + let row = self.currentFixtureIndex + let indexPath = IndexPath(row: row, section: 0) + guard let cell = self.tableView.cellForRow(at: indexPath) as? SSEMarkdownCell else { return } + + let tableWidth = self.tableView.bounds.width + guard tableWidth > 0 else { return } + + let measured = cell.measuredRowHeight(tableWidth: tableWidth) + let previous = self.rowHeights[row] ?? Self.placeholderRowHeight + let delta = measured - previous + + guard force || abs(delta) >= self.layoutHeightDeltaThreshold else { return } + + let expectedRows = self.visibleRowCount() + let actualRows = self.tableView.numberOfRows(inSection: 0) + guard actualRows == expectedRows else { + self.tableView.reloadData() + return + } + + self.rowHeights[row] = measured + + let tableView = self.tableView + let shouldPin = self.pinsStreamToBottom && self.isViewportNearStreamBottom() + + CATransaction.begin() + CATransaction.setDisableActions(true) + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + if shouldPin, delta > 0.5 { + let insetBottom = tableView.adjustedContentInset.bottom + let maxOffsetY = max( + -tableView.adjustedContentInset.top, + tableView.contentSize.height - tableView.bounds.height + insetBottom + ) + tableView.contentOffset.y = min(tableView.contentOffset.y + delta, maxOffsetY) + } + CATransaction.commit() + } + + private func isViewportNearStreamBottom(threshold: CGFloat = 120) -> Bool { + let tableView = self.tableView + let viewportBottom = tableView.contentOffset.y + tableView.bounds.height - tableView.adjustedContentInset.bottom + return tableView.contentSize.height - viewportBottom < threshold + } + + private func pinTableToBottom(animated: Bool) { + let tableView = self.tableView + tableView.layoutIfNeeded() + let insetBottom = tableView.adjustedContentInset.bottom + let maxOffsetY = max( + -tableView.adjustedContentInset.top, + tableView.contentSize.height - tableView.bounds.height + insetBottom + ) + tableView.setContentOffset(CGPoint(x: 0, y: maxOffsetY), animated: animated) + } + + private func scheduleDeferredLayoutFlush() { + guard self.layoutFlushWorkItem == nil else { return } + let work = DispatchWorkItem { [weak self] in + self?.flushTableLayoutIfNeeded(force: true) + } + self.layoutFlushWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + self.layoutFlushMinInterval, execute: work) + } +} + +// MARK: - UITableView + +extension STSSEViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + self.visibleRowCount() + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + self.rowHeights[indexPath.row] ?? Self.placeholderRowHeight + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: SSEMarkdownCell.reuseIdentifier, + for: indexPath + ) as! SSEMarkdownCell + let row = indexPath.row + let fixture = self.fixtures[row] + + cell.bindFixture( + name: fixture.name, + blockCount: fixture.blockCount, + tableOfContentsCount: fixture.tableOfContentsCount + ) + + if fixture.isCompleted { + cell.showFinal(markdown: fixture.content) + } else if row < self.currentFixtureIndex { + cell.showFinal(markdown: fixture.content) + } else if row == self.currentFixtureIndex, self.isStreaming { + if cell.accumulatedMarkdown.isEmpty, fixture.streamedMarkdown.isEmpty == false { + cell.syncStreamingProgress(fixture.streamedMarkdown) + } + } else { + cell.showPlaceholder() + } + + return cell + } +} + +// MARK: - UIScrollViewDelegate + +extension STSSEViewController: UIScrollViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + self.pinsStreamToBottom = false + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if decelerate == false { + self.syncPinsStreamToBottomFromScrollOffset() + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.syncPinsStreamToBottomFromScrollOffset() + } + + private func syncPinsStreamToBottomFromScrollOffset() { + self.pinsStreamToBottom = self.isViewportNearStreamBottom() + } +} + +// MARK: - SSEMarkdownCell + +/// 单路消息 Cell:流式阶段只用 ``appendMarkdownFragment`` 逐字追加,不用智能流式增量合并(避免标题重复)。 +private final class SSEMarkdownCell: UITableViewCell { + + static let reuseIdentifier = "SSEMarkdownCell" + + private var boundFixtureName: String? + private(set) var accumulatedMarkdown: String = "" + + private let titleLabel = UILabel() + private let metaLabel = UILabel() + private var markdownHeightConstraint: NSLayoutConstraint? + + private let markdownView: STMarkdownStreamingTextView = { + let view = STMarkdownStreamingTextView( + style: .default, + advancedRenderers: .empty, + engine: STMarkdownEngine() + ) + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemBackground + view.layer.cornerRadius = 12 + view.clipsToBounds = true + view.isTextSelectionEnabled = true + view.tokenFadeDuration = 0.06 + view.animateAcrossNewlines = false + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + self.backgroundColor = .clear + self.selectionStyle = .none + + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + self.titleLabel.textColor = .label + + self.metaLabel.translatesAutoresizingMaskIntoConstraints = false + self.metaLabel.font = .systemFont(ofSize: 12) + self.metaLabel.textColor = .secondaryLabel + + self.contentView.addSubview(self.titleLabel) + self.contentView.addSubview(self.metaLabel) + self.contentView.addSubview(self.markdownView) + + NSLayoutConstraint.activate([ + self.titleLabel.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 8), + self.titleLabel.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 16), + self.titleLabel.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -16), + + self.metaLabel.topAnchor.constraint(equalTo: self.titleLabel.bottomAnchor, constant: 2), + self.metaLabel.leadingAnchor.constraint(equalTo: self.titleLabel.leadingAnchor), + self.metaLabel.trailingAnchor.constraint(equalTo: self.titleLabel.trailingAnchor), + + self.markdownView.topAnchor.constraint(equalTo: self.metaLabel.bottomAnchor, constant: 8), + self.markdownView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 12), + self.markdownView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -12) + ]) + + let heightConstraint = self.markdownView.heightAnchor.constraint(equalToConstant: 1) + heightConstraint.priority = .defaultHigh + heightConstraint.isActive = true + self.markdownHeightConstraint = heightConstraint + + self.markdownView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -8).isActive = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + self.boundFixtureName = nil + self.accumulatedMarkdown = "" + self.markdownView.reset() + } + + func bindFixture(name: String, blockCount: Int, tableOfContentsCount: Int) { + self.titleLabel.text = "\(name).txt" + self.metaLabel.text = "STMarkdown render blocks: \(blockCount) · TOC: \(tableOfContentsCount)" + if self.boundFixtureName != name { + self.boundFixtureName = name + self.accumulatedMarkdown = "" + self.markdownView.reset() + } + } + + func showPlaceholder() { + guard self.accumulatedMarkdown.isEmpty == false else { return } + self.accumulatedMarkdown = "" + self.markdownView.reset() + } + + func startStreaming() { + self.accumulatedMarkdown = "" + self.markdownView.reset() + if let shimmer = self.markdownView.contentTextView as? STShimmerTextView { + shimmer.characterStaggerInterval = 0.002 + } + } + + /// Cell 滚出屏幕后回屏:仅当与 Model 不一致时恢复,避免打断正在 tick 的 Cell。 + func syncStreamingProgress(_ markdown: String) { + guard self.accumulatedMarkdown != markdown else { return } + if markdown.isEmpty { + self.startStreaming() + return + } + self.accumulatedMarkdown = markdown + self.markdownView.setMarkdown(markdown, animated: false) + } + + func appendStreamingChunk(_ chunk: String) { + guard chunk.isEmpty == false else { return } + self.accumulatedMarkdown += chunk + self.markdownView.appendMarkdownFragment(chunk, animated: true) + self.updateMarkdownHeightConstraintIfNeeded() + } + + func measuredRowHeight(tableWidth: CGFloat) -> CGFloat { + let horizontalInset: CGFloat = 12 + 16 + let contentWidth = max(0, tableWidth - horizontalInset * 2) + self.markdownView.preferredContentWidth = contentWidth + self.updateMarkdownHeightConstraintIfNeeded() + self.setNeedsLayout() + self.layoutIfNeeded() + let target = CGSize(width: tableWidth, height: UIView.layoutFittingCompressedSize.height) + return ceil(self.contentView.systemLayoutSizeFitting( + target, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height) + } + + private func updateMarkdownHeightConstraintIfNeeded() { + let width = self.markdownView.preferredContentWidth > 0 + ? self.markdownView.preferredContentWidth + : self.markdownView.bounds.width + guard width > 0 else { return } + let contentHeight = self.markdownView.sizeThatFitsMarkdown(width: width).height + guard contentHeight > 0 else { return } + self.markdownHeightConstraint?.constant = ceil(contentHeight) + } + + func showFinal(markdown: String) { + guard self.accumulatedMarkdown != markdown else { + self.markdownView.finishStreaming() + return + } + self.accumulatedMarkdown = markdown + self.markdownView.setMarkdown(markdown, animated: false) + self.markdownView.finishStreaming() + } + + func finishStreaming(finalMarkdown: String) { + self.showFinal(markdown: finalMarkdown) + } +} diff --git a/Example/STBaseProjectExample/ViewControllers/STSSEViewController.xib b/Example/STBaseProjectExample/ViewControllers/STSSEViewController.xib new file mode 100644 index 0000000..5ed94e2 --- /dev/null +++ b/Example/STBaseProjectExample/ViewControllers/STSSEViewController.xib @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/STBaseProjectExampleTests/STMarkdownParsingResourceFixtureTests.swift b/Example/STBaseProjectExampleTests/STMarkdownParsingResourceFixtureTests.swift new file mode 100644 index 0000000..8909451 --- /dev/null +++ b/Example/STBaseProjectExampleTests/STMarkdownParsingResourceFixtureTests.swift @@ -0,0 +1,356 @@ +// +// STMarkdownParsingResourceFixtureTests.swift +// STBaseProjectExampleTests +// +// 以 Example/STBaseProjectExample/Resources/data1~3.txt 为 fixture, +// 覆盖 STMarkdown/Parsing 目录各模块在真实 LLM 输出上的行为。 +// +// 模块映射: +// - STMarkdownInputSanitizer / defaultRules → sanitizer 阶段 +// - STMarkdownMalformedTableNormalizer → 表格预修复(data2) +// - STMarkdownMathNormalizer → StructureParser 内块级公式(data1~3 无 $$ 时不应误拆) +// - STMarkdownStructureParser + FootnoteSupport + HTMLBlockClassifier → parse 阶段 +// - STMarkdownSemanticNormalizer → 语义归一 +// - STMarkdownAST / STMarkdownRenderAST → 管线结果结构 +// - STMarkdownFootnoteDeepLink(internal)→ 由 STMarkdownFootnoteAndHTMLTests / Renderer 间接覆盖 +// + +import XCTest +import STBaseProject + +// MARK: - Fixture 加载 + +private enum STMarkdownParsingResourceFixture { + static let names = ["data1", "data2", "data3"] + + static func text(named name: String) throws -> String { + let testFileURL = URL(fileURLWithPath: #filePath) + let projectRoot = testFileURL + .deletingLastPathComponent() + .deletingLastPathComponent() + let fixtureURL = projectRoot + .appendingPathComponent("STBaseProjectExample") + .appendingPathComponent("Resources") + .appendingPathComponent(name) + .appendingPathExtension("txt") + return try String(contentsOf: fixtureURL, encoding: .utf8) + } +} + +// MARK: - 轻量 AST 辅助 + +private func st_joinInlinePlainText(_ nodes: [STMarkdownInlineNode]) -> String { + nodes.map { st_inlinePlainText($0) }.joined() +} + +private func st_inlinePlainText(_ node: STMarkdownInlineNode) -> String { + switch node { + case .text(let s): + return s + case .softBreak: + return "\n" + case .inlineMath(let f, _): + return f + case .code(let s): + return s + case .emphasis(let c), .strong(let c), .strikethrough(let c): + return st_joinInlinePlainText(c) + case .link(_, let c): + return st_joinInlinePlainText(c) + case .image(_, let alt, _): + return alt + case .footnoteReference(let label): + return "[^\(label)]" + case .inlineRawHTML(let raw): + return raw + } +} + +private func st_documentPlainText(_ document: STMarkdownDocument) -> String { + func walkBlocks(_ blocks: [STMarkdownBlockNode]) -> String { + blocks.map { block in + switch block { + case .paragraph(let inlines): + return st_joinInlinePlainText(inlines) + case .heading(_, let inlines): + return st_joinInlinePlainText(inlines) + case .quote(let children): + return walkBlocks(children) + case .list(_, let items): + return items.map { walkBlocks($0.blocks) }.joined() + case .table(let table): + let header = (table.header ?? []).flatMap { $0 } + let rows = table.rows.flatMap { $0 }.flatMap { $0 } + return st_joinInlinePlainText(header + rows) + case .codeBlock(_, let code): + return code + case .mathBlock(let formula): + return formula + case .image(_, let alt, let title): + return alt + (title ?? "") + case .thematicBreak: + return "" + case .details(let summary, let body): + return st_joinInlinePlainText(summary) + walkBlocks(body) + case .rawHTML(let html): + return html + } + }.joined(separator: "\n") + } + return walkBlocks(document.blocks) +} + +private func st_containsInlineLink(to term: String, in nodes: [STMarkdownInlineNode]) -> Bool { + for node in nodes { + switch node { + case .link(let destination, let children): + let text = st_joinInlinePlainText(children) + if text.contains(term) { return true } + if destination.contains(term) { return true } + case .emphasis(let c), .strong(let c), .strikethrough(let c): + if st_containsInlineLink(to: term, in: c) { return true } + default: + break + } + } + return false +} + +private func st_collectAllInlines(from document: STMarkdownDocument) -> [STMarkdownInlineNode] { + var result: [STMarkdownInlineNode] = [] + func walkBlocks(_ blocks: [STMarkdownBlockNode]) { + for block in blocks { + switch block { + case .paragraph(let inlines), .heading(_, let inlines): + result.append(contentsOf: inlines) + case .quote(let children): + walkBlocks(children) + case .list(_, let items): + for item in items { walkBlocks(item.blocks) } + case .table(let table): + let header = (table.header ?? []).flatMap { $0 } + let rows = table.rows.flatMap { $0 }.flatMap { $0 } + result.append(contentsOf: header + rows) + case .details(let summary, let body): + result.append(contentsOf: summary) + walkBlocks(body) + default: + break + } + } + } + walkBlocks(document.blocks) + return result +} + +private func st_assertNoRawMarkdownSyntaxLeaks(in output: String, file: StaticString = #filePath, line: UInt = #line) { + let forbidden = ["```", "{{ST_MATH_BLOCK:", "$$", "\\(", "\\)"] + for token in forbidden { + XCTAssertFalse( + output.contains(token), + "渲染可见串不得残留定界符:\(token.debugDescription)", + file: file, + line: line + ) + } +} + +// MARK: - Tests + +final class STMarkdownParsingResourceFixtureTests: XCTestCase { + + // MARK: 全 fixture 管线冒烟(Engine = Sanitizer + MalformedTable + Parser + Semantic + RenderAdapter) + + func testAllResourceFixturesCompletePipeline() throws { + let engine = STMarkdownEngine() + for name in STMarkdownParsingResourceFixture.names { + let markdown = try STMarkdownParsingResourceFixture.text(named: name) + XCTAssertFalse(markdown.isEmpty, "\(name) fixture 不应为空") + + let result = engine.process(markdown) + XCTAssertFalse(result.sourceDocument.blocks.isEmpty, "\(name) source AST 不应为空") + XCTAssertFalse(result.normalizedDocument.blocks.isEmpty, "\(name) normalized AST 不应为空") + XCTAssertFalse(result.renderDocument.blocks.isEmpty, "\(name) render AST 不应为空") + XCTAssertFalse(result.tableOfContents.isEmpty, "\(name) 应抽取至少一个标题目录项") + } + } + + // MARK: STMarkdownInputSanitizer + + func testInputSanitizer_AllResourceFixturesProduceNonEmptySanitizedText() throws { + let sanitizer = STMarkdownInputSanitizer(rules: STMarkdownInputSanitizer.defaultRules) + for name in STMarkdownParsingResourceFixture.names { + let raw = try STMarkdownParsingResourceFixture.text(named: name) + let outcome = sanitizer.sanitize(raw) + XCTAssertFalse(outcome.sanitizedText.isEmpty, "\(name) sanitizer 输出不应为空") + XCTAssertGreaterThan( + outcome.sanitizedText.count, + raw.count / 4, + "\(name) sanitizer 不应过度截断正文" + ) + } + } + + // MARK: STMarkdownMalformedTableNormalizer + + func testMalformedTableNormalizer_Data2IsIdempotent() throws { + let raw = try STMarkdownParsingResourceFixture.text(named: "data2") + let sanitizer = STMarkdownInputSanitizer(rules: STMarkdownInputSanitizer.defaultRules) + let sanitized = sanitizer.sanitize(raw).sanitizedText + + let once = STMarkdownMalformedTableNormalizer.normalize(sanitized) + let twice = STMarkdownMalformedTableNormalizer.normalize(once) + XCTAssertEqual(once, twice, "data2 表格预修复应幂等") + XCTAssertTrue(once.contains("|"), "data2 预修复后应保留表格竖线") + } + + // MARK: STMarkdownMathNormalizer(经 StructureParser 间接) + + func testMathNormalizer_ResourceFixturesDoNotFabricateBlockMathPlaceholders() throws { + let parser = STMarkdownStructureParser() + for name in STMarkdownParsingResourceFixture.names { + let raw = try STMarkdownParsingResourceFixture.text(named: name) + let normalized = STMarkdownMathNormalizer.normalizeBlocks(in: raw) + XCTAssertTrue( + normalized.blockMap.isEmpty, + "\(name) 不含独立 $$ 块级公式时不应生成 math block 占位" + ) + let doc = parser.parse(normalized.text) + XCTAssertFalse(doc.blocks.isEmpty, "\(name) 经 math 预处理后仍应可解析") + let plain = st_documentPlainText(doc) + XCTAssertFalse( + plain.contains("{{ST_MATH_BLOCK:"), + "\(name) 可见语义文本不应含块公式占位符" + ) + } + } + + // MARK: STMarkdownStructureParser(含 FootnoteSupport / HTMLBlockClassifier 路径) + + func testStructureParser_Data1PreservesBracketTermsAndHeadings() throws { + let raw = try STMarkdownParsingResourceFixture.text(named: "data1") + let doc = STMarkdownStructureParser().parse(raw) + let plain = st_documentPlainText(doc) + XCTAssertTrue(plain.contains("第一步"), "data1 应解析出「第一步」") + XCTAssertTrue(plain.contains("偏头痛") || plain.contains("咖啡因"), "data1 应保留方括号术语正文") + + let inlines = st_collectAllInlines(from: doc) + XCTAssertTrue( + st_containsInlineLink(to: "偏头痛", in: inlines) + || plain.contains("[偏头痛]") + || plain.contains("偏头痛"), + "data1 方括号术语应解析为链接或保留可读正文" + ) + + let headingCount = doc.blocks.compactMap { block -> Int? in + if case .heading(let level, _) = block { return level } + return nil + }.count + XCTAssertGreaterThanOrEqual(headingCount, 5, "data1 应含多级标题") + } + + func testStructureParser_Data2ProducesSingleWellFormedTable() throws { + let raw = try STMarkdownParsingResourceFixture.text(named: "data2") + let sanitized = STMarkdownInputSanitizer(rules: STMarkdownInputSanitizer.defaultRules) + .sanitize(raw).sanitizedText + let parserInput = STMarkdownMalformedTableNormalizer.normalize(sanitized) + let doc = STMarkdownStructureParser().parse(parserInput) + + let tables = doc.blocks.compactMap { block -> STMarkdownTableModel? in + if case .table(let model) = block { return model } + return nil + } + XCTAssertEqual(tables.count, 1, "data2 应解析出单个主表格") + guard let table = tables.first else { return } + XCTAssertGreaterThanOrEqual(table.header?.count ?? 0, 3) + XCTAssertGreaterThanOrEqual(table.rows.count, 8) + + let headerText = st_joinInlinePlainText((table.header ?? []).flatMap { $0 }) + XCTAssertTrue(headerText.contains("类别")) + XCTAssertTrue(headerText.contains("具体建议")) + } + + func testStructureParser_Data3NestedListsCodeBlocksAndCitationText() throws { + let raw = try STMarkdownParsingResourceFixture.text(named: "data3") + let doc = STMarkdownStructureParser().parse(raw) + + let codeBlocks = doc.blocks.compactMap { block -> (String?, String)? in + if case .codeBlock(let lang, let code) = block { return (lang, code) } + return nil + } + XCTAssertGreaterThanOrEqual(codeBlocks.count, 2, "data3 应含 markdown/html 代码块") + XCTAssertTrue(codeBlocks.contains { ($0.0 ?? "").lowercased().contains("markdown") }) + XCTAssertTrue(codeBlocks.contains { ($0.0 ?? "").lowercased().contains("html") }) + + let lists = doc.blocks.compactMap { block -> STMarkdownListKind? in + if case .list(let kind, _) = block { return kind } + return nil + } + XCTAssertFalse(lists.isEmpty, "data3 应含列表块") + XCTAssertTrue(lists.contains { if case .ordered = $0 { return true }; return false }) + + let plain = st_documentPlainText(doc) + XCTAssertTrue(plain.contains("[5]") || plain.contains("[8]"), "data3 引用角标应保留在 AST 语义文本中") + XCTAssertTrue(plain.contains("应用场景")) + } + + // MARK: STMarkdownSemanticNormalizer + + func testSemanticNormalizer_PassthroughPreservesResourceBlockCount() throws { + let parser = STMarkdownStructureParser() + let normalizer = STMarkdownSemanticNormalizer.passthrough + for name in STMarkdownParsingResourceFixture.names { + let raw = try STMarkdownParsingResourceFixture.text(named: name) + let parsed = parser.parse(raw) + let normalized = normalizer.normalize(parsed) + XCTAssertEqual( + parsed.blocks.count, + normalized.blocks.count, + "\(name) passthrough 归一化不应改变块数量" + ) + } + } + + // MARK: 端到端渲染(Parsing 输出经 RenderAdapter + Renderer) + + func testRenderedPlainText_AllResourceFixturesNoSyntaxLeaks() throws { + let engine = STMarkdownEngine() + let renderer = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + for name in STMarkdownParsingResourceFixture.names { + let markdown = try STMarkdownParsingResourceFixture.text(named: name) + let result = engine.process(markdown) + let plain = renderer.render(document: result.renderDocument).string + XCTAssertFalse(plain.isEmpty, "\(name) 渲染结果不应为空") + st_assertNoRawMarkdownSyntaxLeaks(in: plain) + } + } + + func testRenderedPlainText_Data1ContainsExpectedSectionTitles() throws { + let markdown = try STMarkdownParsingResourceFixture.text(named: "data1") + let plain = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: STMarkdownEngine().process(markdown).renderDocument) + .string + XCTAssertTrue(plain.contains("第一步")) + XCTAssertTrue(plain.contains("第三步")) + XCTAssertTrue(plain.contains("立即")) + } + + func testRenderedPlainText_Data2ContainsTableContent() throws { + let markdown = try STMarkdownParsingResourceFixture.text(named: "data2") + let plain = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: STMarkdownEngine().process(markdown).renderDocument) + .string + XCTAssertTrue(plain.contains("减肥方法")) + XCTAssertTrue(plain.contains("控制热量摄入")) + } + + func testRenderedPlainText_Data3PreservesExampleSectionTitles() throws { + let markdown = try STMarkdownParsingResourceFixture.text(named: "data3") + let plain = STMarkdownAttributedStringRenderer(style: .default, advancedRenderers: .empty) + .render(document: STMarkdownEngine().process(markdown).renderDocument) + .string + XCTAssertTrue(plain.contains("Markdown实现示例")) + XCTAssertTrue(plain.contains("HTML实现示例")) + XCTAssertTrue(plain.contains("应用场景")) + } +} diff --git a/Package.resolved b/Package.resolved index 677de88..e250ba8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "branch" : "gfm", - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6" + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-markdown.git", "state" : { - "branch" : "main", - "revision" : "55d66d9a9e8d4fd3f48d111b0d437e82fe451903" + "revision" : "3c6f9523da3a1ec2fd829673e472d95b8097a3b8", + "version" : "0.8.0" } }, { @@ -23,7 +23,6 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mgriebling/SwiftMath.git", "state" : { - "branch" : "main", "revision" : "48ff188ba118c37d024551238041113560ab09b9" } } diff --git a/Package.swift b/Package.swift index 7cb412b..772f728 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-markdown.git", revision: "55d66d9a9e8d4fd3f48d111b0d437e82fe451903"), + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.8.0"), .package(url: "https://github.com/mgriebling/SwiftMath.git", revision: "48ff188ba118c37d024551238041113560ab09b9") ], targets: [ diff --git a/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved b/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved index 99b74a3..459b1ad 100644 --- a/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/STBaseProject.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "branch" : "gfm", - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6" + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" } }, { @@ -15,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-markdown.git", "state" : { - "revision" : "55d66d9a9e8d4fd3f48d111b0d437e82fe451903" + "revision" : "3c6f9523da3a1ec2fd829673e472d95b8097a3b8", + "version" : "0.8.0" } }, { diff --git a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift index a220024..7a79a95 100644 --- a/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift +++ b/Sources/STMarkdown/Core/STMarkdownStreamBuffer.swift @@ -101,6 +101,22 @@ public final class STMarkdownStreamBuffer { let startPosition = indexInCurrentText(offset: lastSafeUpperBoundOffset) if let pending = detectPendingStructure(in: textToAnalyze) { + if pending == .table, hasTableSeparatorRow(in: textToAnalyze) { + let newSlice = startPosition < textToAnalyze.endIndex + ? String(textToAnalyze[startPosition...]) + : "" + let trimmed = newSlice.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + lastSafeUpperBoundOffset = textToAnalyze.distance( + from: textToAnalyze.startIndex, to: textToAnalyze.endIndex) + return ModuleDetectionResult( + completeModules: [trimmed], + pendingText: "", + hasPendingStructure: false, + pendingType: nil + ) + } + } let pendingSlice = startPosition < textToAnalyze.endIndex ? String(textToAnalyze[startPosition...]) : "" @@ -397,4 +413,18 @@ public final class STMarkdownStreamBuffer { } return "" } + + private func hasTableSeparatorRow(in text: String) -> Bool { + for line in text.components(separatedBy: "\n") { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("|") && t.contains("-") else { continue } + let cells = t.components(separatedBy: "|").filter { !$0.isEmpty } + if !cells.isEmpty && cells.allSatisfy({ + $0.trimmingCharacters(in: .whitespaces).allSatisfy({ $0 == "-" || $0 == ":" || $0 == " " }) + }) { + return true + } + } + return false + } } diff --git a/Sources/STMarkdown/Core/STMarkdownStyle.swift b/Sources/STMarkdown/Core/STMarkdownStyle.swift index 9a6b9a5..32be114 100644 --- a/Sources/STMarkdown/Core/STMarkdownStyle.swift +++ b/Sources/STMarkdown/Core/STMarkdownStyle.swift @@ -117,6 +117,13 @@ public struct STMarkdownStyle: @unchecked Sendable { public var streamMinModuleLength: Int /// ``STMarkdownRenderBlock/rawHTML`` 与 ``STMarkdownInlineNode/inlineRawHTML`` 的展示策略。 public var rawHTMLPolicy: STMarkdownRawHTMLPolicy + /// 流式输出时的字符级 fade-in 动画开关。 + /// `false` 时 ``STMarkdownStreamingTextView`` 的 ``tokenFadeDuration`` 强制为 0。 + public var streamFadeInEnabled: Bool + /// 流式输出时的行级 CAGradientLayer 水平扫入动画开关。 + /// `true` 时使用与 FluidMarkdown 相同的行级渐现效果,替代默认的字符级 foregroundColor 淡入。 + /// `streamFadeInEnabled` 为 `false` 时此属性无效。 + public var streamLineFadeEnabled: Bool public init( font: UIFont, @@ -179,7 +186,9 @@ public struct STMarkdownStyle: @unchecked Sendable { codeBlockSeparatorSpacing: CGFloat = 8, codeBlockButtonRowReservedWidth: CGFloat = 120, streamMinModuleLength: Int = 20, - rawHTMLPolicy: STMarkdownRawHTMLPolicy = .suppress + rawHTMLPolicy: STMarkdownRawHTMLPolicy = .suppress, + streamFadeInEnabled: Bool = true, + streamLineFadeEnabled: Bool = false ) { self.font = font self.boldFont = boldFont @@ -242,6 +251,8 @@ public struct STMarkdownStyle: @unchecked Sendable { self.codeBlockButtonRowReservedWidth = codeBlockButtonRowReservedWidth self.streamMinModuleLength = max(1, streamMinModuleLength) self.rawHTMLPolicy = rawHTMLPolicy + self.streamFadeInEnabled = streamFadeInEnabled + self.streamLineFadeEnabled = streamLineFadeEnabled } public static let `default` = STMarkdownStyle( diff --git a/Sources/STMarkdown/Core/STMarkdownTypography.swift b/Sources/STMarkdown/Core/STMarkdownTypography.swift index 1502509..93154c5 100644 --- a/Sources/STMarkdown/Core/STMarkdownTypography.swift +++ b/Sources/STMarkdown/Core/STMarkdownTypography.swift @@ -123,7 +123,7 @@ public enum STMarkdownListStyleResolver { markerFont = UIFont.st_systemFont(ofSize: unorderedLevel0Size, weight: .regular) contentIndent = markerIndent + unorderedLevel0Size + unorderedLevel0Spacing case 1: - markerText = "\t●\t" + markerText = "\t○\t" markerFont = UIFont.st_systemFont(ofSize: unorderedLevel1Size, weight: .regular) contentIndent = markerIndent + unorderedLevel1Size + unorderedLevel1Spacing default: diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index ae4ae56..2bd8f91 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -497,7 +497,7 @@ private extension STMarkdownAttributedStringRenderer { let layout = self.listLayout(for: item) let markerAttributes: [NSAttributedString.Key: Any] = [ .font: layout.markerFont, - .foregroundColor: self.style.textColor, + .foregroundColor: self.style.listMarkerColor ?? self.style.textColor, .paragraphStyle: layout.paragraphStyle, .baselineOffset: layout.baselineOffset, ] @@ -657,12 +657,19 @@ private extension STMarkdownAttributedStringRenderer { let orderedIndex = item.orderedIndex ?? 1 markerText = "\(orderedIndex).\t" let markerWidth = ceil(("\(orderedIndex)." as NSString).size(withAttributes: [.font: markerFont]).width) - contentIndent = firstLineIndent + markerWidth + 5 + contentIndent = firstLineIndent + max(markerWidth + 5, self.style.listMarkerWidth) baselineOffset = 0 } else { - markerText = item.level == 0 ? "\t●\t" : "\t○\t" - markerFont = .st_systemFont(ofSize: 7, weight: .regular) - contentIndent = firstLineIndent + 13 + let bulletSymbol: String + switch item.level { + case 0: bulletSymbol = "●" + case 1: bulletSymbol = "○" + default: bulletSymbol = "▪" + } + markerText = "\(bulletSymbol)\t" + let bulletSize = max(round(self.style.font.pointSize * 0.44), 5) + markerFont = .st_systemFont(ofSize: bulletSize, weight: .regular) + contentIndent = firstLineIndent + self.style.listMarkerWidth let baseMidline = (self.style.font.ascender + self.style.font.descender) / 2 let markerMidline = (markerFont.ascender + markerFont.descender) / 2 baselineOffset = baseMidline - markerMidline diff --git a/Sources/STMarkdown/Table/STMarkdownTableView.swift b/Sources/STMarkdown/Table/STMarkdownTableView.swift index e508473..1738c81 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableView.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableView.swift @@ -41,8 +41,8 @@ public final class STMarkdownTableView: UIView { self.gridLayout = STMarkdownTableGridLayout() self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.gridLayout) super.init(frame: .zero) - self.setupGradientLayers() self.setupCollectionView() + self.setupGradientLayers() self.applyStyle() } diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index 784107e..14b48dc 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -21,7 +21,10 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { public var tokenFadeDuration: TimeInterval { get { self.shimmerTextView.tokenFadeDuration } - set { self.shimmerTextView.tokenFadeDuration = newValue } + set { + _requestedTokenFadeDuration = newValue + self.shimmerTextView.tokenFadeDuration = self.markdownStyle.streamFadeInEnabled ? newValue : 0 + } } public var customDocumentRenderer: ((STMarkdownRenderDocument) -> NSAttributedString)? @@ -47,6 +50,8 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { private var pendingAnimatedSuffix: PendingAnimatedSuffix? private var streamingWatchdogWorkItem: DispatchWorkItem? private var streamingWatchdogGeneration: Int = 0 + /// tokenFadeDuration 最后一次被外部请求设置的值;样式允许 fade 时以此值写入 shimmerTextView。 + private var _requestedTokenFadeDuration: TimeInterval = 0.3 /// 上一帧已交给 ``setMarkdown(_:animated:)`` 的「安全前缀」展示串(经 ``stripUnclosedTailMarkers``)。 /// 当缓冲器仅增长尾部、``committedSafePrefix`` 不变时跳过整段重解析,降低流式 CPU 占用(对齐对比文档 P0)。 private var lastSmartStreamRenderedDisplayMarkdown: String? @@ -87,7 +92,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.shimmerTextView.renderedAttributedText } - public init(frame: CGRect, usesTextLayoutManager: Bool = false) { + public init(frame: CGRect, usesTextLayoutManager: Bool = true) { super.init( textView: STShimmerTextView(usingTextLayoutManager: usesTextLayoutManager), frame: frame, @@ -103,7 +108,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { style: STMarkdownStyle = .default, advancedRenderers: STMarkdownAdvancedRenderers = .empty, engine: STMarkdownEngine = STMarkdownEngine(), - usesTextLayoutManager: Bool = false + usesTextLayoutManager: Bool = true ) { self.init(frame: .zero, usesTextLayoutManager: usesTextLayoutManager) self.applyConfigurationCommon( @@ -115,7 +120,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { public required init?(coder: NSCoder) { super.init( - textView: STShimmerTextView(usingTextLayoutManager: false), + textView: STShimmerTextView(usingTextLayoutManager: true), coder: coder, style: .default, advancedRenderers: .empty, @@ -307,6 +312,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { } internal override func configurationDidChangeRerender() { + self.syncTokenFadeDurationFromStyle() if self.smartStreamingSessionActive { self.smartStreamBuffer?.updateMinModuleLength(self.markdownStyle.streamMinModuleLength) self.applySmartStreamingPresentation(animated: false, force: true) @@ -324,6 +330,23 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { self.lastSmartStreamingRenderMode = nil } + internal override func applyConfigurationCommon( + style: STMarkdownStyle, + advancedRenderers: STMarkdownAdvancedRenderers, + engine: STMarkdownEngine + ) { + super.applyConfigurationCommon(style: style, advancedRenderers: advancedRenderers, engine: engine) + self.syncTokenFadeDurationFromStyle() + } + + private func syncTokenFadeDurationFromStyle() { + let isLineFade = self.markdownStyle.streamFadeInEnabled && self.markdownStyle.streamLineFadeEnabled + self.shimmerTextView.lineFadeMode = isLineFade + self.shimmerTextView.tokenFadeDuration = (self.markdownStyle.streamFadeInEnabled && !isLineFade) + ? _requestedTokenFadeDuration + : 0 + } + private func applySmartStreamingPresentation(animated: Bool, force: Bool = false) { guard let buffer = self.smartStreamBuffer else { return } let accumulated = buffer.fullAccumulatedText diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift index 72668b8..518f895 100644 --- a/Sources/STUIKit/STTextView/STShimmerTextView.swift +++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift @@ -13,6 +13,20 @@ open class STShimmerTextView: UITextView { /// 直接以最终颜色渲染。用于 list marker、block separator、heading 等结构元素。 public static let skipFadeInAttributeKey = NSAttributedString.Key("STShimmerTextView.skipFadeIn") + // MARK: - LineFadeLayer(行级 CAGradientLayer 遮罩扫入动画) + private final class LineFadeLayer: CAGradientLayer { + /// 动画已完成(待下次 applyLineFadeAnimation 时清理折入基底层)。 + var isFadeComplete: Bool = false + } + + private final class LineFadeAnimationDelegate: NSObject, CAAnimationDelegate { + private let onComplete: () -> Void + init(_ onComplete: @escaping () -> Void) { self.onComplete = onComplete } + func animationDidStop(_ anim: CAAnimation, finished: Bool) { + if finished { onComplete() } + } + } + private struct AnimatingColorRun { let range: NSRange let targetColor: UIColor @@ -35,10 +49,19 @@ open class STShimmerTextView: UITextView { /// 为 true 时,跨换行也保持连续字符级渐显; /// 为 false 时,新增 delta 中最后一个换行前的内容会立即显示,仅最后一行保留动画。 public var animateAcrossNewlines: Bool = false + /// `true` 时改用 FluidMarkdown 风格的行级 CAGradientLayer 水平扫入遮罩动画, + /// 替代默认的字符级 foregroundColor 淡入;由 STMarkdownStreamingTextView 根据样式同步。 + public var lineFadeMode: Bool = false + /// 行级扫入动画时长(秒),默认 0.15 s,与 FluidMarkdown 对齐。 + public var lineFadeDuration: TimeInterval = 0.15 public var suppressSystemTextMenu: Bool = false public var onAnimationStateChange: ((Bool) -> Void)? private var displayLink: CADisplayLink? private var animatingTokens: [AnimatingToken] = [] + /// 行级遮罩所用父 CALayer(挂在 self.layer.mask)。 + private var _lineFadeMaskLayer: CALayer? + /// 遮罩内的基础不透明层,覆盖所有"已完成动画"的文本区域。 + private var _lineFadeBaseLayer: CALayer? /// 最终目标态的 attributed text(全不透明),不含任何动画中间状态的 alpha 值。 /// 供外部做 "已渲染前缀" 比较时使用,避免因动画过渡期 alpha < 1 导致前缀比较误判。 private var _baseAttributedText: NSMutableAttributedString = NSMutableAttributedString() @@ -71,12 +94,11 @@ open class STShimmerTextView: UITextView { /// - Parameter usingTextLayoutManager: `true` 时使用 TextKit 2 栈(iOS 16+);低版本系统始终为 TextKit 1。 public convenience init(usingTextLayoutManager: Bool) { if #available(iOS 16.0, *) { - if usingTextLayoutManager { - let shell = UITextView(usingTextLayoutManager: true) - self.init(frame: .zero, textContainer: shell.textContainer) - } else { - self.init(frame: .zero, textContainer: nil) - } + // UITextView(frame:textContainer:nil) 在 iOS 16+ 默认启用 TextKit 2, + // 导致 textLayoutManager != nil;行级遮罩动画依赖 NSLayoutManager, + // 必须通过 UITextView(usingTextLayoutManager:) 显式指定版本。 + let shell = UITextView(usingTextLayoutManager: usingTextLayoutManager) + self.init(frame: .zero, textContainer: shell.textContainer) } else { self.init(frame: .zero, textContainer: nil) } @@ -145,11 +167,23 @@ open class STShimmerTextView: UITextView { let appended = NSMutableAttributedString(attributedString: attributedText) let defaultColor = self.baseForegroundColor(from: self.defaultTextAttributes) self.ensureForegroundColor(in: appended, defaultColor: defaultColor) - // 保存 contentOffset:UITextView 在 textStorage 修改后可能意外偏移, - // 导致非滚动文本视图出现上下抖动。在所有 textStorage 操作之前保存。 + // 保存 contentOffset:UITextView 在 textStorage 修改后可能意外偏移。 let savedOffset = self.contentOffset - // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画, - // 确保前一段落完全显示后新段落才开始淡入,避免两段同时渲染的视觉问题。 + + // 行级 CAGradientLayer 扫入模式:文本保持全不透明,由遮罩层控制可见性。 + if animated && self.lineFadeMode { + _baseAttributedText.append(appended) + self.textStorage.beginEditing() + self.textStorage.append(appended) + self.textStorage.endEditing() + if self.contentOffset != savedOffset { self.contentOffset = savedOffset } + self.applyLineFadeAnimation( + changedRange: NSRange(location: startLocation, length: appended.length) + ) + return + } + + // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画 if animated, self.tokenFadeDuration > 0, !self.animateAcrossNewlines { self.finishAnimationsBeforeLastNewline() } @@ -213,6 +247,7 @@ open class STShimmerTextView: UITextView { public func setRenderedAttributedText(_ attributedText: NSAttributedString) { self.stopDisplayLink() self.animatingTokens.removeAll() + self.removeLineFadeMask() _baseAttributedText = NSMutableAttributedString(attributedString: attributedText) self.textStorage.beginEditing() self.textStorage.setAttributedString(attributedText) @@ -243,6 +278,7 @@ open class STShimmerTextView: UITextView { self.textStorage.endEditing() } self.animatingTokens.removeAll() + if self.lineFadeMode { self.removeLineFadeMask() } // 计算旧尾部字符串,用于后续判断哪些是"真正新增"的字符 let oldTrailingLength = self.textStorage.length - clampedLocation @@ -335,6 +371,7 @@ open class STShimmerTextView: UITextView { public func reset() { self.stopDisplayLink() self.animatingTokens.removeAll() + self.removeLineFadeMask() _baseAttributedText = NSMutableAttributedString() self.textStorage.beginEditing() self.textStorage.setAttributedString(NSAttributedString()) @@ -345,6 +382,7 @@ open class STShimmerTextView: UITextView { self.stopDisplayLink() let pendingTokens = self.animatingTokens self.animatingTokens.removeAll() + self.removeLineFadeMask() guard !pendingTokens.isEmpty else { return } self.textStorage.beginEditing() for token in pendingTokens { @@ -618,6 +656,157 @@ open class STShimmerTextView: UITextView { } } + // MARK: - Line Fade Mask + + private func removeLineFadeMask() { + guard _lineFadeMaskLayer != nil else { return } + self.layer.mask = nil + _lineFadeMaskLayer = nil + _lineFadeBaseLayer = nil + } + + /// 对 `changedRange` 所在的最末行应用 CAGradientLayer 水平扫入遮罩动画(FluidMarkdown 风格)。 + /// 遮罩结构:父 CALayer(mask)→ 黑色基础层(覆盖已完成行)+ LineFadeLayer(当前行动画)。 + /// TK2(iOS 16+)优先;TK2 不可用时回退到 TK1 layoutManager。 + private func applyLineFadeAnimation(changedRange: NSRange) { + guard changedRange.length > 0, self.bounds.width > 1 else { return } + + if _lineFadeMaskLayer == nil { + let null = NSNull() + let mask = CALayer() + mask.actions = [ + "bounds": null, "position": null, + "frame": null, "sublayerTransform": null, "transition": null, + ] + let base = CALayer() + base.backgroundColor = UIColor.black.cgColor + base.actions = ["bounds": null, "position": null, "frame": null, "transition": null] + mask.addSublayer(base) + _lineFadeBaseLayer = base + _lineFadeMaskLayer = mask + self.layer.mask = mask + } + guard let mask = _lineFadeMaskLayer, let base = _lineFadeBaseLayer else { return } + mask.frame = self.bounds + // FluidMarkdown 对齐:补偿滚动偏移(isScrollEnabled=false 时为 identity,仍保留以确保正确性) + mask.sublayerTransform = CATransform3DMakeTranslation(contentOffset.x, -contentOffset.y, 0) + + if #available(iOS 16.0, *), let tlm = self.textLayoutManager { + applyLineFadeAnimation_tk2(changedRange: changedRange, tlm: tlm, mask: mask, base: base) + } else { + applyLineFadeAnimation_tk1(changedRange: changedRange, mask: mask, base: base) + } + } + + @available(iOS 16.0, *) + private func applyLineFadeAnimation_tk2( + changedRange: NSRange, + tlm: NSTextLayoutManager, + mask: CALayer, + base: CALayer + ) { + guard let tcs = tlm.textContentManager else { return } + let docStart = tcs.documentRange.location + guard + let rs = tcs.location(docStart, offsetBy: changedRange.location), + let re = tcs.location(rs, offsetBy: changedRange.length), + let textRange = NSTextRange(location: rs, end: re) + else { return } + tlm.ensureLayout(for: textRange) + + // 按 minY 分组得到最末行的 union 矩形 + var prevMinY: CGFloat = .nan + var curLineRect: CGRect = .null + var lastLineRect: CGRect = .null + tlm.enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { _, sf, _, _ in + if abs(sf.minY - prevMinY) > 0.5 { + prevMinY = sf.minY + curLineRect = sf + } else { + curLineRect = curLineRect.union(sf) + } + lastLineRect = curLineRect + return true + } + guard !lastLineRect.isNull else { return } + installLineFadeLayer(lineRect: lastLineRect, rightEdge: lastLineRect.maxX, mask: mask, base: base) + } + + private func applyLineFadeAnimation_tk1(changedRange: NSRange, mask: CALayer, base: CALayer) { + let glyphRange = self.layoutManager.glyphRange( + forCharacterRange: changedRange, actualCharacterRange: nil + ) + self.layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { [weak self] + rect, usedRect, _, lineGlyphRange, _ in + guard let self else { return } + guard NSMaxRange(glyphRange) == NSMaxRange(lineGlyphRange) else { return } + self.installLineFadeLayer(lineRect: rect, rightEdge: usedRect.maxX, mask: mask, base: base) + } + } + + /// 在遮罩层上为当前最末行追加或更新一个 LineFadeLayer。 + /// - Parameters: + /// - lineRect: 行片段矩形(用于确定 y 位置和行高)。 + /// - rightEdge: 行内已用文字的右边界(TK1 用 usedRect.maxX;TK2 用 segment union 的 maxX)。 + private func installLineFadeLayer(lineRect rect: CGRect, rightEdge: CGFloat, mask: CALayer, base: CALayer) { + // 基础层覆盖当前行以上的所有内容 + base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.minY) + + // lineDetectRect:稍扩展以容纳浮点误差(与 FluidMarkdown 相同) + let lineDetectRect = CGRect( + x: floor(rect.minX), y: floor(rect.minY), + width: ceil(rect.width), height: ceil(rect.height + 1) + ) + var latestX: CGFloat = rect.minX + for sub in mask.sublayers ?? [] { + guard let fl = sub as? LineFadeLayer else { continue } + if lineDetectRect.contains(fl.frame) { + if fl.isFadeComplete { + // 已完成的层:折入基础层并移除 + fl.removeFromSuperlayer() + base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.maxY) + } else { + latestX = max(latestX, fl.frame.maxX) + } + } else { + // 其他行的旧层:基础层已覆盖,直接移除 + fl.removeFromSuperlayer() + } + } + + let newFrame = CGRect(x: latestX, y: rect.minY, width: rightEdge - latestX, height: rect.height) + guard newFrame.width > 0.5, newFrame.height > 0.5 else { return } + + // 去重:整数像素级别比较(FluidMarkdown 用 CGRectIntegral) + let isDuplicate = (mask.sublayers ?? []).compactMap { $0 as? LineFadeLayer }.contains { + CGRectEqualToRect(CGRectIntegral($0.frame), CGRectIntegral(newFrame)) + } + guard !isDuplicate else { return } + + let fl = LineFadeLayer() + fl.startPoint = CGPoint(x: 0, y: 0.5) + fl.endPoint = CGPoint(x: 1, y: 0.5) + fl.frame = newFrame + // 模型值 = 最终状态(动画移除后 layer 回退到此值,无视觉跳变) + fl.colors = [UIColor.black.cgColor, UIColor.black.cgColor] + + let anim = CAKeyframeAnimation(keyPath: "colors") + anim.values = [ + [UIColor.clear.cgColor, UIColor.clear.cgColor], + [UIColor.black.cgColor, UIColor.clear.cgColor], + [UIColor.black.cgColor, UIColor.black.cgColor], + ] + anim.calculationMode = .linear + anim.fillMode = .both // FluidMarkdown: kCAFillModeBoth + anim.isRemovedOnCompletion = true // FluidMarkdown: removedOnCompletion = YES + anim.duration = lineFadeDuration + anim.delegate = LineFadeAnimationDelegate { [weak fl] in + fl?.isFadeComplete = true // 供下次 applyLineFadeAnimation 做懒清理 + } + mask.addSublayer(fl) + fl.add(anim, forKey: "fadeIn") + } + /// 子类可重写:禁止系统长按复制/粘贴菜单,仅使用自定义 popupMenuItems(如 Bajoseek 回复区) open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if self.suppressSystemTextMenu { From dabb15dfc164df4e72e8dce1c5736248e1953d06 Mon Sep 17 00:00:00 2001 From: stack Date: Sun, 24 May 2026 19:46:01 +0800 Subject: [PATCH 20/27] Refactor STMarkdown parsing files by removing outdated comments and simplifying citation extraction logic - Updated comments in `STMarkdownFootnoteDeepLink.swift` to reflect creation date. - Removed unnecessary comment in `STMarkdownFootnoteSupport.swift`. - Simplified citation number extraction in `STMarkdownTableViewModel.swift` by utilizing `STMarkdownCitationReferenceSupport`. - Cleaned up comments in `STMarkdownMalformedTableNormalizer.swift` for clarity. --- .../STMarkdownCitationReferenceSupport.swift | 35 + .../STMarkdownCitationURLMatcher.swift | 141 +++ .../Parsing/STMarkdownFootnoteDeepLink.swift | 2 +- .../Parsing/STMarkdownFootnoteSupport.swift | 2 - .../STMarkdownMalformedTableNormalizer.swift | 1 - .../STMarkdownStreamingStateMachine.swift | 219 ++++ .../STMarkdownStreamingTransforms.swift | 967 ++++++++++++++++++ .../Table/STMarkdownTableViewModel.swift | 11 +- 8 files changed, 1364 insertions(+), 14 deletions(-) create mode 100644 Sources/STMarkdown/Parsing/STMarkdownCitationReferenceSupport.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownCitationURLMatcher.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownStreamingStateMachine.swift create mode 100644 Sources/STMarkdown/Parsing/STMarkdownStreamingTransforms.swift diff --git a/Sources/STMarkdown/Parsing/STMarkdownCitationReferenceSupport.swift b/Sources/STMarkdown/Parsing/STMarkdownCitationReferenceSupport.swift new file mode 100644 index 0000000..036cbcd --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownCitationReferenceSupport.swift @@ -0,0 +1,35 @@ +// +// STMarkdownCitationReferenceSupport.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +public enum STMarkdownCitationReferenceSupport { + private static let citationLinkChildrenRegex = try! NSRegularExpression( + pattern: #"^citation\s*:?\s*(\d+)$"#, + options: [.caseInsensitive] + ) + private static let webpageLinkChildrenRegex = try! NSRegularExpression( + pattern: #"^webpage\s*:?\s*(\d+)$"#, + options: [.caseInsensitive] + ) + + public static func extractCitationNumber(from children: [STMarkdownInlineNode]) -> String? { + guard children.count == 1, case .text(let text) = children[0] else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespaces) + let range = NSRange(location: 0, length: trimmed.utf16.count) + for regex in [Self.citationLinkChildrenRegex, Self.webpageLinkChildrenRegex] { + if let match = regex.firstMatch(in: trimmed, options: [], range: range), + match.numberOfRanges >= 2 { + return (trimmed as NSString).substring(with: match.range(at: 1)) + } + } + if !trimmed.isEmpty, trimmed.allSatisfy(\.isNumber) { + return trimmed + } + return nil + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownCitationURLMatcher.swift b/Sources/STMarkdown/Parsing/STMarkdownCitationURLMatcher.swift new file mode 100644 index 0000000..4b2af20 --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownCitationURLMatcher.swift @@ -0,0 +1,141 @@ +// +// STMarkdownCitationURLMatcher.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +public struct STMarkdownCitationURLMatcher: Sendable { + private static let markdownLinkRegex = try! NSRegularExpression( + pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, + options: [] + ) + + private let citationURLMapping: [String: Int] + private let citationHostMapping: [String: [Int]] + private let citationRegisteredDomainMapping: [String: [Int]] + + public init(citationURLMapping: [String: Int]) { + self.citationURLMapping = citationURLMapping + let maps = Self.makeCitationHostMaps(from: citationURLMapping) + self.citationHostMapping = maps.host + self.citationRegisteredDomainMapping = maps.registeredDomain + } + + public func resolveCitationID(for url: String) -> Int? { + if let id = self.citationURLMapping[url] { return id } + let trimmed = url.hasSuffix("/") ? String(url.dropLast()) : url + "/" + if let id = self.citationURLMapping[trimmed] { return id } + + let normalized: String + if url.hasPrefix("https://") { + normalized = "http://" + url.dropFirst("https://".count) + } else if url.hasPrefix("http://") { + normalized = "https://" + url.dropFirst("http://".count) + } else { + normalized = url + } + if normalized != url { + if let id = self.citationURLMapping[normalized] { return id } + let normalizedTrimmed = normalized.hasSuffix("/") ? String(normalized.dropLast()) : normalized + "/" + if let id = self.citationURLMapping[normalizedTrimmed] { return id } + } + + if var comps = URLComponents(string: url), (comps.query != nil || comps.fragment != nil) { + comps.query = nil + comps.fragment = nil + if let strippedURL = comps.string { + if let id = self.citationURLMapping[strippedURL] { return id } + let strippedTrimmed = strippedURL.hasSuffix("/") ? String(strippedURL.dropLast()) : strippedURL + "/" + if let id = self.citationURLMapping[strippedTrimmed] { return id } + } + } + + for (mappingURL, citationID) in self.citationURLMapping { + if var mappingComps = URLComponents(string: mappingURL), + (mappingComps.query != nil || mappingComps.fragment != nil) { + mappingComps.query = nil + mappingComps.fragment = nil + if let strippedMappingURL = mappingComps.string, + strippedMappingURL == url || strippedMappingURL == trimmed { + return citationID + } + } + } + + if let comps = URLComponents(string: url), let host = comps.host?.lowercased() { + if let ids = self.citationHostMapping[host], ids.count == 1 { + return ids[0] + } + let registeredDomain = Self.extractRegisteredDomain(from: host) + if registeredDomain != host, + let ids = self.citationRegisteredDomainMapping[registeredDomain], + let firstID = ids.first { + return firstID + } + } + return nil + } + + public func replaceMarkdownLinksWithCitations(in text: String) -> String { + guard !self.citationURLMapping.isEmpty else { return text } + let ns = text as NSString + let fullRange = NSRange(location: 0, length: ns.length) + let matches = Self.markdownLinkRegex.matches(in: text, range: fullRange) + guard !matches.isEmpty else { return text } + let result = NSMutableString(string: text) + for match in matches.reversed() { + guard match.numberOfRanges >= 3 else { continue } + let urlRange = match.range(at: 2) + var linkURL = ns.substring(with: urlRange) + linkURL = linkURL.replacingOccurrences(of: "\\/", with: "/") + if let citationID = self.resolveCitationID(for: linkURL) { + result.replaceCharacters(in: match.range, with: "[Citation:\(citationID)]") + } + } + return result as String + } + + private static func extractRegisteredDomain(from host: String) -> String { + let parts = host.split(separator: ".").map(String.init) + guard parts.count >= 2 else { return host } + let doubleSuffixTLDs: Set = [ + "co.uk", "co.jp", "co.kr", "co.nz", "co.za", "co.in", + "com.cn", "com.hk", "com.tw", "com.au", "com.br", "com.sg", + "net.cn", "org.cn", "gov.cn", "ac.uk", "org.uk", + ] + if parts.count >= 3 { + let lastTwo = parts.suffix(2).joined(separator: ".") + if doubleSuffixTLDs.contains(lastTwo) { + return parts.suffix(3).joined(separator: ".") + } + } + return parts.suffix(2).joined(separator: ".") + } + + private static func makeCitationHostMaps( + from citationURLMapping: [String: Int] + ) -> (host: [String: [Int]], registeredDomain: [String: [Int]]) { + var hostMap: [String: [Int]] = [:] + var domainMap: [String: [Int]] = [:] + for (urlString, citationID) in citationURLMapping { + guard let comps = URLComponents(string: urlString), let host = comps.host?.lowercased() else { continue } + hostMap[host, default: []].append(citationID) + let registeredDomain = Self.extractRegisteredDomain(from: host) + domainMap[registeredDomain, default: []].append(citationID) + } + for host in hostMap.keys { + if let ids = hostMap[host] { + hostMap[host] = Array(Set(ids)).sorted() + } + } + for domain in domainMap.keys { + if let ids = domainMap[domain] { + domainMap[domain] = Array(Set(ids)).sorted() + } + } + return (hostMap, domainMap) + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownFootnoteDeepLink.swift b/Sources/STMarkdown/Parsing/STMarkdownFootnoteDeepLink.swift index 3370001..dbeee79 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownFootnoteDeepLink.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownFootnoteDeepLink.swift @@ -2,7 +2,7 @@ // STMarkdownFootnoteDeepLink.swift // STBaseProject // -// 脚注引用在富文本上使用自定义 URL scheme,与正文 ``onLinkTap`` 分流,避免与表格 Citation 角标语义混用。 +// Created by 寒江孤影 on 2019/03/16. // import Foundation diff --git a/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift index 29fc023..9eaa8c3 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownFootnoteSupport.swift @@ -7,8 +7,6 @@ import Foundation -// MARK: - 定义行剥离 - enum STMarkdownFootnoteDefinitionScanner { private static let definitionLine = try! NSRegularExpression( pattern: #"^\[\^([^\]]+)\]:\s*(.*)$"#, diff --git a/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift b/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift index 3189e54..799b4f6 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownMalformedTableNormalizer.swift @@ -7,7 +7,6 @@ import Foundation -/// 将常见异常表格 Markdown 规整为更接近标准 GFM 的形态,提升 `swift-markdown` 解析成功率。 public enum STMarkdownMalformedTableNormalizer: Sendable { /// 按开关对输入做表格规整;`enabled == false` 时原样返回。 diff --git a/Sources/STMarkdown/Parsing/STMarkdownStreamingStateMachine.swift b/Sources/STMarkdown/Parsing/STMarkdownStreamingStateMachine.swift new file mode 100644 index 0000000..16cc93e --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownStreamingStateMachine.swift @@ -0,0 +1,219 @@ +// +// STMarkdownCitationURLMatcher.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +/// 逐字流式 markdown 的最小状态机骨架: +/// - 输入:全量 raw markdown(可由上游累积) +/// - 输出:稳定前缀 + 悬挂尾部(用于"只延迟不稳定尾部,不回退整段") +/// +/// 说明: +/// 1) 当前实现优先保证行为可解释与可测试,不引入侵入式渲染改动。 +/// 2) 先覆盖最常见不稳定结构:fenced code、inline code、emphasis。 +/// 3) link/list/table 等结构可在后续扩展到同一状态机内。 +public final class STMarkdownStreamingStateMachine { + + public struct Snapshot: Equatable { + /// 可以安全提交到渲染层的稳定前缀(单调增长目标)。 + public let stablePrefix: String + /// 仍在构建中的尾部(建议走占位/延迟提交,而非整段回退)。 + public let danglingSuffix: String + + public var renderableText: String { + stablePrefix + danglingSuffix + } + + public init(stablePrefix: String, danglingSuffix: String) { + self.stablePrefix = stablePrefix + self.danglingSuffix = danglingSuffix + } + } + + public private(set) var lastSnapshot: Snapshot = .init(stablePrefix: "", danglingSuffix: "") + + public init() {} + + public func reset() { + self.lastSnapshot = .init(stablePrefix: "", danglingSuffix: "") + } + + @discardableResult + public func ingest(fullMarkdown: String) -> Snapshot { + let boundary = Self.lastStableBoundary(in: fullMarkdown) + var stable = String(fullMarkdown.prefix(boundary)) + var suffix = String(fullMarkdown.dropFirst(boundary)) + + // 流式阶段默认只追加文本,不允许已提交前缀回退。 + // 若本次计算出的 stable 更短,但旧 stable 仍然是当前 fullMarkdown 的前缀, + // 则保留旧 stable,把新增部分继续留在 danglingSuffix。 + // 这样可以兜住 markdown 语义在尾部补全时对边界的短暂重算, + // 避免上层已渲染正文被撤回后再提交,引发闪烁。 + let previousStable = self.lastSnapshot.stablePrefix + if stable.count < previousStable.count, + fullMarkdown.hasPrefix(previousStable) { + stable = previousStable + suffix = String(fullMarkdown.dropFirst(previousStable.count)) + } + + let snapshot = Snapshot(stablePrefix: stable, danglingSuffix: suffix) + self.lastSnapshot = snapshot + return snapshot + } + + // MARK: - Stable boundary detection + + /// 返回"最后一个稳定边界"索引(字符偏移)。 + /// 边界之后属于 danglingSuffix,不应直接按最终语义提交。 + private static func lastStableBoundary(in text: String) -> Int { + if text.isEmpty { return 0 } + + var inFencedCodeBlock = false + var fenceToken: String? + var inlineCodeOpenCount = 0 + var strongStarOpenCount = 0 + var strongUnderscoreOpenCount = 0 + var emStarOpenCount = 0 + var emUnderscoreOpenCount = 0 + + var lastSafeBoundary = 0 + + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var consumed = 0 + + for (lineIndex, rawLine) in lines.enumerated() { + let line = rawLine.trimmingCharacters(in: .whitespaces) + + if line.hasPrefix("```") || line.hasPrefix("~~~") { + let token = String(line.prefix(3)) + if !inFencedCodeBlock { + inFencedCodeBlock = true + fenceToken = token + } else if token == fenceToken { + inFencedCodeBlock = false + fenceToken = nil + } + } + + if !inFencedCodeBlock { + inlineCodeOpenCount += Self.countUnescaped("`", in: rawLine) + strongStarOpenCount += Self.countUnescaped("**", in: rawLine) + strongUnderscoreOpenCount += Self.countUnescaped("__", in: rawLine) + emStarOpenCount += Self.countUnescapedSingle("*", in: rawLine) + emUnderscoreOpenCount += Self.countUnescapedSingle("_", in: rawLine) + } + + consumed += rawLine.count + let hasLineBreak = lineIndex < lines.count - 1 + if hasLineBreak { consumed += 1 } + + let balanced = + !inFencedCodeBlock + && (inlineCodeOpenCount % 2 == 0) + && (strongStarOpenCount % 2 == 0) + && (strongUnderscoreOpenCount % 2 == 0) + && (emStarOpenCount % 2 == 0) + && (emUnderscoreOpenCount % 2 == 0) + + if balanced { + lastSafeBoundary = consumed + } + } + + let syntaxAdjusted = Self.adjustBoundaryForDanglingLinkLikeSyntax( + in: text, + boundary: lastSafeBoundary + ) + return max(0, min(syntaxAdjusted, text.count)) + } + + /// 在"已平衡边界"基础上,进一步回退到不暴露半截 link/citation 的位置。 + private static func adjustBoundaryForDanglingLinkLikeSyntax(in text: String, boundary: Int) -> Int { + guard boundary > 0 else { return boundary } + var adjusted = boundary + let stable = String(text.prefix(boundary)) + + // 0) markdown image:`![alt](url` / `![alt` + if let imageOpen = stable.range(of: "![", options: .backwards) { + let tail = stable[imageOpen.lowerBound...] + let imageClosed = tail.contains("]") && tail.contains(")") + if !imageClosed { + adjusted = min(adjusted, stable.distance(from: stable.startIndex, to: imageOpen.lowerBound)) + } + } + + // 1) `[[citation:...` / `[[webpage:...` 未闭合 `]]` + if let open = stable.range(of: "[[", options: .backwards) { + let tail = stable[open.lowerBound...] + if !tail.contains("]]") { + adjusted = min(adjusted, stable.distance(from: stable.startIndex, to: open.lowerBound)) + } + } + + // 2) markdown link:`[text](url` 末尾未闭合 `)` + if let linkOpen = stable.range(of: "](", options: .backwards) { + let tail = stable[linkOpen.lowerBound...] + if !tail.contains(")") { + adjusted = min(adjusted, stable.distance(from: stable.startIndex, to: linkOpen.lowerBound)) + } + } + + // 3) 裸 `[` 末尾未闭合,避免外露 `[xxx` + if let bracketOpen = stable.range(of: "[", options: .backwards) { + let tail = stable[bracketOpen.lowerBound...] + if !tail.contains("]") { + adjusted = min(adjusted, stable.distance(from: stable.startIndex, to: bracketOpen.lowerBound)) + } + } + + return adjusted + } + + private static func countUnescaped(_ token: String, in text: String) -> Int { + if token.isEmpty || text.isEmpty { return 0 } + let chars = Array(text) + let tokenChars = Array(token) + if chars.count < tokenChars.count { return 0 } + + var i = 0 + var count = 0 + while i <= chars.count - tokenChars.count { + if i > 0, chars[i - 1] == "\\" { + i += 1 + continue + } + var matched = true + for j in 0.. Int { + guard token == "*" || token == "_" else { return 0 } + guard !text.isEmpty else { return 0 } + let chars = Array(text) + var count = 0 + for idx in chars.indices { + if chars[idx] != Character(token) { continue } + if idx > 0, chars[idx - 1] == "\\" { continue } + let prevSame = idx > 0 && chars[idx - 1] == Character(token) + let nextSame = idx + 1 < chars.count && chars[idx + 1] == Character(token) + if prevSame || nextSame { continue } + count += 1 + } + return count + } +} diff --git a/Sources/STMarkdown/Parsing/STMarkdownStreamingTransforms.swift b/Sources/STMarkdown/Parsing/STMarkdownStreamingTransforms.swift new file mode 100644 index 0000000..f04db56 --- /dev/null +++ b/Sources/STMarkdown/Parsing/STMarkdownStreamingTransforms.swift @@ -0,0 +1,967 @@ +// +// STMarkdownCitationURLMatcher.swift +// STBaseProject +// +// Created by 寒江孤影 on 2019/03/16. +// + +import Foundation + +public enum STMarkdownStreamingTransforms { + private static let trailingStreamingMarkdownMarkerRegexes: [NSRegularExpression] = [ + try! NSRegularExpression(pattern: #"(?s)\n?[ \t]{0,3}#{1,6}[ \t]*$"#), + try! NSRegularExpression(pattern: #"(?s)\n?[ \t]{0,3}#{2,6}\S*$"#), + try! NSRegularExpression(pattern: #"(?s)\n?[ \t]{0,3}>[ \t]*$"#), + try! NSRegularExpression(pattern: #"(?is)webpage\s*\d*$"#), + try! NSRegularExpression(pattern: #"(?is)citation\s*:?\s*\d*$"#), + try! NSRegularExpression(pattern: #"(?s)(?[ \t]*$"#), + try! NSRegularExpression(pattern: #"(?is)webpage\s*\d*$"#), + try! NSRegularExpression(pattern: #"(?is)citation\s*:?\s*\d*$"#), + try! NSRegularExpression(pattern: #"(?s)(?]{0,100})?$"#, + options: [] + ) + private static let trailingIncompleteHtmlCommentRegex = try! NSRegularExpression( + pattern: #"