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