Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Always install to Gray'iPhone if it connected, otherwise install to simulator
- The corresponding Android project is located in ../v2er-android
186 changes: 185 additions & 1 deletion V2er/Sources/RichView/Models/RenderStylesheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -36,6 +40,8 @@ public struct RenderStylesheet: Equatable {
self.list = list
self.mention = mention
self.image = image
self.table = table
self.horizontalRule = horizontalRule
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
)
}()
Expand Down
79 changes: 75 additions & 4 deletions V2er/Sources/RichView/Renderers/MarkdownRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The horizontal rule character has been changed from "—" (em dash) to "─" (box drawing character), but the existing test in MarkdownRendererTests.swift line 290 still checks for "—". This will cause the test to fail. Consider updating the test to check for "─" instead.

Copilot uses AI. Check for mistakes.
} else if line.starts(with: "|") && line.hasSuffix("|") {
// Markdown table
let (tableBlock, linesConsumed) = extractTableBlock(lines, startIndex: index)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -342,6 +353,66 @@ public class MarkdownRenderer {
continue
}

// Check for underline (<u>text</u>)
if let underlineMatch = currentText.firstMatch(of: /<u>(.+?)<\/u>/) {
// Add text before underline
let beforeRange = currentText.startIndex..<underlineMatch.range.lowerBound
if !beforeRange.isEmpty {
result.append(renderPlainText(String(currentText[beforeRange])))
}

// Add underlined text
var underlineText = AttributedString(String(underlineMatch.1))
underlineText.font = .system(size: stylesheet.body.fontSize)
underlineText.foregroundColor = stylesheet.body.color.uiColor
underlineText.underlineStyle = .single
result.append(underlineText)

// Continue with remaining text
currentText = String(currentText[underlineMatch.range.upperBound...])
continue
}

// Check for superscript (<sup>text</sup>)
if let supMatch = currentText.firstMatch(of: /<sup>(.+?)<\/sup>/) {
// Add text before superscript
let beforeRange = currentText.startIndex..<supMatch.range.lowerBound
if !beforeRange.isEmpty {
result.append(renderPlainText(String(currentText[beforeRange])))
}

// Add superscript text (smaller font, baseline offset)
var supText = AttributedString(String(supMatch.1))
supText.font = .system(size: stylesheet.body.fontSize * 0.7)
supText.foregroundColor = stylesheet.body.color.uiColor
supText.baselineOffset = stylesheet.body.fontSize * 0.3
result.append(supText)

// Continue with remaining text
currentText = String(currentText[supMatch.range.upperBound...])
continue
}

// Check for subscript (<sub>text</sub>)
if let subMatch = currentText.firstMatch(of: /<sub>(.+?)<\/sub>/) {
// Add text before subscript
let beforeRange = currentText.startIndex..<subMatch.range.lowerBound
if !beforeRange.isEmpty {
result.append(renderPlainText(String(currentText[beforeRange])))
}

// Add subscript text (smaller font, negative baseline offset)
var subText = AttributedString(String(subMatch.1))
subText.font = .system(size: stylesheet.body.fontSize * 0.7)
subText.foregroundColor = stylesheet.body.color.uiColor
subText.baselineOffset = -stylesheet.body.fontSize * 0.2
result.append(subText)

// Continue with remaining text
currentText = String(currentText[subMatch.range.upperBound...])
continue
}
Comment on lines +356 to +414
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new underline, superscript, and subscript HTML tag rendering features lack test coverage. Since V2erTests/RichView/MarkdownRendererTests.swift has comprehensive tests for other inline formatting features (bold, italic, code, links, mentions, strikethrough, highlights), these new features should also have corresponding test cases to ensure they work correctly.

Copilot uses AI. Check for mistakes.

// No more special elements, add remaining text
result.append(renderPlainText(currentText))
break
Expand Down Expand Up @@ -432,15 +503,15 @@ public class MarkdownRenderer {

// Apply header style for first row
if rowIndex == 0 {
cellText.font = .system(size: stylesheet.body.fontSize, weight: .semibold)
cellText.font = .system(size: stylesheet.body.fontSize, weight: stylesheet.table.headerFontWeight)
}

result.append(cellText)

// Add separator between cells
if cellIndex < row.count - 1 {
var separator = AttributedString(" │ ")
separator.foregroundColor = Color.gray.opacity(0.5)
separator.foregroundColor = stylesheet.table.separatorColor.uiColor
result.append(separator)
}
}
Expand All @@ -450,7 +521,7 @@ public class MarkdownRenderer {
// Add separator line after header
if rowIndex == 0 && rows.count > 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)
}
}
Expand Down
Loading
Loading