Skip to content

Commit b83e847

Browse files
graycreateclaude
andauthored
feat: optimize RichView styles to match Android app (#80)
* feat: optimize RichView styles to match Android app 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: - <u> underline with underlineStyle attribute - <sup> superscript with smaller font and positive baseline offset - <sub> 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 <noreply@anthropic.com> * fix: address Copilot review comments - 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75f5d90 commit b83e847

File tree

4 files changed

+302
-7
lines changed

4 files changed

+302
-7
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,5 @@ fastlane sync_certificates
159159
- Website submodule: Located at `website/` (separate repository)
160160
- Create PR should always use English
161161
- **CHANGELOG.md is required** for all releases - the build will fail if the current version is missing from the changelog
162-
- Always install to Gray'iPhone if it connected, otherwise install to simulator
162+
- Always install to Gray'iPhone if it connected, otherwise install to simulator
163+
- The corresponding Android project is located in ../v2er-android

V2er/Sources/RichView/Models/RenderStylesheet.swift

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public struct RenderStylesheet: Equatable {
1717
public var list: ListStyle
1818
public var mention: MentionStyle
1919
public var image: ImageStyle
20+
public var table: TableStyle
21+
public var horizontalRule: HorizontalRuleStyle
2022

2123
public init(
2224
body: TextStyle = TextStyle(),
@@ -26,7 +28,9 @@ public struct RenderStylesheet: Equatable {
2628
blockquote: BlockquoteStyle = BlockquoteStyle(),
2729
list: ListStyle = ListStyle(),
2830
mention: MentionStyle = MentionStyle(),
29-
image: ImageStyle = ImageStyle()
31+
image: ImageStyle = ImageStyle(),
32+
table: TableStyle = TableStyle(),
33+
horizontalRule: HorizontalRuleStyle = HorizontalRuleStyle()
3034
) {
3135
self.body = body
3236
self.heading = heading
@@ -36,6 +40,8 @@ public struct RenderStylesheet: Equatable {
3640
self.list = list
3741
self.mention = mention
3842
self.image = image
43+
self.table = table
44+
self.horizontalRule = horizontalRule
3945
}
4046
}
4147

@@ -257,6 +263,58 @@ public struct ImageStyle: Equatable {
257263
}
258264
}
259265

266+
/// Table styling
267+
public struct TableStyle: Equatable {
268+
/// Font weight for header row cells
269+
public var headerFontWeight: Font.Weight
270+
/// Background color for header row (reserved for future use)
271+
public var headerBackgroundColor: Color
272+
/// Padding around cell content (reserved for future use)
273+
public var cellPadding: CGFloat
274+
/// Color for cell separators
275+
public var separatorColor: Color
276+
/// Width of separator lines (reserved for future use)
277+
public var separatorWidth: CGFloat
278+
/// Alternating row background color (reserved for future use)
279+
public var alternateRowColor: Color?
280+
281+
public init(
282+
headerFontWeight: Font.Weight = .semibold,
283+
headerBackgroundColor: Color = .clear,
284+
cellPadding: CGFloat = 8,
285+
separatorColor: Color = Color.gray.opacity(0.3),
286+
separatorWidth: CGFloat = 0.5,
287+
alternateRowColor: Color? = nil
288+
) {
289+
self.headerFontWeight = headerFontWeight
290+
self.headerBackgroundColor = headerBackgroundColor
291+
self.cellPadding = cellPadding
292+
self.separatorColor = separatorColor
293+
self.separatorWidth = separatorWidth
294+
self.alternateRowColor = alternateRowColor
295+
}
296+
}
297+
298+
/// Horizontal rule styling
299+
public struct HorizontalRuleStyle: Equatable {
300+
/// Color of the horizontal rule line
301+
public var color: Color
302+
/// Height/thickness of the rule (reserved for future use when using graphical rendering)
303+
public var height: CGFloat
304+
/// Vertical padding above and below the rule (reserved for future use)
305+
public var verticalPadding: CGFloat
306+
307+
public init(
308+
color: Color = Color(hex: "#f4f2f2"),
309+
height: CGFloat = 0.8,
310+
verticalPadding: CGFloat = 8
311+
) {
312+
self.color = color
313+
self.height = height
314+
self.verticalPadding = verticalPadding
315+
}
316+
}
317+
260318
// MARK: - Presets
261319

262320
extension RenderStylesheet {
@@ -335,6 +393,132 @@ extension RenderStylesheet {
335393
light: Color(hex: "#d0d7de"),
336394
dark: Color(hex: "#3d444d")
337395
)
396+
),
397+
table: TableStyle(
398+
separatorColor: Color.adaptive(
399+
light: Color.gray.opacity(0.3),
400+
dark: Color.gray.opacity(0.4)
401+
)
402+
),
403+
horizontalRule: HorizontalRuleStyle(
404+
color: Color.adaptive(
405+
light: Color(hex: "#f4f2f2"),
406+
dark: Color(hex: "#202020")
407+
)
408+
)
409+
)
410+
}()
411+
412+
/// V2EX styling matching Android app
413+
public static let v2ex: RenderStylesheet = {
414+
RenderStylesheet(
415+
body: TextStyle(
416+
fontSize: 16,
417+
fontWeight: .regular,
418+
lineSpacing: 4,
419+
paragraphSpacing: 8,
420+
color: Color.adaptive(
421+
light: Color(hex: "#555555"),
422+
dark: Color.white.opacity(0.9)
423+
)
424+
),
425+
heading: HeadingStyle(
426+
h1Size: 22,
427+
h2Size: 18,
428+
h3Size: 16,
429+
h4Size: 15,
430+
h5Size: 12,
431+
h6Size: 10,
432+
fontWeight: .medium,
433+
topSpacing: 15,
434+
bottomSpacing: 15,
435+
color: Color.adaptive(
436+
light: Color.black,
437+
dark: Color(hex: "#7F8080")
438+
)
439+
),
440+
link: LinkStyle(
441+
color: Color.adaptive(
442+
light: Color(hex: "#778087"),
443+
dark: Color(hex: "#58a6ff")
444+
),
445+
underline: false,
446+
fontWeight: .regular
447+
),
448+
code: CodeStyle(
449+
inlineFontSize: 13, // 80% of 16
450+
inlineBackgroundColor: Color.adaptive(
451+
light: Color(hex: "#f6f8fa"),
452+
dark: Color.clear
453+
),
454+
inlineTextColor: Color.adaptive(
455+
light: Color(hex: "#24292e"),
456+
dark: Color(hex: "#7F8082")
457+
),
458+
blockFontSize: 13,
459+
blockBackgroundColor: Color.adaptive(
460+
light: Color(hex: "#f6f8fa"),
461+
dark: Color(hex: "#111214")
462+
),
463+
blockTextColor: Color.adaptive(
464+
light: Color(hex: "#24292e"),
465+
dark: Color(hex: "#7F8082")
466+
),
467+
highlightTheme: .tomorrowNight
468+
),
469+
blockquote: BlockquoteStyle(
470+
borderColor: Color(hex: "#7e7e7e").opacity(0.5),
471+
borderWidth: 3,
472+
backgroundColor: Color.adaptive(
473+
light: Color(hex: "#fafafa").opacity(0.5),
474+
dark: Color(hex: "#08090b")
475+
),
476+
padding: EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5),
477+
fontSize: 15
478+
),
479+
list: ListStyle(
480+
indentWidth: 16,
481+
itemSpacing: 4,
482+
bulletColor: Color.adaptive(
483+
light: Color(hex: "#555555"),
484+
dark: Color.white.opacity(0.9)
485+
),
486+
numberColor: Color.adaptive(
487+
light: Color(hex: "#555555"),
488+
dark: Color.white.opacity(0.9)
489+
)
490+
),
491+
mention: MentionStyle(
492+
textColor: Color.adaptive(
493+
light: Color(hex: "#778087"),
494+
dark: Color(hex: "#58a6ff")
495+
),
496+
backgroundColor: Color.adaptive(
497+
light: Color(hex: "#778087").opacity(0.1),
498+
dark: Color(hex: "#58a6ff").opacity(0.15)
499+
),
500+
fontWeight: .medium
501+
),
502+
image: ImageStyle(
503+
maxHeight: 400,
504+
cornerRadius: 8,
505+
borderColor: .clear,
506+
borderWidth: 0
507+
),
508+
table: TableStyle(
509+
headerFontWeight: .medium,
510+
separatorColor: Color.adaptive(
511+
light: Color(hex: "#f4f2f2"),
512+
dark: Color(hex: "#202020")
513+
),
514+
separatorWidth: 0.5
515+
),
516+
horizontalRule: HorizontalRuleStyle(
517+
color: Color.adaptive(
518+
light: Color(hex: "#f4f2f2"),
519+
dark: Color(hex: "#202020")
520+
),
521+
height: 0.8
338522
)
339523
)
340524
}()

V2er/Sources/RichView/Renderers/MarkdownRenderer.swift

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public class MarkdownRenderer {
7979
attributedString.append(renderListItem(content, ordered: true, number: number))
8080
} else if line.starts(with: "---") {
8181
// Horizontal rule
82-
attributedString.append(AttributedString("—————————————\n"))
82+
attributedString.append(renderHorizontalRule())
8383
} else if line.starts(with: "|") && line.hasSuffix("|") {
8484
// Markdown table
8585
let (tableBlock, linesConsumed) = extractTableBlock(lines, startIndex: index)
@@ -169,6 +169,17 @@ public class MarkdownRenderer {
169169
return attributed
170170
}
171171

172+
// MARK: - Horizontal Rule Rendering
173+
174+
private func renderHorizontalRule() -> AttributedString {
175+
var attributed = AttributedString("\n")
176+
var line = AttributedString(String(repeating: "", count: 40))
177+
line.foregroundColor = stylesheet.horizontalRule.color.uiColor
178+
attributed.append(line)
179+
attributed.append(AttributedString("\n\n"))
180+
return attributed
181+
}
182+
172183
// MARK: - List Rendering
173184

174185
private func renderListItem(_ text: String, ordered: Bool, number: Int) -> AttributedString {
@@ -342,6 +353,66 @@ public class MarkdownRenderer {
342353
continue
343354
}
344355

356+
// Check for underline (<u>text</u>)
357+
if let underlineMatch = currentText.firstMatch(of: /<u>(.+?)<\/u>/) {
358+
// Add text before underline
359+
let beforeRange = currentText.startIndex..<underlineMatch.range.lowerBound
360+
if !beforeRange.isEmpty {
361+
result.append(renderPlainText(String(currentText[beforeRange])))
362+
}
363+
364+
// Add underlined text
365+
var underlineText = AttributedString(String(underlineMatch.1))
366+
underlineText.font = .system(size: stylesheet.body.fontSize)
367+
underlineText.foregroundColor = stylesheet.body.color.uiColor
368+
underlineText.underlineStyle = .single
369+
result.append(underlineText)
370+
371+
// Continue with remaining text
372+
currentText = String(currentText[underlineMatch.range.upperBound...])
373+
continue
374+
}
375+
376+
// Check for superscript (<sup>text</sup>)
377+
if let supMatch = currentText.firstMatch(of: /<sup>(.+?)<\/sup>/) {
378+
// Add text before superscript
379+
let beforeRange = currentText.startIndex..<supMatch.range.lowerBound
380+
if !beforeRange.isEmpty {
381+
result.append(renderPlainText(String(currentText[beforeRange])))
382+
}
383+
384+
// Add superscript text (smaller font, baseline offset)
385+
var supText = AttributedString(String(supMatch.1))
386+
supText.font = .system(size: stylesheet.body.fontSize * 0.7)
387+
supText.foregroundColor = stylesheet.body.color.uiColor
388+
supText.baselineOffset = stylesheet.body.fontSize * 0.3
389+
result.append(supText)
390+
391+
// Continue with remaining text
392+
currentText = String(currentText[supMatch.range.upperBound...])
393+
continue
394+
}
395+
396+
// Check for subscript (<sub>text</sub>)
397+
if let subMatch = currentText.firstMatch(of: /<sub>(.+?)<\/sub>/) {
398+
// Add text before subscript
399+
let beforeRange = currentText.startIndex..<subMatch.range.lowerBound
400+
if !beforeRange.isEmpty {
401+
result.append(renderPlainText(String(currentText[beforeRange])))
402+
}
403+
404+
// Add subscript text (smaller font, negative baseline offset)
405+
var subText = AttributedString(String(subMatch.1))
406+
subText.font = .system(size: stylesheet.body.fontSize * 0.7)
407+
subText.foregroundColor = stylesheet.body.color.uiColor
408+
subText.baselineOffset = -stylesheet.body.fontSize * 0.2
409+
result.append(subText)
410+
411+
// Continue with remaining text
412+
currentText = String(currentText[subMatch.range.upperBound...])
413+
continue
414+
}
415+
345416
// No more special elements, add remaining text
346417
result.append(renderPlainText(currentText))
347418
break
@@ -432,15 +503,15 @@ public class MarkdownRenderer {
432503

433504
// Apply header style for first row
434505
if rowIndex == 0 {
435-
cellText.font = .system(size: stylesheet.body.fontSize, weight: .semibold)
506+
cellText.font = .system(size: stylesheet.body.fontSize, weight: stylesheet.table.headerFontWeight)
436507
}
437508

438509
result.append(cellText)
439510

440511
// Add separator between cells
441512
if cellIndex < row.count - 1 {
442513
var separator = AttributedString("")
443-
separator.foregroundColor = Color.gray.opacity(0.5)
514+
separator.foregroundColor = stylesheet.table.separatorColor.uiColor
444515
result.append(separator)
445516
}
446517
}
@@ -450,7 +521,7 @@ public class MarkdownRenderer {
450521
// Add separator line after header
451522
if rowIndex == 0 && rows.count > 1 {
452523
var separatorLine = AttributedString(String(repeating: "", count: 40) + "\n")
453-
separatorLine.foregroundColor = Color.gray.opacity(0.3)
524+
separatorLine.foregroundColor = stylesheet.table.separatorColor.uiColor
454525
result.append(separatorLine)
455526
}
456527
}

0 commit comments

Comments
 (0)