name: yuedu-tour
description: yuedu app code tour for the local iOS EPUB/TXT/web-novel reader checkout. Use before Gemini works on, reviews, debugs, refactors, or explains code under /Users/zhangruilin/Desktop/Yuedu-reader/, especially reader rendering, CoreText paging or scrolling, bookshelf data, online reading, browser-imported books, book source rules, ModernRuleEngine, ChapterFetchManager, OnlineReadingPipeline, BookSourceFetcher, RuleExtractor, suspicious chapter content, generation tokens, ReaderTelemetry, localization, or app architecture. Skip only for trivial config, copy, or asset-only edits.
yuedu App Code Tour
Use this skill to orient before touching the yuedu app. The app is an iOS EPUB/TXT/web-novel reader built with SwiftUI, CoreText pagination, and Readium.
Project root:
/Users/zhangruilin/Desktop/Yuedu-reader
Current top-level source layout:
Modules/Core/: shared parsing, reader engine, CoreText, TTS, book source, replacement, comic, TXT/EPUB/Markdown logic.Modules/Services/: persistence, online fetching, networking, RSS, sync, account, OPDS/WebDAV/LAN services.Modules/Features/: SwiftUI/UIKit feature screens for bookshelf, reader, settings, search, RSS, manga, browser, explore, book source, stats.Modules/SharedUI/: design tokens and shared SwiftUI components.Targets/Yuedu/: app entry, dependency injection, shared shell, iPhone/iPad target-specific UI.Resources/: assets and*.lproj/Localizable.strings.Tests/: iOS unit/UI tests.
Start Here
- Classify the requested change using the area map below.
- Read the matching entry files before editing.
- Read the relevant pitfalls section before changing CoreText, online reading, localization, or persistence.
- Prefer existing protocols, registries, and dependency injection points over new parallel systems.
Skill Maintenance
Keep the global and repo-local copies of this skill synchronized whenever updating yuedu-tour:
- Global:
/Users/zhangruilin/.agents/skills/yuedu-tour/SKILL.md - Repo-local:
/Users/zhangruilin/Desktop/Yuedu-reader/.agents/skills/yuedu-tour/SKILL.md
When changing either copy, apply the same content change to the other copy before reporting completion.
Area Map
| Task area | Main folders |
|---|---|
| Reading rendering, layout, fonts, margins, paging | Modules/Core/ReaderCore/, Modules/Core/ReaderCore/CoreText/, Modules/Features/Reader/ |
| Bookshelf, book CRUD, grouping, drag sorting | Modules/Services/LibraryStore/, Modules/Features/Bookshelf/ |
| Book sources, online books, rule engine | Modules/Core/BookSource/, Modules/Services/Online/, Modules/Core/RuleEngine/, Modules/Features/BookSource/, Modules/Features/Explore/ |
| Global settings, themes, DI | Targets/Yuedu/SharedApp/, Modules/SharedUI/DesignSystem/ |
| Account, sign-in, Google Sign-In, Apple Sign-In | Modules/Services/Account/, Modules/Features/Settings/ProfileView.swift, Modules/Features/Settings/UserDetailView.swift, Modules/Features/Settings/LoginView.swift, Targets/Yuedu/SharedApp/GlobalSettings.swift |
| TTS | Modules/Core/TTS/, Modules/Features/Reader/TTS/, Modules/Features/Settings/TTSSettingsView.swift |
| Search | Modules/Features/Search/, Modules/Services/Online/SearchAggregator.swift |
| Sync and offline download | Modules/Services/iCloud/, Modules/Services/WebDAV/, Modules/Services/Network/, Modules/Services/Online/OnlineReadingPipeline.swift, Modules/Features/Settings/DownloadManagementView.swift |
| RSS, comics, replacement rules | Modules/Services/RSS/, Modules/Features/RSS/, Modules/Core/Comic/, Modules/Features/Manga/, Modules/Core/Replace/, Modules/Features/Settings/ReplaceRuleListView.swift |
Entry Points
| Need | Read first |
|---|---|
| App launch and environment injection | Targets/Yuedu/SharedApp/yuedu_appApp.swift, Targets/Yuedu/SharedApp/AppDependencies.swift |
| Main tabs | Targets/Yuedu/SharedApp/ContentView.swift |
| Bookshelf | Modules/Features/Bookshelf/HomeView.swift |
| Book model and store | Modules/Services/LibraryStore/Models.swift (ReadingBook, Bookmark), Modules/Services/LibraryStore/BookStore.swift (BookStore) |
| Reader screen | Modules/Features/Reader/ReaderView.swift, Modules/Features/Reader/ReaderViewFactory.swift |
| Reader state | Modules/Features/Reader/ReaderViewModel.swift |
| Paged CoreText layout | Modules/Core/ReaderCore/CoreText/CoreTextPaginator.swift |
| CoreText contributor docs | docs/coretext/README.md |
| Vertical infinite scrolling | Modules/Core/ReaderCore/CoreText/CoreTextScrollEngine.swift, Modules/Core/ReaderCore/CoreText/CoreTextChunkSlicer.swift, Modules/Features/Reader/CoreTextCollectionScrollViewController.swift |
| Single-page CoreText rendering | Modules/Core/ReaderCore/CoreText/CoreTextPageView.swift |
| Scroll chunk rendering | Modules/Features/Reader/CoreTextChunkCell.swift |
| EPUB CSS parsing | Modules/Core/ReaderCore/CoreText/EPUBStyleResolver.swift |
| HTML/Markdown/TXT attributed strings | Modules/Core/ReaderCore/CoreText/*AttributedStringBuilder.swift |
| Vertical text normalization & config | Modules/Core/ReaderCore/CoreText/CoreTextCommon/String+VerticalNormalization.swift, Modules/Core/ReaderCore/CoreText/CoreTextCommon/VerticalLayoutConfig.swift |
| Settings | Targets/Yuedu/SharedApp/GlobalSettings.swift (GlobalSettings.shared), Modules/Features/Settings/ |
| Account row and sign-in | Modules/Features/Settings/ProfileView.swift, Modules/Features/Settings/UserDetailView.swift, Modules/Features/Settings/LoginView.swift |
| Online reading and download | Modules/Services/Online/OnlineReadingPipeline.swift, Modules/Services/Online/ChapterFetcher.swift, Modules/Features/Settings/DownloadManagementView.swift |
| RSS list and feed parsing | Modules/Features/RSS/RSSListView.swift, Modules/Features/RSS/RSSFeedView.swift, Modules/Services/RSS/RSSFetcher.swift |
| Design tokens | Modules/SharedUI/DesignSystem/DesignTokens.swift (DSColor, DSFont, DSSpacing, DSLayout) |
Search Patterns
Use rg from the project root:
ROOT="/Users/zhangruilin/Desktop/Yuedu-reader"
rg -n "struct YourViewName" "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
rg -n "store\\.yourMethod|\\.yourProperty" "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
rg -n '"Button text"' "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
rg -n '"Button text"' "$ROOT"/Resources/zh-Hant.lproj/Localizable.strings
rg -n "Notification\\.Name|NotificationCenter" "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
rg -n "@Published" "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
rg -n "^protocol " "$ROOT"/Modules "$ROOT"/Targets -g '*.swift'
CoreText Pitfalls
Paged Layout
- Margin flow is
ReaderConfig.shared/GlobalSettings.pageMarginH/V->ReaderRenderSettings.contentInsets->CoreTextPaginator.paginate(contentInsets:)->ChapterLayout.contentInsets->CoreTextPageView.draw(). Do not bypass this chain. - Footer layout settings are split:
ReaderConfig.footerBottomPaddingis the literal SwiftUI footer.padding(.bottom, value)and must not silently addwindowSafeBottom;ReaderConfig.footerTextGapbelongs in the CoreText bottom inset as the reserved gap between the last text line area and the footer. - Reader layout setting changes must both update
EPUBPageRenderer.updateRenderSettings(...)and invalidate the active engine. The engine'sonChapterReadycallback should replace the current page withengine.pageViewController(at:)after invalidation; otherwise the old VC can remain visible until navigation or reader re-entry. - Full-page bottom alignment and orphan/widow control are competing goals. If the product wants every normal text page filled to the last configured line, keep the usable content height aligned to the line-height grid and do not move a previous page's last line forward just to avoid orphan/widow lines.
CTFrameGetLineOriginsreturns coordinates relative to the path bounding rect, not absolute context coordinates. AddcontentMinXandcontentMinYindrawLines.- After
invalidateLayout(), call the engine'sonChapterReadycallback. Handlers must fetch a fresh view controller withengine.pageViewController(at:); do not reuse a stale VC. - EPUB CSS parsing supports
margin(shorthand),margin-left,margin-right,margin-top,margin-bottom,padding(shorthand),padding-left,padding-right,text-indent,width,height. Percentage values for these properties resolve againstconfig.renderWidth(content width), not font size, matching the CSS spec for containing-block-relative percentages. CoreTextPageView.drawLinesusesisCJKDominant()to detect script per line. CJK-dominant non-last lines with coverage > 0.85 are justified viaCTLineCreateJustifiedLine; Latin/English-dominant lines use natural alignment to prevent CTFrame from stretching word spacing excessively. When adding justification logic, do not apply CJK-style justification to Latin text without hyphenation support.CoreTextPageEngine.resolveInternalLinkignoreskindle:scheme links. EPUB TOC entries may referencekindle:embed:...URIs that cannot resolve to EPUB content; the resolver returnsnilsilently instead of attempting path resolution.
EPUB CSS Rendering Pipeline
Two parallel paths both produce NSAttributedString; keep them in sync when changing CSS support:
| Path | Builder | Converter | Renderer |
|---|---|---|---|
| Legacy (paged engine preload) | HTMLAttributedStringBuilder.build() |
(none — direct NSAttributedString) | CoreTextPageView.drawLines |
| RenderableNode (EPUB builder) | HTMLAttributedStringBuilder.buildStyledAST() |
HTMLStyledASTRenderableNodeConverter → RenderableNode |
NodeAttributedStringRenderer |
Both paths share CSSParser, HTMLBuilderDOMParser, HTMLBuilderStyleResolver, HTMLCSSPropertyApplierRegistry, and ResolvedStyle. Changing ResolvedStyle fields or CSS resolution affects both. Adding a field to RenderStyle also requires the converter (RenderStyle.from) and NodeAttributedStringRenderer updates.
The legacy path calls build() which internally calls buildStyledAST() + coreTextRenderer.render(). The RenderableNode path calls buildStyledAST() separately then converts to [RenderableNode] and renders via NodeAttributedStringRenderer.
EPUB TOC Panel
- The reader TOC panel (
ReaderMenuView) should prioritizesession.tocEntries(fromtoc.ncx/nav.xhtml) oversession.chapters(spine). SeeReaderView.applyPublicationSession. tocEntriescome fromPublicationSession.flattenTableOfContents(publication.manifest.tableOfContents). EachEPUBTocEntryhashref,title,level.- Map tocEntry href to spine index via
session.chaptershref matching. Strip#fragmentfrom tocEntry hrefs before matching. - Only fall back to spine chapter list when
tocEntriesis empty. - Deduplicate consecutive identically-titled entries in the fallback path (e.g. multiple "Contents" pages from spine-only items not in toc.ncx).
:first-letter / Drop Cap
- CSS
:first-letterrules are parsed separately inCSSParser.parseWithFirstLetter. The pseudo-selector suffix is stripped, and the base selector is matched normally. Rules land inconfig.firstLetterRules→ParsedHTML.firstLetterRules. - Matching happens in
HTMLAttributedStringBuilder.resolvedStyle. Onlyfont-size,font-weight, andcolorare resolved;float,line-height, andmarginare intentionally ignored for the simplified implementation. - The resolved values (
firstLetterFontSizeMultiplier,firstLetterFontWeight,firstLetterColor) flow through both pipelines. InrenderBlockElement/renderBlock, apply to the first typographic letter unit viafirstLetterRange(in:). firstLetterRangeskips leading whitespace and punctuation to find the actual first letter; the returned range includes adjacent punctuation (e.g."Wfor left-quote + W).- The oversized first-letter glyph will be clipped unless
maximumLineHeightis relaxed. After applying the first-letter font, check ifmaximumLineHeightis too small and set it to0(no ceiling) if the drop cap needs more room. - Do NOT chase full
float: lefttext wrapping in the simplified version. The first letter appears as a large inline glyph at the paragraph start.
Nested Block Margin Accumulation
- CoreText uses a single frame for all text. Paragraph
headIndentis measured from the frame edge, not from a parent container's content edge. Parent blockmargin-leftdoes NOT automatically compound into child block indentation. ResolvedStyle.inheritedBlockMarginLeftaccumulates ancestor block margins. InresolvedStyle, block elements add their ownmarginLeftto the inherited value.makeParagraphStyleandNodeAttributedStringRenderer.applyBlockStyleaddinheritedBlockMarginLefttoheadIndent.RenderContext.inheritedBlockMarginLeftpropagates this through the RenderableNode path.
HR Divider Rendering
<hr>elements produce anHRDividerStylestored inhrDividerAttributeon a zero-width NSAttributedString line.CoreTextPageView.drawLinesdraws the line. The extendedHRDividerStylecarriesruleWidth,ruleWidthPercent,marginLeft,marginRight,inheritedBlockMarginLeft,alignment, andisHorizontallyCentered.- The drawing code computes available width from contentWidth minus margins, resolves percentage width, and positions the line per text-align/center.
- The legacy path sets these fields in
makeHRDividerfromResolvedStyle. The RenderableNode path sets them inNodeAttributedStringRenderer.horizontalRulefromRenderStyle+RenderContext. RenderStylemust carrymarginRightandrawWidthPercentfor the RenderableNode path to work.
Link Tap Hit-Testing
CTLineGetStringIndexForPositionreturns the nearest character even for taps far to the right of the text. This makes blank space trigger links and blocks page-turning on TOC pages.- In
CoreTextPageView.stringIndex(at:in:)andCoreTextChunk.stringIndex(atLocalPoint:), check that the tap X coordinate is within[lineOrigin.x - tolerance, lineOrigin.x + lineWidth + tolerance]before looking up the character index. - Use
CTLineGetTypographicBoundsto get the line's actual typographic width. Returnnilwhen the tap is outside text bounds so the parent gesture (page turn) can handle it.
Font Trait Fallback
- When an EPUB embedded font or user-imported font does not support the requested bold/italic traits,
withSymbolicTraitsfails silently and the traits are lost. - In both
HTMLAttributedStringBuilder.styledEmbeddedFontandNodeAttributedStringRenderer.applyTraits, when the target font cannot satisfy the requested traits, fall back to the system font with the correct weight/italic.
Underline / Strikethrough
- Semantic HTML tags
u/ins(underline) ands/strike/del(strikethrough) are mapped inresolvedStyletag-based checks, independent of CSS. ResolvedStyle.underlineand.strikethroughpropagate throughinheritedStyleandbaseTextAttributessets.underlineStyle/.strikethroughStyleon the attributed string.RenderStyleandNodeAttributedStringRenderer.RenderContextmirror these fields for the RenderableNode path.- CoreText does NOT automatically draw underlines or strikethroughs; visual rendering requires manual drawing in the page view which is not yet implemented.
Stable Reading Position
- Do not treat
globalPageas stable content identity. Unloaded chapters may be estimated as one page while offsets rebuild, soglobalPagecan shift. - Use
(spineIndex, charOffset)as the durable position identity. This matchesEPUBPageRendererprogress persistence and avoids wrong previous-page behavior near chapter boundaries. - Keep
globalPageas derived display/navigation state, not the source of truth for content identity.
Reader Fonts
- User-imported reader fonts live behind
UserFontStorageManager,GlobalSettings.userFonts, andGlobalSettings.selectedReaderFontPostScript. - Gate the font picker with
ReadingBook.allowsUserSelectedReaderFont, not onlyBookPipelineKind, because online books resolve to.htmlbut should still expose user fonts. - Apply user fonts through
UserReaderFontResolverfor TXT and online TXT fallback. For online HTML, pass the selected PostScript name asHTMLAttributedStringBuilder.Config.fontFamilyNameso it acts as the default font while CSSfont-familycan still override it. - Do not expose user-selected fonts for EPUB; EPUB embedded fonts and CSS font rules remain higher priority.
Reader Settings UI
- Reader UI development should use native SwiftUI/UIKit presentation by default for navigation bars, toolbar items, dismiss/close buttons, sheets, menus, pickers, and standard controls. Do not hand-roll custom circular header buttons, fake navigation bars, or bespoke chrome unless the user explicitly asks for a custom visual treatment.
- Reader settings should remain a SwiftUI/system-control surface. Prefer system
Picker,Menu,Stepper,Slider,Toggle,Button, and SF Symbols; customize with tint, spacing, and grouping before creating custom controls. - Keep
ReaderSettingsViewnavigation actions system-native by default. Do not replace the standard inline title and完成toolbar button with custom circular header buttons unless the user explicitly requests that specific custom header. - Keep high-frequency settings visible and low-frequency settings compact: reading mode can stay segmented, while multi-option page-turn animation is better as a
Menuor another compact system selection. - Do not expose vertical page margin controls in reader settings unless the product explicitly reopens that setting. The visible page margin setting is horizontal only.
- Keep line height, letter spacing, paragraph spacing, horizontal page margin, and explicit footer spacing controls together in one layout/accessibility section. If the UI exposes a
自訂toggle for that section, disabling it should hide the advanced sliders and restore the default values. - Footer spacing controls should be literal and inspectable: "bottom footer distance" maps to the footer view's bottom padding, while "text to footer" maps to the reader content bottom inset. Do not combine the bottom footer slider with safe-area compensation unless the UI label says so.
- Reader settings preview cards must keep stable dimensions. Let font size, line spacing, and theme update inside a fixed preview frame, but do not bind preview card height or padding to live reader margins; otherwise changing text settings makes the whole settings sheet jump.
- Theme changes in paged CoreText must refresh the visible page immediately. If the current page index does not change, call the engine theme update path and replace the current page view controller; otherwise the reader may only show the new colors after exiting and re-entering.
Reader Overlay Controls
- Keep the bottom reader menu in
ReaderBottomControlBaras a presentational SwiftUI component.ReaderViewshould own reader navigation, chapter state, download/source/TTS sheets, and persistence; pass only bindings, display strings, derived booleans, and callbacks into the bar. - Local bottom-bar UI state such as brightness expansion and slider draft progress can live inside
ReaderBottomControlBar; actions that mutate reading position, source state, brightness synchronization, or sheets should call back intoReaderView.
TTS Media Controls
- For reader TTS system media controls, keep
AVAudioSession,MPNowPlayingInfoCenter, andMPRemoteCommandCenterownership centralized inTTSCoordinator; lower-level engines such asTTSManagerandHTTPTTSEngineshould only play, pause, resume, and stop their audio source. - The main app
Info.plistmust includeUIBackgroundModeswithaudio; verify the built.app/Info.plist, not only project build settings, because array-valued Info.plist keys may not be generated correctly from scalarINFOPLIST_KEY_*settings. - Activate the audio session only when TTS playback starts. Use
.playbackwith.spokenAudio; avoid.duckOthersor.mixWithOtherswhen TTS should appear as the active Control Center media source. - Remote command handlers should dispatch back to the main queue before touching
AVSpeechSynthesizer,AVPlayer, published state, or reader callbacks. Pause should keep the audio session and Now Playing card alive with playback rate0; stop should clear Now Playing and deactivate the session. MPRemoteCommandCenterandMPNowPlayingInfoCenterare global system surfaces, not per-view objects. If settings test playback and reader chapter playback each create aTTSCoordinator, the active playback coordinator must explicitly claim ownership and re-register remote commands on every start/resume. Only the active owner should write Now Playing, disable commands, clear Now Playing, or deactivate the audio session; otherwise a settings-test coordinator can leave the chapter player visible on Dynamic Island but unable to pause from system controls.- Prefer HTTP TTS audio playback over
AVSpeechSynthesizerfor Control Center, lock screen, Dynamic Island, and reliable pause/resume.CustomHTTPProviderreadsGlobalSettings.httpTtsUrlTemplate; templates support{{text}},{{title}}, and{{speakSpeed}}. - For local backend testing from a real iPhone, do not use
localhost; use the Mac's LAN IP, for examplehttp://<mac-lan-ip>:5001/tts?text={{text}}. The appInfo.plistalready allows arbitrary loads and local networking. - Treat TTS previous/next controls as segment controls inside
HTTPTTSEngine, not reader page controls. The engine should publish current segment index/total/text throughTTSCoordinator, andReaderViewcan use the current segment text to drive CoreText playback highlighting. - CoreText TTS highlighting should reuse existing text-selection geometry where possible. Add a non-interactive overlay behind the normal selection overlay, use yellow fill with no handles, and map the current segment text into the visible page range before calling selection-rect logic. This is whole-segment highlighting; word-level karaoke needs provider timestamps or a separate time-to-index estimator.
CustomHTTPProviderresolves{{speakSpeed}}as an Edge TTS style percentage string such as+0%,+30%, or-20%, not the earlier0.50float. Keep HTTP backend templates and settings copy aligned with that format.HTTPTTSEngineshould merge punctuation-only chunks into the previous speakable chunk;containsSpeakableContentuses letters and digits as the speakable-content test.- Segment skip actions should publish the new segment index before starting playback and a background task, otherwise the panel or system media surfaces can briefly show the old segment.
Writing Mode
ReaderWritingModeis a global reader setting persisted byGlobalSettings.readerWritingMode.- Do not expose writing direction as a general user setting in the reader settings UI. Treat vertical writing as a format/content capability: current vertical mode is for paged CoreText TXT and online books only; EPUB and scroll mode resolve to horizontal.
- Paged vertical mode flows through
ReaderRenderSettings.writingMode->PaginationRequest.writingMode->CoreTextPaginator.paginate(... writingMode:)->ChapterLayout.writingMode->CoreTextPageView. CoreTextPaginatorapplieskCTVerticalFormsAttributeNameand creates frames withkCTFrameProgressionAttributeName = CTFrameProgression.rightToLeft. UseCoreTextPaginator.makeFrame(...)for any new CoreText frame in this pipeline.- Vertical page text interaction supports tap hit-testing, internal links, selection rects, and underline overlays through
CoreTextPageViewvertical geometry. Do not reuse horizontal math: verticalCTLineGetStringIndexForPositionuses X as inline advance, while column block extents come from ascent/descent. - App-introduced page/chunk break offsets should pass through
CJKTypographyProcessor.protectedLineBreakOffset(...)to avoid splitting surrogate pairs or forcing forbidden CJK punctuation at line/page boundaries. - Before larger CoreText changes, read
docs/coretext/README.mdand the linked vertical-writing / interaction notes.
Vertical CoreText Rendering (vertical-rl)
Vertical mode wiring: ReaderWritingMode.verticalRTL → kCTFrameProgressionAttributeName = rightToLeft + kCTVerticalFormsAttributeName = true. CoreTextPageView.renderPage dispatches to drawVerticalFrame (calls CTFrameDraw) instead of drawHorizontalFrame (line-by-line drawing).
Coordinate System Differences
In vertical CoreText, CTLineGetTypographicBounds and CTLineGetStringRange return values in different axes than horizontal mode:
| Property | Horizontal | Vertical-rl |
|---|---|---|
lineOrigin.x |
Text left edge | Column position in block direction (RTL: rightmost = highest x) |
lineOrigin.y |
Baseline Y | Position along column in inline direction (top-to-bottom) |
Typographic width |
X extent of line | Y extent of column (inline direction) |
Typographic ascent |
Y extent above baseline | X extent toward block-start (rightward in RTL) |
Typographic descent |
Y extent below baseline | X extent toward block-end (leftward in RTL) |
Critical: ascent and descent from CTLineGetTypographicBounds / CTRunGetTypographicBounds are X-axis values in vertical mode. Never add them to Y coordinates. CTRunDelegate callbacks follow the same axis mapping: getAscent/getDescent → block (X), getWidth → inline (Y).
Negative descent: In vertical mode, CTRunDelegate runs (especially images with large block-direction extents) can return negative descent values. Example: image ascent=189, descent=-167.4. Both values may point in the same direction relative to baseline. Use abs(lineDescent) for total block extent: lineAscent + abs(lineDescent).
Paragraph Style Limitations
CoreText's NSParagraphStyle properties have different semantics in vertical mode. Properties that work in the block direction (X) have no inline-direction equivalents:
| Property | Horizontal effect | Vertical-rl effect |
|---|---|---|
paragraphSpacingBefore |
Space above paragraph (Y) | Ignored (X-direction spacing, unreliable) |
firstLineHeadIndent |
Indent from left (X) | Ignored for Y positioning |
headIndent / tailIndent |
Left/right margins (X) | Block-direction margins |
textAlignment |
Horizontal alignment | Works: .right → bottom-aligned (inline-end) |
minimumLineHeight |
Min line height (Y) | Min column height (Y inline) — works for Y constraints |
paragraphSpacing |
Space after paragraph (Y) | Block-direction spacing (X) |
lineSpacing |
Space between lines (Y) | Block-direction spacing (X) |
CSS margin-top is NOT supported in vertical CoreText. CSS margin-top maps to paragraphSpacingBefore which CoreText ignores in vertical-rl for inline (Y) positioning. Do not try to work around this with:
NSBaselineOffsetAttributeName— shifts baseline in block (X) direction, not inline (Y)CTRunDelegatespacer characters — disrupt pagination and image extractionfirstLineHeadIndent— no effect on Y position
The only working inline-direction positioning via paragraph style is textAlignment (right=bottom, center=middle, left/natural=top).
Extract Block Renderables — Vertical Mode
CoreTextPaginator.extractBlockRenderables merges per-line rects into block-level rects. In vertical mode, the original code added lineAscent (X-direction) to adjustedOrigin.y, producing nonsense Y coordinates. Fix:
uiY = renderSize.height - adjustedOrigin.y(column top, no ascent term)lineHeight = lineWidth(inline/Y extent, not ascent+descent)rectX = adjustedOrigin.x - rectW / 2(center block on column baseline)- In
blockImageRect, always center image horizontally in vertical mode (override CSS alignment)
ImageRunInfo / CTRunDelegate
extractImages and makeBlockImageAttachment cast ALL CTRunDelegate refCons to ImageRunInfo via Unmanaged<ImageRunInfo>.fromOpaque. Any new non-image CTRunDelegate runs must be marked with HTMLAttributedStringBuilder.spacerRunAttribute and guarded at both sites, or they will crash when the refCon casting fails.
Vertical Font Cascade and Normalization
preparedAttributedString in CoreTextPaginator applies:
- Half-to-full-width bracket conversion (1:1 length-preserving via
replaceOccurrencesonmutableString) - Per-font vertical substitution map (
VerticalLayoutConfig— detects glyphs lacking vertical alternates) - CTFont cascade: PingFang → Songti TC → Kaiti TC → Heiti TC
- Default paragraph style vertical defaults (firstLineHeadIndent=2em, paragraphSpacing=0.8em, lineSpacing=0.3em)
kCTVerticalFormsAttributeName = trueglobally, removed for ASCII[a-zA-Z0-9\\s]+
Vertical Links, Selection, and Underlines
CoreTextPageView.makeInteractionContext()is shared by horizontal and vertical pages. Vertical hit-testing first chooses a column by X, computes inline advance from the column top, then callsCTLineGetStringIndexForPosition(line, CGPoint(x: advance, y: 0)).- Vertical selection rects use the same string ranges as horizontal selection but return tall column-local rects. Keep link taps, long-press selection, handle dragging, TTS highlights, and underline bookmarks on this common range geometry.
InteractionOverlayView.drawsVerticalUnderlinesdraws underline bookmarks as vertical side strokes for vertical pages. Do not use horizontal bottom-line underline drawing for vertical text.
Step 1 uses mutableString.replaceOccurrences which preserves NSAttributedString attributes (unlike setString which destroys them). All replacements are length-preserving (1:1 UTF-16).
Vertical Scroll Engine
Paged and scroll reading are separate engines inside EPUBPageRenderer:
| Mode | Pipeline |
|---|---|
| Paged | CoreTextPageEngine -> UIPageViewController -> CoreTextPageView |
| Scroll | CoreTextScrollEngine -> UITableView -> CoreTextChunkCell |
Guardrails:
ReaderView.bodyrouting order is sensitive.else if settings.scrollMode { scrollBody }must stay before the paged-engine-ready branch, or EPUB readiness prevents scroll mode from rendering.- When
useRenderableNodePipelineis false, the paged engine can use theresourceProvider:path without a builder, but the scroll engine still needs anAttributedStringBuilding. BuildEPUBAttributedStringBuilderfor both branches inEPUBPageRenderer.load. - EPUB full-page images use
result.imagePage, not an attributed-stringCTRunDelegate. Scroll mode must synthesize anisImageOnlychunk or cover chapters disappear. - Inline images use
\u{FFFC}pluskCTRunDelegateAttributeName(ImageRunInfo).CTFrameDrawreserves space only; draw images after extracting run rects viaCoreTextChunkAttachmentExtractor. - The slicer height cap is about 2000 pt. Large covers can make
CTFramesetterSuggestFrameSizeWithConstraintsreturnlength=0; retry with.greatestFiniteMagnitudeand ensureblockImageHeight()can fit the image. - Table updates race easily.
numberOfRowsInSectionshould use the VC-owneddisplayedCount, notengine.chunks.count; bump it synchronously insidebeginUpdates/endUpdates. - Start scroll layout from the UIKit VC
viewDidLayoutSubviewswidth. SwiftUI preference size can still be(0, 0)when.task(id:)runs. - Scroll progress changes must update the paged engine through
pagedEngine.pageIndex(forSpine:charOffset:), or switching back to paged mode jumps to an old page. - Clearing selection must also hide
UIMenuController, or the copy menu can stay visible after highlight clears.
Online Reading Pitfalls
bookSourceId separates two online book types:
| Book-source book | Browser-imported book | |
|---|---|---|
bookSourceId |
non-nil UUID |
nil |
| Chapter fetch | BookSourceFetcher.fetchChapterPackage |
fetchBrowserImportedChapter |
| Parsing | CSS/XPath/Regex rule engine | WebView original HTML to text |
| Source | configured book source | user imports from built-in browser |
Rules:
fetchBrowserImportedChapteris the primary path for browser-imported books, not a fallback.ChapterFetchManageris anactor; its state is serialized by Swift concurrency.- Generation tokens are coupled to task lifecycle. Create a new
UUIDfor each new task and drop stale task results when the token no longer matches. ModernParserBridge.makeEngine()intentionally creates a freshModernRuleEngineper parse to prevent state bleed during overlapping async operations.isSuspiciousChapterContentdetects dirty cached chapters that merged multiple chapters, using length over 50,000 or more than three chapter-title matches.- For browser-imported HTML and other webpage-to-reader conversion, preserve semantic HTML blocks when they already exist. Follow the NetNewsWire pattern: keep feed/extracted
contentHTMLas HTML, sanitize unsafe tags/attributes, and inject the cleaned body into the reader template. Do not split existing<p>elements by character count. - Treat inline tags such as
<a>,<strong>,<em>,<span>, and<code>as part of their parent paragraph. Never promote an inline link into its own paragraph; this causes broken text like "處理伊朗" and the linked phrase appearing on separate paragraphs. - Only synthesize paragraphs for true plain-text fallback or HTML with no usable block structure. In that fallback, split on explicit blank lines first; heuristic sentence/length splitting is a last resort.
- Remove obvious article noise before rendering or text extraction: dangerous tags, ad/noise DOM nodes, and standalone ad labels such as
廣告,广告, orAdvertisement. Do this with precise selectors/text checks, not broad substring deletion that can destroy legitimate words.
Bookshelf And Persistence
BookStore.booksis@Published [ReadingBook]; order is insertion/order state.- Sorting changes should go through
BookStore.moveBooks(ids:before:), which saves metadata. - Delete books through
BookStore.delete(bookId:); it clears cache directories and font resources. - Cover files live under Documents and are referenced by
book.coverImagePath. - Bookmarks should not use
globalPageor legacypageIndexas stored identity.Bookmark.positionisCoreTextReadingPosition(spineIndex, charOffset); sorting, deduplication, and jumps should use that stable position. - The top-bar bookmark currently means a chapter-start bookmark. Use
.chapterStart(chapterIndex)for its toggle/check state; bookmark-list jumps should use the bookmark's owncharOffset. - Preserve legacy bookmark decode fallback.
Bookmark.CodingKeysstill includespageIndex,spineIndex, andcharOffsetso older metadata can migrate. - For new per-book settings, add a Codable field to
ReadingBook, handle decode defaults, add aBookStore.set...method, and callsaveMeta().
Account Sign-In
- Account state is currently stored in
GlobalSettings.shared:isLoggedIn,accountDisplayName,accountEmail,accountProvider, andaccountAvatarData, all persisted throughUserDefaults. - The settings entry point is
Modules/Features/Settings/ProfileView.swift(SettingsView) ->UserDetailView;LoginViewlives underModules/Features/Settings/LoginView.swift. Do not put sign-in UI underModules/Features/Reader/TTS/; that folder is for reader text-to-speech UI. - Sign-out must go through
GlobalSettings.signOut(...); do not mutateisLoggedIndirectly from views. Google accounts need to clearGIDSignIn.sharedInstancefirst: normal sign-out usessignOut(), while revoke uses thedisconnectpath. - On successful sign-in,
LoginViewshould call its success callback anddismiss()so the parent sheet binding and actual presentation state stay in sync. - Avatar changes use
PhotosPicker-> resize/compress ->GlobalSettings.updateAccountAvatar(data:).ProfileViewandUserDetailViewshould share the same account avatar rendering instead of duplicating state. - Google Sign-In has three required app-side pieces:
Info.plistGIDClientIDplus URL scheme,project.pbxprojpackage products forGoogleSignInandGoogleSignInSwift, and thegoogle_logoasset. - Downloaded
client_*.plistOAuth config files are not read automatically unless the Xcode project references them or code loads them. Before committing one, verify it is actually needed.
RSS
RSSFetcheris@MainActor final class; keep@Publishedstate updates on the main actor.- Keep the explicit
URLRequesttimeout,.reloadIgnoringLocalCacheData, andMozilla/5.0User-Agent. Some feeds reject the default URLSession user agent. RSSXMLParserhandles RSS and Atom. Lowercase element names, read Atom<link href="">only forrel="alternate"or empty rel, supportdescription,summary,content, andcontent:encoded, and append CDATA text.- Treat parser errors and empty feeds separately. Parser errors should clear
items; successful empty feeds should show an empty state with reload. - For RSS article rendering, mirror NetNewsWire's body strategy: parser records keep raw
contentHTML; the article reader sanitizes it and renders the sanitized HTML body. AvoidNSAttributedString(data: .html)in SwiftUI rows, and avoid reparagraphing already-structured article HTML in the reader. - The RSS reader template should stay close to NetNewsWire's
ArticleRenderer+template.html+ iOSpage.html+stylesheet.css: feed/source header, optional icon, linked article title, dateline, and a body container that receives already-sanitized HTML. - Treat inline tags such as
<a>,<strong>,<em>,<span>, and<code>as part of their parent paragraph. Never promote an inline link into its own paragraph; that creates broken text such as a phrase before the link and the linked phrase appearing as separate paragraphs. - Only synthesize paragraphs for true plain-text fallback or HTML with no usable block structure. In fallback mode, split explicit blank lines first; sentence/length splitting is a last resort.
- Remove article noise before rendering or extraction with precise selectors/text checks: dangerous tags, ad/noise DOM nodes, and standalone ad labels such as
廣告,广告, orAdvertisement. Do not delete broad substrings from normal body text. - Reader View/full-text extraction is a separate step from feed rendering. NetNewsWire gets cleaned
ExtractedArticle.contentfrom Mercury/Feedbin and then injects that content directly; local extraction should likewise output cleaned HTML plus plain text, not a lossy plain-text-only body.
Localization
All UI strings must use localized() in SwiftUI views:
Text(localized("選取"))
Label(localized("列表"), systemImage: "list.bullet")
Do not write raw UI strings directly inside Text, Button, Label, TextField, or similar views.
For each new UI key, update all three files:
Resources/zh-Hant.lproj/Localizable.stringsResources/zh-Hans.lproj/Localizable.stringsResources/en.lproj/Localizable.strings
Extension Points
| Add | Use |
|---|---|
| New file format | BookParser + BookParserRegistry.parsers in Modules/Core/EPUB/BookParsing.swift |
| New rendered content type | ChapterContent in Modules/Services/LibraryStore/UniversalBookInterfaces.swift + Modules/Features/Reader/ReaderViewFactory.swift |
| New chapter source | BookContentProvider in Modules/Services/Online/BookContentProvider.swift |
| New layout engine | PagedReaderEngine/ScrollReaderEngine, PageIndexProviding, and PageViewControllerVending in Modules/Core/ReaderCore/CoreText/PageRenderingProvider.swift |
| New attributed-string source | AttributedStringBuilding in Modules/Core/ReaderCore/CoreText/AttributedStringBuilding.swift |
| New TTS engine | TTSPlayable in Modules/Core/TTS/TTSPlayable.swift |
| New book-source fetch logic | BookSource + Modules/Core/BookSource/BookSourceFetcher+* extensions |
| New CSS property | HTMLCSSPropertyApplier in Modules/Core/ReaderCore/CoreText/CSSPropertyApplier.swift |
| New global service | Define a protocol, add it to Targets/Yuedu/SharedApp/AppDependencies.swift, inject via EnvironmentValues |
| New page transition effect | ProgrammaticPageTransitionControlling in Modules/Core/ReaderCore/ProgrammaticPageTransitionPerformer.swift |
| Format-gated reader settings | book.resolvedPipelineKind, ReadingBook capability fields, or a persisted ReadingBook field |
Before adding behavior, search for existing protocols and registries.
Workflows
New Feature
- Locate the feature area and entry files.
- Search for an existing protocol, registry, store method, feature flag, or dependency injection point.
- Read one or two nearby implementations and match the local style.
- Implement through the existing extension point. Add a new abstraction only when the current ones cannot represent the behavior.
Bug Fix
- Search the exact error text, function name, notification, or state property.
- Trace the call chain backward to the state owner.
- Reproduce or inspect the broken invariant before changing code.
- For CoreText reading-position bugs, verify whether the issue is caused by using
globalPageas identity instead of(spineIndex, charOffset).
UI Change
- Locate the view.
- Follow
@EnvironmentObject,@ObservedObject,@State, and bindings back to their model owner. - Use existing
DesignTokensand localization patterns. - Add all localization keys immediately.
CoreText Change
- Read the relevant CoreText pitfalls above.
- Preserve the margin, layout invalidation, and reading-position identity flows.
- Verify both paged and scroll modes when the touched code can affect both.
Build
Use the app build to verify compilation:
cd "/Users/zhangruilin/Desktop/Yuedu-reader"
xcodebuild -project "Yuedu-Reader.xcodeproj" \
-scheme "Yuedu-Reader" \
-destination 'generic/platform=iOS Simulator' \
build
Simulator-specific smoke build:
cd "/Users/zhangruilin/Desktop/Yuedu-reader"
xcodebuild -scheme "Yuedu-Reader" \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug build
If xcodebuild test fails with Cannot find 'TXTBookIngester' in scope, treat it as the known test-target compile issue around TXTToXHTMLConverter.swift unless reverified otherwise.