From a661e685936926f3c583172345726a4f20d3555b Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:55:06 +0800 Subject: [PATCH 1/6] Fix comic panel merging, contour-safe translation and long-strip loading --- docs/comic-audit-remediation.md | 53 ++++++ mreader/AIModelProtocol.swift | 4 +- mreader/AITranslator.swift | 11 +- mreader/Base.lproj/Localizable.strings | 6 +- mreader/BubbleContourGeometry.swift | 56 ++++++ mreader/ComicManager.swift | 103 +++++++--- mreader/ContentView.swift | 2 +- mreader/GeometryPanelDetector.swift | 64 +++++-- mreader/LocalBubbleContour.swift | 110 +++++++++++ mreader/LocalWebServer.swift | 26 ++- mreader/LongPageGeometry.swift | 31 +++ mreader/MangaOCRPipeline.swift | 54 ++++-- mreader/MangaVisionCalibrationProfile.swift | 8 +- mreader/MangaVisionHardCases.swift | 2 +- mreader/MangaVisionOCRIntegration.swift | 17 +- mreader/MangaVisionProvider.swift | 7 + mreader/MangaVisionRuntimeFoundation.swift | 3 + mreader/MangaVisionService.swift | 18 +- mreader/OCRPreprocessor.swift | 110 ++++++++++- mreader/OCRRecognitionCache.swift | 57 +++--- mreader/OfflineTranslationPageProvider.swift | 4 +- mreader/PanelDetectionService.swift | 25 ++- mreader/ReaderImageLoadEntry.swift | 10 + mreader/ReaderMemoryBudget.swift | 15 +- mreader/ReaderStripTiles.swift | 71 +++++++ mreader/ReaderUITestFixture.swift | 29 ++- mreader/ReaderView.swift | 137 ++++++++----- mreader/TranslationGeometryRefiner.swift | 30 ++- mreader/en.lproj/Localizable.strings | 6 +- mreader/ja.lproj/Localizable.strings | 6 +- mreader/ko.lproj/Localizable.strings | 6 +- mreader/zh-Hans.lproj/Localizable.strings | 6 +- mreaderTests/ComicAuditFixTests.swift | 190 +++++++++++++++++++ mreaderTests/mreaderTests.swift | 13 ++ mreaderUITests/mreaderUITests.swift | 23 +++ scripts/materialize_private_model_weight.sh | 7 + scripts/private_model_source.conf | 3 + 37 files changed, 1091 insertions(+), 232 deletions(-) create mode 100644 docs/comic-audit-remediation.md create mode 100644 mreader/BubbleContourGeometry.swift create mode 100644 mreader/LocalBubbleContour.swift create mode 100644 mreader/LongPageGeometry.swift create mode 100644 mreader/ReaderImageLoadEntry.swift create mode 100644 mreader/ReaderStripTiles.swift create mode 100644 mreaderTests/ComicAuditFixTests.swift diff --git a/docs/comic-audit-remediation.md b/docs/comic-audit-remediation.md new file mode 100644 index 00000000..910e67bb --- /dev/null +++ b/docs/comic-audit-remediation.md @@ -0,0 +1,53 @@ +# 漫画审查修复与验收边界 + +基于主分支 `6f5b3a3568d02df897723a511bcbc8f849ec8fcc`。修改只依据该源码和确定性反例,不依据 PR 合并记录推断效果。 + +## 本分支的变更 + +| 报告项 | 实现 | 仍需验收的边界 | +|---|---|---| +| F01 分镜候选丢失 | panel 跨推理合并增加 0.72 面积相近约束,父框和真实孩子均保留;结构融合决定是否舍弃页容器 | 不新增模型权重,不给出未测的检测准确率 | +| F02 长页尺度 | 几何先切重叠局部窗口,再生成 1024 分析栅格;拒绝窗口内部裁断的候选;按页尺寸缩放分镜最小宽高/面积及数量预算;结果映回原页 | 窗口不是导航分镜。无可靠证据仍整页阅读;极大的跨窗分镜可能需模型补充 | +| F03 OCR 先缩后切 | 已知长页从原图裁 OCR 窗口后逐片准备输入,统一映回全页;普通页保持原路径;长页备用图按短边和像素预算解码 | ImageIO 裁图可能解码整张 backing store,不能宣称压缩格式都支持区域解码。原图超过 6400 万像素或当前内存余量不足、EXIF 非正向时明确使用有界备用路径 | +| F04 本地无轮廓 | OCR 后从框内浅色闭合连通域恢复保守轮廓;限制局部栅格为 256 边;拒绝开放、泄漏、明显非凸及歧义区域;已有有效轮廓优先 | 本地模型仍只有框,未伪造 mask 输出。彩色、透明、凹形、相连气泡不保证恢复,保留原框降级 | +| F05 文字越轮廓 | 从验证过的轮廓求带边缘距离余量的全内接矩形;测量和绘制继续共用 CoreText;显示时对云端/离线轮廓也重新求安全区 | 采用保守内接区,不声称任意凹形的逐行最优排字。黑/彩色只影响颜色 | +| F06 加载取消竞态 | 不再加入 cancelled 预加载;为任务加独立 ID,旧完成只清理自己的 entry/成本;视图加加载 ID、URL、retain 和 cancellation 提交检查 | 同步 ImageIO 已开始后不可强制中止;原全局解码 permits 保留,不能把取消当已释放容量 | +| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O | +| F08 CBZ 头部完整解压 | 找到尺寸后抛出专用终止结果中断 ZIP consumer;头部预算 1 MiB;真正完整页读取继续 CRC | 某些格式无法在预算内读到头部时返回未知尺寸 | +| F09 PDF 批量渲染 | 阅读入口只建立 `mreader-pdf` 页面描述;读取某页时才渲染;接入阅读、几何、OCR、视觉、离线来源及带源修订缓存键;每页渲染有像素上限 | 导入转换到图像目录的现有功能未改为懒加载;PDF 仍只读,复杂 PDF 每页渲染需实测 | +| F10 字号承诺冲突 | 五种语言改成偏好字号,明确还会缩小;不可容纳显示被动省略标记;辅助功能可读完整译文 | 未恢复已删除的放大镜/展开按钮,也不把 2pt 当可读性能证明 | +| F11 布局缓存键 | 加入偏好可读字号,提升布局版本 | 需设备确认改设置立即重排 | +| F12 离线安全区丢失 | 复制完整 TextBlock 后改局部 OCR 几何,保留安全区和其余独立字段 | 匹配质量仍由现有文本/位置匹配器决定 | +| F13 校准说明 | 改为当前 V2B5 跨推理合并名称,说明 raw decoder 与 merge calibration 区别及五类输出;提升 merge revision | 原 adaptive manifest **已经**含 calibration revision,没有修改不存在的遗漏 | +| F14 上传认证顺序 | worker 在 header 阶段验证 POST token/路径和长度上限,创建 body 临时文件前拒绝;正确请求仍使用原 parser | 最大连接数/单次上传上限保持现有配置 | +| F15 质量证据不足 | 增加原生反例和真实 Reader 长页合成 UI 场景,记录 rapid reverse scroll 的内存/hitch telemetry | 合成图不是人工真实页面标注,不替代 Python/Swift 实图 parity、人工 gold、真机门禁,不能填入假通过结果 | +| F16 AI 响应上限 | 使用已有 chunk-based 有界 HTTP reader,JSON 响应上限 8 MiB,保留可注入 session configuration 和现有错误/重试策略 | 未改变各模型原生/兼容协议 | + +另外,条漫 fitWidth 使用 CATiledLayer 的按需 compositor tiles,避免将整条图作为一个巨大显示纹理;**解码源仍是已有分档、有界整页图像**,不是磁盘区域解码引擎。内存余量较小时不再由固定缓存 floor 强行申请超过余量的预算。私有权重 materialize 使用当前实际权重的 SHA-256 进行校验,不能只凭文件大小接受不匹配权重。 + +## 本地可执行检查 + +当前工作环境没有 Xcode、iOS 模拟器或 Core ML 原生运行时。只运行并核验: + +- `git diff --check`。 +- shell 语法检查和使用当前已物化权重的 materialize 脚本。 +- Swift tree-sitter 语法检查,与主分支诊断对照;它不是 Swift 类型检查或原生测试。 +- 图和权重哈希分别为 `d60dc41d370ce20805e4f6e8e83e49cd6057e27ae0affb095ca1d243148ebaa5`、`8e708d893f752b14a42b9d478a8256a525a750121d4abe4446e019e61fdbec80`;两者内容未改。 + +原生 build-for-testing、Release、全部单测、UI smoke 由本次 PR 的 CI 执行;必须看该提交结果,不借用主分支绿灯。 + +## 补充的原生回归 + +`ComicAuditFixTests` 覆盖父子框两个置信度顺序、inset 和近似重复、800×20000 / 800×40000 尺度、800×20000 真实几何栅格、椭圆/凹形内接区与全文 CoreText 可见范围、像素闭合气泡/开放降级、cancelled entry、离线字段保留、认证前 body 写入、懒 PDF 描述及原文件不变、极低余量预算。AI transport 测试覆盖无 Content-Length 的超限响应。 + +UI 测试使用普通 ComicManager + Reader 生命周期,五张 800×20000 的带分镜、椭圆气泡和文本合成页,快速上下往返;记录 XCTest memory 和 hitch 指标,并检查 reader 存活/加载失败状态。指标不硬编码为真实设备 FPS 或准确率。 + +## 发布前仍须用真实素材和真机执行 + +1. 同一失败页比较原始候选、合并前后、过滤原因和最终导航,不把模型框自身当人工真值。 +2. 至少 5–10 张不同真实原页的 Python/Swift parity;人工审核分镜与椭圆、带尾、凹形、彩色、透明、相连气泡 gold。待审核条目不能自动改为 humanVerified。 +3. iPad mini 5 / 低内存支持设备及常用设备执行冷启动、长页页边界、快速反向、恢复到书中间、OCR 热缓存、开启翻译和热状态的 Instruments memory/hitch 记录。 +4. 黑/彩字切换时比较字号、换行、几何完全一致;小气泡不能扩大白底盖画,放不下的全文须可辅助阅读。 +5. 图像超过原图 admission budget、非正向 EXIF、未知头部、复杂/旋转 PDF 和云端返回异常几何的降级截图。 + +本分支不宣称真实分镜准确率、气泡 mask IoU、卡顿下降比例、峰值内存或被跳过的真机测试已通过。跨设备 iCloud sidecar 并发合并和全面架构拆分属于报告的额外风险,未借本次阅读链修复擅自迁移存储格式。 diff --git a/mreader/AIModelProtocol.swift b/mreader/AIModelProtocol.swift index 66cfd2c0..da49838e 100644 --- a/mreader/AIModelProtocol.swift +++ b/mreader/AIModelProtocol.swift @@ -253,7 +253,9 @@ nonisolated final class AITranslationClient: AITransporting, @unchecked Sendable try Task.checkCancellation() let startedAt = Date() do { - let (data, response) = try await session.data(for: urlRequest) + let (data, response) = try await BoundedHTTPResponseReader.data( + for: urlRequest, maximumBytes: 8 * 1024 * 1024, using: session + ) guard let httpResponse = response as? HTTPURLResponse else { throw AITranslationRequestError.invalidConfiguration("响应无效") } diff --git a/mreader/AITranslator.swift b/mreader/AITranslator.swift index 75dbda6d..6c2812cc 100644 --- a/mreader/AITranslator.swift +++ b/mreader/AITranslator.swift @@ -3896,11 +3896,14 @@ static func visualReviewedBlockForDiagnostics(original: TextBlock, review: Visio rect.maxY <= 1.02 } - nonisolated static func annotatedMangaTextBlocks(_ blocks: [TextBlock], safeAreaInset: Double, minimumTextHeight: Double, isRightToLeft: Bool) -> [TextBlock] { + nonisolated static func annotatedMangaTextBlocks(_ blocks: [TextBlock], safeAreaInset: Double, minimumTextHeight: Double, isRightToLeft: Bool, pageSize: CGSize? = nil) -> [TextBlock] { + let xScale = pageSize.map { min(1, $0.height / max($0.width, 1)) } ?? 1 + let yScale = pageSize.map { min(1, $0.width / max($0.height, 1)) } ?? 1 let inset = min(max(CGFloat(safeAreaInset), 0), 0.3) - let minimumHeight = min(max(CGFloat(minimumTextHeight), 0.002), 0.05) - let minimumArea = minimumHeight * 0.0048 - let safeRect = CGRect(x: inset, y: inset, width: max(0, 1 - inset * 2), height: max(0, 1 - inset * 2)) + let minimumHeight = min(max(CGFloat(minimumTextHeight), 0.002), 0.05) * yScale + let minimumArea = minimumHeight * 0.0048 * xScale + let safeRect = CGRect(x: inset * xScale, y: inset * yScale, + width: max(0, 1 - inset * xScale * 2), height: max(0, 1 - inset * yScale * 2)) return sortedTextBlocks(blocks.map { block in var annotated = block diff --git a/mreader/Base.lproj/Localizable.strings b/mreader/Base.lproj/Localizable.strings index 01fbec60..771d4ebb 100644 --- a/mreader/Base.lproj/Localizable.strings +++ b/mreader/Base.lproj/Localizable.strings @@ -414,9 +414,9 @@ "offlineTranslation.errorDetails" = "View error details"; "nav.close" = "Close"; -"ocr.minimumReadableTranslationFontSize" = "最小可读译文字号"; -"ocr.minimumReadableTranslationFontSizeValue" = "可读下限:%d pt"; -"ocr.minimumReadableTranslationFontSizeDescription" = "低于此字号时不再继续缩小,而改为可展开阅读。"; +"ocr.minimumReadableTranslationFontSize" = "偏好可读译文字号"; +"ocr.minimumReadableTranslationFontSizeValue" = "偏好字号:%d pt"; +"ocr.minimumReadableTranslationFontSizeDescription" = "气泡空间不足时仍会缩小以适配;完整译文可通过辅助功能朗读。"; "ocr.translationNeedsExpansion" = "译文需要展开阅读"; "ocr.inPlaceTranslation" = "In-place translation"; "ocr.inPlaceTranslationDescription" = "Use reversible in-place coverage only for reliable dialogue bubbles; sound effects keep their original artwork and use annotations."; diff --git a/mreader/BubbleContourGeometry.swift b/mreader/BubbleContourGeometry.swift new file mode 100644 index 00000000..6c63bb9c --- /dev/null +++ b/mreader/BubbleContourGeometry.swift @@ -0,0 +1,56 @@ +import CoreGraphics +import Foundation + +/// A conservative inscribed rectangle, not the contour's bounding box. Every +/// accepted grid cell lies inside the path with a border-distance margin. This +/// also handles concave contours; testing only the four outer corners would not. +nonisolated enum BubbleContourGeometry { + static func safeRectangle(polygon: [CGPoint], bounds: CGRect) -> CGRect? { + guard polygon.count >= 3, polygon.count <= 512, bounds.width > 0, bounds.height > 0, + polygon.allSatisfy({ $0.x.isFinite && $0.y.isFinite }) else { return nil } + let points = polygon.map { CGPoint(x: ($0.x - bounds.minX) / bounds.width, + y: ($0.y - bounds.minY) / bounds.height) } + let path = CGMutablePath() + path.addLines(between: points) + path.closeSubpath() + let n = 40 + let radius = sqrt(CGFloat(2)) / CGFloat(n * 2) + 0.025 + var heights = [Int](repeating: 0, count: n) + var best = CGRect.null + var bestArea = 0 + for y in 0..= radius + } + heights[x] = inside ? heights[x] + 1 : 0 + } + // Largest all-safe rectangle ending in this row. At 40 columns this + // bounded search is cheaper and less error-prone than rasterizing page pixels. + for left in 0.. bestArea { + bestArea = area + best = CGRect(x: CGFloat(left) / CGFloat(n), y: CGFloat(y + 1 - height) / CGFloat(n), + width: CGFloat(right - left + 1) / CGFloat(n), height: CGFloat(height) / CGFloat(n)) + } + } + } + } + guard bestArea >= 4 else { return nil } + return CGRect(x: bounds.minX + best.minX * bounds.width, y: bounds.minY + best.minY * bounds.height, + width: best.width * bounds.width, height: best.height * bounds.height) + } + + private static func distance(_ p: CGPoint, to a: CGPoint, _ b: CGPoint) -> CGFloat { + let dx = b.x - a.x, dy = b.y - a.y + let length = dx * dx + dy * dy + let t = length > 0 ? min(1, max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)) : 0 + return hypot(p.x - a.x - t * dx, p.y - a.y - t * dy) + } +} diff --git a/mreader/ComicManager.swift b/mreader/ComicManager.swift index 51f5c86d..e0a92e3e 100644 --- a/mreader/ComicManager.swift +++ b/mreader/ComicManager.swift @@ -247,11 +247,7 @@ class ComicManager { } if url.pathExtension.lowercased() == "pdf" { // PDF 章节:原 PDF 文件保留,仅把页面按需渲染到临时缓存后读取,绝不改写或删除原文件。 - guard let extractedURL = extractImagesFromPDFSynchronously(url) else { return nil } - let sortedURLs = getAllImages(from: extractedURL) - .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } - let pages = sortedURLs.enumerated().map { ComicPage(index: $0, url: $1) } - logMemory("load-pages-end \(url.lastPathComponent) count=\(pages.count)") + let pages = pdfPages(from: url) return pages.isEmpty ? nil : LoadResult(url: url, accessToken: accessToken, pages: pages) } guard let pageSourceURL = pageSourceURL(for: url) else { @@ -293,11 +289,8 @@ class ComicManager { nonisolated static func loadDownloadedRemotePages(from sourceURL: URL) async -> LoadResult? { if sourceURL.pathExtension.lowercased() == "pdf" { - guard let pageSourceURL = await extractImagesFromPDF(sourceURL) else { return nil } - let sortedURLs = getAllImages(from: pageSourceURL) - .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } - let pages = sortedURLs.enumerated().map { ComicPage(index: $0, url: $1) } - return pages.isEmpty ? nil : LoadResult(url: pageSourceURL, accessToken: nil, pages: pages) + let pages = pdfPages(from: sourceURL) + return pages.isEmpty ? nil : LoadResult(url: sourceURL, accessToken: nil, pages: pages) } if supportedImageExtensions.contains(sourceURL.pathExtension.lowercased()) { return LoadResult( @@ -713,7 +706,10 @@ class ComicManager { document.pageCount > 0, let page = document.page(at: pageIndex) else { return nil } let pageRect = page.bounds(for: .mediaBox) - let size = CGSize(width: pageRect.width * scale, height: pageRect.height * scale) + let requested = CGSize(width: pageRect.width * scale, height: pageRect.height * scale) + guard requested.width.isFinite, requested.height.isFinite, requested.width > 0, requested.height > 0 else { return nil } + let boundedScale = min(scale, sqrt(12_000_000 / max(pageRect.width * pageRect.height, 1))) + let size = CGSize(width: pageRect.width * boundedScale, height: pageRect.height * boundedScale) let format = UIGraphicsImageRendererFormat() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: size, format: format) @@ -721,7 +717,7 @@ class ComicManager { UIColor.white.set() ctx.fill(CGRect(origin: .zero, size: size)) ctx.cgContext.translateBy(x: 0.0, y: size.height) - ctx.cgContext.scaleBy(x: scale, y: -scale) + ctx.cgContext.scaleBy(x: boundedScale, y: -boundedScale) page.draw(with: .mediaBox, to: ctx.cgContext) } return image.jpegData(compressionQuality: 0.85) @@ -1311,11 +1307,58 @@ class ComicManager { return sourceURL } + nonisolated static func isLocalPageURL(_ url: URL) -> Bool { + isArchivePageURL(url) || url.scheme == "mreader-pdf" + } + + nonisolated static func pdfPages(from pdfURL: URL) -> [ComicPage] { + guard let document = PDFDocument(url: pdfURL), document.pageCount > 0 else { return [] } + return (0.. (URL, Int)? { + guard url.scheme == "mreader-pdf", let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let path = items.first(where: { $0.name == "source" })?.value, + let rawIndex = items.first(where: { $0.name == "index" })?.value, + let index = Int(rawIndex), index >= 0 else { return nil } + return (URL(fileURLWithPath: path), index) + } + + nonisolated static func pdfPageCacheKey(for url: URL) -> String? { + guard let (source, index) = pdfPageComponents(url) else { return nil } + let values = try? source.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return "pdf-v1|\(source.path)|\(index)|\(values?.fileSize ?? 0)|\(values?.contentModificationDate?.timeIntervalSince1970 ?? 0)" + } + + nonisolated private static func pdfPageData(for url: URL) -> Data? { + guard let (source, index) = pdfPageComponents(url), !Task.isCancelled else { return nil } + let read = { renderPDFPageJPEGData(from: source, pageIndex: index, scale: 3) } + return withSelectedLibraryRoot { _ in read() } ?? read() + } + + nonisolated private static func pdfPagePixelSize(for url: URL) -> CGSize? { + guard let (source, index) = pdfPageComponents(url) else { return nil } + let read = { () -> CGSize? in + guard let page = PDFDocument(url: source)?.page(at: index) else { return nil } + let size = page.bounds(for: .mediaBox).size + return CGSize(width: size.width * 3, height: size.height * 3) + } + return withSelectedLibraryRoot { _ in read() } ?? read() + } + nonisolated static func isArchivePageURL(_ url: URL) -> Bool { url.scheme == archivePageScheme } nonisolated static func archivePageCacheKey(for url: URL) -> String? { + if let pdfKey = pdfPageCacheKey(for: url) { return pdfKey } guard let (archiveURL, entryPath, _, _) = archivePageComponents(from: url), let values = try? archiveURL.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) else { return nil @@ -1329,6 +1372,7 @@ class ComicManager { forArchivePageURL url: URL, securityScopedAccessHeld: Bool = false ) -> Data? { + if url.scheme == "mreader-pdf" { return pdfPageData(for: url) } guard let (archiveURL, entryPath, encodingRawValue, format) = archivePageComponents(from: url) else { logger.error("archive-page-url-invalid url=\(url.absoluteString, privacy: .public)") return nil @@ -1364,9 +1408,10 @@ class ComicManager { } /// 只读取归档页图片的尺寸,不整张解码像素(审查 #18)。 - /// 用增量 ImageIO 边解压边尝试读宽高;ZIPFoundation 暂不支持提前中断解压, - /// 但一旦拿到尺寸就不再做后续工作,也无需构造完整 Data。 + /// Incremental ImageIO probes at most 1 MiB. A throwing ZIP consumer stops + /// extraction as soon as dimensions are available; full page reads still check CRC. nonisolated static func imagePixelSizeForArchivePageURL(_ url: URL) -> CGSize? { + if url.scheme == "mreader-pdf" { return pdfPagePixelSize(for: url) } guard let (archiveURL, entryPath, encodingRawValue, format) = archivePageComponents(from: url), format == .zip else { return nil @@ -1383,22 +1428,22 @@ class ComicManager { } let source = CGImageSourceCreateIncremental(nil) var accumulated = Data() - var result: CGSize? - _ = try archive.extract(entry, skipCRC32: false) { chunk in - guard result == nil else { return } - guard UInt64(accumulated.count) + UInt64(chunk.count) <= maxArchiveImageBytes else { - throw ArchiveReadError.memoryLimit - } - accumulated.append(chunk) - CGImageSourceUpdateData(source, accumulated as CFData, false) - if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, - let height = properties[kCGImagePropertyPixelHeight] as? CGFloat, - width > 0, height > 0 { - result = CGSize(width: width, height: height) + enum ProbeStop: Error { case found(CGSize); case prefixLimit } + do { + _ = try archive.extract(entry, skipCRC32: true) { chunk in + try Task.checkCancellation() + guard accumulated.count + chunk.count <= 1_024 * 1_024 else { throw ProbeStop.prefixLimit } + accumulated.append(chunk) + CGImageSourceUpdateData(source, accumulated as CFData, false) + if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, + let height = properties[kCGImagePropertyPixelHeight] as? CGFloat, + width > 0, height > 0 { + throw ProbeStop.found(CGSize(width: width, height: height)) + } } - } - return result + } catch ProbeStop.found(let size) { return size } + return nil } do { if let size = try withSelectedLibraryRoot({ _ in try readSize() }) { diff --git a/mreader/ContentView.swift b/mreader/ContentView.swift index 3e5760d0..6e4d8c87 100644 --- a/mreader/ContentView.swift +++ b/mreader/ContentView.swift @@ -3878,7 +3878,7 @@ struct CoverImageView: View { if let remoteURL = URL(string: path), OPDSProvider.isCoverReference(remoteURL) { guard let data = await RemoteSourceRuntimeService.coverData(for: remoteURL) else { return nil } source = CGImageSourceCreateWithData(data as CFData, nil) - } else if let archiveURL = URL(string: path), ComicManager.isArchivePageURL(archiveURL) { + } else if let archiveURL = URL(string: path), ComicManager.isLocalPageURL(archiveURL) { guard let data = ComicManager.imageData(forArchivePageURL: archiveURL) else { return nil } source = CGImageSourceCreateWithData(data as CFData, nil) } else { diff --git a/mreader/GeometryPanelDetector.swift b/mreader/GeometryPanelDetector.swift index 1ed86d5d..4e028d3c 100644 --- a/mreader/GeometryPanelDetector.swift +++ b/mreader/GeometryPanelDetector.swift @@ -9,7 +9,7 @@ import Foundation /// learned Manga Vision frame detector remains useful later as a residual detector for /// borderless/inset/irregular cases, but it is no longer the sole authority for navigation. nonisolated struct GeometryPanelDetector: PanelDetecting { - let identifier = "page-geometry-xycut-v3" + let identifier = "page-geometry-xycut-local-windows-v4" private let maximumDimension: Int @@ -18,16 +18,37 @@ nonisolated struct GeometryPanelDetector: PanelDetecting { } func detectPanels(in image: CGImage) throws -> [DetectedPanel] { - guard let raster = PanelGeometryRaster( - image: image, - maximumDimension: maximumDimension - ) else { - return [] + let size = CGSize(width: image.width, height: image.height) + let windows = LongPageGeometry.windows(sourceSize: size, maximumAspect: 2.5) + var candidates: [DetectedPanel] = [] + for window in windows { + try Task.checkCancellation() + let pixelRect = CGRect(x: window.minX * size.width, y: window.minY * size.height, + width: window.width * size.width, height: window.height * size.height).integral + guard let crop = image.cropping(to: pixelRect), + let raster = PanelGeometryRaster(image: crop, maximumDimension: maximumDimension) else { continue } + for panel in PanelGeometryAnalyzer(raster: raster).detectPanels() { + // A cut at the crop edge is not evidence of a panel boundary. An + // overlapping window may see the complete panel; otherwise use model evidence. + if windows.count > 1 { + let r = panel.rect + if (window.minY > 0 && r.minY < 0.008) || (window.maxY < 1 && r.maxY > 0.992) + || (window.minX > 0 && r.minX < 0.008) || (window.maxX < 1 && r.maxX > 0.992) { continue } + } + let actual = CGRect(x: pixelRect.minX / size.width, y: pixelRect.minY / size.height, + width: pixelRect.width / size.width, height: pixelRect.height / size.height) + let rect = CGRect(x: actual.minX + panel.rect.minX * actual.width, + y: actual.minY + panel.rect.minY * actual.height, + width: panel.rect.width * actual.width, height: panel.rect.height * actual.height) + let contour = panel.contour?.map { CGPoint(x: actual.minX + $0.x * actual.width, + y: actual.minY + $0.y * actual.height) } + candidates.append(DetectedPanel(rect: rect, confidence: panel.confidence, + source: panel.source, contour: contour)) + } } - - let analyzer = PanelGeometryAnalyzer(raster: raster) - return analyzer.detectPanels() + return PanelPostProcessor.process(candidates, pageSize: size) } + } /// Chooses navigation geometry from two independent evidence sources. @@ -38,7 +59,7 @@ nonisolated struct GeometryPanelDetector: PanelDetecting { /// 3. the model owns the page only when geometry has no credible structure; /// 4. if neither source is credible, Guided Panel yields no synthetic sub-panels and the caller keeps whole-page reading. nonisolated enum PanelCandidateFusion { - static let revision = "geometry-first-fusion-v13" + static let revision = "geometry-first-fusion-scaled-v14" struct Resolution: Sendable { let panels: [DetectedPanel] @@ -50,12 +71,13 @@ nonisolated enum PanelCandidateFusion { model: [DetectedPanel], contentBounds: CGRect, balloonRegions: [CGRect] = [], - textRegions: [CGRect] = [] + textRegions: [CGRect] = [], + pageSize: CGSize = CGSize(width: 1, height: 1) ) -> Resolution { // Content bounds are useful for fallback viewport planning and for deciding // whether a learned box looks like a page container. They are not precise enough // to rewrite real detector geometry. Preserve frame boundaries exactly here. - let geometryPanels = PanelPostProcessor.process(geometry) + let geometryPanels = PanelPostProcessor.process(geometry, pageSize: pageSize) let modelPanels = suppressLikelyPageContainers( PanelPostProcessor.process( model.filter { @@ -64,19 +86,19 @@ nonisolated enum PanelCandidateFusion { balloons: balloonRegions, texts: textRegions ) - } + }, pageSize: pageSize ), contentBounds: contentBounds ) - let geometryUsable = isGeometryAuthoritative(geometryPanels) - let modelUsable = PanelLayoutQuality.isUsable(modelPanels) + let geometryUsable = isGeometryAuthoritative(geometryPanels, pageSize: pageSize) + let modelUsable = PanelLayoutQuality.isUsable(modelPanels, pageSize: pageSize) if geometryUsable { let refined = PanelPostProcessor.process( - refineGeometryLeaves(geometryPanels, with: modelPanels) + refineGeometryLeaves(geometryPanels, with: modelPanels), pageSize: pageSize ) - if PanelLayoutQuality.isUsable(refined) { + if PanelLayoutQuality.isUsable(refined, pageSize: pageSize) { return Resolution( panels: refined, reason: "geometry-primary" @@ -110,10 +132,10 @@ nonisolated enum PanelCandidateFusion { ) } - let combined = PanelPostProcessor.process(geometryPanels + modelPanels) + let combined = PanelPostProcessor.process(geometryPanels + modelPanels, pageSize: pageSize) if combined.count >= 2, !hasUnsupportedCrossSourceContainment(combined), - PanelLayoutQuality.isUsable(combined) { + PanelLayoutQuality.isUsable(combined, pageSize: pageSize) { return Resolution( panels: combined, reason: "combined-recovery" @@ -207,9 +229,9 @@ nonisolated enum PanelCandidateFusion { return tightlyMatchingText } - static func isGeometryAuthoritative(_ panels: [DetectedPanel]) -> Bool { + static func isGeometryAuthoritative(_ panels: [DetectedPanel], pageSize: CGSize = CGSize(width: 1, height: 1)) -> Bool { guard panels.count >= 2, - PanelLayoutQuality.isUsable(panels) else { + PanelLayoutQuality.isUsable(panels, pageSize: pageSize) else { return false } let average = panels.reduce(Float.zero) { $0 + $1.confidence } diff --git a/mreader/LocalBubbleContour.swift b/mreader/LocalBubbleContour.swift new file mode 100644 index 00000000..e7d3ef5c --- /dev/null +++ b/mreader/LocalBubbleContour.swift @@ -0,0 +1,110 @@ +import CoreGraphics +import Foundation + +/// Pixel evidence for closed, light speech balloons. Rejections retain the +/// original detector box; no ellipse or invented contour is substituted. +nonisolated enum LocalBubbleContour { + static let revision = "closed-light-balloon-v1" + + static func recover(in image: CGImage, balloon: CGRect, textRegions: [CGRect]) -> [CGPoint]? { + let size = CGSize(width: image.width, height: image.height) + let pixelRect = CGRect(x: balloon.minX * size.width, y: balloon.minY * size.height, + width: balloon.width * size.width, height: balloon.height * size.height).integral + .intersection(CGRect(origin: .zero, size: size)) + guard pixelRect.width >= 12, pixelRect.height >= 12, + let crop = image.cropping(to: pixelRect) else { return nil } + let scale = min(1, 256 / max(pixelRect.width, pixelRect.height)) + let w = max(1, Int(pixelRect.width * scale)), h = max(1, Int(pixelRect.height * scale)) + var luma = [UInt8](repeating: 0, count: w * h) + let rendered = luma.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8, + bytesPerRow: w, space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue) else { return false } + context.setFillColor(gray: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: w, height: h)) + context.draw(crop, in: CGRect(x: 0, y: 0, width: w, height: h)) + return true + } + guard rendered, w >= 12, h >= 12 else { return nil } + let localTexts = textRegions.filter { $0.intersects(balloon) }.map { + CGRect(x: ($0.minX * size.width - pixelRect.minX) * CGFloat(w) / pixelRect.width, + y: ($0.minY * size.height - pixelRect.minY) * CGFloat(h) / pixelRect.height, + width: $0.width * size.width * CGFloat(w) / pixelRect.width, + height: $0.height * size.height * CGFloat(h) / pixelRect.height) + } + guard !localTexts.isEmpty else { return nil } + // Find a light seed close to the OCR union, avoiding the dark characters. + let text = localTexts.reduce(CGRect.null) { $0.union($1) } + let cx = min(w - 2, max(1, Int(text.midX))), cy = min(h - 2, max(1, Int(text.midY))) + var seed: Int? + for r in 0...max(4, min(w, h) / 3) { + if seed != nil { break } + for y in max(1, cy-r)...min(h-2, cy+r) { + for x in max(1, cx-r)...min(w-2, cx+r) where abs(x-cx) == r || abs(y-cy) == r { + if luma[y*w+x] >= 225 { seed = y*w+x; break } + } + if seed != nil { break } + } + } + guard let seed else { return nil } + var visited = [Bool](repeating: false, count: w*h) + var queue = [seed], cursor = 0 + visited[seed] = true + var boundary: [CGPoint] = [] + while cursor < queue.count { + let index = queue[cursor]; cursor += 1 + let x = index % w, y = index / w + // A region reaching the crop boundary has no observed closed border. + guard x > 0, y > 0, x < w-1, y < h-1 else { return nil } + var isBoundary = false + for next in [index-1, index+1, index-w, index+w] { + if luma[next] < 210 { isBoundary = true; continue } + if !visited[next] { visited[next] = true; queue.append(next) } + } + if isBoundary { boundary.append(CGPoint(x: CGFloat(x)+0.5, y: CGFloat(y)+0.5)) } + } + guard queue.count >= w*h/10, queue.count <= w*h*9/10 else { return nil } + // Exclude text holes from the external boundary. Convexity is required: + // irregular/concave cases must not be filled across unobserved artwork. + let hull = convexHull(boundary) + guard hull.count >= 8 else { return nil } + let hullArea = abs(hull.indices.reduce(CGFloat.zero) { sum, i in + let a = hull[i], b = hull[(i+1)%hull.count] + return sum + a.x*b.y-b.x*a.y + }) / 2 + guard hullArea > 0, CGFloat(queue.count) / hullArea >= 0.88 else { return nil } + let path = CGMutablePath(); path.addLines(between: hull); path.closeSubpath() + // The hull must not bridge a dark border or separate balloon. Sample its + // interior away from the OCR ink; reject instead of hiding picture pixels. + for y in stride(from: 1, to: h-1, by: 2) { + for x in stride(from: 1, to: w-1, by: 2) { + let point = CGPoint(x: CGFloat(x)+0.5, y: CGFloat(y)+0.5) + if path.contains(point), !visited[y*w+x], + !localTexts.contains(where: { $0.insetBy(dx: -2, dy: -2).contains(point) }) { return nil } + } + } + guard localTexts.allSatisfy({ rect in + path.contains(CGPoint(x: rect.midX, y: rect.midY)) + }) else { return nil } + return hull.map { CGPoint(x: (pixelRect.minX + $0.x * pixelRect.width / CGFloat(w)) / size.width, + y: (pixelRect.minY + $0.y * pixelRect.height / CGFloat(h)) / size.height) } + } + + private static func convexHull(_ points: [CGPoint]) -> [CGPoint] { + let sorted = points.sorted { $0.x == $1.x ? $0.y < $1.y : $0.x < $1.x } + guard sorted.count >= 3 else { return [] } + func cross(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> CGFloat { + (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x) + } + var lower: [CGPoint] = [], upper: [CGPoint] = [] + for point in sorted { + while lower.count >= 2, cross(lower[lower.count-2], lower[lower.count-1], point) <= 0 { lower.removeLast() } + lower.append(point) + } + for point in sorted.reversed() { + while upper.count >= 2, cross(upper[upper.count-2], upper[upper.count-1], point) <= 0 { upper.removeLast() } + upper.append(point) + } + return Array(lower.dropLast()) + Array(upper.dropLast()) + } +} diff --git a/mreader/LocalWebServer.swift b/mreader/LocalWebServer.swift index 892561b1..a6b1a3ff 100644 --- a/mreader/LocalWebServer.swift +++ b/mreader/LocalWebServer.swift @@ -4,6 +4,8 @@ import Combine private enum HTTPRequestReceiveError: Error { case headerTooLarge + case forbidden + case payloadTooLarge } private enum LocalWebServerFileError: LocalizedError { @@ -25,6 +27,10 @@ private enum LocalWebServerFileError: LocalizedError { /// callbacks to retain the state while that queue provides the synchronization. nonisolated final class HTTPRequestReceiveState: @unchecked Sendable { private static let maxHeaderBytes = 64 * 1024 + private let authorizeHeader: ((String) throws -> Void)? + init(authorizeHeader: ((String) throws -> Void)? = nil) { + self.authorizeHeader = authorizeHeader + } private var headerBuffer = Data() private var bodyHandle: FileHandle? private var didFinish = false @@ -69,6 +75,9 @@ nonisolated final class HTTPRequestReceiveState: @unchecked Sendable { contentLength = method == "POST" ? Self.contentLength(from: header ?? "") ?? -1 : 0 + // Validate headers before creating a body file or accepting the body + // bytes that may have arrived in the same Network.framework chunk. + try authorizeHeader?(header ?? "") if contentLength > 0 { let url = FileManager.default.temporaryDirectory .appendingPathComponent("MReaderWebUpload-\(UUID().uuidString).body") @@ -136,7 +145,7 @@ nonisolated final class HTTPRequestReceiveState: @unchecked Sendable { receivedBodyBytes += chunk.count } - private static func contentLength(from header: String) -> Int? { + static func contentLength(from header: String) -> Int? { header .components(separatedBy: "\r\n") .first { $0.lowercased().hasPrefix("content-length:") } @@ -361,7 +370,16 @@ nonisolated private final class LocalWebServerWorker: @unchecked Sendable { return } - let state = HTTPRequestReceiveState() + let expectedPath = "/\(token)/upload" + let maximumBytes = maxUploadSize + let state = HTTPRequestReceiveState { header in + if let request = Self.requestLine(from: header), request.method == "POST" { + guard request.path == expectedPath else { throw HTTPRequestReceiveError.forbidden } + if let length = HTTPRequestReceiveState.contentLength(from: header), length > maximumBytes { + throw HTTPRequestReceiveError.payloadTooLarge + } + } + } activeConnections[ObjectIdentifier(connection)] = ActiveConnection( connection: connection, state: state @@ -404,6 +422,10 @@ nonisolated private final class LocalWebServerWorker: @unchecked Sendable { contentType: "text/plain; charset=utf-8", body: "请求头过大" ) + } else if case HTTPRequestReceiveError.forbidden = receiveError { + response = httpResponse(status: "403 Forbidden", contentType: "text/plain; charset=utf-8", body: "禁止访问") + } else if case HTTPRequestReceiveError.payloadTooLarge = receiveError { + response = httpResponse(status: "413 Payload Too Large", contentType: "text/plain; charset=utf-8", body: "上传文件过大") } else { response = httpResponse( status: "500 Internal Server Error", diff --git a/mreader/LongPageGeometry.swift b/mreader/LongPageGeometry.swift new file mode 100644 index 00000000..019357c2 --- /dev/null +++ b/mreader/LongPageGeometry.swift @@ -0,0 +1,31 @@ +import CoreGraphics +import Foundation + +/// Shared geometry only: these windows never become synthetic navigation panels. +nonisolated enum LongPageGeometry { + static func windows(sourceSize: CGSize, maximumAspect: CGFloat = 2.5) -> [CGRect] { + guard sourceSize.width > 0, sourceSize.height > 0 else { return [] } + let vertical = sourceSize.height >= sourceSize.width + let aspect = max(sourceSize.width, sourceSize.height) / min(sourceSize.width, sourceSize.height) + guard aspect > maximumAspect else { return [CGRect(x: 0, y: 0, width: 1, height: 1)] } + let fraction = maximumAspect / aspect + let count = Int(ceil((1 - fraction) / (fraction * 0.72))) + 1 + return (0.. CGFloat { + guard sourceSize.width > 0, sourceSize.height > 0 else { return ordinaryMaximum } + let long = max(sourceSize.width, sourceSize.height) + let short = min(sourceSize.width, sourceSize.height) + let aspect = long / short + guard aspect >= 3 else { return ordinaryMaximum } + return min(long, min(shortAxisTarget * aspect, sqrt(maximumPixels * aspect))) + } +} diff --git a/mreader/MangaOCRPipeline.swift b/mreader/MangaOCRPipeline.swift index 894c2422..79e5c7e0 100644 --- a/mreader/MangaOCRPipeline.swift +++ b/mreader/MangaOCRPipeline.swift @@ -337,20 +337,22 @@ nonisolated enum MangaOCRPipeline { static func recognize( in image: UIImage, options: OCRPreprocessor.Options, - mangaAnalysis: MangaPageAnalysis? = nil + mangaAnalysis: MangaPageAnalysis? = nil, + sourceURL: URL? = nil ) async throws -> OCRPipelineResult { - let candidateResult = try await OCRROICoverageRecognizer.recognizeCandidatesWithReference( - in: image, - options: options, - visionTextRegions: mangaAnalysis?.texts ?? [] - ) + let sourceCandidates: OCRCandidateRecognitionResult? + if let sourceURL { sourceCandidates = try await OCRPreprocessor.originalLongPageCandidates(from: sourceURL, options: options) } + else { sourceCandidates = nil } + let candidateResult: OCRCandidateRecognitionResult + if let sourceCandidates { candidateResult = sourceCandidates } + else { + candidateResult = try await OCRROICoverageRecognizer.recognizeCandidatesWithReference( + in: image, options: options, visionTextRegions: mangaAnalysis?.texts ?? []) + } let visionBlocks = candidateResult.blocks - let verticalBlocks = await JapaneseVerticalOCRService.recognizeIfNeeded( - in: image, - existingBlocks: visionBlocks, - options: options, - visionKitReference: candidateResult.visionKitReference - ) + let verticalBlocks = sourceCandidates == nil ? await JapaneseVerticalOCRService.recognizeIfNeeded( + in: image, existingBlocks: visionBlocks, options: options, + visionKitReference: candidateResult.visionKitReference) : [] let rawBlocks = visionBlocks + verticalBlocks let result = resolveForDiagnostics( rawBlocks, @@ -359,8 +361,34 @@ nonisolated enum MangaOCRPipeline { visionKitReference: candidateResult.visionKitReference ) guard let mangaAnalysis else { return result } + let attached = MangaVisionOCRGeometry.applyingDetectedGeometry(to: result.resolvedBlocks, analysis: mangaAnalysis) + var polygons: [(rect: CGRect, points: [CGPoint])] = [] + if let cgImage = image.cgImage { + var seen: [CGRect] = [] + for bubble in attached.compactMap(\.bubbleBox) where !seen.contains(bubble) { + seen.append(bubble) + try Task.checkCancellation() + if let contour = LocalBubbleContour.recover(in: cgImage, balloon: bubble, + textRegions: attached.filter { $0.bubbleBox == bubble }.map(\.boundingBox)) { + polygons.append((bubble, contour)) + } + } + } + func enrich(_ blocks: [TextBlock]) -> [TextBlock] { + MangaVisionOCRGeometry.applyingDetectedGeometry(to: blocks, analysis: mangaAnalysis).map { block in + var block = block + if block.bubblePolygon.isEmpty, let bubble = block.bubbleBox, let polygon = polygons.first(where: { $0.rect == bubble })?.points { + block.bubblePolygon = polygon + block.layoutSafeRegion = BubbleContourGeometry.safeRectangle(polygon: polygon, bounds: bubble) + } + return block + } + } + let contoured = OCRPipelineResult(rawBlocks: enrich(result.rawBlocks), resolvedBlocks: enrich(result.resolvedBlocks), + lineBlocks: enrich(result.lineBlocks), bubbleBlocks: enrich(result.bubbleBlocks), rejectedBlocks: result.rejectedBlocks, + detectedLanguage: result.detectedLanguage, quality: result.quality) return MangaVisionOCROrdering.applyingReadingOrder( - to: result, + to: contoured, analysis: mangaAnalysis, isRightToLeft: options.isRightToLeft ) diff --git a/mreader/MangaVisionCalibrationProfile.swift b/mreader/MangaVisionCalibrationProfile.swift index a8e2f57b..436755e3 100644 --- a/mreader/MangaVisionCalibrationProfile.swift +++ b/mreader/MangaVisionCalibrationProfile.swift @@ -7,7 +7,8 @@ nonisolated struct MangaVisionClassCalibration: Sendable, Equatable { let containmentThreshold: CGFloat } -/// One revisioned source of truth for detector filtering and same-class deduplication. +/// Revisioned cross-pass merge calibration. V2B5's raw decoder owns its own +/// confidence/NMS thresholds; the confidence fields here are legacy metadata. /// Any production threshold change must bump `revision`; the manifest includes this /// value in cache identity so cached analyses cannot outlive their calibration. nonisolated struct MangaVisionCalibrationProfile: Sendable, Equatable { @@ -15,7 +16,7 @@ nonisolated struct MangaVisionCalibrationProfile: Sendable, Equatable { let byRegionType: [MangaRegionType: MangaVisionClassCalibration] static let bundled = MangaVisionCalibrationProfile( - revision: "manga109-yolo26s-seg-calibration-2026-09-17-v1", + revision: "v2b5-cross-pass-merge-2026-10-02-v2", byRegionType: [ .panel: MangaVisionClassCalibration( confidenceThreshold: 0.24, @@ -32,8 +33,7 @@ nonisolated struct MangaVisionCalibrationProfile: Sendable, Equatable { nmsIOUThreshold: 0.58, containmentThreshold: 0.90 ), - // Kept explicit for forward-compatible checkpoints even though the - // currently bundled checkpoint exports only frame/text/balloon. + // The bundled V2B5 checkpoint exports all five classes. .face: MangaVisionClassCalibration( confidenceThreshold: 0.20, nmsIOUThreshold: 0.45, diff --git a/mreader/MangaVisionHardCases.swift b/mreader/MangaVisionHardCases.swift index 378f6353..395a10b5 100644 --- a/mreader/MangaVisionHardCases.swift +++ b/mreader/MangaVisionHardCases.swift @@ -773,7 +773,7 @@ nonisolated enum MangaVisionHardCasePageDataLoader { if RemotePageLoader.isRemotePageURL(url) { return await RemotePageLoader.imageData(forRemotePageURL: url) } - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { return await Task.detached(priority: .utility) { ComicManager.imageData(forArchivePageURL: url) }.value diff --git a/mreader/MangaVisionOCRIntegration.swift b/mreader/MangaVisionOCRIntegration.swift index 15804227..8dd2959c 100644 --- a/mreader/MangaVisionOCRIntegration.swift +++ b/mreader/MangaVisionOCRIntegration.swift @@ -351,18 +351,8 @@ nonisolated enum MangaVisionOCRGeometry { ) -> CGRect { guard balloon.polygon.count >= 3 else { return balloon.rect } - // Bounding boxes include the empty corners around oval/irregular balloons. - // Reserve a small contour-aware margin for typography while never excluding - // the actual OCR text that must remain visible in the translated surface. - let inset = balloon.rect.insetBy( - dx: balloon.rect.width * 0.06, - dy: balloon.rect.height * 0.06 - ) - guard inset.width > 0, inset.height > 0 else { return balloon.rect } - let safe = inset.union(textRect).intersection(balloon.rect) - return safe.isNull || safe.width <= 0 || safe.height <= 0 - ? balloon.rect - : safe + return BubbleContourGeometry.safeRectangle(polygon: balloon.polygon, bounds: balloon.rect) + ?? textRect.intersection(balloon.rect) } private static func bestTextSafeRegion( @@ -443,7 +433,8 @@ nonisolated enum MangaVisionOCRTranslationPreparation { enrichedCandidates, safeAreaInset: safeAreaInset, minimumTextHeight: minimumTextHeight, - isRightToLeft: isRightToLeft + isRightToLeft: isRightToLeft, + pageSize: analysis?.imageSize ) let visible = annotated.filter { !$0.isFiltered } let filteredOut = annotated.filter(\.isFiltered) diff --git a/mreader/MangaVisionProvider.swift b/mreader/MangaVisionProvider.swift index d327e4a3..7250037e 100644 --- a/mreader/MangaVisionProvider.swift +++ b/mreader/MangaVisionProvider.swift @@ -85,6 +85,13 @@ nonisolated enum MangaVisionRegionPostProcessor { for candidate in regions.sorted(by: { $0.confidence > $1.confidence }) { let duplicate = kept.contains { existing in guard existing.type == candidate.type else { return false } + // Containment alone cannot distinguish a page container from a real + // child/inset frame. Only similarly sized panels are duplicates. + if candidate.type == .panel { + let a = MangaPageCoordinateSpace.area(existing.normalizedRect) + let b = MangaPageCoordinateSpace.area(candidate.normalizedRect) + guard min(a, b) / max(max(a, b), 0.000_001) >= 0.72 else { return false } + } return MangaPageCoordinateSpace.intersectionOverUnion( existing.normalizedRect, candidate.normalizedRect diff --git a/mreader/MangaVisionRuntimeFoundation.swift b/mreader/MangaVisionRuntimeFoundation.swift index dc033694..0d390e84 100644 --- a/mreader/MangaVisionRuntimeFoundation.swift +++ b/mreader/MangaVisionRuntimeFoundation.swift @@ -151,6 +151,9 @@ nonisolated struct PageContentIdentity: Sendable, Equatable, Hashable { /// identity directly; existing callers still get content-aware local/Komga identities. nonisolated enum PageContentIdentityResolver { static func identity(for pageURL: URL) -> PageContentIdentity { + if let revision = ComicManager.pdfPageCacheKey(for: pageURL) { + return .remote(provider: "local-pdf", resource: pageURL.absoluteString, revision: revision) + } if let request = RemotePageLoader.RemotePageRequest(url: pageURL) { let resource = "\(request.sourceID.uuidString.lowercased())/\(request.bookID)/\(request.pageIndex)" return .remote( diff --git a/mreader/MangaVisionService.swift b/mreader/MangaVisionService.swift index 1a198e18..de4597fe 100644 --- a/mreader/MangaVisionService.swift +++ b/mreader/MangaVisionService.swift @@ -374,12 +374,18 @@ actor MangaVisionService { image: UIImage, requestClass: MangaVisionRequestClass = .currentTask ) async -> String { + let size = image.cgImage.map { CGSize(width: $0.width, height: $0.height) } + ?? CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale) + return await expectedDependencyIdentity(sourceSize: size, requestClass: requestClass) + } + + func expectedDependencyIdentity(sourceSize: CGSize, + requestClass: MangaVisionRequestClass = .currentTask) async -> String { let manifest = await modelManifest() - return manifest.cacheIdentity + "|" + analysisDemandIdentity( - image: image, - manifest: manifest, - requestClass: requestClass - ) + let demand = provider is any MangaVisionSourceImageAnalyzing + ? MangaVisionInferencePlanner.cacheDemandIdentity(sourceSize: sourceSize, inputSize: manifest.inputSize, + requestClass: requestClass, resourceState: .current) : "single-pass" + return manifest.cacheIdentity + "|" + demand } private func analysisDemandIdentity( @@ -857,7 +863,7 @@ actor MangaVisionService { guard let data = await RemotePageLoader.imageData(forRemotePageURL: url) else { return nil } return thumbnail(data: data, maximumDimension: maximumDimension) } - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { guard let data = ComicManager.imageData(forArchivePageURL: url) else { return nil } return thumbnail(data: data, maximumDimension: maximumDimension) } diff --git a/mreader/OCRPreprocessor.swift b/mreader/OCRPreprocessor.swift index 6ddf2447..7d2f48bf 100644 --- a/mreader/OCRPreprocessor.swift +++ b/mreader/OCRPreprocessor.swift @@ -68,6 +68,42 @@ struct OCRPreprocessor { } } + nonisolated static func sourcePixelSize(from url: URL, fallback: UIImage) async -> CGSize { + if ComicManager.isLocalPageURL(url), + let size = await Task.detached(priority: .userInitiated, operation: { + ComicManager.imagePixelSizeForArchivePageURL(url) + }).value { return size } + let size = await Task.detached(priority: .userInitiated) { () -> CGSize? in + let source: CGImageSource? + if RemotePageLoader.isRemotePageURL(url), let data = await RemotePageLoader.imageData(forRemotePageURL: url) { + source = CGImageSourceCreateWithData(data as CFData, nil) + } else { source = CGImageSourceCreateWithURL(url as CFURL, nil) } + guard let source else { return nil } + return sourceSize(source) + }.value + return size ?? pixelSize(for: fallback) + } + + nonisolated private static func sourceSize(_ source: CGImageSource) -> CGSize? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, + let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else { return nil } + return CGSize(width: width, height: height) + } + + nonisolated static func preparedPixelSize(sourceSize: CGSize) -> CGSize { + let maximum = sourceMaximumPixelSize(sourceSize: sourceSize) + let scale = min(1, maximum / max(max(sourceSize.width, sourceSize.height), 1)) + return CGSize(width: max(1, (sourceSize.width * scale).rounded()), + height: max(1, (sourceSize.height * scale).rounded())) + } + + nonisolated private static func sourceMaximumPixelSize(sourceSize: CGSize) -> CGFloat { + let ordinary = OCRMemoryPolicy.currentHighResolutionMaxPixelSize() + return LongPageGeometry.maximumPixelSize(sourceSize: sourceSize, ordinaryMaximum: ordinary, + shortAxisTarget: 1_280, maximumPixels: ordinary <= 2_800 ? 6_000_000 : 12_000_000) + } + nonisolated static func highResolutionImage(from url: URL, fallback: UIImage?) async -> UIImage? { await Task.detached(priority: .userInitiated) { let maxPixelSize = OCRMemoryPolicy.currentHighResolutionMaxPixelSize() @@ -76,7 +112,7 @@ struct OCRPreprocessor { let image = imageFromData(data, maxPixelSize: maxPixelSize) { return image } - if ComicManager.isArchivePageURL(url), + if ComicManager.isLocalPageURL(url), let data = ComicManager.imageData(forArchivePageURL: url), let image = imageFromData(data, maxPixelSize: maxPixelSize) { return image @@ -89,6 +125,75 @@ struct OCRPreprocessor { }.value } + /// Crop original source pixels before preparing each OCR input. ImageIO may + /// decode the source backing store when a crop is drawn, so admit only pages + /// whose full backing store fits the explicit headroom budget. + nonisolated static func originalLongPageCandidates(from url: URL, options: Options) async throws -> OCRCandidateRecognitionResult? { + let sourceTask = Task.detached(priority: .userInitiated) { () async throws -> CGImage? in + try Task.checkCancellation() + let source: CGImageSource? + if ComicManager.isLocalPageURL(url), let data = ComicManager.imageData(forArchivePageURL: url) { + source = CGImageSourceCreateWithData(data as CFData, nil) + } else if RemotePageLoader.isRemotePageURL(url), let data = await RemotePageLoader.imageData(forRemotePageURL: url) { + source = CGImageSourceCreateWithData(data as CFData, nil) + } else { source = CGImageSourceCreateWithURL(url as CFURL, nil) } + guard let source, let size = sourceSize(source), + max(size.width, size.height) / max(min(size.width, size.height), 1) >= 3 else { return nil } + let available = CGFloat(os_proc_available_memory()) + let maximumSourcePixels = min(64_000_000, available > 0 ? available / 16 : 12_000_000) + guard size.width * size.height <= maximumSourcePixels else { return nil } + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let orientation: Int = (properties?[kCGImagePropertyOrientation] as? Int) ?? 1 + guard orientation == 1 else { return nil } + try Task.checkCancellation() + return CGImageSourceCreateImageAtIndex(source, 0, [kCGImageSourceShouldCache: false] as CFDictionary) + } + let original = try await withTaskCancellationHandler { try await sourceTask.value } onCancel: { sourceTask.cancel() } + guard let original else { return nil } + let size = CGSize(width: original.width, height: original.height) + var blocks: [TextBlock] = [] + for window in LongPageGeometry.windows(sourceSize: size, maximumAspect: 2.5) { + try Task.checkCancellation() + let pixels = CGRect(x: window.minX * size.width, y: window.minY * size.height, + width: window.width * size.width, height: window.height * size.height).integral + .intersection(CGRect(origin: .zero, size: size)) + guard let crop = original.cropping(to: pixels) else { continue } + let tile = downsampledImage(UIImage(cgImage: crop), maxDimension: 3_600) ?? UIImage(cgImage: crop) + let tileOptions = options + let candidate = try await recognizeCandidatesWithReference(in: tile, options: tileOptions) + let vertical = await JapaneseVerticalOCRService.recognizeIfNeeded(in: tile, existingBlocks: candidate.blocks, + options: tileOptions, visionKitReference: candidate.visionKitReference) + let actual = CGRect(x: pixels.minX / size.width, y: pixels.minY / size.height, + width: pixels.width / size.width, height: pixels.height / size.height) + func point(_ p: CGPoint) -> CGPoint { CGPoint(x: actual.minX + p.x * actual.width, y: actual.minY + p.y * actual.height) } + func rect(_ r: CGRect) -> CGRect { + CGRect(x: actual.minX + r.minX * actual.width, y: actual.minY + r.minY * actual.height, + width: r.width * actual.width, height: r.height * actual.height) + } + blocks.append(contentsOf: (candidate.blocks + vertical).map { block in + var mapped = block + mapped.boundingBox = rect(block.boundingBox) + mapped.bubbleBox = block.bubbleBox.map(rect) + mapped.layoutSafeRegion = block.layoutSafeRegion.map(rect) + mapped.polygon = block.polygon.map(point) + mapped.bubblePolygon = block.bubblePolygon.map(point) + mapped.estimatedFontScale *= Double(block.textOrientation == .vertical ? actual.width : actual.height) + return mapped + }) + } + // Overlap is intentional. The existing segmenter resolves duplicate rows + // while retaining independent neighbouring bubbles and complete geometry. + var kept: [TextBlock] = [] + for block in blocks.sorted(by: { $0.confidence > $1.confidence }) { + let duplicate = kept.contains { + $0.text.trimmingCharacters(in: .whitespacesAndNewlines) == block.text.trimmingCharacters(in: .whitespacesAndNewlines) + && MangaPageCoordinateSpace.intersectionOverUnion($0.boundingBox, block.boundingBox) >= 0.55 + } + if !duplicate { kept.append(block) } + } + return OCRCandidateRecognitionResult(blocks: kept, visionKitReference: nil) + } + nonisolated static func recognizeText(in image: UIImage, options: Options) async throws -> [TextBlock] { try await MangaOCRPipeline.recognize(in: image, options: options).bubbleBlocks } @@ -660,11 +765,12 @@ struct OCRPreprocessor { } nonisolated private static func imageFromSource(_ source: CGImageSource, maxPixelSize: CGFloat) -> UIImage? { + let maximum = sourceSize(source).map { sourceMaximumPixelSize(sourceSize: $0) } ?? maxPixelSize let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, - kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + kCGImageSourceThumbnailMaxPixelSize: maximum ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } return UIImage(cgImage: cgImage) diff --git a/mreader/OCRRecognitionCache.swift b/mreader/OCRRecognitionCache.swift index c1cffdd8..96c3043a 100644 --- a/mreader/OCRRecognitionCache.swift +++ b/mreader/OCRRecognitionCache.swift @@ -18,7 +18,7 @@ nonisolated struct OCRRecognitionCacheRequest: @unchecked Sendable { // v11 keeps Manga Vision text ROI discovery and adds first-class // balloon/layout geometry. Do not reuse pages written before that // translation-unit contract existed. - "local-ocr-v12-lossless-quality-snapshot", + "local-ocr-v13-contours-width-preserving", MangaVisionService.analysisRevision, analysisIdentity, JapaneseVerticalOCRService.revision, @@ -50,7 +50,12 @@ actor OCRRecognitionCache { private let diskByteLimit: Int64 = 30 * 1024 * 1024 private var memoryCache: [String: OCRPipelineResult] = [:] private var memoryOrder: [String] = [] - private let workPool = SharedPageTaskPool() + private struct PreparedResult: Sendable { + let result: OCRPipelineResult + let identity: String + let isPersistent: Bool + } + private let workPool = SharedPageTaskPool() private var generation = UUID() private var activeReaderSessionID: UUID? @@ -62,45 +67,33 @@ actor OCRRecognitionCache { func result(for request: OCRRecognitionCacheRequest) async throws -> OCRPipelineResult { let epoch = generation - let image = await OCRPreprocessor.highResolutionImage( - from: request.pageURL, fallback: request.fallbackImage - ) ?? request.fallbackImage + let sourceSize = await OCRPreprocessor.sourcePixelSize(from: request.pageURL, fallback: request.fallbackImage) try Task.checkCancellation() guard epoch == generation else { throw CancellationError() } - - // Loading the already-decoded/high-resolution source is cheap compared with - // Manga Vision + OCR. Use its dimensions to derive the expected visual - // dependency and check the OCR cache before starting model work. var preparedRequest = request preparedRequest.analysisIdentity = await MangaVisionService.shared.expectedDependencyIdentity( - image: image + sourceSize: OCRPreprocessor.preparedPixelSize(sourceSize: sourceSize) ) - var key = preparedRequest.cacheKey + let key = preparedRequest.cacheKey if let cached = cachedResult(forKey: key) { return cached } - - let analysis = try? await MangaVisionService.shared.analysis( - comicID: request.comicID, pageIndex: request.pageIndex, - pageURL: request.pageURL, image: image - ) - try Task.checkCancellation() - guard epoch == generation else { throw CancellationError() } - - let actualIdentity = await MangaVisionService.shared.dependencyIdentity(for: analysis) - if actualIdentity != preparedRequest.analysisIdentity { - preparedRequest.analysisIdentity = actualIdentity - key = preparedRequest.cacheKey - if let cached = cachedResult(forKey: key) { return cached } - } - - let options = request.options - let result = try await workPool.value(forKey: key) { - try await MangaOCRPipeline.recognize(in: image, options: options, mangaAnalysis: analysis) + // Coalesce preparation as well as OCR. Concurrent consumers must not each + // decode the high-resolution source before discovering the shared work. + let prepared = try await workPool.value(forKey: key) { + let image = await OCRPreprocessor.highResolutionImage(from: request.pageURL, fallback: request.fallbackImage) + ?? request.fallbackImage + try Task.checkCancellation() + let analysis = try? await MangaVisionService.shared.analysis(comicID: request.comicID, + pageIndex: request.pageIndex, pageURL: request.pageURL, image: image) + try Task.checkCancellation() + let identity = await MangaVisionService.shared.dependencyIdentity(for: analysis) + let result = try await MangaOCRPipeline.recognize(in: image, options: request.options, mangaAnalysis: analysis, sourceURL: request.pageURL) + return PreparedResult(result: result, identity: identity, isPersistent: analysis != nil) } try Task.checkCancellation() guard epoch == generation else { throw CancellationError() } - // A temporary model failure must not become a persistent OCR decision. - if analysis != nil { store(result, forKey: key) } - return result + preparedRequest.analysisIdentity = prepared.identity + if prepared.isPersistent { store(prepared.result, forKey: preparedRequest.cacheKey) } + return prepared.result } func beginReaderSession(sessionID: UUID) async { diff --git a/mreader/OfflineTranslationPageProvider.swift b/mreader/OfflineTranslationPageProvider.swift index 77c7a0c9..de0d39e2 100644 --- a/mreader/OfflineTranslationPageProvider.swift +++ b/mreader/OfflineTranslationPageProvider.swift @@ -281,7 +281,7 @@ nonisolated enum OfflineTranslationPageProvider { ? await RemotePageLoader.imageData(forRemotePageURL: page.url) : try? Data(contentsOf: page.url) case .opds: - data = ComicManager.isArchivePageURL(page.url) + data = ComicManager.isLocalPageURL(page.url) ? ComicManager.imageData(forArchivePageURL: page.url) : try? Data(contentsOf: page.url) } @@ -316,7 +316,7 @@ nonisolated enum OfflineTranslationPageProvider { defer { if fallbackStarted { fallbackURL?.stopAccessingSecurityScopedResource() } } - if ComicManager.isArchivePageURL(pageURL) { + if ComicManager.isLocalPageURL(pageURL) { return ComicManager.imageData( forArchivePageURL: pageURL, securityScopedAccessHeld: session?.hasActiveSecurityScope == true diff --git a/mreader/PanelDetectionService.swift b/mreader/PanelDetectionService.swift index 97ca19ea..af96bdc0 100644 --- a/mreader/PanelDetectionService.swift +++ b/mreader/PanelDetectionService.swift @@ -162,7 +162,9 @@ nonisolated enum PanelPostProcessor { private static let structuralAlignmentTolerance: CGFloat = 0.035 private static let pageEdgeTolerance: CGFloat = 0.075 - static func process(_ candidates: [DetectedPanel]) -> [DetectedPanel] { + static func process(_ candidates: [DetectedPanel], pageSize: CGSize = CGSize(width: 1, height: 1)) -> [DetectedPanel] { + let xScale = min(1, pageSize.height / max(pageSize.width, 1)) + let yScale = min(1, pageSize.width / max(pageSize.height, 1)) let unit = CGRect(x: 0, y: 0, width: 1, height: 1) let filtered = candidates.compactMap { panel -> DetectedPanel? in let standardized = panel.rect.standardized @@ -170,15 +172,15 @@ nonisolated enum PanelPostProcessor { ? standardized : standardized.intersection(unit) guard !rect.isNull, - rect.width >= 0.055, - rect.height >= 0.045 else { + rect.width >= 0.055 * xScale, + rect.height >= 0.045 * yScale else { return nil } let area = rect.width * rect.height let maximumArea: CGFloat = ( panel.source == .coreML || panel.source == .pageGeometry ) ? 1.0 : 0.94 - guard area >= 0.012, + guard area >= 0.012 * xScale * yScale, area <= maximumArea, panel.confidence >= 0.24 else { return nil @@ -363,7 +365,7 @@ nonisolated enum PanelPostProcessor { } nonisolated enum PanelLayoutQuality { - static func isUsable(_ panels: [DetectedPanel]) -> Bool { + static func isUsable(_ panels: [DetectedPanel], pageSize: CGSize = CGSize(width: 1, height: 1)) -> Bool { let maximumPanelCount: Int if panels.allSatisfy({ $0.source == .coreML }) { maximumPanelCount = 18 @@ -386,7 +388,9 @@ nonisolated enum PanelLayoutQuality { } } - guard (2...maximumPanelCount).contains(panels.count) else { return false } + let aspect = max(pageSize.width, pageSize.height) / max(min(pageSize.width, pageSize.height), 1) + let scaledMaximum = min(256, maximumPanelCount * max(1, Int(ceil(aspect / 2.5)))) + guard (2...scaledMaximum).contains(panels.count) else { return false } let averageConfidence = panels.reduce(Float.zero) { $0 + $1.confidence } / Float(panels.count) guard averageConfidence >= 0.34 else { return false } guard PanelPostProcessor.hasVisionPanelStructure(panels) else { return false } @@ -630,7 +634,9 @@ actor PanelDetectionService { // still runs (or reuses its cache) as independent residual evidence: accuracy is // more important here than saving one inference, because a seemingly healthy // geometry layout can still contain one under-segmented leaf. - let geometryCandidates = (try? fallbackDetector.detectPanels(in: analysisImage)) ?? [] + // Tiling must precede the geometry raster's longest-edge reduction. + let geometrySource = image.cgImage ?? analysisImage + let geometryCandidates = (try? fallbackDetector.detectPanels(in: geometrySource)) ?? [] var mangaAnalysis = await visionService.cachedAnalysis( comicID: comicID, @@ -707,13 +713,14 @@ actor PanelDetectionService { model: modelCandidates, contentBounds: contentBounds, balloonRegions: mangaAnalysis?.balloons.map(\.normalizedRect) ?? [], - textRegions: mangaAnalysis?.texts.map(\.normalizedRect) ?? [] + textRegions: mangaAnalysis?.texts.map(\.normalizedRect) ?? [], + pageSize: CGSize(width: geometrySource.width, height: geometrySource.height) ) let processed = resolution.panels let detectorIdentifier = primaryIdentifier var result: PanelPageLayout - if PanelLayoutQuality.isUsable(processed) { + if PanelLayoutQuality.isUsable(processed, pageSize: CGSize(width: geometrySource.width, height: geometrySource.height)) { let structure = MangaPageStructureGraph( panels: processed, analysis: mangaAnalysis, diff --git a/mreader/ReaderImageLoadEntry.swift b/mreader/ReaderImageLoadEntry.swift new file mode 100644 index 00000000..2cf6a039 --- /dev/null +++ b/mreader/ReaderImageLoadEntry.swift @@ -0,0 +1,10 @@ +import Foundation +import UIKit + +/// An abandoned decode may finish after a replacement with the same source key. +/// Identity protects cleanup; cancellation prevents a foreground join of abandoned work. +nonisolated struct ReaderImageLoadEntry: @unchecked Sendable { + let id = UUID() + let task: Task + var canJoin: Bool { !task.isCancelled } +} diff --git a/mreader/ReaderMemoryBudget.swift b/mreader/ReaderMemoryBudget.swift index 492a8aa4..2cc7bd32 100644 --- a/mreader/ReaderMemoryBudget.swift +++ b/mreader/ReaderMemoryBudget.swift @@ -70,21 +70,22 @@ nonisolated enum ReaderMemoryBudgetPlanner { // Keep each reader subsystem to a conservative share instead of pretending // the physical-RAM tier is a process-wide hard allocation budget. let availableMB = max(1, Int(availableMemoryBytes / (1024 * 1024))) - func clamped(_ value: Int, fraction: Double, floor: Int) -> Int { - min(value, max(floor, Int(Double(availableMB) * fraction))) + func clamped(_ value: Int, fraction: Double) -> Int { + // A fixed floor must not exceed the process' remaining headroom. + min(value, max(1, Int(Double(availableMB) * fraction))) } - let decoded = clamped(baseline.decodedImageCacheMB, fraction: 0.32, floor: 96) + let decoded = clamped(baseline.decodedImageCacheMB, fraction: 0.32) let preload = min( - clamped(baseline.decodedImagePreloadMB, fraction: 0.18, floor: 64), - max(64, decoded * 2 / 3) + clamped(baseline.decodedImagePreloadMB, fraction: 0.18), + max(1, decoded * 2 / 3) ) return ReaderMemoryBudget( decodedImageCacheMB: decoded, decodedImagePreloadMB: preload, - remotePageDataCacheMB: clamped(baseline.remotePageDataCacheMB, fraction: 0.10, floor: 48), + remotePageDataCacheMB: clamped(baseline.remotePageDataCacheMB, fraction: 0.10), remotePageDataDiskMB: baseline.remotePageDataDiskMB, - remotePrefetchMB: clamped(baseline.remotePrefetchMB, fraction: 0.18, floor: 64) + remotePrefetchMB: clamped(baseline.remotePrefetchMB, fraction: 0.18) ) } diff --git a/mreader/ReaderStripTiles.swift b/mreader/ReaderStripTiles.swift new file mode 100644 index 00000000..6a5ec4c1 --- /dev/null +++ b/mreader/ReaderStripTiles.swift @@ -0,0 +1,71 @@ +import QuartzCore +import SwiftUI +import UIKit + +/// The decoded source remains bounded by Reader's existing tier policy. Only +/// compositor tiles are allocated for a long strip, avoiding one giant texture. +struct ReaderStripTiles: UIViewRepresentable { + let image: CGImage + func makeUIView(context: Context) -> ReaderStripTileView { ReaderStripTileView() } + func updateUIView(_ view: ReaderStripTileView, context: Context) { view.setImage(image) } +} + +final class ReaderStripTileView: UIView { + override class var layerClass: AnyClass { ReaderStripTileLayer.self } + private var image: CGImage? + func setImage(_ image: CGImage) { + guard self.image !== image else { return } + self.image = image + updateSnapshot() + } + override func layoutSubviews() { + super.layoutSubviews() + updateSnapshot() + } + private func updateSnapshot() { + guard let layer = layer as? ReaderStripTileLayer, let image else { return } + layer.update(image: image, size: bounds.size) + } +} + +/// CATiledLayer calls draw on background threads. Read one immutable snapshot +/// under a lock; never read UIKit view state or mutate layer contents there. +nonisolated final class ReaderStripTileLayer: CATiledLayer, @unchecked Sendable { + private let snapshotLock = NSLock() + private var snapshot: (image: CGImage, size: CGSize)? + override init() { + super.init() + tileSize = CGSize(width: 512, height: 512) + levelsOfDetail = 1 + levelsOfDetailBias = 2 + } + override init(layer: Any) { super.init(layer: layer) } + required init?(coder: NSCoder) { super.init(coder: coder) } + override class func fadeDuration() -> CFTimeInterval { 0 } + + func update(image: CGImage, size: CGSize) { + let changed = snapshotLock.withLock { + if let previous = snapshot, previous.image === image, previous.size == size { return false } + snapshot = (image, size) + return true + } + if changed { setNeedsDisplay() } + } + override func draw(in context: CGContext) { + guard let state = snapshotLock.withLock({ snapshot }), state.size.width > 0, state.size.height > 0 else { return } + let region = context.boundingBoxOfClipPath.intersection(CGRect(origin: .zero, size: state.size)) + guard !region.isNull, region.width > 0, region.height > 0 else { return } + let sx = CGFloat(state.image.width) / state.size.width + let sy = CGFloat(state.image.height) / state.size.height + let pixels = CGRect(x: region.minX * sx, y: region.minY * sy, width: region.width * sx, height: region.height * sy) + .integral.intersection(CGRect(x: 0, y: 0, width: state.image.width, height: state.image.height)) + guard let tile = state.image.cropping(to: pixels) else { return } + let target = CGRect(x: pixels.minX / sx, y: pixels.minY / sy, width: pixels.width / sx, height: pixels.height / sy) + context.saveGState() + context.translateBy(x: target.minX, y: target.maxY) + context.scaleBy(x: 1, y: -1) + context.interpolationQuality = .medium + context.draw(tile, in: CGRect(origin: .zero, size: target.size)) + context.restoreGState() + } +} diff --git a/mreader/ReaderUITestFixture.swift b/mreader/ReaderUITestFixture.swift index 34b7445a..75c962bb 100644 --- a/mreader/ReaderUITestFixture.swift +++ b/mreader/ReaderUITestFixture.swift @@ -17,8 +17,9 @@ enum ReaderUITestFixture { static func makeComic(force: Bool = false) -> ComicBook? { guard force || isEnabled else { return nil } + let longStrip = ProcessInfo.processInfo.arguments.contains("-mreader-ui-testing-long-strip") let root = FileManager.default.temporaryDirectory - .appendingPathComponent("MReaderUITestReader", isDirectory: true) + .appendingPathComponent(longStrip ? "MReaderUITestLongStrip" : "MReaderUITestReader", isDirectory: true) do { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let colors: [UIColor] = [ @@ -31,7 +32,7 @@ enum ReaderUITestFixture { for (index, color) in colors.enumerated() { let pageURL = root.appendingPathComponent(String(format: "%02d.png", index + 1)) if !FileManager.default.fileExists(atPath: pageURL.path) { - try pageData(color: color).write(to: pageURL, options: .atomic) + try (longStrip ? longStripData(index: index) : pageData(color: color)).write(to: pageURL, options: .atomic) } } let bookmarkData = try root.bookmarkData( @@ -42,7 +43,7 @@ enum ReaderUITestFixture { let firstPage = root.appendingPathComponent("01.png") return ComicBook( id: UUID(uuidString: "A0D0C0DE-7E57-4D2C-9B2A-7D6F2C2B9E01")!, - title: "UI Test Reader", + title: longStrip ? "UI Test Long Strip" : "UI Test Reader", bookmarkData: bookmarkData, totalPages: colors.count, coverImagePath: firstPage.path, @@ -60,15 +61,33 @@ enum ReaderUITestFixture { isOCREnabled: true, isAITranslationEnabled: true, hasInitializedReadingPreset: true, - readingModeRaw: ReadingMode.horizontalPage.rawValue, + readingModeRaw: longStrip ? ReadingMode.continuousScroll.rawValue : ReadingMode.horizontalPage.rawValue, pageTurnAnimationRaw: PageTurnAnimation.slide.rawValue, - imageFitModeRaw: ImageFitMode.fitScreen.rawValue + imageFitModeRaw: longStrip ? ImageFitMode.fitWidth.rawValue : ImageFitMode.fitScreen.rawValue ) } catch { return nil } } + private static func longStripData(index: Int) -> Data { + let format = UIGraphicsImageRendererFormat(); format.scale = 1; format.opaque = true + return UIGraphicsImageRenderer(size: CGSize(width: 800, height: 20_000), format: format).pngData { context in + UIColor.white.setFill(); context.fill(CGRect(x: 0, y: 0, width: 800, height: 20_000)) + for (panel, y) in stride(from: 80, to: 19_400, by: 900).enumerated() { + UIColor(hue: CGFloat((index + panel) % 10)/10, saturation: 0.5, brightness: 0.65, alpha: 1).setFill() + context.fill(CGRect(x: 30, y: y, width: 740, height: 720)) + UIColor.black.setStroke(); context.cgContext.setLineWidth(4) + context.cgContext.stroke(CGRect(x: 30, y: y, width: 740, height: 720)) + let bubble = UIBezierPath(ovalIn: CGRect(x: 170, y: y+90, width: 460, height: 280)) + UIColor.white.setFill(); bubble.fill(); bubble.lineWidth = 4; bubble.stroke() + ("PAGE \(index+1) PANEL \(panel+1)" as NSString).draw( + in: CGRect(x: 240, y: y+170, width: 320, height: 100), + withAttributes: [.font: UIFont.boldSystemFont(ofSize: 28), .foregroundColor: UIColor.black]) + } + } + } + private static func pageData(color: UIColor) -> Data { let format = UIGraphicsImageRendererFormat() format.scale = 1 diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index c8cb90a5..9a0cc26c 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -87,7 +87,7 @@ struct ReaderContainerView: View { // 宽高比,所以在首屏出现前用有限预算登记前若干页,避免长条漫画 // 先按兜底比例布局、图片落地后再整体重排(审查 #17)。 if comic.sourceType == .local { - await PageGeometryStore.shared.preloadSizes(for: result.pages) + await PageGeometryStore.shared.preloadSizes(for: result.pages, around: comic.currentPageIndex) } // Present the reader before any 8192px warm-up. manager.applyLoadedPages(result) @@ -518,7 +518,7 @@ nonisolated private enum InitialReadingPresetDetector { guard let data = await RemotePageLoader.imageData(forRemotePageURL: url) else { return nil } return imagePixelSize(from: data) } - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { return await Task.detached(priority: .utility) { // 轻量尺寸读取,避免为了读宽高而完整解压图片(审查 #18) ComicManager.imagePixelSizeForArchivePageURL(url) @@ -611,10 +611,12 @@ final class PageGeometryStore { /// `budgetNanoseconds` 限制总耗时,避免为了预登记而拖慢首屏。 func preloadSizes( for pages: [ComicPage], + around pageIndex: Int = 0, limit: Int = 12, budgetNanoseconds: UInt64 = 150_000_000 ) async { - let candidates = pages.prefix(max(0, limit)).filter { size(for: $0.url) == nil } + let candidates = pages.sorted { abs($0.index - pageIndex) < abs($1.index - pageIndex) } + .prefix(max(0, limit)).filter { size(for: $0.url) == nil } guard !candidates.isEmpty else { return } let started = DispatchTime.now().uptimeNanoseconds @@ -623,7 +625,7 @@ final class PageGeometryStore { if Task.isCancelled { return } let url = page.url let size = await Task.detached(priority: .userInitiated) { () -> CGSize? in - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { return ComicManager.imagePixelSizeForArchivePageURL(url) } guard url.isFileURL else { return nil } @@ -648,7 +650,7 @@ private final class ReaderImageSourceMetadataStore { private var insertionOrder: [String] = [] func entry(for url: URL) -> Entry { - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { let sourceKey = ComicManager.archivePageCacheKey(for: url) ?? url.absoluteString return Entry(cacheIdentity: sourceKey, fileSize: 0) } @@ -823,8 +825,8 @@ private final class ReaderImageCache { } private let cache = NSCache() - private var inFlightLoads: [String: Task] = [:] - private var loadingCosts: [String: Int] = [:] + private var inFlightLoads: [String: ReaderImageLoadEntry] = [:] + private var loadingCosts: [UUID: Int] = [:] private var scheduledPreload: Task? private var pendingPreloadRequest: PendingPreloadRequest? private var preloadQueue: [PreloadCandidate] = [] @@ -917,7 +919,7 @@ private final class ReaderImageCache { private func inFlightKeySatisfying(url: URL, maxPixelSize: CGFloat) -> String? { for tier in resolutionTiers where tier >= maxPixelSize { let key = cacheKey(for: url, maxPixelSize: tier) - if inFlightLoads[key] != nil { + if inFlightLoads[key]?.canJoin == true { return key } } @@ -942,6 +944,7 @@ private final class ReaderImageCache { key: String, token: UUID, epoch: UUID, + requestID: UUID, cancelIfUnneeded: Bool ) { guard epoch == generation, @@ -950,14 +953,14 @@ private final class ReaderImageCache { var cancelledInFlightTask = false if consumers.isEmpty { foregroundConsumers[key] = nil - if cancelIfUnneeded { + if cancelIfUnneeded, inFlightLoads[key]?.id == requestID { if preloadKeys.contains(key) { if !desiredPreloadKeys.contains(key) { - inFlightLoads[key]?.cancel() + inFlightLoads[key]?.task.cancel() cancelledInFlightTask = true } } else { - inFlightLoads[key]?.cancel() + inFlightLoads[key]?.task.cancel() cancelledInFlightTask = true } } @@ -974,7 +977,7 @@ private final class ReaderImageCache { } private func awaitForegroundTask( - _ imageTask: Task, + _ entry: ReaderImageLoadEntry, key: String, epoch: UUID ) async -> UIImage? { @@ -984,17 +987,19 @@ private final class ReaderImageCache { key: key, token: token, epoch: epoch, + requestID: entry.id, cancelIfUnneeded: false ) } let image = await withTaskCancellationHandler { - await imageTask.value + await entry.task.value } onCancel: { Task { @MainActor in ReaderImageCache.shared.finishForegroundConsumer( key: key, token: token, epoch: epoch, + requestID: entry.id, cancelIfUnneeded: true ) } @@ -1012,13 +1017,13 @@ private final class ReaderImageCache { } let epoch = generation let key = cacheKey(for: url, maxPixelSize: maxPixelSize) - if let existingTask = inFlightLoads[key] { + if let existingTask = inFlightLoads[key], existingTask.canJoin { let image = await awaitForegroundTask(existingTask, key: key, epoch: epoch) return epoch == generation && !Task.isCancelled ? image : nil } // 加入更高分辨率的在途解码任务,避免同时双解码 if let higherKey = inFlightKeySatisfying(url: url, maxPixelSize: maxPixelSize), - let existingTask = inFlightLoads[higherKey] { + let existingTask = inFlightLoads[higherKey], existingTask.canJoin { let image = await awaitForegroundTask(existingTask, key: higherKey, epoch: epoch) return epoch == generation && !Task.isCancelled ? image : nil } @@ -1028,23 +1033,26 @@ private final class ReaderImageCache { let task = Task.detached(priority: .userInitiated) { await loadForegroundReaderImage(from: url, maxPixelSize: maxPixelSize) } - inFlightLoads[key] = task - loadingCosts[key] = estimatedCost - let image = await awaitForegroundTask(task, key: key, epoch: epoch) + let entry = ReaderImageLoadEntry(task: task) + inFlightLoads[key] = entry + loadingCosts[entry.id] = estimatedCost + let image = await awaitForegroundTask(entry, key: key, epoch: epoch) if epoch == generation { // The owner still cleans the shared entry only after the detached task has // actually completed. Cancellation now reaches tasks that are merely queued // for a permit, while synchronous ImageIO already in progress finishes safely. - inFlightLoads[key] = nil - loadingCosts[key] = nil - preloadKeys.remove(key) + loadingCosts[entry.id] = nil + if inFlightLoads[key]?.id == entry.id { + inFlightLoads[key] = nil + preloadKeys.remove(key) + } drainPreloadQueue() } // clearMemoryCache() may have advanced generation and a new Reader may already own // the same key. Old completions must not touch that new session or repopulate cache. guard epoch == generation else { return nil } - if let image { + if let image, !task.isCancelled { // Cache a successful shared decode even if the task that originally created it // has since been cancelled. Another LocalImageView may still have been waiting // on the same work; tying cache insertion to the creator's lifetime would force @@ -1112,7 +1120,7 @@ private final class ReaderImageCache { desiredPreloadKeys = desiredKeys for staleKey in preloadKeys.subtracting(desiredKeys) { guard !hasForegroundConsumer(for: staleKey) else { continue } - inFlightLoads[staleKey]?.cancel() + inFlightLoads[staleKey]?.task.cancel() } let candidates = request.urls @@ -1149,7 +1157,7 @@ private final class ReaderImageCache { } let candidate = preloadQueue[0] guard cache.object(forKey: candidate.key as NSString) == nil, - inFlightLoads[candidate.key] == nil else { + inFlightLoads[candidate.key]?.canJoin != true else { preloadQueue.removeFirst() continue } @@ -1161,22 +1169,26 @@ private final class ReaderImageCache { activePreloadCount += 1 preloadKeys.insert(candidate.key) - loadingCosts[candidate.key] = candidate.cost let task: Task = Task.detached(priority: .utility) { guard !Task.isCancelled else { return nil } let image = await decodeReaderImage(from: candidate.url, maxPixelSize: candidate.maxPixelSize) return Task.isCancelled ? nil : image } - inFlightLoads[candidate.key] = task + let entry = ReaderImageLoadEntry(task: task) + loadingCosts[entry.id] = candidate.cost + inFlightLoads[candidate.key] = entry let epoch = generation Task { @MainActor [weak self] in let image = await task.value guard let self, self.generation == epoch else { return } - self.inFlightLoads[candidate.key] = nil - self.loadingCosts[candidate.key] = nil - self.preloadKeys.remove(candidate.key) + self.loadingCosts[entry.id] = nil + let isCurrent = self.inFlightLoads[candidate.key]?.id == entry.id + if isCurrent { + self.inFlightLoads[candidate.key] = nil + self.preloadKeys.remove(candidate.key) + } self.activePreloadCount = max(0, self.activePreloadCount - 1) - if let image, !Task.isCancelled { + if let image, isCurrent, !task.isCancelled { self.cache.setObject(image, forKey: candidate.key as NSString, cost: image.cacheCost) } self.drainPreloadQueue() @@ -1197,8 +1209,8 @@ private final class ReaderImageCache { scheduledPreload = nil pendingPreloadRequest = nil preloadQueue.removeAll() - for task in inFlightLoads.values { - task.cancel() + for entry in inFlightLoads.values { + entry.task.cancel() } inFlightLoads.removeAll() loadingCosts.removeAll() @@ -1231,7 +1243,7 @@ private final class ReaderImageCache { let scale = min(1, maxPixelSize / max(pixelSize.width, pixelSize.height)) return max(1, Int(pixelSize.width * scale * pixelSize.height * scale * 4)) } - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { // 估算阶段不能再次解压 CBZ;优先使用已经登记的几何信息,未知时采用保守预算。 if let pixelSize = PageGeometryStore.shared.size(for: url), pixelSize.width > 0, @@ -1283,7 +1295,7 @@ nonisolated private func decodeReaderImage(from url: URL, maxPixelSize: CGFloat) } let image = autoreleasepool { () -> UIImage? in let source: CGImageSource? - if ComicManager.isArchivePageURL(url), let data = ComicManager.imageData(forArchivePageURL: url) { + if ComicManager.isLocalPageURL(url), let data = ComicManager.imageData(forArchivePageURL: url) { source = CGImageSourceCreateWithData(data as CFData, nil) } else if let remoteData { source = CGImageSourceCreateWithData(remoteData as CFData, nil) @@ -1301,7 +1313,7 @@ nonisolated private func decodeReaderImage(from url: URL, maxPixelSize: CGFloat) if let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) { return UIImage(cgImage: cgImage) } - return UIImage(contentsOfFile: url.path) + return nil } if Task.isCancelled { await readerImageDecodeLimiter.release() @@ -2740,7 +2752,7 @@ struct ReaderView: View { guard let data = await RemotePageLoader.imageData(forRemotePageURL: url) else { return nil } return imagePixelSize(from: data) } - if ComicManager.isArchivePageURL(url) { + if ComicManager.isLocalPageURL(url) { return await Task.detached(priority: .utility) { guard let data = ComicManager.imageData(forArchivePageURL: url) else { return nil } return imagePixelSize(from: data) @@ -5765,6 +5777,7 @@ struct LocalImageView: View { let onShowControls: () -> Void let onHideControls: () -> Void @State private var uiImage: UIImage? = nil + @State private var imageLoadID = UUID() /// 已经加载进 `uiImage` 的页面。分镜跨页会复用同一个视图身份,靠它判断是否需要重新加载。 @State private var loadedPageURL: URL? #if DEBUG @@ -6250,6 +6263,7 @@ struct LocalImageView: View { } private func performImageLoadTask() async { + imageLoadID = UUID() guard retainsDecodedImage else { cancelLiveTranslationForPage() offlineTranslationTask?.cancel() @@ -6307,10 +6321,15 @@ struct LocalImageView: View { .resizable() .scaledToFit() case .fitWidth: - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) + if let cgImage = image.cgImage, image.size.height / max(image.size.width, 1) >= 3 { + ReaderStripTiles(image: cgImage) + .frame(width: max(viewportWidth, 1), height: displayHeight(for: image)) + } else { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } case .fitHeight: Image(uiImage: image) .resizable() @@ -6747,8 +6766,16 @@ struct LocalImageView: View { within: imageBounds ) } - let allowedBounds = resolvedSafeRegion ?? usableBubbleBounds ?? fallbackBounds - let layoutBounds = OCRBubbleLayoutEngine.resolvedTranslationLayoutBounds( + // Compute from the physical contour at display time as well: cloud and + // offline blocks may not have passed through the local OCR enrichment. + let mappedContour = block.bubblePolygon.map { + OCRCoordinateMapper.displayPoint(forNormalizedPagePoint: $0, using: transform) + } + let contourSafe = usableBubbleBounds.flatMap { + BubbleContourGeometry.safeRectangle(polygon: mappedContour, bounds: $0) + } + let allowedBounds = contourSafe ?? resolvedSafeRegion ?? usableBubbleBounds ?? fallbackBounds + let layoutBounds = contourSafe ?? OCRBubbleLayoutEngine.resolvedTranslationLayoutBounds( sourceRect: textRect, resolvedSafeRegion: resolvedSafeRegion, reliableBubbleBounds: usableBubbleBounds, @@ -6960,7 +6987,8 @@ struct LocalImageView: View { String(format: "%.5f", ocrMinimumTextHeight), String(format: "%.4f", normalizedOCRScale), String(format: "%.4f", uniformOCRFontSize), - String(format: "%.2f", measuredTextTranslationFontSize) + String(format: "%.2f", measuredTextTranslationFontSize), + String(format: "%.2f", comic?.minimumReadableTranslationFontSize ?? ComicBook.defaultMinimumReadableTranslationFontSize) ].joined(separator: "|") } @@ -7110,6 +7138,8 @@ struct LocalImageView: View { } private func loadImage() async { + let loadID = UUID() + imageLoadID = loadID let pageURL = url await MainActor.run { cancelLiveTranslationForPage() @@ -7122,7 +7152,7 @@ struct LocalImageView: View { let maxPixelSize = preferredDecodeMaxPixelSize if let cachedImage = ReaderImageCache.shared.cachedImage(for: url, maxPixelSize: maxPixelSize) { await MainActor.run { - guard self.url == pageURL else { return } + guard self.url == pageURL, self.imageLoadID == loadID, self.retainsDecodedImage, !Task.isCancelled else { return } isLoadingImage = false loadFailed = false uiImage = cachedImage @@ -7147,6 +7177,7 @@ struct LocalImageView: View { pendingSingleTapWorkItem?.cancel() pendingSingleTapWorkItem = nil } + guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } let hasOfflineTranslation = await loadOfflineTranslationIfAvailable() if isAutoTranslationEnabled, !hasOfflineTranslation { await MainActor.run { startTranslation() } @@ -7184,12 +7215,13 @@ struct LocalImageView: View { let loadedImage = await ReaderImageCache.shared.loadImage(for: url, maxPixelSize: maxPixelSize) await MainActor.run { - guard self.url == pageURL else { return } + guard self.url == pageURL, self.imageLoadID == loadID, self.retainsDecodedImage, !Task.isCancelled else { return } self.uiImage = loadedImage self.loadedPageURL = loadedImage == nil ? nil : pageURL self.loadFailed = loadedImage == nil self.isLoadingImage = false } + guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } let hasOfflineTranslation = await loadOfflineTranslationIfAvailable() if isAutoTranslationEnabled, !hasOfflineTranslation { await MainActor.run { startTranslation() } @@ -7975,7 +8007,7 @@ private struct TranslationLayoutItem: Identifiable { /// 字体与布局样式。命中缓存时直接复用上一次的结果。 private final class TranslationLayoutStore { /// 排版算法版本。算法语义变化时必须 +1,避免旧布局被复用。 - static let layoutRevision = 5 + static let layoutRevision = 6 struct Key: Equatable { let scope: String @@ -8230,6 +8262,16 @@ private struct TranslationTextRenderer: View { .padding(contentPadding) .frame(width: layoutSize.width, height: layoutSize.height) .clipped() + .overlay(alignment: .bottomTrailing) { + if layoutStatus == .needsExpansion { + Image(systemName: "ellipsis") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.black) + .padding(2) + .background(.white.opacity(0.9), in: Capsule()) + .accessibilityHidden(true) + } + } .allowsHitTesting(false) } @@ -8263,6 +8305,9 @@ private struct CoreTextTranslationView: UIViewRepresentable { func updateUIView(_ uiView: TranslationTextUIView, context: Context) { var needsRedraw = false + uiView.isAccessibilityElement = true + uiView.accessibilityLabel = text + uiView.accessibilityTraits = .staticText if uiView.text != text { uiView.text = text needsRedraw = true diff --git a/mreader/TranslationGeometryRefiner.swift b/mreader/TranslationGeometryRefiner.swift index 3c13bf24..7a697610 100644 --- a/mreader/TranslationGeometryRefiner.swift +++ b/mreader/TranslationGeometryRefiner.swift @@ -32,25 +32,17 @@ nonisolated enum TranslationGeometryRefiner { vision.layoutRole == .standalone || locals.contains(where: { $0.layoutRole == .standalone }) ? .standalone : .dialogue - return TextBlock( - id: vision.id, - text: vision.text, - boundingBox: localBounds, - translation: vision.translation, - confidence: max(vision.confidence, locals.map(\.confidence).max() ?? 0), - ocrSource: "\(vision.ocrSource)+local-geometry", - isFiltered: vision.isFiltered, - filterReason: vision.filterReason, - estimatedFontScale: weightedMedianFontScale(locals), - textColorHex: locals.compactMap(\.textColorHex).first ?? vision.textColorHex, - bubbleBox: vision.bubbleBox, - polygon: locals.flatMap(\.polygon).isEmpty ? vision.polygon : locals.flatMap(\.polygon), - bubblePolygon: vision.bubblePolygon, - translationLines: vision.translationLines, - textOrientation: majorityOrientation(locals), - layoutRole: inheritedLayoutRole, - sourceLineCount: max(vision.sourceLineCount, sourceLineCount) - ) + var refined = vision + refined.boundingBox = localBounds + refined.confidence = max(vision.confidence, locals.map(\.confidence).max() ?? 0) + refined.ocrSource = "\(vision.ocrSource)+local-geometry" + refined.estimatedFontScale = weightedMedianFontScale(locals) + refined.textColorHex = locals.compactMap(\.textColorHex).first ?? vision.textColorHex + refined.polygon = locals.flatMap(\.polygon).isEmpty ? vision.polygon : locals.flatMap(\.polygon) + refined.textOrientation = majorityOrientation(locals) + refined.layoutRole = inheritedLayoutRole + refined.sourceLineCount = max(vision.sourceLineCount, sourceLineCount) + return refined } } diff --git a/mreader/en.lproj/Localizable.strings b/mreader/en.lproj/Localizable.strings index f646e59c..b6c2eaa7 100644 --- a/mreader/en.lproj/Localizable.strings +++ b/mreader/en.lproj/Localizable.strings @@ -582,9 +582,9 @@ "offlineTranslation.errorDetails" = "View error details"; "nav.close" = "Close"; -"ocr.minimumReadableTranslationFontSize" = "Minimum readable translation size"; -"ocr.minimumReadableTranslationFontSizeValue" = "Readable floor: %d pt"; -"ocr.minimumReadableTranslationFontSizeDescription" = "Below this size, text is not shrunk further; use the expandable translation instead."; +"ocr.minimumReadableTranslationFontSize" = "Preferred readable translation size"; +"ocr.minimumReadableTranslationFontSizeValue" = "Preferred size: %d pt"; +"ocr.minimumReadableTranslationFontSizeDescription" = "Text can shrink further to fit a bubble. Accessibility reads the full translation."; "ocr.translationNeedsExpansion" = "Translation needs expanded reading"; "ocr.inPlaceTranslation" = "In-place translation"; "ocr.inPlaceTranslationDescription" = "Use reversible in-place coverage only for reliable dialogue bubbles; sound effects keep their original artwork and use annotations."; diff --git a/mreader/ja.lproj/Localizable.strings b/mreader/ja.lproj/Localizable.strings index 9e082f3b..dbad559d 100644 --- a/mreader/ja.lproj/Localizable.strings +++ b/mreader/ja.lproj/Localizable.strings @@ -572,9 +572,9 @@ "offlineTranslation.errorDetails" = "エラーの詳細を表示"; "nav.close" = "閉じる"; -"ocr.minimumReadableTranslationFontSize" = "翻訳の最小可読サイズ"; -"ocr.minimumReadableTranslationFontSizeValue" = "可読下限:%d pt"; -"ocr.minimumReadableTranslationFontSizeDescription" = "このサイズより小さくせず、収まらない場合は展開表示で全文を読みます。"; +"ocr.minimumReadableTranslationFontSize" = "翻訳の優先可読サイズ"; +"ocr.minimumReadableTranslationFontSizeValue" = "優先サイズ:%d pt"; +"ocr.minimumReadableTranslationFontSizeDescription" = "吹き出しに収めるためさらに縮小する場合があります。全文はアクセシビリティで読み上げます。"; "ocr.translationNeedsExpansion" = "翻訳を展開して読む必要があります"; "ocr.inPlaceTranslation" = "インプレース翻訳"; "ocr.inPlaceTranslationDescription" = "信頼できる吹き出しだけを可逆なインプレース表示にし、効果音の原画は保持します。"; diff --git a/mreader/ko.lproj/Localizable.strings b/mreader/ko.lproj/Localizable.strings index 13a45177..cdb28061 100644 --- a/mreader/ko.lproj/Localizable.strings +++ b/mreader/ko.lproj/Localizable.strings @@ -572,9 +572,9 @@ "offlineTranslation.errorDetails" = "오류 세부 정보 보기"; "nav.close" = "닫기"; -"ocr.minimumReadableTranslationFontSize" = "번역 최소 가독 글자 크기"; -"ocr.minimumReadableTranslationFontSizeValue" = "가독 하한: %d pt"; -"ocr.minimumReadableTranslationFontSizeDescription" = "이 크기보다 더 줄이지 않고, 공간이 부족하면 펼쳐서 전체 번역을 읽습니다."; +"ocr.minimumReadableTranslationFontSize" = "번역 선호 가독 글자 크기"; +"ocr.minimumReadableTranslationFontSizeValue" = "선호 크기: %d pt"; +"ocr.minimumReadableTranslationFontSizeDescription" = "말풍선에 맞추기 위해 더 작아질 수 있습니다. 접근성 기능으로 전체 번역을 읽을 수 있습니다。"; "ocr.translationNeedsExpansion" = "번역을 펼쳐서 읽어야 합니다"; "ocr.inPlaceTranslation" = "원위치 번역"; "ocr.inPlaceTranslationDescription" = "신뢰할 수 있는 대사 말풍선만 되돌릴 수 있는 원위치 표시를 사용하고 효과음 원화는 유지합니다."; diff --git a/mreader/zh-Hans.lproj/Localizable.strings b/mreader/zh-Hans.lproj/Localizable.strings index ad02dcb9..b1c79b5a 100644 --- a/mreader/zh-Hans.lproj/Localizable.strings +++ b/mreader/zh-Hans.lproj/Localizable.strings @@ -582,9 +582,9 @@ "offlineTranslation.errorDetails" = "查看错误详情"; "nav.close" = "关闭"; -"ocr.minimumReadableTranslationFontSize" = "最小可读译文字号"; -"ocr.minimumReadableTranslationFontSizeValue" = "可读下限:%d pt"; -"ocr.minimumReadableTranslationFontSizeDescription" = "低于此字号时不再继续缩小,而改为可展开阅读。"; +"ocr.minimumReadableTranslationFontSize" = "偏好可读译文字号"; +"ocr.minimumReadableTranslationFontSizeValue" = "偏好字号:%d pt"; +"ocr.minimumReadableTranslationFontSizeDescription" = "气泡空间不足时仍会缩小以适配;完整译文可通过辅助功能朗读。"; "ocr.translationNeedsExpansion" = "译文需要展开阅读"; "ocr.inPlaceTranslation" = "原位译文"; "ocr.inPlaceTranslationDescription" = "仅对可靠对白气泡启用可随时关闭的原位覆盖;拟声词仍保留原艺术字并使用注释。"; diff --git a/mreaderTests/ComicAuditFixTests.swift b/mreaderTests/ComicAuditFixTests.swift new file mode 100644 index 00000000..76248334 --- /dev/null +++ b/mreaderTests/ComicAuditFixTests.swift @@ -0,0 +1,190 @@ +import CoreGraphics +import Foundation +import PDFKit +import Testing +import UIKit +@testable import mreader + +@Suite(.serialized) +@MainActor +struct ComicAuditFixTests { + @Test(arguments: [Float(0.95), Float(0.70)]) + func pageContainerCannotDeleteChildrenBeforeFusion(parentConfidence: Float) { + let parent = region(CGRect(x: 0, y: 0, width: 1, height: 1), confidence: parentConfidence) + let children = [CGFloat(0.05), 0.375, 0.70].map { + region(CGRect(x: 0.05, y: $0, width: 0.90, height: 0.25), confidence: 0.90) + } + let kept = MangaVisionCalibrationProfile.bundled.deduplicated([parent] + children, type: .panel) + #expect(kept.count == 4) + let navigation = PanelCandidateFusion.resolve(geometry: [], model: kept.map { + DetectedPanel(rect: $0.normalizedRect, confidence: $0.confidence, source: .coreML) + }, contentBounds: CGRect(x: 0, y: 0, width: 1, height: 1)) + #expect(navigation.panels.count == 3) + #expect(navigation.panels.allSatisfy { $0.source == .coreML }) + for child in children { #expect(navigation.panels.contains { $0.rect == child.normalizedRect }) } + } + + @Test func genuineInsetSurvivesWhileRepeatedTileBoxDeduplicates() { + let parent = region(CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.8), confidence: 0.95) + let inset = region(CGRect(x: 0.6, y: 0.6, width: 0.2, height: 0.2), confidence: 0.85) + let duplicate = region(CGRect(x: 0.605, y: 0.60, width: 0.20, height: 0.20), confidence: 0.8) + let kept = MangaVisionCalibrationProfile.bundled.deduplicated([parent, inset, duplicate], type: .panel) + #expect(kept.count == 2) + #expect(kept.contains { $0.normalizedRect == inset.normalizedRect }) + } + + @Test(arguments: [CGFloat(20_000), CGFloat(40_000)]) + func localPanelDoesNotDisappearWhenPageGetsLonger(height: CGFloat) { + let size = CGSize(width: 800, height: height) + let panel = DetectedPanel(rect: CGRect(x: 0.05, y: 1200/height, width: 0.9, height: 600/height), + confidence: 0.9, source: .coreML) + #expect(PanelPostProcessor.process([panel], pageSize: size).count == 1) + let windows = LongPageGeometry.windows(sourceSize: size) + #expect(windows.count > 6) + #expect(windows.first?.minY == 0) + #expect(abs((windows.last?.maxY ?? 0) - 1) < 0.000_001) + #expect(windows.allSatisfy { $0.height * height / 800 <= 2.500_001 }) + for pair in zip(windows, windows.dropFirst()) { #expect(pair.0.maxY > pair.1.minY) } + } + + @Test func geometryFindsRealPanelsInTwentyThousandPixelStrip() throws { + let size = CGSize(width: 800, height: 20_000) + let image = render(size: size) { context in + UIColor.white.setFill(); context.fill(CGRect(origin: .zero, size: size)) + for y in stride(from: 100, to: 19_400, by: 750) { + UIColor(white: 0.35, alpha: 1).setFill() + context.fill(CGRect(x: 40, y: y, width: 720, height: 600)) + UIColor.black.setStroke() + context.cgContext.setLineWidth(5) + context.cgContext.stroke(CGRect(x: 40, y: y, width: 720, height: 600)) + } + } + let panels = try GeometryPanelDetector().detectPanels(in: #require(image.cgImage)) + #expect(panels.count >= 20) + #expect(panels.allSatisfy { $0.source == .pageGeometry }) + #expect(panels.allSatisfy { $0.rect.height < 0.06 }) + #expect(PanelLayoutQuality.isUsable(panels, pageSize: size)) + } + + @Test func ellipseSafeRectangleExcludesBoundingBoxCornersAndFitsCompleteText() throws { + let polygon = (0..<80).map { index in + let theta = CGFloat(index) * .pi * 2 / 80 + return CGPoint(x: 100 + cos(theta)*100, y: 60 + sin(theta)*60) + } + let safe = try #require(BubbleContourGeometry.safeRectangle(polygon: polygon, + bounds: CGRect(x: 0, y: 0, width: 200, height: 120))) + for y in stride(from: safe.minY, through: safe.maxY, by: 2) { + for x in stride(from: safe.minX, through: safe.maxX, by: 2) { + #expect(pow((x-100)/100, 2) + pow((y-60)/60, 2) < 1) + } + } + for orientation in [TextOrientation.horizontal, .vertical] { + let measured = TranslationTypesetter.measurement(text: "完整译文", fontSize: 12, + bounds: safe.size, orientation: orientation, lineSpacing: 2) + #expect(measured.fitsAllText) + } + } + + @Test func concaveSafeRectangleCannotBridgeTheNotch() throws { + let points = [CGPoint(x: 0, y: 0), CGPoint(x: 100, y: 0), CGPoint(x: 100, y: 100), + CGPoint(x: 60, y: 100), CGPoint(x: 60, y: 35), CGPoint(x: 40, y: 35), + CGPoint(x: 40, y: 100), CGPoint(x: 0, y: 100)] + let safe = try #require(BubbleContourGeometry.safeRectangle(polygon: points, + bounds: CGRect(x: 0, y: 0, width: 100, height: 100))) + #expect(!safe.intersects(CGRect(x: 40, y: 35, width: 20, height: 65))) + } + + @Test func closedLightBalloonUsesPixelContourAndOpenRegionDeclines() throws { + let size = CGSize(width: 300, height: 400) + let image = render(size: size) { context in + UIColor(white: 0.3, alpha: 1).setFill(); context.fill(CGRect(origin: .zero, size: size)) + UIColor.white.setFill() + let ellipse = UIBezierPath(ovalIn: CGRect(x: 60, y: 70, width: 180, height: 220)) + ellipse.fill(); UIColor.black.setStroke(); ellipse.lineWidth = 5; ellipse.stroke() + UIColor.black.setFill(); context.fill(CGRect(x: 115, y: 145, width: 60, height: 10)) + } + let balloon = CGRect(x: 0.15, y: 0.15, width: 0.70, height: 0.65) + let text = CGRect(x: 115/300.0, y: 145/400.0, width: 60/300.0, height: 10/400.0) + let contour = try #require(LocalBubbleContour.recover(in: #require(image.cgImage), balloon: balloon, textRegions: [text])) + #expect(contour.count >= 8) + #expect(contour.map(\.y).min()! < 0.20) + #expect(contour.map(\.y).max()! < 0.75) + let blank = render(size: size) { context in UIColor.white.setFill(); context.fill(CGRect(origin: .zero, size: size)) } + #expect(LocalBubbleContour.recover(in: #require(blank.cgImage), balloon: balloon, textRegions: [text]) == nil) + } + + @Test func cancelledImageEntryCannotBeJoinedAndReplacementHasOwnIdentity() async { + let oldTask = Task { try? await Task.sleep(for: .seconds(60)); return nil } + let old = ReaderImageLoadEntry(task: oldTask) + oldTask.cancel() + let replacement = ReaderImageLoadEntry(task: Task { nil }) + #expect(!old.canJoin) + #expect(replacement.canJoin) + #expect(old.id != replacement.id) + _ = await oldTask.value + } + + @Test func offlineRefinementPreservesIndependentSafeRegion() { + let safe = CGRect(x: 0.15, y: 0.16, width: 0.30, height: 0.20) + var vision = TextBlock(text: "hello", boundingBox: CGRect(x: 0.2, y: 0.2, width: 0.1, height: 0.05), translation: "你好") + vision.layoutSafeRegion = safe + vision.bubbleBox = CGRect(x: 0.1, y: 0.1, width: 0.4, height: 0.3) + vision.bubblePolygon = [CGPoint(x: 0.1, y: 0.1), CGPoint(x: 0.5, y: 0.1), CGPoint(x: 0.3, y: 0.4)] + let local = TextBlock(text: "hello", boundingBox: CGRect(x: 0.21, y: 0.20, width: 0.1, height: 0.05)) + let refined = TranslationGeometryRefiner.refine(visionBlocks: [vision], localOCRBlocks: [local], isRightToLeft: false)[0] + #expect(refined.boundingBox == local.boundingBox) + #expect(refined.layoutSafeRegion == safe) + #expect(refined.bubblePolygon == vision.bubblePolygon) + #expect(refined.translation == vision.translation) + #expect(refined.id == vision.id) + } + + @Test func unauthorizedHeaderCannotCreateOrWriteBodyFile() throws { + enum Rejection: Error { case unauthorized } + let state = HTTPRequestReceiveState { _ in throw Rejection.unauthorized } + defer { state.cleanup() } + var rejected = false + do { try state.append(Data("POST /wrong/upload HTTP/1.1\r\nContent-Length: 4\r\n\r\nbody".utf8)) } + catch Rejection.unauthorized { rejected = true } + #expect(rejected) + #expect(state.bodyFileURL == nil) + #expect(state.receivedBodyBytes == 0) + } + + @Test func pdfPageDescriptorsAreLazyAndReadOnly() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("book.pdf") + let document = PDFDocument() + let image = render(size: CGSize(width: 100, height: 150)) { context in + UIColor.white.setFill(); context.fill(CGRect(x: 0, y: 0, width: 100, height: 150)) + } + for i in 0..<20 { document.insert(try #require(PDFPage(image: image)), at: i) } + #expect(document.write(to: url)) + let before = try Data(contentsOf: url) + let pages = ComicManager.pdfPages(from: url) + #expect(pages.count == 20) + #expect(pages.allSatisfy { $0.url.scheme == "mreader-pdf" }) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["book.pdf"]) + #expect(ComicManager.imagePixelSizeForArchivePageURL(pages[7].url) != nil) + #expect(ComicManager.imageData(forArchivePageURL: pages[7].url) != nil) + #expect(try Data(contentsOf: url) == before) + } + + @Test func veryLowHeadroomDoesNotForceCacheFloorsAboveAvailableMemory() { + let budget = ReaderMemoryBudgetPlanner.budget(forPhysicalMemoryBytes: 8 * 1024 * 1024 * 1024, + availableMemoryBytes: 32 * 1024 * 1024) + #expect(budget.decodedImageCacheMB + budget.remotePageDataCacheMB < 32) + #expect(budget.decodedImagePreloadMB < budget.decodedImageCacheMB) + #expect(budget.remotePrefetchMB < 32) + } + + private func region(_ rect: CGRect, confidence: Float) -> MangaVisionRegion { + MangaVisionRegion(type: .panel, normalizedRect: rect, confidence: confidence) + } + private func render(size: CGSize, body: (UIGraphicsImageRendererContext) -> Void) -> UIImage { + let format = UIGraphicsImageRendererFormat(); format.scale = 1; format.opaque = true + return UIGraphicsImageRenderer(size: size, format: format).image(actions: body) + } +} diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index 0e12cb2c..b6c4f7e8 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -181,6 +181,19 @@ struct mreaderTests { #expect(AIChatResponseDecoder.decode(anthropic).finishReason == "end_turn") } + @Test func aiTranslationClientRejectsOversizedResponseWithoutContentLength() async throws { + AITransportRecordingURLProtocol.configure(responseData: Data(repeating: 0x41, count: 8 * 1024 * 1024 + 1)) + let client = AITranslationClient(apiKey: "secret", baseURL: "https://api.example.test/v1", + session: aiTransportRecordingSession()) + var rejected = false + do { + _ = try await client.send(AITransportRequest( + model: AIModelDescriptor(id: "test", apiProtocol: .openAIChatCompletions), + systemPrompt: "system", userPrompt: "hello", maxTokens: 32)) + } catch BoundedHTTPResponseError.tooLarge { rejected = true } + #expect(rejected) + } + @Test func aiTranslationClientBuildsChatCompletionsRequest() async throws { AITransportRecordingURLProtocol.configure(responseData: Data(#"{"output_text":"ok"}"#.utf8)) let client = AITranslationClient( diff --git a/mreaderUITests/mreaderUITests.swift b/mreaderUITests/mreaderUITests.swift index 5559aff1..a9c45382 100644 --- a/mreaderUITests/mreaderUITests.swift +++ b/mreaderUITests/mreaderUITests.swift @@ -227,6 +227,29 @@ final class mreaderUITests: XCTestCase { } } + @MainActor + func testLongStripRapidReverseScrollMemoryAndHitchMetrics() throws { + let app = XCUIApplication() + app.launchArguments += ["-mreader-ui-testing", "-mreader-ui-testing-long-strip"] + app.launch() + let openReader = element("mreader.shelf.uiTestingFixture", in: app) + XCTAssertTrue(openReader.waitForExistence(timeout: timeout)) + openReader.tap() + let reader = element("mreader.reader.root", in: app) + XCTAssertTrue(reader.waitForExistence(timeout: timeout)) + let options = XCTMeasureOptions() + options.iterationCount = 1 + options.invocationOptions = .manuallyStop + measure(metrics: [XCTMemoryMetric(application: app), XCTHitchMetric(application: app)], options: options) { + // Exercise page boundaries as well as scrolling inside a single strip. + for _ in 0..<25 { reader.swipeUp(velocity: .fast) } + for _ in 0..<25 { reader.swipeDown(velocity: .fast) } + stopMeasuring() + XCTAssertTrue(reader.exists) + XCTAssertFalse(app.staticTexts["图片加载失败"].exists) + } + } + @MainActor func testV2B5ProviderReaderGuidedPanelAndOCRControls() throws { let app = launchV2B5ReaderFixture() diff --git a/scripts/materialize_private_model_weight.sh b/scripts/materialize_private_model_weight.sh index 5477ea5a..33608726 100644 --- a/scripts/materialize_private_model_weight.sh +++ b/scripts/materialize_private_model_weight.sh @@ -24,6 +24,13 @@ is_real_weight() { if head -n 1 "$path" 2>/dev/null | grep -q '^version https://git-lfs.github.com/spec/v1$'; then return 1 fi + local digest + if command -v shasum >/dev/null 2>&1; then + digest="$(shasum -a 256 "$path" | awk '{print $1}')" + else + digest="$(sha256sum "$path" | awk '{print $1}')" + fi + [[ "$digest" == "$PUBLIC_MODEL_WEIGHT_SHA256" ]] || return 1 return 0 } diff --git a/scripts/private_model_source.conf b/scripts/private_model_source.conf index 46c4015b..9ec98f40 100644 --- a/scripts/private_model_source.conf +++ b/scripts/private_model_source.conf @@ -4,3 +4,6 @@ PRIVATE_MODEL_REPOSITORY_SSH="git@github.com:zyk1172/manga-vision-training.git" PRIVATE_MODEL_REF="main" PRIVATE_MODEL_PATH="exports/coreml/MangaVisionV2B5/weight.bin" PUBLIC_MODEL_WEIGHT_PATH="mreader/MangaVisionV2B5.mlpackage/Data/com.apple.CoreML/weights/weight.bin" + +# Freeze the graph/weight pair; changing exports requires an intentional checksum update. +PUBLIC_MODEL_WEIGHT_SHA256="8e708d893f752b14a42b9d478a8256a525a750121d4abe4446e019e61fdbec80" From 66db6207fb05fdbcca6579783d77046cc213cb0b Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:00:08 +0800 Subject: [PATCH 2/6] Preserve text geometry by value-copy replacement and ordinary-page thresholds --- mreader/AITranslator.swift | 15 ++++++++++++--- mreader/OCRPreprocessor.swift | 3 +-- mreader/PanelDetectionService.swift | 5 +++-- mreader/ReaderView.swift | 5 ++++- mreader/TranslationGeometryRefiner.swift | 3 +-- mreaderTests/ComicAuditFixTests.swift | 12 ++++++++++++ 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/mreader/AITranslator.swift b/mreader/AITranslator.swift index 6c2812cc..4432ad31 100644 --- a/mreader/AITranslator.swift +++ b/mreader/AITranslator.swift @@ -151,7 +151,7 @@ nonisolated enum TranslationLayoutRole: String, Codable, Sendable { nonisolated struct TextBlock: Identifiable, Codable, Sendable { let id: UUID let text: String - let boundingBox: CGRect // 原图中的相对坐标 (0.0 ~ 1.0) + private(set) var boundingBox: CGRect // 原图中的相对坐标 (0.0 ~ 1.0) var translation: String? var confidence: Double var ocrSource: String @@ -207,6 +207,14 @@ nonisolated struct TextBlock: Identifiable, Codable, Sendable { ) } + /// Geometry replacements copy the complete value, preserving independent + /// bubble/layout evidence and any fields subsequently added to this type. + nonisolated func replacingBoundingBox(_ rect: CGRect) -> TextBlock { + var copy = self + copy.boundingBox = rect + return copy + } + /// OCR 字号尺度始终取原文短边:横排文字对应 textBox 高度,竖排文字对应宽度。 /// 显示时必须用同一坐标轴的页面尺寸还原,不能统一乘图片短边。 nonisolated func sourceFontSize(in imageRect: CGRect) -> CGFloat { @@ -3897,8 +3905,9 @@ static func visualReviewedBlockForDiagnostics(original: TextBlock, review: Visio } nonisolated static func annotatedMangaTextBlocks(_ blocks: [TextBlock], safeAreaInset: Double, minimumTextHeight: Double, isRightToLeft: Bool, pageSize: CGSize? = nil) -> [TextBlock] { - let xScale = pageSize.map { min(1, $0.height / max($0.width, 1)) } ?? 1 - let yScale = pageSize.map { min(1, $0.width / max($0.height, 1)) } ?? 1 + let longPage = pageSize.map { max($0.width, $0.height) / max(min($0.width, $0.height), 1) >= 3 } ?? false + let xScale = longPage ? (pageSize.map { min(1, $0.height / max($0.width, 1)) } ?? 1) : 1 + let yScale = longPage ? (pageSize.map { min(1, $0.width / max($0.height, 1)) } ?? 1) : 1 let inset = min(max(CGFloat(safeAreaInset), 0), 0.3) let minimumHeight = min(max(CGFloat(minimumTextHeight), 0.002), 0.05) * yScale let minimumArea = minimumHeight * 0.0048 * xScale diff --git a/mreader/OCRPreprocessor.swift b/mreader/OCRPreprocessor.swift index 7d2f48bf..5a1155dd 100644 --- a/mreader/OCRPreprocessor.swift +++ b/mreader/OCRPreprocessor.swift @@ -171,8 +171,7 @@ struct OCRPreprocessor { width: r.width * actual.width, height: r.height * actual.height) } blocks.append(contentsOf: (candidate.blocks + vertical).map { block in - var mapped = block - mapped.boundingBox = rect(block.boundingBox) + var mapped = block.replacingBoundingBox(rect(block.boundingBox)) mapped.bubbleBox = block.bubbleBox.map(rect) mapped.layoutSafeRegion = block.layoutSafeRegion.map(rect) mapped.polygon = block.polygon.map(point) diff --git a/mreader/PanelDetectionService.swift b/mreader/PanelDetectionService.swift index af96bdc0..684bd831 100644 --- a/mreader/PanelDetectionService.swift +++ b/mreader/PanelDetectionService.swift @@ -163,8 +163,9 @@ nonisolated enum PanelPostProcessor { private static let pageEdgeTolerance: CGFloat = 0.075 static func process(_ candidates: [DetectedPanel], pageSize: CGSize = CGSize(width: 1, height: 1)) -> [DetectedPanel] { - let xScale = min(1, pageSize.height / max(pageSize.width, 1)) - let yScale = min(1, pageSize.width / max(pageSize.height, 1)) + let aspect = max(pageSize.width, pageSize.height) / max(min(pageSize.width, pageSize.height), 1) + let xScale = aspect >= 3 ? min(1, pageSize.height / max(pageSize.width, 1)) : 1 + let yScale = aspect >= 3 ? min(1, pageSize.width / max(pageSize.height, 1)) : 1 let unit = CGRect(x: 0, y: 0, width: 1, height: 1) let filtered = candidates.compactMap { panel -> DetectedPanel? in let standardized = panel.rect.standardized diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 9a0cc26c..3e0c10ee 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -6876,7 +6876,8 @@ struct LocalImageView: View { using: transform ) }.filter { $0.x.isFinite && $0.y.isFinite } - guard mapped.count >= 3 else { + guard mapped.count >= 3, + BubbleContourGeometry.safeRectangle(polygon: mapped, bounds: surfaceRect) != nil else { return (surfaceRect, []) } @@ -7179,6 +7180,7 @@ struct LocalImageView: View { } guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } let hasOfflineTranslation = await loadOfflineTranslationIfAvailable() + guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } if isAutoTranslationEnabled, !hasOfflineTranslation { await MainActor.run { startTranslation() } } @@ -7223,6 +7225,7 @@ struct LocalImageView: View { } guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } let hasOfflineTranslation = await loadOfflineTranslationIfAvailable() + guard imageLoadID == loadID, !Task.isCancelled, retainsDecodedImage else { return } if isAutoTranslationEnabled, !hasOfflineTranslation { await MainActor.run { startTranslation() } } diff --git a/mreader/TranslationGeometryRefiner.swift b/mreader/TranslationGeometryRefiner.swift index 7a697610..7af531d7 100644 --- a/mreader/TranslationGeometryRefiner.swift +++ b/mreader/TranslationGeometryRefiner.swift @@ -32,8 +32,7 @@ nonisolated enum TranslationGeometryRefiner { vision.layoutRole == .standalone || locals.contains(where: { $0.layoutRole == .standalone }) ? .standalone : .dialogue - var refined = vision - refined.boundingBox = localBounds + var refined = vision.replacingBoundingBox(localBounds) refined.confidence = max(vision.confidence, locals.map(\.confidence).max() ?? 0) refined.ocrSource = "\(vision.ocrSource)+local-geometry" refined.estimatedFontScale = weightedMedianFontScale(locals) diff --git a/mreaderTests/ComicAuditFixTests.swift b/mreaderTests/ComicAuditFixTests.swift index 76248334..30028462 100644 --- a/mreaderTests/ComicAuditFixTests.swift +++ b/mreaderTests/ComicAuditFixTests.swift @@ -47,6 +47,18 @@ struct ComicAuditFixTests { for pair in zip(windows, windows.dropFirst()) { #expect(pair.0.maxY > pair.1.minY) } } + @Test func ordinaryPageKeepsOriginalMinimumPanelThresholds() { + let small = DetectedPanel(rect: CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.04), confidence: 0.9, source: .coreML) + #expect(PanelPostProcessor.process([small], pageSize: CGSize(width: 800, height: 1200)).isEmpty) + } + + @Test func longPageOCRFilterUsesLocalScale() { + let block = TextBlock(text: "HELLO", boundingBox: CGRect(x: 0.4, y: 0.3, width: 0.2, height: 20/20000.0)) + let annotated = AITranslator.annotatedMangaTextBlocks([block], safeAreaInset: 0, + minimumTextHeight: 0.006, isRightToLeft: false, pageSize: CGSize(width: 800, height: 20_000)) + #expect(annotated[0].isFiltered == false) + } + @Test func geometryFindsRealPanelsInTwentyThousandPixelStrip() throws { let size = CGSize(width: 800, height: 20_000) let image = render(size: size) { context in From c99e473d9ba3276bfc3e55a09c4d2b3fedb8ec82 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:04:58 +0800 Subject: [PATCH 3/6] Unwrap test images before Swift Testing assertions --- docs/comic-audit-remediation.md | 2 +- mreaderTests/ComicAuditFixTests.swift | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/comic-audit-remediation.md b/docs/comic-audit-remediation.md index 910e67bb..d9dafa99 100644 --- a/docs/comic-audit-remediation.md +++ b/docs/comic-audit-remediation.md @@ -23,7 +23,7 @@ | F15 质量证据不足 | 增加原生反例和真实 Reader 长页合成 UI 场景,记录 rapid reverse scroll 的内存/hitch telemetry | 合成图不是人工真实页面标注,不替代 Python/Swift 实图 parity、人工 gold、真机门禁,不能填入假通过结果 | | F16 AI 响应上限 | 使用已有 chunk-based 有界 HTTP reader,JSON 响应上限 8 MiB,保留可注入 session configuration 和现有错误/重试策略 | 未改变各模型原生/兼容协议 | -另外,条漫 fitWidth 使用 CATiledLayer 的按需 compositor tiles,避免将整条图作为一个巨大显示纹理;**解码源仍是已有分档、有界整页图像**,不是磁盘区域解码引擎。内存余量较小时不再由固定缓存 floor 强行申请超过余量的预算。私有权重 materialize 使用当前实际权重的 SHA-256 进行校验,不能只凭文件大小接受不匹配权重。 +另外,条漫 fitWidth 使用 CATiledLayer 的按需 compositor tiles,避免将整条图作为一个巨大显示纹理;**解码源仍是已有分档、有界整页图像**,不是磁盘区域解码引擎。内存余量较小时不再由固定缓存 floor 维持高于余量的缓存预算。私有权重 materialize 使用当前实际权重的 SHA-256 进行校验,不能只凭文件大小接受不匹配权重。 ## 本地可执行检查 diff --git a/mreaderTests/ComicAuditFixTests.swift b/mreaderTests/ComicAuditFixTests.swift index 30028462..187cf501 100644 --- a/mreaderTests/ComicAuditFixTests.swift +++ b/mreaderTests/ComicAuditFixTests.swift @@ -117,12 +117,14 @@ struct ComicAuditFixTests { } let balloon = CGRect(x: 0.15, y: 0.15, width: 0.70, height: 0.65) let text = CGRect(x: 115/300.0, y: 145/400.0, width: 60/300.0, height: 10/400.0) - let contour = try #require(LocalBubbleContour.recover(in: #require(image.cgImage), balloon: balloon, textRegions: [text])) + let cgImage = try #require(image.cgImage) + let contour = try #require(LocalBubbleContour.recover(in: cgImage, balloon: balloon, textRegions: [text])) #expect(contour.count >= 8) #expect(contour.map(\.y).min()! < 0.20) #expect(contour.map(\.y).max()! < 0.75) let blank = render(size: size) { context in UIColor.white.setFill(); context.fill(CGRect(origin: .zero, size: size)) } - #expect(LocalBubbleContour.recover(in: #require(blank.cgImage), balloon: balloon, textRegions: [text]) == nil) + let blankCGImage = try #require(blank.cgImage) + #expect(LocalBubbleContour.recover(in: blankCGImage, balloon: balloon, textRegions: [text]) == nil) } @Test func cancelledImageEntryCannotBeJoinedAndReplacementHasOwnIdentity() async { From 0c21d77ad89e2912da7aff443b44ca35b3da556e Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:12:33 +0800 Subject: [PATCH 4/6] Keep OCR results tied to the source revision across asynchronous reads --- docs/comic-audit-remediation.md | 4 ++-- mreader/MangaVisionRuntimeFoundation.swift | 6 +++++- mreader/OCRRecognitionCache.swift | 14 ++++++++++++-- mreaderTests/ComicAuditFixTests.swift | 22 ++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/comic-audit-remediation.md b/docs/comic-audit-remediation.md index d9dafa99..e8a0ad61 100644 --- a/docs/comic-audit-remediation.md +++ b/docs/comic-audit-remediation.md @@ -12,7 +12,7 @@ | F04 本地无轮廓 | OCR 后从框内浅色闭合连通域恢复保守轮廓;限制局部栅格为 256 边;拒绝开放、泄漏、明显非凸及歧义区域;已有有效轮廓优先 | 本地模型仍只有框,未伪造 mask 输出。彩色、透明、凹形、相连气泡不保证恢复,保留原框降级 | | F05 文字越轮廓 | 从验证过的轮廓求带边缘距离余量的全内接矩形;测量和绘制继续共用 CoreText;显示时对云端/离线轮廓也重新求安全区 | 采用保守内接区,不声称任意凹形的逐行最优排字。黑/彩色只影响颜色 | | F06 加载取消竞态 | 不再加入 cancelled 预加载;为任务加独立 ID,旧完成只清理自己的 entry/成本;视图加加载 ID、URL、retain 和 cancellation 提交检查 | 同步 ImageIO 已开始后不可强制中止;原全局解码 permits 保留,不能把取消当已释放容量 | -| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O | +| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池;CBZ 内页身份关联归档修订,OCR 请求冻结源身份并在返回/落盘前复核 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O | | F08 CBZ 头部完整解压 | 找到尺寸后抛出专用终止结果中断 ZIP consumer;头部预算 1 MiB;真正完整页读取继续 CRC | 某些格式无法在预算内读到头部时返回未知尺寸 | | F09 PDF 批量渲染 | 阅读入口只建立 `mreader-pdf` 页面描述;读取某页时才渲染;接入阅读、几何、OCR、视觉、离线来源及带源修订缓存键;每页渲染有像素上限 | 导入转换到图像目录的现有功能未改为懒加载;PDF 仍只读,复杂 PDF 每页渲染需实测 | | F10 字号承诺冲突 | 五种语言改成偏好字号,明确还会缩小;不可容纳显示被动省略标记;辅助功能可读完整译文 | 未恢复已删除的放大镜/展开按钮,也不把 2pt 当可读性能证明 | @@ -38,7 +38,7 @@ ## 补充的原生回归 -`ComicAuditFixTests` 覆盖父子框两个置信度顺序、inset 和近似重复、800×20000 / 800×40000 尺度、800×20000 真实几何栅格、椭圆/凹形内接区与全文 CoreText 可见范围、像素闭合气泡/开放降级、cancelled entry、离线字段保留、认证前 body 写入、懒 PDF 描述及原文件不变、极低余量预算。AI transport 测试覆盖无 Content-Length 的超限响应。 +`ComicAuditFixTests` 覆盖父子框两个置信度顺序、inset 和近似重复、800×20000 / 800×40000 尺度、800×20000 真实几何栅格、椭圆/凹形内接区与全文 CoreText 可见范围、像素闭合气泡/开放降级、cancelled entry、离线字段保留、认证前 body 写入、懒 PDF 描述及原文件不变、极低余量预算、替换归档后的虚拟页缓存失效。AI transport 测试覆盖无 Content-Length 的超限响应。 UI 测试使用普通 ComicManager + Reader 生命周期,五张 800×20000 的带分镜、椭圆气泡和文本合成页,快速上下往返;记录 XCTest memory 和 hitch 指标,并检查 reader 存活/加载失败状态。指标不硬编码为真实设备 FPS 或准确率。 diff --git a/mreader/MangaVisionRuntimeFoundation.swift b/mreader/MangaVisionRuntimeFoundation.swift index 0d390e84..9388a569 100644 --- a/mreader/MangaVisionRuntimeFoundation.swift +++ b/mreader/MangaVisionRuntimeFoundation.swift @@ -154,6 +154,10 @@ nonisolated enum PageContentIdentityResolver { if let revision = ComicManager.pdfPageCacheKey(for: pageURL) { return .remote(provider: "local-pdf", resource: pageURL.absoluteString, revision: revision) } + if ComicManager.isArchivePageURL(pageURL), + let revision = ComicManager.archivePageCacheKey(for: pageURL) { + return .remote(provider: "local-archive", resource: pageURL.absoluteString, revision: revision) + } if let request = RemotePageLoader.RemotePageRequest(url: pageURL) { let resource = "\(request.sourceID.uuidString.lowercased())/\(request.bookID)/\(request.pageIndex)" return .remote( @@ -175,7 +179,7 @@ nonisolated enum PageContentIdentityResolver { ) } - // Archive/OPDS/custom source layers can replace this compatibility identity by + // OPDS/custom source layers can replace this compatibility identity by // passing PageContentIdentity explicitly to MangaVisionService.analysis(). return .remote( provider: pageURL.scheme ?? "remote", diff --git a/mreader/OCRRecognitionCache.swift b/mreader/OCRRecognitionCache.swift index 96c3043a..485bf500 100644 --- a/mreader/OCRRecognitionCache.swift +++ b/mreader/OCRRecognitionCache.swift @@ -11,9 +11,10 @@ nonisolated struct OCRRecognitionCacheRequest: @unchecked Sendable { var pageIndex: Int? = nil var analysisIdentity: String = "unprepared" + var sourceIdentityOverride: String? = nil var cacheKey: String { - let sourceIdentity = PageContentIdentityResolver.identity(for: pageURL).fingerprint + let sourceIdentity = sourceIdentityOverride ?? PageContentIdentityResolver.identity(for: pageURL).fingerprint let rawValue = [ // v11 keeps Manga Vision text ROI discovery and adds first-class // balloon/layout geometry. Do not reuse pages written before that @@ -71,9 +72,14 @@ actor OCRRecognitionCache { try Task.checkCancellation() guard epoch == generation else { throw CancellationError() } var preparedRequest = request + let sourceIdentity = PageContentIdentityResolver.identity(for: request.pageURL).fingerprint + preparedRequest.sourceIdentityOverride = sourceIdentity preparedRequest.analysisIdentity = await MangaVisionService.shared.expectedDependencyIdentity( sourceSize: OCRPreprocessor.preparedPixelSize(sourceSize: sourceSize) ) + guard PageContentIdentityResolver.identity(for: request.pageURL).fingerprint == sourceIdentity else { + throw CancellationError() + } let key = preparedRequest.cacheKey if let cached = cachedResult(forKey: key) { return cached } // Coalesce preparation as well as OCR. Concurrent consumers must not each @@ -91,6 +97,11 @@ actor OCRRecognitionCache { } try Task.checkCancellation() guard epoch == generation else { throw CancellationError() } + // Source reads span suspension points. Never return or persist a mixture + // of revisions, or label an old result with the file's new cache key. + guard PageContentIdentityResolver.identity(for: request.pageURL).fingerprint == sourceIdentity else { + throw CancellationError() + } preparedRequest.analysisIdentity = prepared.identity if prepared.isPersistent { store(prepared.result, forKey: preparedRequest.cacheKey) } return prepared.result @@ -186,4 +197,3 @@ actor OCRRecognitionCache { } } } - diff --git a/mreaderTests/ComicAuditFixTests.swift b/mreaderTests/ComicAuditFixTests.swift index 187cf501..bbc70fe4 100644 --- a/mreaderTests/ComicAuditFixTests.swift +++ b/mreaderTests/ComicAuditFixTests.swift @@ -194,6 +194,28 @@ struct ComicAuditFixTests { #expect(budget.remotePrefetchMB < 32) } + @Test func replacingArchiveInvalidatesVirtualPageIdentity() throws { + let archive = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".cbz") + defer { try? FileManager.default.removeItem(at: archive) } + try Data([1]).write(to: archive) + func encode(_ value: String) -> String { + Data(value.utf8).base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + var components = URLComponents() + components.scheme = "mreader-zip-page" + components.host = "page" + components.queryItems = [URLQueryItem(name: "archive", value: encode(archive.path)), + URLQueryItem(name: "entry", value: encode("page.png")), + URLQueryItem(name: "format", value: "zip"), + URLQueryItem(name: "index", value: "0")] + let page = try #require(components.url) + let original = PageContentIdentityResolver.identity(for: page).fingerprint + try Data([1, 2]).write(to: archive, options: .atomic) + let replacement = PageContentIdentityResolver.identity(for: page).fingerprint + #expect(original != replacement) + } + private func region(_ rect: CGRect, confidence: Float) -> MangaVisionRegion { MangaVisionRegion(type: .panel, normalizedRect: rect, confidence: confidence) } From abb3d5c23e39f083fd2717019ca231b959bd1ca9 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:44:15 +0800 Subject: [PATCH 5/6] Retain whole-page geometry for frames spanning local windows --- docs/comic-audit-remediation.md | 4 ++-- mreader/GeometryPanelDetector.swift | 8 ++++++-- mreader/LongPageGeometry.swift | 5 +++-- mreader/OCRRecognitionCache.swift | 9 +++++++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/comic-audit-remediation.md b/docs/comic-audit-remediation.md index e8a0ad61..7aa5ad62 100644 --- a/docs/comic-audit-remediation.md +++ b/docs/comic-audit-remediation.md @@ -7,12 +7,12 @@ | 报告项 | 实现 | 仍需验收的边界 | |---|---|---| | F01 分镜候选丢失 | panel 跨推理合并增加 0.72 面积相近约束,父框和真实孩子均保留;结构融合决定是否舍弃页容器 | 不新增模型权重,不给出未测的检测准确率 | -| F02 长页尺度 | 几何先切重叠局部窗口,再生成 1024 分析栅格;拒绝窗口内部裁断的候选;按页尺寸缩放分镜最小宽高/面积及数量预算;结果映回原页 | 窗口不是导航分镜。无可靠证据仍整页阅读;极大的跨窗分镜可能需模型补充 | +| F02 长页尺度 | 几何保留整页分析补充跨窗大框,另切 50% 重叠局部窗口再生成 1024 分析栅格;拒绝窗口内部裁断的候选;按页尺寸缩放分镜最小宽高/面积及数量预算;结果映回原页 | 窗口不是导航分镜。无可靠证据仍整页阅读;极大的跨窗分镜可能需模型补充 | | F03 OCR 先缩后切 | 已知长页从原图裁 OCR 窗口后逐片准备输入,统一映回全页;普通页保持原路径;长页备用图按短边和像素预算解码 | ImageIO 裁图可能解码整张 backing store,不能宣称压缩格式都支持区域解码。原图超过 6400 万像素或当前内存余量不足、EXIF 非正向时明确使用有界备用路径 | | F04 本地无轮廓 | OCR 后从框内浅色闭合连通域恢复保守轮廓;限制局部栅格为 256 边;拒绝开放、泄漏、明显非凸及歧义区域;已有有效轮廓优先 | 本地模型仍只有框,未伪造 mask 输出。彩色、透明、凹形、相连气泡不保证恢复,保留原框降级 | | F05 文字越轮廓 | 从验证过的轮廓求带边缘距离余量的全内接矩形;测量和绘制继续共用 CoreText;显示时对云端/离线轮廓也重新求安全区 | 采用保守内接区,不声称任意凹形的逐行最优排字。黑/彩色只影响颜色 | | F06 加载取消竞态 | 不再加入 cancelled 预加载;为任务加独立 ID,旧完成只清理自己的 entry/成本;视图加加载 ID、URL、retain 和 cancellation 提交检查 | 同步 ImageIO 已开始后不可强制中止;原全局解码 permits 保留,不能把取消当已释放容量 | -| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池;CBZ 内页身份关联归档修订,OCR 请求冻结源身份并在返回/落盘前复核 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O | +| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池;CBZ 内页身份关联归档修订,OCR 请求及视觉分析共用冻结源身份,并在返回/落盘前复核 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O | | F08 CBZ 头部完整解压 | 找到尺寸后抛出专用终止结果中断 ZIP consumer;头部预算 1 MiB;真正完整页读取继续 CRC | 某些格式无法在预算内读到头部时返回未知尺寸 | | F09 PDF 批量渲染 | 阅读入口只建立 `mreader-pdf` 页面描述;读取某页时才渲染;接入阅读、几何、OCR、视觉、离线来源及带源修订缓存键;每页渲染有像素上限 | 导入转换到图像目录的现有功能未改为懒加载;PDF 仍只读,复杂 PDF 每页渲染需实测 | | F10 字号承诺冲突 | 五种语言改成偏好字号,明确还会缩小;不可容纳显示被动省略标记;辅助功能可读完整译文 | 未恢复已删除的放大镜/展开按钮,也不把 2pt 当可读性能证明 | diff --git a/mreader/GeometryPanelDetector.swift b/mreader/GeometryPanelDetector.swift index 4e028d3c..c358ecb4 100644 --- a/mreader/GeometryPanelDetector.swift +++ b/mreader/GeometryPanelDetector.swift @@ -9,7 +9,7 @@ import Foundation /// learned Manga Vision frame detector remains useful later as a residual detector for /// borderless/inset/irregular cases, but it is no longer the sole authority for navigation. nonisolated struct GeometryPanelDetector: PanelDetecting { - let identifier = "page-geometry-xycut-local-windows-v4" + let identifier = "page-geometry-xycut-overview-local-windows-v5" private let maximumDimension: Int @@ -19,7 +19,11 @@ nonisolated struct GeometryPanelDetector: PanelDetecting { func detectPanels(in image: CGImage) throws -> [DetectedPanel] { let size = CGSize(width: image.width, height: image.height) - let windows = LongPageGeometry.windows(sourceSize: size, maximumAspect: 2.5) + let localWindows = LongPageGeometry.windows(sourceSize: size, maximumAspect: 2.5, overlap: 0.5) + // Large frames can span more than one local window. Keep the original + // whole-page pass as independent evidence, alongside local fine detail. + let windows = localWindows.count > 1 + ? [CGRect(x: 0, y: 0, width: 1, height: 1)] + localWindows : localWindows var candidates: [DetectedPanel] = [] for window in windows { try Task.checkCancellation() diff --git a/mreader/LongPageGeometry.swift b/mreader/LongPageGeometry.swift index 019357c2..d2808ea6 100644 --- a/mreader/LongPageGeometry.swift +++ b/mreader/LongPageGeometry.swift @@ -3,13 +3,14 @@ import Foundation /// Shared geometry only: these windows never become synthetic navigation panels. nonisolated enum LongPageGeometry { - static func windows(sourceSize: CGSize, maximumAspect: CGFloat = 2.5) -> [CGRect] { + static func windows(sourceSize: CGSize, maximumAspect: CGFloat = 2.5, overlap: CGFloat = 0.28) -> [CGRect] { guard sourceSize.width > 0, sourceSize.height > 0 else { return [] } let vertical = sourceSize.height >= sourceSize.width let aspect = max(sourceSize.width, sourceSize.height) / min(sourceSize.width, sourceSize.height) guard aspect > maximumAspect else { return [CGRect(x: 0, y: 0, width: 1, height: 1)] } let fraction = maximumAspect / aspect - let count = Int(ceil((1 - fraction) / (fraction * 0.72))) + 1 + let step = fraction * (1 - min(max(overlap, 0), 0.75)) + let count = Int(ceil((1 - fraction) / step)) + 1 return (0.. Date: Fri, 2 Oct 2026 22:51:53 +0800 Subject: [PATCH 6/6] Map geometric windows using the clipped source pixel rectangle --- mreader/GeometryPanelDetector.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/mreader/GeometryPanelDetector.swift b/mreader/GeometryPanelDetector.swift index c358ecb4..d2321d02 100644 --- a/mreader/GeometryPanelDetector.swift +++ b/mreader/GeometryPanelDetector.swift @@ -29,6 +29,7 @@ nonisolated struct GeometryPanelDetector: PanelDetecting { try Task.checkCancellation() let pixelRect = CGRect(x: window.minX * size.width, y: window.minY * size.height, width: window.width * size.width, height: window.height * size.height).integral + .intersection(CGRect(origin: .zero, size: size)) guard let crop = image.cropping(to: pixelRect), let raster = PanelGeometryRaster(image: crop, maximumDimension: maximumDimension) else { continue } for panel in PanelGeometryAnalyzer(raster: raster).detectPanels() {