From 38a7aa38b9eae9213825df72a8079747482ae10b Mon Sep 17 00:00:00 2001 From: graycreate Date: Mon, 1 Dec 2025 18:28:45 +0800 Subject: [PATCH 1/2] feat: optimize RichView styles to match Android app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add V2EX preset stylesheet with styles matching the Android app: - Smaller heading sizes (h1: 22, h2: 18, h3: 16, h4: 15, h5: 12, h6: 10) - Medium font weight for headings (matching Android's 500) - Body text color #555555 (gray) for light mode - Link color #778087 (grayish) matching V2EX web style - Blockquote: 3px left border with subtle background - Code: 80% font size (13px) relative to body Add new styling components: - TableStyle: header font weight, separator color, cell padding - HorizontalRuleStyle: color, height, vertical padding Add rendering support for HTML tags: - underline with underlineStyle attribute - superscript with smaller font and positive baseline offset - subscript with smaller font and negative baseline offset Update MarkdownRenderer to use stylesheet styles for: - Horizontal rules - Table headers and separators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 3 +- .../RichView/Models/RenderStylesheet.swift | 177 +++++++++++++++++- .../RichView/Renderers/MarkdownRenderer.swift | 79 +++++++- 3 files changed, 253 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e0c01cc..f254614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,4 +159,5 @@ fastlane sync_certificates - Website submodule: Located at `website/` (separate repository) - Create PR should always use English - **CHANGELOG.md is required** for all releases - the build will fail if the current version is missing from the changelog -- Always install to Gray'iPhone if it connected, otherwise install to simulator \ No newline at end of file +- Always install to Gray'iPhone if it connected, otherwise install to simulator +- the coresponding android project located in ../v2er-android \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/RenderStylesheet.swift b/V2er/Sources/RichView/Models/RenderStylesheet.swift index eede426..9a4dee5 100644 --- a/V2er/Sources/RichView/Models/RenderStylesheet.swift +++ b/V2er/Sources/RichView/Models/RenderStylesheet.swift @@ -17,6 +17,8 @@ public struct RenderStylesheet: Equatable { public var list: ListStyle public var mention: MentionStyle public var image: ImageStyle + public var table: TableStyle + public var horizontalRule: HorizontalRuleStyle public init( body: TextStyle = TextStyle(), @@ -26,7 +28,9 @@ public struct RenderStylesheet: Equatable { blockquote: BlockquoteStyle = BlockquoteStyle(), list: ListStyle = ListStyle(), mention: MentionStyle = MentionStyle(), - image: ImageStyle = ImageStyle() + image: ImageStyle = ImageStyle(), + table: TableStyle = TableStyle(), + horizontalRule: HorizontalRuleStyle = HorizontalRuleStyle() ) { self.body = body self.heading = heading @@ -36,6 +40,8 @@ public struct RenderStylesheet: Equatable { self.list = list self.mention = mention self.image = image + self.table = table + self.horizontalRule = horizontalRule } } @@ -257,6 +263,49 @@ public struct ImageStyle: Equatable { } } +/// Table styling +public struct TableStyle: Equatable { + public var headerFontWeight: Font.Weight + public var headerBackgroundColor: Color + public var cellPadding: CGFloat + public var separatorColor: Color + public var separatorWidth: CGFloat + public var alternateRowColor: Color? + + public init( + headerFontWeight: Font.Weight = .semibold, + headerBackgroundColor: Color = .clear, + cellPadding: CGFloat = 8, + separatorColor: Color = Color.gray.opacity(0.3), + separatorWidth: CGFloat = 0.5, + alternateRowColor: Color? = nil + ) { + self.headerFontWeight = headerFontWeight + self.headerBackgroundColor = headerBackgroundColor + self.cellPadding = cellPadding + self.separatorColor = separatorColor + self.separatorWidth = separatorWidth + self.alternateRowColor = alternateRowColor + } +} + +/// Horizontal rule styling +public struct HorizontalRuleStyle: Equatable { + public var color: Color + public var height: CGFloat + public var verticalPadding: CGFloat + + public init( + color: Color = Color(hex: "#f4f2f2"), + height: CGFloat = 0.8, + verticalPadding: CGFloat = 8 + ) { + self.color = color + self.height = height + self.verticalPadding = verticalPadding + } +} + // MARK: - Presets extension RenderStylesheet { @@ -335,6 +384,132 @@ extension RenderStylesheet { light: Color(hex: "#d0d7de"), dark: Color(hex: "#3d444d") ) + ), + table: TableStyle( + separatorColor: Color.adaptive( + light: Color.gray.opacity(0.3), + dark: Color.gray.opacity(0.4) + ) + ), + horizontalRule: HorizontalRuleStyle( + color: Color.adaptive( + light: Color(hex: "#f4f2f2"), + dark: Color(hex: "#202020") + ) + ) + ) + }() + + /// V2EX styling matching Android app + public static let v2ex: RenderStylesheet = { + RenderStylesheet( + body: TextStyle( + fontSize: 16, + fontWeight: .regular, + lineSpacing: 4, + paragraphSpacing: 8, + color: Color.adaptive( + light: Color(hex: "#555555"), + dark: Color.white.opacity(0.9) + ) + ), + heading: HeadingStyle( + h1Size: 22, + h2Size: 18, + h3Size: 16, + h4Size: 15, + h5Size: 12, + h6Size: 10, + fontWeight: .medium, + topSpacing: 15, + bottomSpacing: 15, + color: Color.adaptive( + light: Color.black, + dark: Color(hex: "#7F8080") + ) + ), + link: LinkStyle( + color: Color.adaptive( + light: Color(hex: "#778087"), + dark: Color(hex: "#58a6ff") + ), + underline: false, + fontWeight: .regular + ), + code: CodeStyle( + inlineFontSize: 13, // 80% of 16 + inlineBackgroundColor: Color.adaptive( + light: Color(hex: "#f6f8fa"), + dark: Color.clear + ), + inlineTextColor: Color.adaptive( + light: Color(hex: "#24292e"), + dark: Color(hex: "#7F8082") + ), + blockFontSize: 13, + blockBackgroundColor: Color.adaptive( + light: Color(hex: "#f6f8fa"), + dark: Color(hex: "#111214") + ), + blockTextColor: Color.adaptive( + light: Color(hex: "#24292e"), + dark: Color(hex: "#7F8082") + ), + highlightTheme: .tomorrowNight + ), + blockquote: BlockquoteStyle( + borderColor: Color(hex: "#7e7e7e").opacity(0.5), + borderWidth: 3, + backgroundColor: Color.adaptive( + light: Color(hex: "#fafafa").opacity(0.5), + dark: Color(hex: "#08090b") + ), + padding: EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5), + fontSize: 15 + ), + list: ListStyle( + indentWidth: 16, + itemSpacing: 4, + bulletColor: Color.adaptive( + light: Color(hex: "#555555"), + dark: Color.white.opacity(0.9) + ), + numberColor: Color.adaptive( + light: Color(hex: "#555555"), + dark: Color.white.opacity(0.9) + ) + ), + mention: MentionStyle( + textColor: Color.adaptive( + light: Color(hex: "#778087"), + dark: Color(hex: "#58a6ff") + ), + backgroundColor: Color.adaptive( + light: Color(hex: "#778087").opacity(0.1), + dark: Color(hex: "#58a6ff").opacity(0.15) + ), + fontWeight: .medium + ), + image: ImageStyle( + maxHeight: 400, + cornerRadius: 8, + borderColor: .clear, + borderWidth: 0 + ), + table: TableStyle( + headerFontWeight: .medium, + separatorColor: Color.adaptive( + light: Color(hex: "#f4f2f2"), + dark: Color(hex: "#202020") + ), + separatorWidth: 0.5 + ), + horizontalRule: HorizontalRuleStyle( + color: Color.adaptive( + light: Color(hex: "#f4f2f2"), + dark: Color(hex: "#202020") + ), + height: 0.8 ) ) }() diff --git a/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift b/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift index d405aee..fe2ee91 100644 --- a/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift +++ b/V2er/Sources/RichView/Renderers/MarkdownRenderer.swift @@ -79,7 +79,7 @@ public class MarkdownRenderer { attributedString.append(renderListItem(content, ordered: true, number: number)) } else if line.starts(with: "---") { // Horizontal rule - attributedString.append(AttributedString("—————————————\n")) + attributedString.append(renderHorizontalRule()) } else if line.starts(with: "|") && line.hasSuffix("|") { // Markdown table let (tableBlock, linesConsumed) = extractTableBlock(lines, startIndex: index) @@ -169,6 +169,17 @@ public class MarkdownRenderer { return attributed } + // MARK: - Horizontal Rule Rendering + + private func renderHorizontalRule() -> AttributedString { + var attributed = AttributedString("\n") + var line = AttributedString(String(repeating: "─", count: 40)) + line.foregroundColor = stylesheet.horizontalRule.color.uiColor + attributed.append(line) + attributed.append(AttributedString("\n\n")) + return attributed + } + // MARK: - List Rendering private func renderListItem(_ text: String, ordered: Bool, number: Int) -> AttributedString { @@ -342,6 +353,66 @@ public class MarkdownRenderer { continue } + // Check for underline (text) + if let underlineMatch = currentText.firstMatch(of: /(.+?)<\/u>/) { + // Add text before underline + let beforeRange = currentText.startIndex..text) + if let supMatch = currentText.firstMatch(of: /(.+?)<\/sup>/) { + // Add text before superscript + let beforeRange = currentText.startIndex..text) + if let subMatch = currentText.firstMatch(of: /(.+?)<\/sub>/) { + // Add text before subscript + let beforeRange = currentText.startIndex.. 1 { var separatorLine = AttributedString(String(repeating: "─", count: 40) + "\n") - separatorLine.foregroundColor = Color.gray.opacity(0.3) + separatorLine.foregroundColor = stylesheet.table.separatorColor.uiColor result.append(separatorLine) } } From 9f75fc2d3797fa7f748fa9edd0f541d0df5ac2b8 Mon Sep 17 00:00:00 2001 From: graycreate Date: Mon, 1 Dec 2025 18:39:54 +0800 Subject: [PATCH 2/2] fix: address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix typo in CLAUDE.md ("coresponding" → "corresponding") - Update horizontal rule test to use box drawing character (─) - Add tests for underline, superscript, and subscript rendering - Add documentation for reserved style properties in TableStyle and HorizontalRuleStyle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 2 +- .../RichView/Models/RenderStylesheet.swift | 9 ++++ .../RichView/MarkdownRendererTests.swift | 41 ++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f254614..7d5873d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,4 +160,4 @@ fastlane sync_certificates - Create PR should always use English - **CHANGELOG.md is required** for all releases - the build will fail if the current version is missing from the changelog - Always install to Gray'iPhone if it connected, otherwise install to simulator -- the coresponding android project located in ../v2er-android \ No newline at end of file +- The corresponding Android project is located in ../v2er-android \ No newline at end of file diff --git a/V2er/Sources/RichView/Models/RenderStylesheet.swift b/V2er/Sources/RichView/Models/RenderStylesheet.swift index 9a4dee5..f65b446 100644 --- a/V2er/Sources/RichView/Models/RenderStylesheet.swift +++ b/V2er/Sources/RichView/Models/RenderStylesheet.swift @@ -265,11 +265,17 @@ public struct ImageStyle: Equatable { /// Table styling public struct TableStyle: Equatable { + /// Font weight for header row cells public var headerFontWeight: Font.Weight + /// Background color for header row (reserved for future use) public var headerBackgroundColor: Color + /// Padding around cell content (reserved for future use) public var cellPadding: CGFloat + /// Color for cell separators public var separatorColor: Color + /// Width of separator lines (reserved for future use) public var separatorWidth: CGFloat + /// Alternating row background color (reserved for future use) public var alternateRowColor: Color? public init( @@ -291,8 +297,11 @@ public struct TableStyle: Equatable { /// Horizontal rule styling public struct HorizontalRuleStyle: Equatable { + /// Color of the horizontal rule line public var color: Color + /// Height/thickness of the rule (reserved for future use when using graphical rendering) public var height: CGFloat + /// Vertical padding above and below the rule (reserved for future use) public var verticalPadding: CGFloat public init( diff --git a/V2erTests/RichView/MarkdownRendererTests.swift b/V2erTests/RichView/MarkdownRendererTests.swift index 9747038..7b90d5b 100644 --- a/V2erTests/RichView/MarkdownRendererTests.swift +++ b/V2erTests/RichView/MarkdownRendererTests.swift @@ -187,6 +187,45 @@ class MarkdownRendererTests: XCTestCase { XCTAssertTrue(string.contains("3. Third")) } + // MARK: - HTML Tag Rendering Tests + + func testUnderlineRendering() throws { + let markdown = "This has underlined text" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("underlined")) + // The underline should be rendered (no HTML tags in output) + XCTAssertFalse(string.contains("")) + XCTAssertFalse(string.contains("")) + } + + func testSuperscriptRendering() throws { + let markdown = "x2 + y3" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("x")) + XCTAssertTrue(string.contains("2")) + XCTAssertTrue(string.contains("3")) + // The superscript should be rendered (no HTML tags in output) + XCTAssertFalse(string.contains("")) + XCTAssertFalse(string.contains("")) + } + + func testSubscriptRendering() throws { + let markdown = "H2O" + let attributed = try renderer.render(markdown) + + let string = String(attributed.characters) + XCTAssertTrue(string.contains("H")) + XCTAssertTrue(string.contains("2")) + XCTAssertTrue(string.contains("O")) + // The subscript should be rendered (no HTML tags in output) + XCTAssertFalse(string.contains("")) + XCTAssertFalse(string.contains("")) + } + // MARK: - Mixed Content Tests func testMixedFormattingRendering() throws { @@ -287,7 +326,7 @@ class MarkdownRendererTests: XCTestCase { let attributed = try renderer.render(markdown) let string = String(attributed.characters) - XCTAssertTrue(string.contains("—")) + XCTAssertTrue(string.contains("─")) // Box drawing character } // MARK: - Performance Tests