feat(07): close native text pagination semantics loop
This commit is contained in:
+14
-14
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: WXRead 深化对齐
|
milestone_name: WXRead 深化对齐
|
||||||
status: ready_to_execute
|
status: ready_to_execute
|
||||||
last_updated: "2026-05-22T12:03:00.000Z"
|
last_updated: "2026-05-22T12:35:00.000Z"
|
||||||
last_activity: 2026-05-22
|
last_activity: 2026-05-22 -- Phase 07 execution completed
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 0
|
completed_phases: 2
|
||||||
total_plans: 11
|
total_plans: 6
|
||||||
completed_plans: 0
|
completed_plans: 6
|
||||||
percent: 0
|
percent: 50
|
||||||
---
|
---
|
||||||
|
|
||||||
# STATE
|
# STATE
|
||||||
@@ -23,18 +23,18 @@ progress:
|
|||||||
参见:`.planning/PROJECT.md`(更新于 2026-05-22)
|
参见:`.planning/PROJECT.md`(更新于 2026-05-22)
|
||||||
|
|
||||||
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
||||||
**当前关注:** `v1.1` 已完成 Phase 6-7 规划,下一步按顺序进入执行或继续规划 Phase 8
|
**当前关注:** `v1.1` 已完成 Phase 6-7 执行闭环,下一步进入 Phase 8 规划或执行
|
||||||
|
|
||||||
## 当前结论(摘要)
|
## 当前结论(摘要)
|
||||||
|
|
||||||
- `v1.1` 已基于现有候选需求正式生成 active `REQUIREMENTS.md` 与 `ROADMAP.md`。
|
- `v1.1` 已基于现有候选需求正式生成 active `REQUIREMENTS.md` 与 `ROADMAP.md`。
|
||||||
- 当前活跃 roadmap 包含 Phase 6-9:页面几何、属性闭环、分页质量/缓存、自动化回归。
|
- 当前活跃 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` 供后续追溯。
|
- `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` 执行,但后续引用历史证据时需注意
|
- `v1.0` 遗留的 requirement bookkeeping gap 已归档,不应阻塞 `v1.1` 执行,但后续引用历史证据时需注意
|
||||||
- simulator 仍会输出既有 `.SFUI-Semibold` CoreText 替代提示,但未造成功能性失败
|
- 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/milestones/`
|
||||||
- `.planning/phases/06-page-geometry-and-interaction-hit-layer/`
|
- `.planning/phases/06-page-geometry-and-interaction-hit-layer/`
|
||||||
@@ -60,7 +60,7 @@ progress:
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 7
|
Phase: 8
|
||||||
Plan: 07-01 / 07-02 / 07-03
|
Plan: pending
|
||||||
Status: Ready for execute-phase
|
Status: Ready for next phase
|
||||||
Last activity: 2026-05-22 — Phase 7 research, validation, patterns, and plans created
|
Last activity: 2026-05-22 -- Phase 07 execution completed with verification passed
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|
||||||
@@ -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("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
||||||
}
|
}
|
||||||
lines.append(contentsOf: matrixLines.prefix(3))
|
lines.append(contentsOf: matrixLines.prefix(3))
|
||||||
lines.append(contentsOf: diagnosticLines.prefix(2))
|
lines.append(contentsOf: prioritizedDiagnosticLines())
|
||||||
if !rerunHint.isEmpty {
|
if !rerunHint.isEmpty {
|
||||||
lines.append(rerunHint)
|
lines.append(rerunHint)
|
||||||
}
|
}
|
||||||
return lines.joined(separator: "\n")
|
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 = {
|
private let statusLabel: UILabel = {
|
||||||
@@ -346,6 +365,9 @@ final class ViewController: UIViewController {
|
|||||||
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
||||||
summaryLines.append("分页诊断:\(paginationSummary)")
|
summaryLines.append("分页诊断:\(paginationSummary)")
|
||||||
}
|
}
|
||||||
|
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
||||||
|
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
||||||
|
}
|
||||||
if let restoreSummary = makeRestoreSummary(
|
if let restoreSummary = makeRestoreSummary(
|
||||||
parser: parser,
|
parser: parser,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
@@ -438,6 +460,9 @@ final class ViewController: UIViewController {
|
|||||||
guard !diagnostics.isEmpty else { return nil }
|
guard !diagnostics.isEmpty else { return nil }
|
||||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||||
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
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
|
let breakReasonCounts = diagnostics
|
||||||
.flatMap(\.breakReasons)
|
.flatMap(\.breakReasons)
|
||||||
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
||||||
@@ -460,6 +485,9 @@ final class ViewController: UIViewController {
|
|||||||
"章节 \(diagnostics.count)",
|
"章节 \(diagnostics.count)",
|
||||||
"attachment 页 \(attachmentPages)",
|
"attachment 页 \(attachmentPages)",
|
||||||
"semantic break 页 \(semanticBreakPages)",
|
"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)]"
|
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
if let note {
|
if let note {
|
||||||
@@ -547,6 +575,14 @@ final class ViewController: UIViewController {
|
|||||||
].joined(separator: " · ")
|
].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) {
|
private func openBook(_ book: DemoBook) {
|
||||||
let controller = RDURLReaderController(bookURL: book.fileURL)
|
let controller = RDURLReaderController(bookURL: book.fileURL)
|
||||||
controller.title = book.title
|
controller.title = book.title
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
|||||||
case frameLimit
|
case frameLimit
|
||||||
case blockBoundary
|
case blockBoundary
|
||||||
case attachmentBoundary
|
case attachmentBoundary
|
||||||
|
case semanticBoundary
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||||
@@ -184,6 +185,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
|||||||
public var blockRange: NSRange?
|
public var blockRange: NSRange?
|
||||||
public var attachmentRanges: [NSRange]
|
public var attachmentRanges: [NSRange]
|
||||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||||
|
public var blockKinds: [RDEPUBTextBlockKind]
|
||||||
|
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||||
|
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||||
public var trailingFragmentID: String?
|
public var trailingFragmentID: String?
|
||||||
public var diagnostics: [String]
|
public var diagnostics: [String]
|
||||||
|
|
||||||
@@ -192,6 +196,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
|||||||
blockRange: NSRange? = nil,
|
blockRange: NSRange? = nil,
|
||||||
attachmentRanges: [NSRange] = [],
|
attachmentRanges: [NSRange] = [],
|
||||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
||||||
|
blockKinds: [RDEPUBTextBlockKind] = [],
|
||||||
|
semanticHints: [RDEPUBTextSemanticHint] = [],
|
||||||
|
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
||||||
trailingFragmentID: String? = nil,
|
trailingFragmentID: String? = nil,
|
||||||
diagnostics: [String] = []
|
diagnostics: [String] = []
|
||||||
) {
|
) {
|
||||||
@@ -199,6 +206,9 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
|||||||
self.blockRange = blockRange
|
self.blockRange = blockRange
|
||||||
self.attachmentRanges = attachmentRanges
|
self.attachmentRanges = attachmentRanges
|
||||||
self.attachmentKinds = attachmentKinds
|
self.attachmentKinds = attachmentKinds
|
||||||
|
self.blockKinds = blockKinds
|
||||||
|
self.semanticHints = semanticHints
|
||||||
|
self.attachmentPlacements = attachmentPlacements
|
||||||
self.trailingFragmentID = trailingFragmentID
|
self.trailingFragmentID = trailingFragmentID
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||||
|
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||||
return RDEPUBRenderedChapterContent(
|
return RDEPUBRenderedChapterContent(
|
||||||
@@ -59,6 +60,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
|
|
||||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||||
|
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||||
return RDEPUBRenderedChapterContent(
|
return RDEPUBRenderedChapterContent(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
|||||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||||
public var attachmentPageCount: Int
|
public var attachmentPageCount: Int
|
||||||
public var blockAdjustedPageCount: Int
|
public var blockAdjustedPageCount: Int
|
||||||
|
public var blockKinds: [RDEPUBTextBlockKind]
|
||||||
|
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||||
|
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||||
public var sampleNotes: [String]
|
public var sampleNotes: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +106,29 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
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(
|
public func build(
|
||||||
parser: RDEPUBParser,
|
parser: RDEPUBParser,
|
||||||
publication: RDEPUBPublication,
|
publication: RDEPUBPublication,
|
||||||
@@ -150,6 +176,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
blockRange: nil,
|
blockRange: nil,
|
||||||
attachmentRanges: [],
|
attachmentRanges: [],
|
||||||
attachmentKinds: [],
|
attachmentKinds: [],
|
||||||
|
blockKinds: [],
|
||||||
|
semanticHints: [],
|
||||||
|
attachmentPlacements: [],
|
||||||
trailingFragmentID: nil,
|
trailingFragmentID: nil,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
"page break: chapterEnd",
|
"page break: chapterEnd",
|
||||||
@@ -197,6 +226,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
breakReasons: pages.map(\.metadata.breakReason),
|
breakReasons: pages.map(\.metadata.breakReason),
|
||||||
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
|
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
|
||||||
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.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(
|
sampleNotes: Array(
|
||||||
pages
|
pages
|
||||||
.flatMap(\.metadata.diagnostics)
|
.flatMap(\.metadata.diagnostics)
|
||||||
@@ -233,4 +265,12 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
}
|
}
|
||||||
return false
|
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 blockRange: NSRange?
|
||||||
var attachmentRanges: [NSRange]
|
var attachmentRanges: [NSRange]
|
||||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||||
|
var blockKinds: [RDEPUBTextBlockKind]
|
||||||
|
var semanticHints: [RDEPUBTextSemanticHint]
|
||||||
|
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||||
var trailingFragmentID: String?
|
var trailingFragmentID: String?
|
||||||
var diagnostics: [String]
|
var diagnostics: [String]
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ struct RDEPUBTextLayoutFrame: Equatable {
|
|||||||
blockRange: blockRange,
|
blockRange: blockRange,
|
||||||
attachmentRanges: attachmentRanges,
|
attachmentRanges: attachmentRanges,
|
||||||
attachmentKinds: attachmentKinds,
|
attachmentKinds: attachmentKinds,
|
||||||
|
blockKinds: blockKinds,
|
||||||
|
semanticHints: semanticHints,
|
||||||
|
attachmentPlacements: attachmentPlacements,
|
||||||
trailingFragmentID: trailingFragmentID,
|
trailingFragmentID: trailingFragmentID,
|
||||||
diagnostics: diagnostics
|
diagnostics: diagnostics
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: adjusted.blockRange,
|
blockRange: adjusted.blockRange,
|
||||||
attachmentRanges: adjusted.attachmentRanges,
|
attachmentRanges: adjusted.attachmentRanges,
|
||||||
attachmentKinds: adjusted.attachmentKinds,
|
attachmentKinds: adjusted.attachmentKinds,
|
||||||
|
blockKinds: adjusted.blockKinds,
|
||||||
|
semanticHints: adjusted.semanticHints,
|
||||||
|
attachmentPlacements: adjusted.attachmentPlacements,
|
||||||
trailingFragmentID: trailingFragmentID,
|
trailingFragmentID: trailingFragmentID,
|
||||||
diagnostics: adjusted.diagnostics
|
diagnostics: adjusted.diagnostics
|
||||||
)
|
)
|
||||||
@@ -67,9 +70,15 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: NSRange?,
|
blockRange: NSRange?,
|
||||||
attachmentRanges: [NSRange],
|
attachmentRanges: [NSRange],
|
||||||
attachmentKinds: [RDEPUBTextAttachmentKind],
|
attachmentKinds: [RDEPUBTextAttachmentKind],
|
||||||
|
blockKinds: [RDEPUBTextBlockKind],
|
||||||
|
semanticHints: [RDEPUBTextSemanticHint],
|
||||||
|
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||||
diagnostics: [String]
|
diagnostics: [String]
|
||||||
) {
|
) {
|
||||||
let pageEnd = proposedRange.location + proposedRange.length
|
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 {
|
guard pageEnd < totalLength else {
|
||||||
return (
|
return (
|
||||||
range: proposedRange,
|
range: proposedRange,
|
||||||
@@ -77,11 +86,17 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||||
attachmentRanges: attachmentRanges(in: proposedRange),
|
attachmentRanges: attachmentRanges(in: proposedRange),
|
||||||
attachmentKinds: attachmentKinds(in: proposedRange),
|
attachmentKinds: attachmentKinds(in: proposedRange),
|
||||||
|
blockKinds: proposedBlockKinds,
|
||||||
|
semanticHints: proposedSemanticHints,
|
||||||
|
attachmentPlacements: proposedAttachmentPlacements,
|
||||||
diagnostics: diagnostics(
|
diagnostics: diagnostics(
|
||||||
reason: .chapterEnd,
|
reason: .chapterEnd,
|
||||||
range: proposedRange,
|
range: proposedRange,
|
||||||
attachmentRanges: attachmentRanges(in: 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 currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||||
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
||||||
let currentAttachmentKinds = attachmentKinds(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(
|
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||||
in: proposedRange,
|
in: proposedRange,
|
||||||
@@ -104,11 +149,17 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: currentBlockRange,
|
blockRange: currentBlockRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
attachmentRanges: currentAttachmentRanges,
|
||||||
attachmentKinds: currentAttachmentKinds,
|
attachmentKinds: currentAttachmentKinds,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
diagnostics: diagnostics(
|
diagnostics: diagnostics(
|
||||||
reason: .attachmentBoundary,
|
reason: .attachmentBoundary,
|
||||||
range: adjustedRange,
|
range: adjustedRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
attachmentRanges: currentAttachmentRanges,
|
||||||
blockRange: currentBlockRange
|
blockRange: currentBlockRange,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -124,11 +175,17 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: currentBlockRange,
|
blockRange: currentBlockRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
attachmentRanges: currentAttachmentRanges,
|
||||||
attachmentKinds: currentAttachmentKinds,
|
attachmentKinds: currentAttachmentKinds,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
diagnostics: diagnostics(
|
diagnostics: diagnostics(
|
||||||
reason: .blockBoundary,
|
reason: .blockBoundary,
|
||||||
range: adjustedRange,
|
range: adjustedRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
attachmentRanges: currentAttachmentRanges,
|
||||||
blockRange: currentBlockRange
|
blockRange: currentBlockRange,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,15 +196,62 @@ struct RDEPUBTextLayouter {
|
|||||||
blockRange: currentBlockRange,
|
blockRange: currentBlockRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
attachmentRanges: currentAttachmentRanges,
|
||||||
attachmentKinds: currentAttachmentKinds,
|
attachmentKinds: currentAttachmentKinds,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
diagnostics: diagnostics(
|
diagnostics: diagnostics(
|
||||||
reason: .frameLimit,
|
reason: .frameLimit,
|
||||||
range: proposedRange,
|
range: proposedRange,
|
||||||
attachmentRanges: currentAttachmentRanges,
|
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? {
|
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||||
var boundary: Int?
|
var boundary: Int?
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||||
@@ -216,6 +320,43 @@ struct RDEPUBTextLayouter {
|
|||||||
return kinds
|
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(
|
private func nearestTrailingFragmentID(
|
||||||
endingAt location: Int,
|
endingAt location: Int,
|
||||||
fragmentOffsets: [String: Int]
|
fragmentOffsets: [String: Int]
|
||||||
@@ -230,7 +371,11 @@ struct RDEPUBTextLayouter {
|
|||||||
reason: RDEPUBTextPageBreakReason,
|
reason: RDEPUBTextPageBreakReason,
|
||||||
range: NSRange,
|
range: NSRange,
|
||||||
attachmentRanges: [NSRange],
|
attachmentRanges: [NSRange],
|
||||||
blockRange: NSRange?
|
blockRange: NSRange?,
|
||||||
|
blockKinds: [RDEPUBTextBlockKind],
|
||||||
|
semanticHints: [RDEPUBTextSemanticHint],
|
||||||
|
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||||
|
trigger: String? = nil
|
||||||
) -> [String] {
|
) -> [String] {
|
||||||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||||||
if let blockRange {
|
if let blockRange {
|
||||||
@@ -239,6 +384,18 @@ struct RDEPUBTextLayouter {
|
|||||||
if !attachmentRanges.isEmpty {
|
if !attachmentRanges.isEmpty {
|
||||||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
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
|
return items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,32 @@ public extension NSAttributedString.Key {
|
|||||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
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 {
|
public struct RDEPUBTextRenderStyle {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import UIKit
|
|||||||
enum RDEPUBTextRendererSupport {
|
enum RDEPUBTextRendererSupport {
|
||||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
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 imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||||
|
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||||
|
|
||||||
static func injectFragmentMarkers(into html: String) -> String {
|
static func injectFragmentMarkers(into html: String) -> String {
|
||||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||||
@@ -44,6 +45,40 @@ enum RDEPUBTextRendererSupport {
|
|||||||
return fragmentOffsets
|
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) {
|
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||||
var blockIndex = 0
|
var blockIndex = 0
|
||||||
@@ -64,6 +99,15 @@ enum RDEPUBTextRendererSupport {
|
|||||||
if let attachmentKind = attachmentKind(for: attributes) {
|
if let attachmentKind = attachmentKind(for: attributes) {
|
||||||
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
|
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)
|
attributedString.setAttributes(updatedAttributes, range: range)
|
||||||
blockIndex += 1
|
blockIndex += 1
|
||||||
}
|
}
|
||||||
@@ -86,7 +130,7 @@ enum RDEPUBTextRendererSupport {
|
|||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle,
|
||||||
resourceResolver: RDEPUBResourceResolver?
|
resourceResolver: RDEPUBResourceResolver?
|
||||||
) -> RDEPUBTextChapterRenderRequest {
|
) -> RDEPUBTextChapterRenderRequest {
|
||||||
let normalizedHTML = normalizeHTML(rawHTML)
|
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
||||||
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
||||||
in: normalizedHTML,
|
in: normalizedHTML,
|
||||||
chapterHref: href,
|
chapterHref: href,
|
||||||
@@ -482,4 +526,261 @@ enum RDEPUBTextRendererSupport {
|
|||||||
|
|
||||||
return nil
|
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,
|
blockRange: nil,
|
||||||
attachmentRanges: [],
|
attachmentRanges: [],
|
||||||
attachmentKinds: [],
|
attachmentKinds: [],
|
||||||
|
blockKinds: [],
|
||||||
|
semanticHints: [],
|
||||||
|
attachmentPlacements: [],
|
||||||
trailingFragmentID: nil,
|
trailingFragmentID: nil,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
"page break: chapterEnd",
|
"page break: chapterEnd",
|
||||||
@@ -69,7 +72,7 @@ public final class RDPlainTextBookBuilder {
|
|||||||
title: spec.title ?? "第 \(index + 1) 章",
|
title: spec.title ?? "第 \(index + 1) 章",
|
||||||
attributedContent: content.copy() as! NSAttributedString,
|
attributedContent: content.copy() as! NSAttributedString,
|
||||||
fragmentOffsets: [:],
|
fragmentOffsets: [:],
|
||||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
pageBreakReasons: pages.map { $0.metadata.breakReason },
|
||||||
pages: pages
|
pages: pages
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -274,6 +274,26 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
activeHighlights.first { $0.id == id }
|
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
|
@discardableResult
|
||||||
public func addHighlight(
|
public func addHighlight(
|
||||||
from selection: RDEPUBSelection? = nil,
|
from selection: RDEPUBSelection? = nil,
|
||||||
|
|||||||
Reference in New Issue
Block a user