diff --git a/CLAUDE.md b/CLAUDE.md index e0c01cc..7d5873d 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 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 eede426..f65b446 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,58 @@ 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( + 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 { + /// 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( + 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 +393,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) } } 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