feat(07): close native text pagination semantics loop
This commit is contained in:
parent
21c8bbb23a
commit
d7339aa255
@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.1
|
||||
milestone_name: WXRead 深化对齐
|
||||
status: ready_to_execute
|
||||
last_updated: "2026-05-22T12:03:00.000Z"
|
||||
last_activity: 2026-05-22
|
||||
last_updated: "2026-05-22T12:35:00.000Z"
|
||||
last_activity: 2026-05-22 -- Phase 07 execution completed
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 0
|
||||
total_plans: 11
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
completed_phases: 2
|
||||
total_plans: 6
|
||||
completed_plans: 6
|
||||
percent: 50
|
||||
---
|
||||
|
||||
# STATE
|
||||
@ -23,18 +23,18 @@ progress:
|
||||
参见:`.planning/PROJECT.md`(更新于 2026-05-22)
|
||||
|
||||
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
||||
**当前关注:** `v1.1` 已完成 Phase 6-7 规划,下一步按顺序进入执行或继续规划 Phase 8
|
||||
**当前关注:** `v1.1` 已完成 Phase 6-7 执行闭环,下一步进入 Phase 8 规划或执行
|
||||
|
||||
## 当前结论(摘要)
|
||||
|
||||
- `v1.1` 已基于现有候选需求正式生成 active `REQUIREMENTS.md` 与 `ROADMAP.md`。
|
||||
- 当前活跃 roadmap 包含 Phase 6-9:页面几何、属性闭环、分页质量/缓存、自动化回归。
|
||||
- Phase 7 已补齐 research / patterns / validation / plans,属性闭环的实现入口已收敛到 renderer → layouter → page metadata 这条链路。
|
||||
- Phase 7 已完成执行与验证,HTML/CSS → attributed string → paginator 的属性闭环已落到 renderer → layouter → page metadata 这条链路。
|
||||
- `v1.0` 归档与 audit 仍保留在 `.planning/milestones/` 和 `.planning/v1.0-MILESTONE-AUDIT.md` 供后续追溯。
|
||||
|
||||
## 当前风险焦点
|
||||
|
||||
- native text 几何查询、自定义分页属性闭环、分页质量与自动化验证是 `v1.1` 的主要技术风险
|
||||
- native text 分页质量/缓存与自动化验证仍是 `v1.1` 的主要技术风险
|
||||
- `v1.0` 遗留的 requirement bookkeeping gap 已归档,不应阻塞 `v1.1` 执行,但后续引用历史证据时需注意
|
||||
- simulator 仍会输出既有 `.SFUI-Semibold` CoreText 替代提示,但未造成功能性失败
|
||||
|
||||
@ -51,7 +51,7 @@ progress:
|
||||
|
||||
## 下一步
|
||||
|
||||
- 建议下一步:`$gsd-execute-phase 6` 或继续 `$gsd-plan-phase 8`
|
||||
- 建议下一步:`$gsd-plan-phase 8` 或在确认顺序后继续 `$gsd-execute-phase 8`
|
||||
- 已完成的产物目录:
|
||||
- `.planning/milestones/`
|
||||
- `.planning/phases/06-page-geometry-and-interaction-hit-layer/`
|
||||
@ -60,7 +60,7 @@ progress:
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 7
|
||||
Plan: 07-01 / 07-02 / 07-03
|
||||
Status: Ready for execute-phase
|
||||
Last activity: 2026-05-22 — Phase 7 research, validation, patterns, and plans created
|
||||
Phase: 8
|
||||
Plan: pending
|
||||
Status: Ready for next phase
|
||||
Last activity: 2026-05-22 -- Phase 07 execution completed with verification passed
|
||||
|
||||
20
.planning/phases/07-wxread/07-01-SUMMARY.md
Normal file
20
.planning/phases/07-wxread/07-01-SUMMARY.md
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
phase: 7
|
||||
plan: 07-01
|
||||
status: complete
|
||||
requirements-completed:
|
||||
- ATTR-01
|
||||
- ATTR-03
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# 07-01 Summary
|
||||
|
||||
- Added explicit native-text semantic types and attributed-string keys for block kinds, pagination hints, and attachment placement.
|
||||
- Extended chapter preprocessing to inject semantic markers for WXRead-style hints such as `avoidPageBreakInside`, `pageBreakBefore`, `pageBreakAfter`, `pageRelate`, and image/body block semantics.
|
||||
- Renderer post-processing now strips those markers and applies real attributes before fragment-offset extraction, so semantic metadata is preserved without breaking the existing offset contract.
|
||||
|
||||
## Verification
|
||||
|
||||
- `build_sim` for `ReadViewDemo` on iOS Simulator succeeded on 2026-05-22.
|
||||
|
||||
21
.planning/phases/07-wxread/07-02-SUMMARY.md
Normal file
21
.planning/phases/07-wxread/07-02-SUMMARY.md
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
phase: 7
|
||||
plan: 07-02
|
||||
status: complete
|
||||
requirements-completed:
|
||||
- ATTR-01
|
||||
- ATTR-02
|
||||
- ATTR-03
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# 07-02 Summary
|
||||
|
||||
- Taught `RDEPUBTextLayouter` to consume semantic hints and emit `semanticBoundary` breaks when explicit page-break or avoid-break rules should influence pagination.
|
||||
- Expanded page metadata and layout frames to retain block kinds, semantic hints, and attachment placements alongside the existing attachment and break diagnostics.
|
||||
- Propagated the richer pagination diagnostics through `RDEPUBTextBookBuilder` and `RDPlainTextBookBuilder`, so chapter summaries can now explain attachment-heavy and block-sensitive pagination behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- `build_sim` for `ReadViewDemo` on iOS Simulator succeeded on 2026-05-22.
|
||||
|
||||
23
.planning/phases/07-wxread/07-03-SUMMARY.md
Normal file
23
.planning/phases/07-wxread/07-03-SUMMARY.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
phase: 7
|
||||
plan: 07-03
|
||||
status: complete
|
||||
requirements-completed:
|
||||
- ATTR-01
|
||||
- ATTR-02
|
||||
- ATTR-03
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# 07-03 Summary
|
||||
|
||||
- Added reusable semantic diagnostic summaries on `RDEPUBTextBookBuilder` and `RDEPUBReaderController` for the native text path.
|
||||
- Updated `ReadViewDemo` startup validation to prioritize a Phase 7 semantic-closure line and a pagination line from native reflowable samples.
|
||||
- Verified the simulator runtime log now surfaces explicit semantic evidence for a real sample book, rather than only generic sample-matrix output.
|
||||
|
||||
## Verification
|
||||
|
||||
- `build_run_sim` for `ReadViewDemo` on iPhone 17 (iOS 26.5 simulator) succeeded on 2026-05-22.
|
||||
- Runtime log included `属性闭环诊断:宝山辽墓材料与释读 · 章节 10 · block kinds [attachment,paragraph] · placements [inline,centered] · block kinds: attachment`.
|
||||
- Runtime log included `分页诊断:宝山辽墓材料与释读 · 章节 10 · attachment 页 54 · semantic break 页 57 · block kinds [attachment,paragraph] · placements [inline,centered] · reasons [attachmentBoundary:31, blockBoundary:26, frameLimit:17, chapterEnd:4] · page break: blockBoundary`.
|
||||
|
||||
35
.planning/phases/07-wxread/07-VERIFICATION.md
Normal file
35
.planning/phases/07-wxread/07-VERIFICATION.md
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
phase: 7
|
||||
status: passed
|
||||
requirements-verified:
|
||||
- ATTR-01
|
||||
- ATTR-02
|
||||
- ATTR-03
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# Phase 7 Verification
|
||||
|
||||
## Result
|
||||
|
||||
Passed.
|
||||
|
||||
## What Was Verified
|
||||
|
||||
- HTML/CSS pagination semantics now survive the native text pipeline as explicit attributed-string metadata.
|
||||
- Paginator and page metadata consume those semantics and expose them in break reasons and diagnostics.
|
||||
- Demo startup validation surfaces semantic-closure evidence for a native reflowable sample book.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `build_sim` for `ReadViewDemo` succeeded after the semantic extraction and layouter-consumption changes.
|
||||
- `build_run_sim` for `ReadViewDemo` succeeded on iPhone 17 (iOS 26.5 simulator).
|
||||
- Runtime log surfaced:
|
||||
- `属性闭环诊断:宝山辽墓材料与释读 · 章节 10 · block kinds [attachment,paragraph] · placements [inline,centered] · block kinds: attachment`
|
||||
- `分页诊断:宝山辽墓材料与释读 · 章节 10 · attachment 页 54 · semantic break 页 57 · block kinds [attachment,paragraph] · placements [inline,centered] · reasons [attachmentBoundary:31, blockBoundary:26, frameLimit:17, chapterEnd:4] · page break: blockBoundary`
|
||||
|
||||
## Residual Risk
|
||||
|
||||
- Current semantic extraction is intentionally conservative and heuristic-driven; future EPUB variants may require additional tag/class/style mappings in Phase 8.
|
||||
- The sample evidence currently proves attachment and paragraph semantics clearly; richer code/list/table/blockquote samples should continue to be exercised as later phases tighten quality and automation.
|
||||
|
||||
@ -51,12 +51,31 @@ final class ViewController: UIViewController {
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
||||
}
|
||||
lines.append(contentsOf: matrixLines.prefix(3))
|
||||
lines.append(contentsOf: diagnosticLines.prefix(2))
|
||||
lines.append(contentsOf: prioritizedDiagnosticLines())
|
||||
if !rerunHint.isEmpty {
|
||||
lines.append(rerunHint)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func prioritizedDiagnosticLines() -> [String] {
|
||||
var selected: [String] = []
|
||||
if let semanticLine = diagnosticLines.first(where: { $0.contains("属性闭环诊断") }) {
|
||||
selected.append(semanticLine)
|
||||
}
|
||||
if let paginationLine = diagnosticLines.first(where: { $0.contains("分页诊断") && !selected.contains($0) }) {
|
||||
selected.append(paginationLine)
|
||||
}
|
||||
if selected.count < 2 {
|
||||
for line in diagnosticLines where !selected.contains(line) {
|
||||
selected.append(line)
|
||||
if selected.count == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
@ -346,6 +365,9 @@ final class ViewController: UIViewController {
|
||||
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
||||
summaryLines.append("分页诊断:\(paginationSummary)")
|
||||
}
|
||||
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
||||
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
||||
}
|
||||
if let restoreSummary = makeRestoreSummary(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@ -438,6 +460,9 @@ final class ViewController: UIViewController {
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
||||
let blockKinds = uniqueValues(diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(diagnostics.flatMap(\.attachmentPlacements))
|
||||
let breakReasonCounts = diagnostics
|
||||
.flatMap(\.breakReasons)
|
||||
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
||||
@ -460,6 +485,9 @@ final class ViewController: UIViewController {
|
||||
"章节 \(diagnostics.count)",
|
||||
"attachment 页 \(attachmentPages)",
|
||||
"semantic break 页 \(semanticBreakPages)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]",
|
||||
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
@ -547,6 +575,14 @@ final class ViewController: UIViewController {
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func uniqueValues<T: Equatable>(_ values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openBook(_ book: DemoBook) {
|
||||
let controller = RDURLReaderController(bookURL: book.fileURL)
|
||||
controller.title = book.title
|
||||
|
||||
@ -172,6 +172,7 @@ public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
||||
case frameLimit
|
||||
case blockBoundary
|
||||
case attachmentBoundary
|
||||
case semanticBoundary
|
||||
}
|
||||
|
||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
@ -184,6 +185,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
public var blockRange: NSRange?
|
||||
public var attachmentRanges: [NSRange]
|
||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
public var trailingFragmentID: String?
|
||||
public var diagnostics: [String]
|
||||
|
||||
@ -192,6 +196,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
blockRange: NSRange? = nil,
|
||||
attachmentRanges: [NSRange] = [],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
||||
blockKinds: [RDEPUBTextBlockKind] = [],
|
||||
semanticHints: [RDEPUBTextSemanticHint] = [],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
||||
trailingFragmentID: String? = nil,
|
||||
diagnostics: [String] = []
|
||||
) {
|
||||
@ -199,6 +206,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
self.blockRange = blockRange
|
||||
self.attachmentRanges = attachmentRanges
|
||||
self.attachmentKinds = attachmentKinds
|
||||
self.blockKinds = blockKinds
|
||||
self.semanticHints = semanticHints
|
||||
self.attachmentPlacements = attachmentPlacements
|
||||
self.trailingFragmentID = trailingFragmentID
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
@ -59,6 +60,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
|
||||
@ -7,6 +7,9 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var attachmentPageCount: Int
|
||||
public var blockAdjustedPageCount: Int
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
@ -103,6 +106,29 @@ public final class RDEPUBTextBookBuilder {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
||||
|
||||
let blockKinds = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.attachmentPlacements))
|
||||
let note = lastBuildPaginationDiagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
|
||||
|
||||
var parts = [
|
||||
title,
|
||||
"章节 \(lastBuildPaginationDiagnostics.count)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@ -150,6 +176,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
@ -197,6 +226,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
breakReasons: pages.map(\.metadata.breakReason),
|
||||
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
|
||||
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
|
||||
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
|
||||
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
|
||||
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
|
||||
sampleNotes: Array(
|
||||
pages
|
||||
.flatMap(\.metadata.diagnostics)
|
||||
@ -233,4 +265,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ struct RDEPUBTextLayoutFrame: Equatable {
|
||||
var blockRange: NSRange?
|
||||
var attachmentRanges: [NSRange]
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
var trailingFragmentID: String?
|
||||
var diagnostics: [String]
|
||||
|
||||
@ -15,6 +18,9 @@ struct RDEPUBTextLayoutFrame: Equatable {
|
||||
blockRange: blockRange,
|
||||
attachmentRanges: attachmentRanges,
|
||||
attachmentKinds: attachmentKinds,
|
||||
blockKinds: blockKinds,
|
||||
semanticHints: semanticHints,
|
||||
attachmentPlacements: attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
|
||||
@ -42,6 +42,9 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: adjusted.blockRange,
|
||||
attachmentRanges: adjusted.attachmentRanges,
|
||||
attachmentKinds: adjusted.attachmentKinds,
|
||||
blockKinds: adjusted.blockKinds,
|
||||
semanticHints: adjusted.semanticHints,
|
||||
attachmentPlacements: adjusted.attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: adjusted.diagnostics
|
||||
)
|
||||
@ -67,9 +70,15 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: NSRange?,
|
||||
attachmentRanges: [NSRange],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind],
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
diagnostics: [String]
|
||||
) {
|
||||
let pageEnd = proposedRange.location + proposedRange.length
|
||||
let proposedBlockKinds = blockKinds(in: proposedRange)
|
||||
let proposedSemanticHints = semanticHints(in: proposedRange)
|
||||
let proposedAttachmentPlacements = attachmentPlacements(in: proposedRange)
|
||||
guard pageEnd < totalLength else {
|
||||
return (
|
||||
range: proposedRange,
|
||||
@ -77,11 +86,17 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
attachmentRanges: attachmentRanges(in: proposedRange),
|
||||
attachmentKinds: attachmentKinds(in: proposedRange),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .chapterEnd,
|
||||
range: proposedRange,
|
||||
attachmentRanges: attachmentRanges(in: proposedRange),
|
||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -92,6 +107,36 @@ struct RDEPUBTextLayouter {
|
||||
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
||||
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
|
||||
let currentBlockKinds = proposedBlockKinds
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
if let semanticBoundary = preferredSemanticBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: semanticBoundary.location - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .semanticBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .semanticBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
trigger: semanticBoundary.trigger
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
@ -104,11 +149,17 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .attachmentBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -124,11 +175,17 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .blockBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -139,15 +196,62 @@ struct RDEPUBTextLayouter {
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .frameLimit,
|
||||
range: proposedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int
|
||||
) -> (location: Int, trigger: String)? {
|
||||
var boundary: (location: Int, trigger: String)?
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, attributeRange, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard !hints.isEmpty else { return }
|
||||
|
||||
if hints.contains(.pageBreakBefore),
|
||||
attributeRange.location > range.location,
|
||||
attributeRange.location >= minimumEnd {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
let attributeEnd = attributeRange.location + attributeRange.length
|
||||
if hints.contains(.pageBreakAfter),
|
||||
attributeEnd > minimumEnd,
|
||||
attributeEnd < range.location + range.length {
|
||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
if hints.contains(.avoidPageBreakInside),
|
||||
attributeRange.location > range.location,
|
||||
attributeRange.location < range.location + range.length,
|
||||
attributeRange.location >= minimumEnd,
|
||||
attributeEnd > range.location + range.length {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.avoidPageBreakInside.rawValue)
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
@ -216,6 +320,43 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
||||
guard let rawValue = value as? String else { return }
|
||||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||||
hints.append(hint)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!placements.contains(placement) else {
|
||||
return
|
||||
}
|
||||
placements.append(placement)
|
||||
}
|
||||
return placements
|
||||
}
|
||||
|
||||
private func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
@ -230,7 +371,11 @@ struct RDEPUBTextLayouter {
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
attachmentRanges: [NSRange],
|
||||
blockRange: NSRange?
|
||||
blockRange: NSRange?,
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
trigger: String? = nil
|
||||
) -> [String] {
|
||||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||||
if let blockRange {
|
||||
@ -239,6 +384,18 @@ struct RDEPUBTextLayouter {
|
||||
if !attachmentRanges.isEmpty {
|
||||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
||||
}
|
||||
if !blockKinds.isEmpty {
|
||||
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !semanticHints.isEmpty {
|
||||
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !attachmentPlacements.isEmpty {
|
||||
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let trigger, !trigger.isEmpty {
|
||||
items.append("semantic trigger: \(trigger)")
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,32 @@ public extension NSAttributedString.Key {
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
}
|
||||
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
}
|
||||
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
}
|
||||
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
|
||||
@ -3,6 +3,7 @@ import UIKit
|
||||
enum RDEPUBTextRendererSupport {
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
@ -44,6 +45,40 @@ enum RDEPUBTextRendererSupport {
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let kind = mutableString.substring(with: match.range(at: 1))
|
||||
let payload = mutableString.substring(with: match.range(at: 2))
|
||||
let markerLocation = match.range.location
|
||||
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
|
||||
if kind == "start" {
|
||||
let semantics = parseSemanticMarkerPayload(payload)
|
||||
openRanges[semantics.id] = (markerLocation, semantics)
|
||||
} else {
|
||||
let markerID = parseSemanticEndID(payload)
|
||||
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
|
||||
let length = max(markerLocation - opened.location, 0)
|
||||
if length > 0 {
|
||||
apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
|
||||
}
|
||||
}
|
||||
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
@ -64,6 +99,15 @@ enum RDEPUBTextRendererSupport {
|
||||
if let attachmentKind = attachmentKind(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
|
||||
}
|
||||
if let placement = normalizeAttachmentPlacement(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
if let blockKind = normalizeBlockKind(for: attributes) {
|
||||
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
if let hints = normalizeSemanticHints(for: attributes), !hints.isEmpty {
|
||||
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
blockIndex += 1
|
||||
}
|
||||
@ -86,7 +130,7 @@ enum RDEPUBTextRendererSupport {
|
||||
style: RDEPUBTextRenderStyle,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> RDEPUBTextChapterRenderRequest {
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
||||
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
||||
in: normalizedHTML,
|
||||
chapterHref: href,
|
||||
@ -482,4 +526,261 @@ enum RDEPUBTextRendererSupport {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func injectPaginationSemanticMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else {
|
||||
return html
|
||||
}
|
||||
|
||||
var output = ""
|
||||
var cursor = 0
|
||||
var openTagStack: [(name: String, id: String)] = []
|
||||
var nextMarkerID = 0
|
||||
|
||||
for match in matches {
|
||||
let tagRange = match.range
|
||||
guard tagRange.location >= cursor else { continue }
|
||||
output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
|
||||
|
||||
let tag = nsHTML.substring(with: tagRange)
|
||||
let loweredTag = tag.lowercased()
|
||||
let tagName = htmlTagName(from: loweredTag)
|
||||
|
||||
if loweredTag.hasPrefix("</"), let tagName {
|
||||
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
|
||||
let markerID = openTagStack.remove(at: index).id
|
||||
output += semanticEndMarker(id: markerID)
|
||||
}
|
||||
output += tag
|
||||
} else if let tagName,
|
||||
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
|
||||
nextMarkerID += 1
|
||||
let markerID = String(nextMarkerID)
|
||||
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
|
||||
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
|
||||
output += startMarker + tag + semanticEndMarker(id: markerID)
|
||||
} else {
|
||||
openTagStack.append((name: tagName, id: markerID))
|
||||
output += tag + startMarker
|
||||
}
|
||||
} else {
|
||||
output += tag
|
||||
}
|
||||
|
||||
cursor = tagRange.location + tagRange.length
|
||||
}
|
||||
|
||||
output += nsHTML.substring(from: cursor)
|
||||
return output
|
||||
}
|
||||
|
||||
private static func htmlTagName(from loweredTag: String) -> String? {
|
||||
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("<") else { return nil }
|
||||
let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
|
||||
let name = body.prefix { $0.isLetter || $0.isNumber }
|
||||
return name.isEmpty ? nil : String(name)
|
||||
}
|
||||
|
||||
private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
|
||||
let loweredTag = rawTag.lowercased()
|
||||
let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
|
||||
let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
|
||||
let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
|
||||
|
||||
guard blockKind != nil || !hints.isEmpty || placement != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDPaginationSemantics(
|
||||
id: "",
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
|
||||
if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
|
||||
return .attachment
|
||||
}
|
||||
switch tagName {
|
||||
case "blockquote":
|
||||
return .blockquote
|
||||
case "ul", "ol", "li":
|
||||
return .list
|
||||
case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
|
||||
return .table
|
||||
case "pre", "code":
|
||||
return .code
|
||||
case "p":
|
||||
return .paragraph
|
||||
case "div":
|
||||
if rawTag.contains("code") || rawTag.contains("highlight") {
|
||||
return .code
|
||||
}
|
||||
if rawTag.contains("quote") || rawTag.contains("blockquote") {
|
||||
return .blockquote
|
||||
}
|
||||
if rawTag.contains("table") {
|
||||
return .table
|
||||
}
|
||||
if rawTag.contains("list") {
|
||||
return .list
|
||||
}
|
||||
return .generic
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
if rawTag.contains("avoidpagebreakinside") ||
|
||||
rawTag.contains("break-inside: avoid") ||
|
||||
rawTag.contains("page-break-inside: avoid") ||
|
||||
["blockquote", "pre", "code", "table", "ul", "ol"].contains(tagName) {
|
||||
hints.append(.avoidPageBreakInside)
|
||||
}
|
||||
if rawTag.contains("pagebreakbefore") ||
|
||||
rawTag.contains("page-break-before: always") ||
|
||||
rawTag.contains("break-before: page") {
|
||||
hints.append(.pageBreakBefore)
|
||||
}
|
||||
if rawTag.contains("pagebreakafter") ||
|
||||
rawTag.contains("page-break-after: always") ||
|
||||
rawTag.contains("break-after: page") {
|
||||
hints.append(.pageBreakAfter)
|
||||
}
|
||||
if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
|
||||
hints.append(.pageRelate)
|
||||
}
|
||||
return hints
|
||||
.reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
|
||||
if !result.contains(hint) {
|
||||
result.append(hint)
|
||||
}
|
||||
}
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
|
||||
return nil
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
|
||||
return .centered
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
|
||||
return .baseline
|
||||
}
|
||||
return .inline
|
||||
}
|
||||
|
||||
private static func isVoidHTMLTag(_ tagName: String) -> Bool {
|
||||
["img", "br", "hr", "input", "meta", "link"].contains(tagName)
|
||||
}
|
||||
|
||||
private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
|
||||
var segments = ["id=\(id)"]
|
||||
if let blockKind = semantics.blockKind {
|
||||
segments.append("block=\(blockKind.rawValue)")
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
segments.append("placement=\(placement.rawValue)")
|
||||
}
|
||||
return "${rd-sem-start:\(segments.joined(separator: ";"))}"
|
||||
}
|
||||
|
||||
private static func semanticEndMarker(id: String) -> String {
|
||||
"${rd-sem-end:id=\(id)}"
|
||||
}
|
||||
|
||||
private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
|
||||
var values: [String: String] = [:]
|
||||
payload.split(separator: ";").forEach { entry in
|
||||
let parts = entry.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else { return }
|
||||
values[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
let hints = values["hints"]?
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
|
||||
let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
return RDPaginationSemantics(
|
||||
id: values["id"] ?? UUID().uuidString,
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseSemanticEndID(_ payload: String) -> String? {
|
||||
payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
|
||||
}
|
||||
|
||||
private static func apply(
|
||||
semantics: RDPaginationSemantics,
|
||||
to range: NSRange,
|
||||
in attributedString: NSMutableAttributedString
|
||||
) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
if let blockKind = semantics.blockKind {
|
||||
attributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
attributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
guard !attributes.isEmpty else { return }
|
||||
attributedString.addAttributes(attributes, range: range)
|
||||
}
|
||||
|
||||
private static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
|
||||
if let rawValue = attributes[.rdPageBlockKind] as? String,
|
||||
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
|
||||
return blockKind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
|
||||
if let rawValue = attributes[.rdPageSemanticHints] as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
return hints.isEmpty ? nil : hints
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
|
||||
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
|
||||
return placement
|
||||
}
|
||||
if let attachmentKind = attachmentKind(for: attributes), attachmentKind == .image {
|
||||
return .inline
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct RDPaginationSemantics {
|
||||
var id: String
|
||||
var blockKind: RDEPUBTextBlockKind?
|
||||
var hints: [RDEPUBTextSemanticHint]
|
||||
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,9 @@ public final class RDPlainTextBookBuilder {
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
@ -69,7 +72,7 @@ public final class RDPlainTextBookBuilder {
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
fragmentOffsets: [:],
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pageBreakReasons: pages.map { $0.metadata.breakReason },
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
|
||||
@ -274,6 +274,26 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let metadata = page.metadata
|
||||
var parts = [
|
||||
"page \(page.absolutePageIndex + 1)",
|
||||
"break \(metadata.breakReason.rawValue)",
|
||||
metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
|
||||
].compactMap { $0 }
|
||||
if let firstDiagnostic = metadata.diagnostics.first {
|
||||
parts.append(firstDiagnostic)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user